Skip to content

v5.2.0

Choose a tag to compare

@github-actions github-actions released this 23 Oct 07:12
e44ec8b

5.2 is a minor release. Workflows can fire on a cron schedule, create entries, move them through their lifecycle and publish to outside services. Fields can be computed. Saved queries group, count and sum in the database. Six new modules ship (Contacts, Notifications, Comments, Scheduling, Reports, Payments) alongside Documents, Qr, CdnPurge and build triggers, and core can reserve against a counter. The admin API contract moves from 9 to 11. The delivery contract stays at 9. The Resend email module moved to the private feed.

Upgrade

From 5.1.x. A 5.0 install upgrades to 5.1 first.

  1. Take a backup and check it restores (docs/backup-and-restore.md).
  2. Stop the 5.1 API. Every file below is applied with the API stopped.
  3. Run db-migrate with the 5.2.0 image, then db-assert. FROM_VERSION=5.1.0 scripts/upgrade-check.sh --list prints each file as a psql line in the order CI applies them. Under migrations/5.2.0/:
    • Core, first: 580-stock-reservations.sql (the stock counter, hold and movement tables, empty), 898-accounts.sql (the accounts tables), 910-inbound-webhook-events.sql, action-link-uses.sql, magic-link-tokens.sql, outbox-messages.sql, pending-builds.sql, signed-link-uses.sql and workflow-schedule-claims.sql (all new, empty tables).
    • Module, after the core files; a host without that module skips its file: 253-comments.sql (BarakoCMS.Comments), 585-documents.sql (BarakoCMS.Documents), booking-resources.sql (BarakoCMS.Booking), contacts.sql (BarakoCMS.Contacts), notifications.sql (BarakoCMS.Notifications), payments-attempts.sql, payments-events.sql and payments-payable-types.sql (BarakoCMS.Payments), report-definitions.sql (BarakoCMS.Reports) and scheduled-entries.sql (BarakoCMS.Scheduling).
  4. Check the defaults. Delivery response caching (Delivery:ResponseCache:Enabled), signed action links (Workflows:ActionLinks:Enabled) and email delivery of notifications are off until you set them. The stock hold sweep (Stock:HoldSweep:Enabled) is on by default and does nothing until a counter has a hold.
  5. Start 5.2.0.

Breaking HTTP changes. ApiContract.Version moves from 9 to 11. Admin 10: POST /api/stations answers 400 when the request lists a platform capability. Admin 11: the Resend settings (Resend:ApiKey, Resend:From, RESEND_API_KEY) are no longer read, so move to Email:ApiKey and Email:From. ApiContract.DeliveryVersion stays at 9. Module contracts: BarakoCMS.AI HttpContractVersion 1 to 2, BarakoCMS.Pages 2 to 3. New modules start at 1.

Package changes. BarakoCMS.Abstractions 5.2.0 adds IBarakoModule.ContentPermissions and the package identity members (PackageId, PackageVersion, Category, Description, Glyph) as default members, IContentLifecycleRunner gains an overload that takes a ContentTransition, and IFileStore gains FindForRunAsync and OpenForRunAsync. A module built against 5.1 still loads. Nothing is removed from the package surface.

Rolling back to 5.1. Stop 5.2, run the rollback lines --list prints, newest first, then remove each rolled-back file's ledger row with db-migrate --forget <key> and start 5.1.x. Every file above has a rollback file under migrations/5.2.0/ named rollback-<file>. Rolling back drops the tables those files created, so their rows are lost: stock counters and holds, accounts, inbound webhook events, action and signed link uses, magic link tokens, outbox messages, pending builds, workflow schedule claims, and each module's comments, documents, bookings, contacts, notifications, payment attempts, report definitions and scheduled entries. A workflow set to fire on Schedule stops firing on 5.1.

Added

  • A user has a display name. GET /api/me returns displayName, which is the username until one is set. PUT /api/me/profile with { "displayName": "..." } sets it (up to 100 characters, no control characters); null or blank clears it. User.DisplayName is a new optional property.
  • A module can declare default content rights for the types it seeds. IBarakoModule.ContentPermissions (a default member, so existing modules compile) takes a ContentPermissionDefaults, and the host applies it after the module's SeedAsync. A role gets the declared actions only if it has no permission entry for that type yet, so a restart is not a write and rights an admin changed are never overwritten. A module that declares nothing behaves as before.
  • Collection syncs can treat an empty answer as complete, and refuse a partial body.
    emptyIsComplete (with archiveMissing) lets a run that read no items archive every entry the
    sync owns, for a source that really can be empty. incompleteWhen (path and equalTo) fails
    the run before anything is written when a JSON response says its list is partial, such as GitHub
    search's incomplete_results: true. Both are optional and off by default, so existing syncs
    behave as before and neither contract number moves. See docs/collection-syncs.md.
  • API key usage. GET /api/api-keys/{id}/usage shows when a key was last used, why it was refused in the last 24 hours (scope, content type, expired, revoked), and the content types it names that no longer exist. Refusals are counted in memory, capped per key, and exported as barakocms_api_key_refusals_total by reason.
  • A saved query can group, count and sum in the database. POST /api/queries takes optional groupBy (a field, with a day, week (ISO week, labelled by its Monday), month or year bucket for dates), measures (sum, count, avg, min, max, each with a name, and an optional via reference field for one hop) and range (a date field with a relative window such as last7days, matched without regard to case, or from and to). POST /api/queries/{slug}/run returns the groups and totals, with from and to in the body to move the range. Figures are computed under the caller's own field and row rules: a field they may not read refuses the run by name, hidden rows are not counted, and days follow the site's time zone. More than 1000 groups, or a query that runs past 30 seconds, is refused with a reason. Preview returns the same rows. A query without the new keys behaves as before.
  • Saving a query with measures now refuses fields, sortField and limit, range or groupBy without a measure, and a count over a field that is not a number. from and to at run time need a saved range. A date with no zone is read in the site's time zone, and a malformed stored date is left out instead of failing the run.
  • An Email step can attach a file an earlier step of the same run produced. Set Attachments to {{actions.N.name}}, where step N is an earlier step that captures an output holding the file id and declares the file in WorkflowActionResult.ProducedFiles (at most 20 are kept, and the attempt says when more were declared). The file may be private, because the run made it. Attachments go out in the order written, and a run file named twice is attached once. An id typed into a field or a parameter is still refused unless it is public and the entry names it, and the existing count and size limits apply. The step inside a Conditional does not get step outputs, and saving a workflow warns about a run file reference there. IFileStore gains FindForRunAsync and OpenForRunAsync (default implementations throw NotSupportedException, which the email step reports as a permanent failure), WorkflowContext gains ProducedFiles and Ordinal.
  • A Request step can keep named values from the response as outputs. A request definition takes optional outputs (a name and a plain JSON path each, such as $.choices[0].message.content), missingOutput (Fail, the default, or Empty) and maxOutputLength (up to 2000). The step returns those values on success, and a later step reads the ones the workflow captures as {{actions.N.name}}. Only the named paths are read, never the whole body or a header. A missing path (under Fail), a value over the cap, a response that is not JSON or over 256 KiB, or a value that repeats a credential the request sent fails the step and is not retried. A value that holds {{...}} is kept as that text.
  • API keys can watch an instance and nothing else. New scope monitor:read reaches GET /api/contents/counts and GET /api/analytics/**, read only, and gets 403 on every other route. A * key does not imply it, so existing keys gain nothing. Counts also accept content:read.
  • API keys can read per-check health. monitor:read now also reaches GET /api/monitoring/health. A key sees each check's name, status and duration, without description, data or tags. The owner still needs the ViewMonitoring capability, and every other monitoring route stays closed to keys.
  • A Contacts module: one record for a person or an organization. BarakoCMS.Contacts stores
    contacts with typed custom fields (declared on the contact-record content type), person to
    organization links with a role, and a unique email per tenant. A duplicate email answers 409
    naming the existing contact, and POST /api/contacts/{id}/merge folds a duplicate into it.
    Reads need read_contacts and writes manage_contacts, both granted to Admin. Published
    contacts are served at /api/public/contacts with Public fields only, never email, phone or
    address. The module ships its own contract number (HttpContractVersion 1) and moves no core
    number. See BarakoCMS.Contacts/README.md.
  • A Notifications module. BarakoCMS.Notifications lets any module tell a user something happened: resolve INotificationService and call RaiseAsync. The notification lands in that user's inbox (GET /api/notifications, GET /api/notifications/unread-count, POST /api/notifications/{id}/read, POST /api/notifications/read-all) and is handed to every registered INotificationSink. A user sees and marks read only their own, per tenant. Email delivery through the host's IEmailService is a built-in sink, off until Modules:Notifications:EmailDelivery:Enabled is true. POST /api/notifications and GET /api/notifications/admin need the new manage_notifications capability, granted to Admin. The table is created by migrations/5.2.0/notifications.sql.
  • A comment thread on any record. The new BarakoCMS.Comments module adds POST /api/comments, GET /api/comments?targetType=&targetId= (paged) and DELETE /api/comments/{id}. A comment can be a reply to another on the same record. Reading or adding one needs read access to the record itself, so nobody sees a thread on an entry they cannot read. Content entries work out of the box, and another record type joins by registering an ICommentTarget. Comment text is stored and returned as text, never as HTML. Capabilities: read_comments, post_comments, manage_comments.
  • A Scheduling module. BarakoCMS.Scheduling lets a module schedule a named action for a due time with IScheduler.ScheduleAsync and cancel it with CancelAsync. A sweep claims each due entry on one instance, hands it to the IScheduledActionHandler registered for its action, and never fires the same entry twice. Recurrence is not in this first slice.
  • Build triggers. The new BuildHook workflow action fires a static build once per burst of publishes. Presets: netlify, vercel, cloudflare-pages and github-dispatch. The first publish in a quiet period (QuietSeconds, default 60) schedules the build, later ones do nothing, and a sweep posts the hook once. The post goes through the webhook path, so a failure shows in WebhookDeliveries. PendingBuild is a new document. See docs/build-triggers.md.
  • A new module, BarakoCMS.CdnPurge, purges Cloudflare by cache tag. Add the CdnPurge workflow action to a workflow that triggers on publish. It purges the entry tag, the type tag and the sitemap tag, built by DeliveryCache.PurgeTags so they match what delivery reads emitted, each starting with the run's tenant. Set Modules:CdnPurge:ZoneId and Modules:CdnPurge:ApiToken. A rejected purge fails the action on the run. DeliveryCache.PurgeTags is new and public. Fastly is not covered yet.
  • Delivery reads can be kept in memory, so a repeat does not reach Postgres. Set Delivery:ResponseCache:Enabled to true. It is off by default, so nothing changes until you ask. An entry is keyed on the tenant, the host, the path, the query, the X-Tenant header and Origin, and holds only a 200 an anonymous caller was sent. A request with an Authorization, cookie, X-Viewer-Token, X-Share-Key or preview token is never served from or stored in it. A commit evicts the cached reads by the same tags Surrogate-Key sends (the entry, its type, the sitemap), so a publish or an unpublish shows on the next read from that instance. Bounds: ExpireSeconds (60), SizeLimitMegabytes (64), MaxBodyKilobytes (512), MaxQueryLength (512). The store is per instance, so other instances expire an entry on their own after ExpireSeconds.
  • Core can reserve against a counter. IStockReservations takes units from a counter in one statement that holds the counter's row, so two callers racing for the last unit get one success and one refusal, and the balance never crosses the counter's floor. It runs in the caller's session transaction, so the reservation and a content save commit together or not at all. A hold can expire (ReleaseExpiredAsync returns its units), and holds are confirmed or released explicitly. Every receipt, reservation, confirmation, release and expiry is a movement, and the balance is their sum. Not gapless, and not INumberAllocator. New tables barako_stock_counters, barako_stock_holds and barako_stock_movements (migrations/5.2.0/580-stock-reservations.sql). A background sweep (Stock:HoldSweep:Enabled, on by default, every minute, one instance at a time) returns lapsed holds on counters nobody reserves against again. A reference is at most 256 characters. No HTTP surface yet.
  • A new module renders an entry to a PDF. BarakoCMS.Documents adds the RenderDocument workflow action ({ "Type": "RenderDocument", "Parameters": { "Template": "invoice" } }), which draws the entry with SkiaSharp from a narrow template language (titles, headings, paragraphs, rules and table rows with {{field}} holes) and stores the PDF through the Files module. The renderer only draws text, so a template cannot fetch anything. A template naming a field that is not Public is refused, using the same check as outbound requests, and two runs with the same idempotency key store one file. There is no template endpoint yet.
  • A module can say who it is. IBarakoModule gains default members PackageId, PackageVersion, Category (one of Content, Comms, Access, Platform, Insight, Business), Description and Glyph, all null unless declared, and GET /api/modules returns them as optional fields. Every first-party module declares them. Existing modules compile unchanged.
  • GET /api/audit takes a since cursor an outside system can poll. Pass a timestamp the first
    time and the cursor of each response after that. Entries come back oldest first in bounded pages,
    and the cursor resumes exactly where the last page stopped. The same masking and tenant rules as
    the list apply. Without since the list is unchanged. This is the pull half of #689; a push
    transport is a later topic on the messaging transport, not a second package.
  • Test fixture module. BarakoCMS.Testing ships FixtureModule (one capability, one content hook, one gated endpoint) and RecordingEmailProvider, so core tests can prove a module mechanism without a shipped domain module.
  • A form can have an opening and closing date. PUT /api/forms/{contentType} takes optional windowContentType, opensAtField and closesAtField, naming date fields of a singleton content type. A submission outside the window is refused with 403, and editing the date moves the window with no restart. The anonymous form definition reports open and closedMessage only when a window is set. A form with no window behaves as before.
  • QR codes for a field's value. New module BarakoCMS.Qr serves GET /api/public/{type}/{id}/qr/{field}?format=png|svg&size=8. A field is encoded only when it is listed under Modules:Qr:Fields:<type> (empty by default), the entry is published and public, and the field is Public on its type. Anything else is a 404. Values over 1024 characters are refused, and size is capped at 20 pixels per module.
  • A workflow can publish to an outside service. The new Publish action takes Url, Topic, MessageType, Payload and an optional Secret. Payload is a JSON object of text values, and only what it names is sent: placeholders are limited to id, contentType, status, createdAt, updatedAt and data.<Field>, and a field that is not Public is refused when the workflow is saved. The action writes an OutboxMessage in the same save that records the run, so the run succeeds once the message is stored and a rolled-back attempt leaves none. A background sender posts it signed like a webhook (X-Barako-Signature, with the message id as X-Barako-Delivery and Idempotency-Key), retries with backoff from 30 seconds up to an hour, dead-letters after Outbox:MaxAttempts (10) or a 4xx refusal, and writes each send to the webhook delivery log. OutboxMessage and OutboxStatus are new public models.
  • Workflows can create an entry and move one through its lifecycle. The CreateEntry action creates an entry of a content type from Data.<field> parameters, validated like POST /api/contents, and returns its Id as an output. The Transition action runs a named lifecycle transition on the triggering entry as the system actor workflow. A refused create or move fails the step. Both appear in GET /api/workflows/actions.
  • A field can be computed. A field definition takes computed: { "fn": "hours", "args": ["TimeIn", "TimeOut"], "round": 2 }. The functions are add, subtract, multiply, divide, round, min, max, hours, days, duration (minutes), concat and coalesce. The server works the value out inside every write (create, update, transition, workflow) and stores it, so it filters, sorts and delivers like any field. A write that sets a computed field gets a 400 naming it, unless it sends the stored value back. Saving a type refuses an unknown function, a missing input, a type that does not fit, a field that reads itself, a cycle, and a computed field more widely readable than its inputs. POST /api/content-types/{name}/fields takes computed. Rollups, backfilling existing entries and if come later.
  • New module BarakoCMS.Reports. Save a report over a content type with one group-by field and count, sum, avg, min and max measures, run it as the caller with a group limit and an optional date range, and export it as CSV with formula characters escaped. POST and GET /api/reports, GET /api/reports/{name}.
  • BarakoCMS.Payments, a module for taking payment for an entry. A content type is declared payable with PUT /api/payments/payable/{contentType} (amount field, currency, description, the paid and failed transitions). POST /api/public/payments/checkout takes an entry id and a provider name, reads the amount from the entry on the server, and returns the provider's hosted checkout URL; an amount in the request is ignored. POST /api/payments/webhooks/{provider} verifies the provider's signature, handles each provider event id once, and runs the paid transition as the payments-webhook system actor. Each try is a PaymentAttempt, Sensitive by default. Providers implement IPaymentProvider; none ships yet. Capability manage_payments, granted to Admin.
  • A workflow can fire on a schedule. Set the trigger event to Schedule and give a 5-field cron expression in schedule, read in scheduleTimeZone (tenant for the site time zone, or an IANA id such as Asia/Manila). Saving returns the next five due times as nextDueTimes. One run is queued per due time however many hosts run, and a host that was stopped over several due times queues one run for the latest. The run reads {{schedule.at}} and {{schedule.previousAt}}.
  • Optional signed one-click approvals. With Workflows:ActionLinks:Enabled set to true (off by default, Workflows:ActionLinks:ExpiryHours defaults to 72), {{links.action "Approve"}} in an Email action resolves to a signed link for that transition, on that entry, for the user the email is addressed to. GET /api/public/action-links/{token} shows what confirming would do and changes nothing. POST /api/public/action-links/{token} (with optional data for the transition's fields) makes the move as that user, with their permission and row conditions checked at that moment, and spends the link. A link fails once spent, after expiry, when the entry has left the state it was issued in, or when the user may no longer make the move, and every failure answers 404. New ActionLinkUse document.
  • A Booking module. New package BarakoCMS.Booking (in the Suite). A resource is a document with an IANA time zone, weekly opening hours, date exceptions and a slot length (or free-form). Bookings are ordinary content entries; a lifecycle hook refuses an overlap with another active booking of the same resource inside the write, under a per-resource lock. GET /api/public/availability?resource=&from=&to= returns free slots only, bounded by range and throttled per IP. Admin routes under /api/booking/resources need the manage_booking_resources capability. Module HTTP contract version 1. Holds, payment links and .ics files come later.
  • Sign in with an emailed link. POST /api/auth/magic-link/request with { "email": "..." } emails a single-use link valid for 15 minutes and always answers 200, whether or not the address is registered. GET /api/auth/magic-link/confirm?token=... only shows a confirm button, so mail scanners cannot spend the link. POST /api/auth/magic-link/confirm with { "token": "..." } spends it and returns the same tokens as /api/auth/otp/verify, including the MFA challenge when the account has MFA. A tenant that disallows otp sign-in disallows links too. MagicLinkToken is a new document type.
  • Import a whole spreadsheet on the server. POST /api/import/spreadsheet takes the file with
    the header row, content type and a column mapping, and creates an entry from every data row, not
    only the 500 that analyze previews. Same create permission and per-row error report as
    POST /api/import/content, one transaction, and a dryRun flag. Past Import:MaxRecords,
    Import:MaxUploadBytes, the expanded size limit, Import:MaxColumns or Import:MaxSeconds it refuses or stops and
    writes nothing. The new route is additive, so no contract number moves. See docs/spreadsheet-import.md.
    Analyze now also refuses a sheet with a cell past Import:MaxColumns or past Excel's
    1,048,576 rows, because the parser pads to the highest address it sees.
  • A tenant can be moved as a bundle. GET /api/tenants/{handle}/export writes one tenant as a
    bundle (the config engine's sections plus its entries, read a page at a time), and POST /api/tenants/import creates a new tenant from one. Platform admins only (manage_tenants or
    SuperAdmin). Password hashes, API keys and mail credentials are not in a bundle. Members, files,
    redirects and the audit log are not carried yet. See docs/tenant-export-import.md (part of #897).
  • Accounts sit above tenants. A TenantAccount holds tenants, and every tenant not assigned to one belongs to the implicit default account, so a deployment that creates none behaves as before. A user made a manager of an account (POST /api/accounts/{slug}/managers) lists and edits only that account's tenants through /api/tenants; manage_tenants still reaches all of them. An account carries console branding as data: name, logo URL and colours, set with PUT /api/accounts/{slug} and read by the console from GET /api/accounts/branding, which falls back to the tenant's Branding colours. Tenant.AccountId is new (null is the default account) and tenant responses and writes carry an optional accountId. First slice of #898. Account members and roles beyond the manager grant, and a console that reads the branding, are not in it.
  • Log events carry the tenant. Once a request has resolved to a tenant, every log line written during it has a Tenant property with the slug, including a tenant set by an API key or station token. A request on the default tenant has none, so single-tenant logs are unchanged. The request log line itself is written before the tenant is resolved and does not carry it.
  • Core has one inbound webhook receiver primitive. IInboundWebhookVerifier checks a provider signature (Hmac or Svix), refuses a timestamp more than five minutes from now, and runs the handler once per provider event id. A replay answers 200 and is not processed again; the handled ids are InboundWebhookEvent documents in the new inbound_webhook_events table (migrations/5.2.0/910-inbound-webhook-events.sql). POST /api/webhooks/resend now uses it, with the same route and the same 401 and 200 answers, so a replayed bounce no longer writes a second EmailEvent. Connector inbound config and payments adopting it are still open on #910.
  • A signed link service. ISignedLinkService issues links that name a subject and expire, signed per purpose with a key derived from the signing secret, so a link for one purpose fails for another. A link can be single use or revoked. Preview tokens are now the preview purpose; the older JWT preview token still validates until it expires.
  • A module can seed, read content and run a lifecycle hook without naming a Marten type. IBarakoModule.SeedAsync(IModuleSeedContext, CancellationToken), IContentLifecycleHook.BeforeSaveAsync(IContentHookContext, CancellationToken) and IContentReader (get, page, count, read all) are new, and CapabilityDefaults.GrantAsync takes the seed context. The Marten-typed members SeedAsync(IDocumentSession, ...), OnBeforeSaveAsync, ContentLifecycleContext.Session and GrantAsync(IDocumentSession, ...) stay and are marked [Obsolete] with removal in 6.0.0. The new members have defaults that call the older ones, so a module written before this seeds and guards writes as it did. First-party modules override the new member and keep their old SeedAsync(IDocumentSession, ...) override as an [Obsolete] wrapper, so a host on core 5.0.x that only calls the old member still seeds them. The reader sees the whole database the session points at, with no tenant filter, as a query on Content did. Four hooks (JournalEntryHook, LedgerService, PageTreeHook, and ParentReferenceHook with ParentFields) and the module template's Notes endpoint still use Marten, because they need the write session for locks or number allocation. BarakoCMS.Abstractions 5.2.0.
  • Durable work can run on Wolverine. It runs behind IDurableOutbox, switched on with Jobs:Wolverine:Enabled (off by default, so the Marten job storage stays as it is). A hosted service with no request can queue a job for a named tenant through DurableTenantPublisher, and its handler runs in that tenant. Adds the WolverineFx.Marten and JasperFx.RuntimeCompiler packages. Storage migration and the workflow runner port come later (#965).
  • The branch code lookup is configurable. Envelope:BranchContentType (default Branch) and Envelope:BranchCodeField (default Code) name the content type and field that hold a branch's short code for {branch} series templates.
  • POST /api/contents/batch creates entries and runs lifecycle transitions in one transaction. Send up to 100 steps, each create (with a client-made id) or transition. Every step faces the checks of the single create and the status change: permissions, write sensitivity, the create rule, save and transition rules, validation and hooks. The first refusal answers with the step as steps[<index>] and saves nothing. A transition may name an entry created earlier in the same request. A creator still needs Lifecycle:AllowSelfTransition:<Transition> to move on their own entry. Idempotency-Key applies as on any write.
  • A content type can declare a realtime topic. Set RealtimeTopic on the type and a signed-in reader of the type can Subscribe to it on /hubs/realtime and read GET /api/changes for it. A branch scoped type notifies only the readers of the entry's branch. Notices and feed items carry ids and the lifecycle state, never field values. GET /api/contents/{id}, by-slug and the list now return lifecycleState.
  • Workflow action CreateContent creates a numbered entry. It goes through the same create path as POST /api/contents, so the type's save rules, validation, lifecycle hooks, numbering and initial state apply, and the entry keeps the run that made it in SourceRunId and the branch of the triggering entry. A retry of the same attempt reports the entry it already made instead of making a second. It runs as the system, so the caller's Create permission is not asked and fields above Public are dropped. The entry is always a draft, and the user who saves the workflow needs create rights on the type.
  • Content events carry a branch, a node and an origin sequence. Every content event is stamped with branch-id (the request's X-Branch-Id), and, when Barako:NodeId is set, origin-node-id and a gapless origin-sequence per node. A content type with BranchScoped: true refuses a create without a branch, and Content.BranchId keeps the branch the entry was written in, rebuilt from the headers. X-Branch-Id must be one of the ids in the member's branches profile attribute, else 403. Access rules read the row's branch as $branch and the request's as $CURRENT_BRANCH. ContentTypeDefinition gains MergeClass and BranchScoped. Apply migrations/5.1.0/event-headers.sql before deploying (rollback-event-headers.sql undoes it). The profile attribute name branch is now reserved for the request's branch: a member write refuses it, and a stored one is ignored, so $CURRENT_USER.branch now reads the request's branch. Rename a stored branch attribute before upgrading. BarakoCMS.Abstractions is 5.1.0.
  • Lifecycle hooks are told the transition. ContentLifecycleContext.Transition carries the transition name and its from and to states, and is null for a plain save. IContentLifecycleRunner gains an overload that takes it; the existing signature and existing hooks keep working. IContentHookContext.Transition exposes the same value to a hook that implements BeforeSaveAsync, and reads null for a context that does not set it.
  • Workflows a module seeds are module-owned. WorkflowDefinition.OwnerModule is set by ModuleWorkflows.SeedAsync. Config apply never plans to delete a module-owned workflow and refuses a document that names one with other content, and config export leaves them out.
  • Modules can add workflow action types. IBarakoModule.ConfigureWorkflowActions(IModuleWorkflowActions actions) registers an IWorkflowAction under a type that starts with the module's name and a dot, such as Sample.PostItem. A type outside that prefix, one core owns or one another module added stops the host at startup with an error naming the module and the type. The action catalog lists module actions with the metadata of the class the module wrote, and a workflow can be saved with them. BarakoCMS.Abstractions is 5.1.0.
  • Entries are numbered per branch when the rule has a series template. A request with X-Branch-Id now carries the branch code, read from the Code of the Branch entry. A create on a type whose template needs a branch is refused with 400 when the branch has no code.
  • A create keeps a number the client took from a ClientAllocated series. The number must fit the series and not be held by another entry of the type. It is recorded in the external number ledger, so the gaps report counts it. Without the flag the server still replaces any number the client sends.
  • The numbering config section and two new fields on the numbering route. GET /api/config/export?sections=numbering and apply carry each type's rule (including seriesTemplate and clientAllocated) under the type name. Applying never removes a rule. PUT /api/content-types/{name}/numbering takes seriesTemplate and clientAllocated.
  • POST /api/numbering/next hands a client the next number of a ClientAllocated series. The caller needs create rights on the type. The number is committed when it is handed out, and the create keeps it when the entry carries it, so a paper form can be numbered before the server is reachable. A series without the flag, an unknown type, and a branch template with no X-Branch-Id are refused.
  • Numbering rules take a series template and a client-allocated flag. SeriesTemplate puts the branch code in the series name ({branch}.customer), so each branch counts on its own, and ClientAllocated marks a series a client may take numbers from. A client-allocated series cannot reset.
  • Members pages resolve for a signed-in viewer. GET /api/public/pages/resolve honours X-Viewer-Token. A valid token for the request's tenant also resolves Members pages, and the answer is private and not stored. A token that does not verify answers 401, as on the core public routes. The response now varies on X-Viewer-Token. IPublicContentProjector gains Project and ProjectAsync overloads taking member, and ViewerAudience exposes the token check to modules.
    The Pages HTTP contract moves from 2 to 3: a public page requested with an invalid or other-tenant viewer token now answers 401 where it answered 200.
  • Content types can declare save and transition rules. ContentTypeDefinition.Rules is a list of RuleDefinition. A rule runs on create and update (When: Save) or on a lifecycle transition (When: Transition). Its conditions must hold or the write is refused with 422 and an errors entry named rules carrying the rule's message. A passing rule can also set fields from templates. Rules can read other entries (related, count, sum, unique, member, covers), compare against $NOW and $TODAY, and call an IRuleLookup a module registers. See docs/rules.md. BarakoCMS.Abstractions gains RuleDefinition, RuleCondition, RuleWhen, IRuleEngine and IRuleLookup.
  • Workflow runs can be traced to what they created. An action reports the entries it created with CreatedRef, kept on its attempt. An entry created by a run carries Content.SourceRunId. GET /api/contents/{id}/runs lists the runs of an entry, and with includeCreated=true the runs that created it. A module can fire a trigger with a key (WorkflowRun.TriggerKey) so a retry queues one run. WorkflowDefinition.NotifyRoleOnFailure names a role to tell about failures. See docs/workflow-runs.md.
  • Four limits are settings. Workflows:ScheduleGraceDays (default 7) and Workflows:SchedulePageSize (default 200) set how long after its fire time a schedule workflow can still queue and how many entries one sweep read takes. Workflows:RunsListLimit (default 100) and Workflows:RunsCandidateLimit (default 1000) bound the runs list of an entry.
  • Scheduled triggers. A workflow can fire relative to a date field, for example 24 hours before an appointment.
  • A json template format. {{data.Lines | json}} writes a list, object, string or number as compact JSON, so a workflow can pass a list field to an action parameter that reads JSON text. Text that already holds a JSON array or object is not encoded a second time. See docs/approval-by-configuration.md.
  • Workflow action Transition moves an entry through a named lifecycle transition. It names the ContentType it moves, and a target of another type, or of another branch than the triggering entry, is refused. The move goes through IContentTransitioner, so the type's rules, required fields, validation and hooks apply and the audit row records the system actor. It runs as the system, so the transition permission is skipped. An entry already in the transition's end state counts as done. The user who saves the workflow needs the transition right on that type and name.
  • Run failure handler. A module can implement IWorkflowRunFailureHandler to act on a failed or partly failed run. It gets the run and the workflow's notifyRoleOnFailure role.

Changed

  • BarakoCMS.Testing: the test host pools its database connections (at most 20) and clears the pool when it is disposed, so a long run no longer runs out of local ports.
  • A station key can no longer hold a platform capability. POST /api/stations answers 400, naming the capability, when the request lists manage_tenants, manage_roles, manage_platform_roles, manage_users, manage_email_settings or view_hidden, the same set member role grants already refuse. Before, a caller who held one could put it on a station key. Station keys already created with one are not touched: list them with GET /api/stations, revoke them and issue new ones. Admin contract moves to 10: a create request that was accepted is now rejected.
  • Listing entries by contentType ignores case. Entries keep the type name as the caller
    spelled it, so Article and article entries of one type were two lists. GET /api/contents?contentType=article
    now returns both. The stored spelling is unchanged and is documented in docs/content-type-names.md.
  • A role's permission for a content type applies whatever the case of the entry's contentType.
    Without it, the list found an Article entry through a rule for article and then hid it.
    A role that holds two spellings of a type uses the exact one first.
  • Config apply and export carry a content type's uniqueness rules, and a test now says so. A
    new type in a config document is created with its rules, checked the way POST /api/content-types
    checks them. A document without uniqueness keeps a stored type's rules, and one with different
    rules is pointed at PUT /api/content-types/{name}/uniqueness. The behaviour was already there
    through the shared upsert; the docs and tests did not cover config.
  • The private site check is cached per tenant. An anonymous /api/public/* request in a tenant
    that is not in Private mode no longer reads the content table to find out. The answer is cleared
    on the node in the same commit that writes, deletes or publishes a site entry, by any path
    (endpoints, config apply, seed files), so a tenant switched to Private refuses the next request.
    Another node learns within Delivery:PrivateSite:CacheSeconds (default 5, 0 reads every
    request). No route, field or status code changes, and neither contract number moves.
  • CI runs the backend tests as four parallel shards. The required check is still Backend (.NET);
    it now waits for the shards and the script gates, and fails if any of them failed, was cancelled or
    was skipped. Classes are balanced by recorded durations (scripts/test-class-durations.json).
  • Semantic search fails loudly on mixed embedding sizes, and the database image carries pgvector. GET /api/public/{type}/semantic answers 409 with a message to reindex when a stored vector and the query vector have different dimensions, where it used to score them 0 and return nothing. Vectors.Cosine throws VectorDimensionMismatchException for the same case. The Postgres image in the compose files, the Kubernetes manifest and the test fixture is now pgvector/pgvector:pg16 (Debian based, so an existing Kubernetes volume may need its owner set to uid 999). Nothing uses the extension yet. The AI module's HttpContractVersion moves from 1 to 2, since the semantic route can now answer 409 where it answered 200.
  • Retention sweeps share one primitive. Workflow run retention, webhook delivery retention and the expiry pass in token cleanup now declare what they keep and for how long, and one sweeper does the advisory lock, the tenant walk, the delete and the log. Windows, config keys and per-tenant behaviour are unchanged. Webhook delivery retention now takes an advisory lock too, so two nodes no longer sweep the same rows. Partitioning the run and delivery tables is not part of this change.
  • The managed platform guide says how uploads work on Azure. PostgresFileStorage is durable
    across restarts and scale-out, so App Service needs nothing extra for uploads. Azure Blob is not
    supported, and the guide names the S3-compatible route (Files.S3 with ServiceUrl). Docs only,
    no route or contract change.
  • The HealthChecks UI dashboard is gone from the image, and the image runs on aspnet:10.0-noble-chiseled-extra. AspNetCore.HealthChecks.UI, .UI.Client and .UI.InMemory.Storage are no longer referenced, which also removes Entity Framework Core and the Roslyn assemblies they pulled in. /health, /health/live and /health/ready answer as before. /health-ui and /health-ui-api are not served any more. A deployment with HealthChecksUI:Enabled set logs one startup warning naming the setting; to get a dashboard back, host AspNetCore.HealthChecks.UI in your own application and point it at /health. The final image has no shell, so a container healthcheck that runs curl or sh inside it must move to a probe from outside (an HTTP probe in Kubernetes, or the database container's own check in compose). Admin contract is already at 10 for this release.
  • ITokenIssuer, TokenIssueResult, IMfaService, IContentValidatorService and IContentLifecycleRunner moved into BarakoCMS.Abstractions. Namespaces are unchanged and barakoCMS forwards each type, so a module can use them without referencing the core. IPermissionResolver stays in the core for now, because its signatures name ReadPredicate and FieldSet. Part of #972.
  • TenantContext, ContentTypeName, ContentTypeFieldLimit, AuditLog and AuditChain moved into BarakoCMS.Abstractions. Namespaces are unchanged and barakoCMS forwards each type, so a module built against 5.0.x loads as before. A module can use them without referencing the core. CapabilityGate and RequiredCapability were already there. BarakoCMS.Abstractions 5.2.0.
  • Behavior change: the branch code lookup (Envelope:BranchContentType) and the content type of a CreateContent target match the type name ignoring case, as every other lookup by type name does.
  • Four modules would have been skipped at publish time. BarakoCMS.Abstractions is 5.2.0, BarakoCMS.Email.Smtp is 4.5.0, BarakoCMS.ExternalAuth is 4.8.0 and BarakoCMS.Templates is 5.2.0, so the release publishes the changes each one gained since its last version.

Removed

  • The Resend email module moved to the private feed. BarakoCMS.Email.Resend is no longer built from this repository, and the core and the Suite no longer read Resend:ApiKey, Resend:From or RESEND_API_KEY. This breaks a host that set any of them. Move to Email:ApiKey, Email:From or EMAIL_API_KEY, or install the private Resend module from the BarakoCMS.Pro feed and set Email:ApiKey (or the key in the admin email settings) for it, since it takes its key from the core settings and does not read Resend:*. Or switch to BarakoCMS.Email.Smtp. The stored email settings are unchanged. The POST /api/webhooks/resend and GET /api/email-events routes belonged to that module and are gone from the Suite. ApiContract.Version moves to 11 for the two removed routes; the Settings, Email request and response are unchanged. The settings test send no longer needs an API key, so it works with SMTP and no longer returns 400 for a missing one. A host that still sets Resend:ApiKey or RESEND_API_KEY gets a startup warning when email falls back to the mock (#1216).

Fixed

  • Cash flow no longer counts a transfer between cash accounts as a receipt and a disbursement.
    ReportingService.CashFlowAsync now nets each entry's cash lines and classifies that net, so a
    deposit from cash on hand to the bank adds to neither side. Detail lines still show both legs and
    closing is unchanged. Accounting 4.5.0.
  • A workflow schedule armed by a host with a later clock no longer stops the sweep. The entry is
    skipped until the sweep clock reaches it, and other due entries still fire.
  • Two scheduler instances no longer fire one entry twice. The sweep claims a due entry with one
    conditional update that wins only when a single row changes.
  • Validating a schedule-triggered workflow reads its schedule fields. schedule, scheduleTimeZone
    and scheduleCatchUp are now accepted by the validate endpoint, so a bad cron expression or time
    zone is reported there instead of passing.
  • A token refresh no longer uses up the sign-in limit. Refresh has its own limit, RateLimiting:Refresh (60 per 15 minutes per IP by default), so reloading the console does not lock out sign-in. Login, OTP and MFA keep RateLimiting:Auth, which no longer governs refresh. A refresh with no cookie is still counted. refresh is now a built-in policy name, so a custom policy already named refresh fails startup: rename it.
  • A connector send no longer buffers the whole response. The body is read only as far as the
    delivery row keeps it, within 30 seconds of the headers, so a provider that never stops sending or
    stops mid-body cannot hold the run.
  • A sweep that lost its advisory lock could repeat its work, and a held schema lock hung startup.
    The scheduled-content and workflow run retention sweeps now check each item again inside the write,
    so a second instance that took over finds the transition already done. The schema apply lock polls
    with a timeout (5 minutes, Schema:ApplyLockTimeoutSeconds) and fails naming the lock key and the
    holder's pid. docs/deploy-in-production.md says session locks need a direct or session-pooled
    connection.
  • A sweep that lost its advisory lock kept sweeping. The scheduled-content sweep and the
    retention sweeps (workflow runs and webhook deliveries) now ask Postgres whether they still own
    their lock before each tenant and stop if not, reporting the lock as not held. The schema apply
    lock, the scheduled-content sweep and the retention sweeps share one lease that serialises use of
    the lock connection and closes it on any failure. It also looks for a transaction-mode pooler (two
    statements on different backends), best effort, since a quiet pooler can hand both to one backend.
    A suspected pooler logs an error once per process. Leadership:RefuseBehindPooler (default false,
    so nothing changes) makes the sweeps decline and the schema lock refuse to start.
  • A batch create now sends the realtime notice. The notice looks the entry up after the commit, but a batch commits once at the end, so the lookup ran before the entry was visible and nothing was sent. The notices of a batch now go out after its transaction commits, and none go out for a batch that rolls back.
  • A failed batch step names itself and gives its reason. The error for a failing step was steps[n] as the message with no name, so the rule or hook message was lost. It now has the name steps[n] and the reason as its message.
  • A branch scoped type is now enforced on the content routes, not only on the change feed and realtime. A caller who holds branch A could read, list, update, move, schedule and batch-write entries stamped branch B unless an admin had written a $CURRENT_BRANCH rule. Now GET /api/contents/{id}, by-slug, history, rollback, runs, schedule, update, status change, erase and the batch transition answer 404 for an entry of a branch the caller does not hold. The list and counts leave those entries out, and paging and totals follow. One service, IBranchScopeGuard, holds the rule for the feed and the routes. Types that are not branch scoped are unchanged. See event-envelope.md.
  • A client-allocated number could be kept under two spellings. Only the form the series formats is kept now (P-0001, not P-00001), so one number has one spelling. The already-issued check reads the same ledger key the number is recorded under, so it also catches a number used twice in one batch or import, and a branch series refuses a write that has no branch.
  • Two branches can each issue their own client-allocated number. The "That number is already issued." check looked at every entry of the type, so a second branch with its own {branch} series was refused P-0001 once the first had taken it. For a series with {branch} the check now looks at the entries of the request branch only.
  • A content type name is matched ignoring case. Config apply and POST /api/content-types store the name lowercase while a module keeps the case it seeds, so triggerContentTypes: ["Order"], schedule.contentType, workflow triggers, transitions, listing and the other definition lookups failed on a case difference. Permission lookups prefer the exact spelling and never match a different name. A name matching two stored types that differ only by case, with none exact, is refused (400 on create and list, no grant in permissions).
  • A workflow CreateContent now numbers a {branch} series. The action set the branch without its code, so a type with a branch number series refused every entry with "The numbering needs a branch with a code." The code is now looked up the way an HTTP request resolves it.
  • A signed-out request with X-Branch-Id was refused with 403. It is now served, and no branch is stamped on it. No branch is stamped on it.
  • Scheduled workflow runs get an id made from their trigger key, so two sweeps that see one value at once queue one run.
  • ContentCreator takes its rule engine as the last, optional argument again, so code that builds it directly with the earlier arguments keeps compiling.
  • The changed rule kind fires on list and object fields. It compared the type name of a list or object, so an edited list read as unchanged or an untouched one as changed. A list or object is now compared as canonical JSON: list order counts, object key order does not, numbers compare as exact decimals. String fields behave as before.
  • Setting a numbering rule with a {branch} template answered 500 after saving. The example number is now built with a placeholder branch.
  • BarakoTestHost pools its database connections. Its connection string had Pooling=false, so every query opened a socket, and a run of about 35 hosts left near 13,000 in TIME_WAIT and failed with "Can't assign requested address". It now pools with Maximum Pool Size=20, and clears the pool when the host is disposed, before its database is removed.
  • A workflow can fill a json field from a template in Transition and CreateTask, and a json field only takes a whole list or object. Data.<Field> values such as {{data.BilledLines}} reached a json, object or array field as text, so a Transition failed with "expects type 'json' but received 'string'" and CreateTask stored the text. Now, for Transition, CreateTask, UpdateField and CreateContent, a json, object or array field is filled only when the parameter is exactly one {{data.X}} or {{data.X | json}} placeholder whose field holds a list or an object. A template that mixes JSON with a placeholder, such as {"note":"{{data.Name}}"}, or a text field that holds JSON, is refused with a permanent failure that names the field, so a typed value cannot add keys.
  • A typed dictionary in a content data bag read back as an array. A hook that stored a Dictionary<string, decimal> (or any string-keyed dictionary other than Dictionary<string, object>) got a list of key and value pairs back. Every string-keyed dictionary is now written as a JSON object, and decimals stay exact.
  • One save starts a conditioned workflow once. The workflow daemon matched each Created and Updated event against the entry as stored when it read the event, not as that event wrote it. When a workflow's UpdateField was followed by a person's save before the daemon caught up, both events read the person's data, so a workflow conditioned on it was queued twice for one save. Each event is now matched against the data it wrote.
  • A workflow applied by config keeps notifyRoleOnFailure. Config apply dropped it, so a failure handler never saw the role. It is now read on apply, written on export, and a change to it shows in the plan.
  • A workflow can fill a json field from a template. UpdateField and CreateContent stored the text of {{data.X | json}} as a string in a field that holds a list or an object. For a json, object or array field they now store the parsed list or object, and fail the action when the text is not a JSON array or object. A whole list or object in a placeholder with no format is now its JSON, not its type name.

Security

  • A client could keep a number the server never handed out. A client-allocated number is kept only when the next-number route has handed it out, and a numbering refusal on batch, import, site settings or a CreateContent action is a 400 or a permanent failure, not a 500.
  • A client-allocated series could use hiLo. It cannot now, because a hiLo counter cannot say which numbers it handed out. Such a rule is refused when it is saved.
  • The next-number route resolves the content type (ignoring case, refusing a name two stored types share) before it checks the caller's permission, and checks it on the stored name.
  • A saved query returns only the rows of the type it names when two stored types differ only by case.
  • A branch code that cannot be part of a series name, or a template name over 64 characters, is a 400 (or a permanent action failure) instead of a 500, and the name is no longer cut short.
  • A client number taken by two creates at once is kept once; the other create gets a 409 instead of a 500.
  • PUT and DELETE on a workflow a module owns are refused with a 409, as config apply already does.
  • The runs list of an entry (includeCreated=true) shows a run of another entry only through the actions that name this entry, without their error text, and lists a created entry only when the caller may read it.
  • A CreateContent action reports an existing entry under its id as done only when it has the same type and branch and was made by the same run. Otherwise it fails for good.
  • The Transition action could move entries of another branch. It now moves only entries of the trigger entry's own branch. A trigger entry with no branch no longer moves entries that have one.