barakoCMS

Keep a Changelog, semantic within a major

Every break, with the reason it was worth it

4.6.0

2026-10-04

Minor

Contributor

Breaking

A manual collection sync run that overlaps another run of the same collection can now answer 409 (#1008).

POST /api/collection-syncs/{slug}/run waits up to five seconds for a run that is filling the same content type, then answers 409 with Retry-After and runs nothing. Some of those overlaps used to complete with 200: a run beside a different sync of the same content type, and a run beside the same sync when every entry already existed. A caller that treats any answer but 200 as a failure should retry on 409. This is a status code change, so it shares the move of ApiContract.Version to 6 with the validation rules change. It is not a second move.

Breaking

Giving a connector a token URL, or changing it, now needs its client secret again.

PUT /api/connectors/{slug} answers 400 when settings.TokenUrl is new or differs at all from the stored one (the whole URL, path included), a ClientSecret is stored and the request neither enters it again nor clears it. It used to be accepted, when the setting meant nothing. The reason is the one baseUrl already has a rule for: the editor cannot read the stored secret, and it would go to the new address. The refusal is part of contract 6, so ApiContract.Version does not move again (#574).

Breaking

onFailure on a workflow action is now read, so a value it cannot take is refused.

The field did not exist before and anything sent under that name was ignored. POST /api/workflows, POST /api/workflows/validate and POST /api/workflows/dry-run now answer 400 to an onFailure that is a string other than Continue or Halt. POST /api/workflows also answers 400 to a number that names neither, and to an onFailure on an action inside a Conditional's ThenActions or ElseActions; validate reports both as errors. Left out, null, Continue and Halt are accepted. Part of contract 6, the move #927 made and no release has carried, so ApiContract.Version does not change again (#576).

Breaking

currency and scale on a field are now read, so a request that carried them by accident is checked.

Creating a content type, adding a field, or importing a type whose field JSON held either member used to be accepted with the member dropped. It is now a 400 when the field is not money, when currency is not three capital letters or is a code the built-in list does not hold and no scale comes with it, when scale is outside 0 to 8, or when scale comes with no currency. No type stored before this release holds either member, so no stored type and no entry is affected. A bundle import is also a 400 when it carries a different currency or scale for a stored field, or none where the stored field declares one: a bundle exported before a currency was declared no longer imports onto that type until the currency is cleared, and on a field the target already stores a declaration is made with PUT /api/content-types/{name}/fields/{field}/currency, not carried in by a bundle. That state cannot exist before this release either. This is part of API contract 6, with the other changes in this release, and ApiContract.Version does not move again (#581).

Breaking

requiredFields and optionalFields on a transition are now read, so a request that carried them by accident is checked.

Creating a content type, or importing one, whose transition JSON held either member used to be accepted with the member dropped. It is now a 400 when the member is not a list of strings, or names a field the type does not declare, a blank name, or one name twice across the two lists. An import that leaves out a field a stored transition names, or that changes what a stored transition requires, is also a 400 where it used to be accepted. No type stored before this release holds either list. This is part of API contract 6, with the other changes in this release, and ApiContract.Version does not move again (#809).

Breaking

Delivery takes an entry's slug only from a field it serves.

The slug was read from a field of type slug, else from any field named Slug, whatever its type or sensitivity, and served to anonymous callers as the top-level slug, in feed and sitemap links, the change stream, share links, Pages and the AI index. A field picked by its name alone is now the slug only when it is a Public string or text field. A type whose only slug-named field is Sensitive, Hidden, a token or of another type now has no slug: GET /api/public/{type}/{slug} answers 404 where it answered 200, the list and search carry slug: null, its entries leave the sitemap, a collection push to it is refused for having no slug field, and its slug uniqueness check no longer runs. A field of type slug is read as before. Declare the field Public, or give it type slug, to keep the routes. This is part of API contract 6, with the other changes in this release, and ApiContract.Version does not move again (#812).

Breaking

A filter[...] parameter the API cannot apply is now refused where it used to be ignored (#825).

GET /api/public/{type}/search, GET /api/public/{type}/semantic and GET /api/contents did not read filter[field][op]=value and answered 200 whatever it held. They now apply it, and answer 400 for a field the caller cannot read or the type does not declare, an unknown operator, a malformed key and a sixth filter. GET /api/contents also answers 400 for a filter without contentType. On all four routes that take filters, GET /api/public/{type} included, a filter value longer than 256 characters is 400, where the list used to accept any length. A caller that sent these parameters and relied on them being ignored should drop them. This tightens request validation, so it shares the move of ApiContract.Version to 6 with the validation rules change. It is not a second move.

Breaking

A permission condition key holding a dot is now a reference path, checked when the role is saved (#827).

POST /api/roles and PUT /api/roles/{id} accepted any condition key. A key holding a dot is now answered 400 unless it is Reference.Field: the first name a reference field of the rule's content type, the second a Public field of the type it points at, with at least one of _eq, _ne, _in and _nin compared against text, on a rule other than Create, for a content type the tenant defines. A key with no dot is saved as before, and an update passes over a dotted condition the stored role already holds unchanged. A stored rule whose key holds a dot also changes meaning: it used to match a row whose own data held a key spelled exactly that, and now it follows the reference or denies. An entry write keeps keys its type does not declare, so the old match let whoever wrote a row satisfy the rule. At start the API logs one warning naming the roles, by name and id, that store a dotted condition following a reference in no tenant. This tightens request validation, so it is part of API contract 6 with the other changes in this release. ApiContract.Version does not move again.

Breaking

Who may read a Sensitive or Hidden value is decided by capability and role id, not by the role names HR and SuperAdmin (#883).

A Sensitive field or entry with no role list of its own is read by a role holding view_sensitive, a Hidden one by a role holding view_hidden, and the seeded SuperAdmin role, by its id, reads everything. The roles are the caller's stored roles in the current tenant, read on each request, so the names in a token decide nothing and a capability taken off a role applies on the next request. A field's visibleToRoles is stored as role ids, so renaming a role no longer changes who reads the field. It is still names on the wire: the content type endpoints, a blueprint and the Portability import accept names, and GET /api/content-types, the sensitivity endpoint and an export answer with them. A name no role carries is kept and still matches a role of exactly that name. No response field or status code changes, but what a response masks for a caller can, so this ships with contract 6 and ApiContract.Version does not move again. Upgrading: stop the API, run migrations/4.6.0/sensitivity-by-capability.sql, then start 4.6.0. The file gives the seeded HR role (id ...0003, still named HR) view_sensitive and rewrites stored names to ids, and the seeder grants the same capability to the same role on every start. A role named HR under another id is not granted, and the file says so in a notice. An earlier release serving a migrated database masks every listed field for the roles on its list until 4.6.0 is up. A role carrying view_hidden is assigned only by a SuperAdmin, like a role carrying manage_roles. Three callers see a difference: a role holding * now reads Sensitive and Hidden values, because * satisfies every capability; a seeded role renamed to SuperAdmin no longer bypasses masking, because the bypass is the seeded SuperAdmin role's id; and an account holding no role no longer matches a list naming User through the token's fallback claim.

Breaking

The tenant API no longer reads or writes a tenant's public profile (#885).

A tenant carried a fixed profile made for one kind of site, and the site entry described the same site a second time. The profile now lives in the tenant's site entry: logoUrl is its Logo field, and about, location, locationUrl, socialHandle, email and contactUrl are the fields About, Location, LocationUrl, SocialHandle, Email and ContactUrl. On the admin surface:

  • GET /api/tenants, and the answers to POST /api/tenants and PUT /api/tenants/{handle}, no longer carry those seven fields.
  • Those two writes answer 400 to a request that sets any of the seven to a value, naming the site field to set instead, where they used to store it.
  • That check runs before the handle is looked up, so PUT /api/tenants/{handle} with a profile value on a handle that does not exist is a 400 where it was a 404.
  • An update no longer blanks a stored profile field the request left out or sent as null. An empty string blanks it, which is how the platform removes a value still on a tenant record.
  • The rule that contactUrl and locationUrl are full http or https addresses is no longer checked on these writes, since no value is accepted. It is applied where the profile is read: GET /api/tenants/{handle}/public answers logoUrl, locationUrl and contactUrl from the site entry, and GET /api/me/tenants its logoUrl, only when the value is an absolute http or https address, and empty otherwise.

Edit the profile by updating and publishing the tenant's site entry, which anyone who may edit that type in the tenant can do, where the tenant API took a platform administrator. These changes are part of API contract 6, so ApiContract.Version does not move again. Nothing changes shape or status on the delivery surface, so ApiContract.DeliveryVersion stays at 6.

Breaking

Who reads or deletes somebody else's private file is decided by the manage_all_files capability, not by the role names Admin and SuperAdmin in the token (#886).

GET /api/files/{id}, DELETE /api/files/{id} and IFileStore.FindAsync, OpenAsync and DeleteAsync answered a caller who was not the uploader when the token carried the role name Admin or SuperAdmin. They now ask the caller's stored roles in the current tenant for manage_all_files, on each request. The seeded Admin role is granted it when the Files module seeds, and the seeded SuperAdmin role satisfies every capability, so a seeded deployment whose module seeders ran reads and deletes what it did before. With Auth:LegacyRoleFallback off, these callers see a difference: an account whose token says Admin or SuperAdmin and whose stored roles carry neither the capability nor * gets 404 on the download, and 403 on a delete that its upload_files used to be enough for. That includes every Admin on a host where the Files seed has not granted the capability: a host that registers the module and never calls RunBarakoModuleSeedersAsync, the Suite started with SKIP_SEEDER=true, and a Suite start where the Files seeder threw (the Suite logs that and carries on). Setting Auth:LegacyRoleFallback to true makes the two names open it again. A custom role holding manage_all_files or * now reaches other users' files. Because status codes change on two Files routes, FilesModule.HttpContractVersion moves from 1 to 2. Core's ApiContract.Version does not move. No migration: the seeder grants the capability on the next start.

Breaking

POST /api/settings refuses five more key names (#889).

A key that contains passwd, pwd, private_key, accesskey or access_key, in any casing, now answers 400 and is not stored, the same as a key containing apikey, api_key, password, secret, token, credential or privatekey already did. Everything in that store is kept in plaintext and returned by GET /api/settings. A setting already stored under one of the twelve words is still returned by GET /api/settings and still read by the API. Its value can no longer be changed through POST /api/settings, and it can now be cleared there: saving the key with an empty value answers 200 and empties the stored value, for a key that already has a row. An empty value for a key with no row is refused like any other. This is tightened request validation, so it is part of API contract 6 and ApiContract.Version does not move again.

Breaking

POST /api/me/switch refuses three requests it used to accept (#891).

The body gained a tenant field, and a tenant the API used to skip is now read. A body that sets tenant and club to different tenants answers 400, where it used to switch to club. A tenant that is not a string answers 400, where it used to be skipped. And only the JSON body names the target now: ?club=<handle> on the URL used to be read after the body and win, and is no longer read, so a request that named its tenant only there answers 400, and one that named a different tenant there than in its body switches to the body's. Membership is checked as before in every case. A client that sends one of tenant or club in the body, as barakoBrew and barako-client do, is not affected. These refusals are part of API contract 6, so ApiContract.Version does not move again.

Breaking

A field's validationRules are now enforced, and a rule the API cannot apply is refused.

An entry write that breaks a stored rule answers 400, and so does saving a content type with an unknown rule name, a rule on a field type it does not apply to, or a pattern that does not compile. Both used to be accepted. ApiContract.Version moves to 6.

Breaking

editor, section and role on a field and routeTemplate on a type are now read, so a request that carried them by accident is checked.

Creating a content type, adding a field, or importing a type whose JSON held one of these members used to be accepted with the member dropped. It is now a 400 when editor or role is not one of the values GET /api/meta/describe lists, or is on a field type it is not for; when two fields of a type declare the same role; when section is blank, padded with spaces, longer than 60 characters or holds a control character; and when routeTemplate is not a path that starts with /, holds {slug} once and has no empty, . or .. segment. No type stored before this release holds any of these members, so no stored type and no entry is affected. A blueprint file that carried one of them was already refused as an unknown property, and is now accepted when the value is valid. This is part of API contract 6, with the other changes in this release, and ApiContract.Version does not move again (#970).

Breaking

Adding a field to an event-sourced content type now checks the field's sensitivity.

POST /api/content-types/{name}/fields answers 400 for a Sensitive or Hidden field on an event-sourced type, the rule type creation, PUT .../fields/{field}/sensitivity and the Portability import already apply. A Public field is added as before, and so is any field on a type that is not event sourced. POST /api/content-types now compares the name with stored types and entries the way the sourcing decision is keyed (trimmed, lowered, a space as a hyphen), so a name that differs from a stored type's only by those answers 409, and a name with entries under such a spelling cannot be created as event sourced. Stored types are not changed. The demo seed records the AttendanceRecord name as document sourced with the type, and is skipped when that name was decided as event sourced, since the demo type holds a Sensitive field. These refusals are part of contract 6, so ApiContract.Version does not move again.

Added

A workflow could not be stopped without going to the database.

Three requests, all behind manage_workflows and all audited. PUT /api/workflows/{id}/enabled switches a workflow off or on: off, it starts no runs on any trigger, and the runner cancels the runs it had queued as it reaches them. DELETE /api/workflows/{id} deletes a workflow and cancels its queued runs in the same transaction, up to 200; past that it answers 409 and asks for the workflow to be switched off first. A delete and a retry of one of the workflow's runs take turns under a lock, so a retry cannot queue an attempt for a workflow that is being deleted. POST /api/workflow-runs/{id}/cancel cancels every action of a run that has not started and leaves one that is running to finish, after which nothing more of the run starts. Runs and actions gain the status Cancelled, sent as that name, which on an action means it never went out; an action that was claimed and never reported back is stopped as Unknown. A run gains cancelledAt, and a workflow gains enabled, which is true when left out and for every workflow stored before it. All additive, so ApiContract.Version does not move. Cancelled runs are kept for the failure retention window. BarakoCMS.Abstractions 4.6.0 adds WorkflowDefinition.Enabled, WorkflowRun.CancelledAt, WorkflowRun.Cancel, WorkflowRun.InFlightWhenStopped, RunStatus.Cancelled and AttemptStatus.Cancelled (#1009, #699).

Added

A module could not add middleware.

IBarakoModule gains ConfigureApp(IApplicationBuilder), with a default that does nothing, so an existing module compiles and behaves as before. UseBarakoCMS calls it for enabled modules, in module order, and what a module adds runs after tenant resolution, authentication and UseAuthorization, and before core's output cache and the endpoints. The module is handed a branch of the pipeline, not the host application, and requests under /health skip module middleware. A hook that throws stops startup with an error naming the module. UseBarakoCMS now builds the Marten store before it calls the hook, so ConfigureSchema runs before ConfigureApp and a refused module schema stops UseBarakoCMS under that module's name. MODULES.md states the position and what a module can and cannot rely on there. The member ships in BarakoCMS.Abstractions 4.6.0 and does not move ModuleContract.Version (#557).

Added

A connector with OAuth2ClientCredentials was refused at send time as not implemented.

It now works: the sender posts grant_type=client_credentials to settings.TokenUrl with settings.ClientId and the ClientSecret secret (HTTP Basic by default, the form body with "ClientAuth": "Body"), plus Scope and Audience when set, and attaches the token as a Bearer header on sends, connector tests and collection sync fetches. The token request goes through the same guarded outbound client as every other call, with a 30 second deadline of its own. A token is cached in memory per instance, keyed by tenant, connector and the connector's updatedAt, until 30 seconds before expires_in and for an hour at most, or one minute when expires_in is missing or unusable, with at most 32 tokens a tenant and 256 an instance. A 401 to a cached token at least a minute old gets one new token and one more send. A failed grant names the token endpoint's host, the status and a standard OAuth error code, never the body. No new request or response field, so ApiContract.Version does not move. See docs/connectors.md (#574).

Added

A workflow could not stop at a failed action.

Every action after a failed one still ran, which is right for notifications and wrong for a chain where the later steps assume the earlier one worked. An action now takes onFailure, Continue or Halt. Continue is the default, also when the field is sent as null, and is what every action saved before this does. With Halt, nothing after the action runs until it has succeeded: the later actions wait while it waits on a retry, and once it fails for good or ends Unknown they become Skipped with haltedBy naming the action that stopped them, and the run is finished. Retrying the halted action through POST /api/workflow-runs/{id}/actions/{ordinal}/retry queues the skipped actions again behind it, and retrying a skipped one on its own answers 409. A workflow's actions and a run's actions gain onFailure, and a run's actions gain haltedBy. A node on an older version does not know the policy, so finish a rollout before saving a workflow that uses Halt, and change such workflows back before rolling back. BarakoCMS.Abstractions 4.6.0 adds WorkflowFailurePolicy, WorkflowAction.OnFailure, WorkflowActionAttempt.OnFailure and WorkflowActionAttempt.HaltedBy (#576).

Added

A money field can declare its currency, and its amounts are then held to that currency's decimal places.

A money field takes currency, an ISO 4217 code in capitals, and an optional scale from 0 to 8 that defaults to the currency's own (2 for USD, 0 for JPY, 3 for KWD). An entry write to such a field answers 400, naming the field and never the amount, for an amount with more decimal places than the scale, for text, and for a number a decimal cannot hold. Nothing is rounded, on write or on read. The amount is still stored and returned as a plain JSON number, so filters, sorts, exports and stored entries are unchanged, and a money field with no currency behaves as before. PUT /api/content-types/{name}/fields/{field}/currency declares, changes or clears the currency of a field that already has entries: it rewrites none, and answers 409 with a count when entries hold an amount that does not fit, or when the code changes on a field that holds amounts, unless force is set. A collection sync skips an item that does not fit, and a bundle import refuses to change a stored field's currency. The writers that only have text read plain decimal text as a number for such a field: a spreadsheet import cell, a form input, and the UpdateField workflow action, which now fails instead of storing text there. GET /api/public/forms/{slug} carries currency and scale on each field. Every field in a content type response now carries currency and scale, null unless declared. See docs/money-fields.md. BarakoCMS.Abstractions 4.6.0 adds FieldDefinition.Currency and FieldDefinition.Scale, and the core adds FieldTypeRegistry.TryReadAmountText and FieldTypeRegistry.TryGetCurrency for a module that has to do the same (#581).

Added

A webhook body did not say which event sent it.

Every Webhook delivery now carries event, the trigger that fired the workflow, beside the fields it already had, so one URL behind several events can tell them apart, including a webhook inside a Conditional action. See docs/webhooks.md. BarakoCMS.Abstractions 4.6.0 adds WorkflowDefinition.TriggerEvents, WorkflowEvents.Unpublished and WorkflowValidationResult.NormalisedTriggerEvents (#663).

Added

A content type can hold a stored file in a file field, and delivery answers the file, not an id to look up.

An image on an entry used to be a url field holding a pasted /api/public/files/{id} string: nothing checked the file existed or that the editor could use it, and its alt text had to be copied into a second field. A file field stores the file's id, written with hyphens. An entry write may name a public file, or a private one the user the write is made for uploaded or administers; anything else, including an id of another tenant, a deleted file, a cached resize or text that is not an id, is a 400 in one message that does not repeat the value. A transition checks the file for its actor, not for the request running, and a write with no user (a system actor, a job) takes public files only. A write sending the field under two keys that differ only in case is refused. The value an entry already holds is not checked again, so a colleague can still save it. The UpdateField workflow action attaches public files only, and a collection sync that maps a source value onto a file field is refused when saved. Anonymous delivery (the list, slug, search, ?include= and share link routes, and the Pages module's resolve route) answers a public file as id, url, fileName, contentType, size, alt and caption, and leaves the field out for any other file, so a private file's name and size never reach an anonymous reader. The event stream and webhook payloads leave every file field out. GET /api/contents and GET /api/contents/{id} keep the id in data and add a files member with the files the caller may download. A response reads the file store once per 500 ids. A file named by a file field counts as used, so DELETE /api/files/{id} answers 409 until forced, and an entry naming a deleted file still reads. A bundle import where the named files are missing is refused as a whole: restore the files first. A host without BarakoCMS.Files starts as before and refuses a new file in such a field. The image editor hint is accepted on a file field. Existing string and url fields are not converted. Image width and height are not answered yet. Every change is an added field or type, so neither ApiContract.Version nor ApiContract.DeliveryVersion moves. See docs/file-fields.md. BarakoCMS.Abstractions 4.6.0 adds IFileStore.FindPublicManyAsync and IFileStore.FindManyAsync, each with a default that asks the single read once per id, StoredFileInfo.PublicUrl, Alt and Caption, and IPublicContentProjector.ProjectAsync, whose default answers what Project does; IPublicContentProjector.Project now leaves file fields out. The core adds overloads of IContentValidatorService.ValidateAsync and ValidateFieldsAsync taking the caller, with defaults that ignore it. BarakoCMS.Files implements the new store members and BarakoCMS.Pages resolves the page it serves (#668).

Added

A request sent through a connector left nothing behind but the run's pass or fail.

Every send a workflow's Request action makes now leaves a delivery row, as a webhook does, and GET /api/connector-deliveries lists them, paged, filtered by connector, requestSlug, workflowId, runId and status, behind view_workflow_runs. One row is one attempt at the action: a send repeated after a 401 with a new OAuth token is one row with requestsSent 2, a request refused before it went out is a row with requestsSent 0 and the reason, and a token request has no row. The request body is not stored and the URL is cut to scheme, host and port. A request header keeps its value only when it is what the request definition composed, its name does not read as a credential and it quotes no redacted value, so the header the connector's credential went out in reads [redacted]. The response body is the first 4096 bytes with those values, the bare token, both halves of a Basic pair, and the credential-named query parameters and request body fields taken out. Reading responseBody or requestHeaders needs view_webhook_response_bodies. The rows are WebhookDelivery documents, so Webhooks:DeliveryLogRetentionDays and Webhooks:ResponseBodyRetentionHours apply to them and there is no new table or index. GET /api/webhook-deliveries lists webhooks only, as before. A row that cannot be written within five seconds is logged and does not change the send's result. BarakoCMS.Abstractions 4.6.0 adds ConnectorId, ConnectorSlug, RequestSlug, Method and RequestsSent to WebhookDelivery, all null on a webhook row. A new route and no changed field, so ApiContract.Version does not move. See docs/connectors.md (#671).

Added

A stored event records the request that wrote it (#691).

Every event now carries the correlation id of its request, the value the response returns in X-Correlation-ID, and the W3C traceparent of the span that wrote it, in Marten's correlation_id and causation_id columns. A request that sends traceparent and no X-Correlation-ID gets its own trace id as the correlation id. A workflow run copies both from the event that triggered it, events its actions write carry the same correlation id, and GET /api/workflow-runs returns it as correlationId. Both are null on anything no request caused and on everything stored before this release. BarakoCMS.Abstractions 4.6.0 adds WorkflowRun.CorrelationId and WorkflowRun.TraceParent.

Added

OpenTelemetry tracing, off until an OTLP endpoint is set (#691).

With Tracing:Otlp:Endpoint configured the API exports a span for each request, continuing the caller's traceparent, one for each outbound HTTP call, and one for each workflow action, which sits under the request that caused its run. Unset, no tracer is registered and no connection is opened. Export is batched on a background thread with a bounded queue and a timeout, so a collector that is down costs spans and not requests. The request path, the query string and the URL of an outbound call are not exported. docs/tracing.md lists every setting and every attribute a span carries.

Added

Workflows:RunnerConcurrency sets how many workflow actions a node runs at once.

A node ran queued actions strictly one at a time, so at two seconds per webhook it managed half an action a second whatever its size. A pass now claims up to this many attempts, runs them together and waits for all of them. The default is 1, which is the behaviour before the setting existed, and the range is 1 to 20: a value outside it stops the API from starting. Actions of one run still run in order, one at a time. The slots of a pass are handed out a round of the tenants at a time, so one tenant's backlog does not take them all. The worst case against a provider is the setting times the number of nodes. No schema change and nothing on the HTTP surface (#694).

Added

WorkflowRun.NextDueAt says when a run can next be claimed.

Recompute sets it from the run's attempts: WorkflowRun.DueAtOnce when an attempt can be claimed now, the end of the wait or lease when not, and null once the run has finished. A run stored before this has no value, and the runner reads that as due. Additive, on BarakoCMS.Abstractions 4.6.0. Not on the HTTP surface (#695).

Added

The workflow runner published no metrics, so whether workflows were running could only be asked of the database.

/metrics now carries barakocms_workflow_runs_queued_total (by trigger), barakocms_workflow_attempts_claimed_total, barakocms_workflow_attempts_total (by action and outcome), barakocms_workflow_action_duration_seconds (by action), barakocms_workflow_runs_finished_total (by status), barakocms_workflow_runs_halted_total, and four gauges: barakocms_workflow_runner_last_pass_timestamp_seconds, barakocms_workflow_due_runs, barakocms_workflow_oldest_due_run_age_seconds and barakocms_workflow_backlog_measured_timestamp_seconds. Every label value is one of a fixed set or the type of a registered action, never a tenant, a workflow, a run, a content type or an error. The three backlog gauges are measured by the runner between passes with one count per tenant partition, for at most 10 seconds, and a measurement that fails leaves the last values in place and does not stop the runner. This is on by default and adds reads an upgraded deployment did not make before: about one more sweep of the partitions every 30 seconds. Workflows:BacklogIntervalSeconds sets the interval (0 to 3600, default 30), and 0 switches the measurement off. docs/workflow-runs.md lists the labels, the most series each metric can create, and alert expressions written per node (#696).

Added

BarakoCMS.Files.FileKeys is public.

It names the two prefixes (PublicPrefix, PrivatePrefix), picks one for a visibility (Prefix), and says whether a key could land under the wrong one (Contradicts), for a host that writes its own IFileStorage. New in BarakoCMS.Files 4.4.0 (#779).

Added

A small image can be kept inside an entry, as an opt-in field type.

A content type could only point at an image by URL. A field of the new type inlineimage holds { "url": "data:image/png;base64,...", "alt": "..." }. An entry write accepts a PNG, JPEG, GIF or WebP of at most 64 KB and 2048 by 2048 pixels: the url's length is checked before anything is decoded, the payload must be plain base64, the bytes must carry the signature of the declared type, and the size is read from the image header. SVG is refused. A refusal is a 400 naming the field and the limits, not the value. A value the entry already holds is not checked again. Delivery returns the object as stored and leaves out a stored value that is not an allowed data URI, and the delivery OpenAPI document describes it. It is not a filter or sort target, and it is left out of search text. The UpdateField and CreateTask workflow actions fail on it, and a collection sync mapping onto it is refused when saved. No existing field changes: a url or string field with the image editor still holds a URL. GET /api/meta/describe lists the type. Additive, so ApiContract.Version and ApiContract.DeliveryVersion stay at 6. See docs/inline-image-fields.md (#782).

Added

ExternalAuth signs in through any OpenID Connect provider, by configuration.

Each sign-in provider used to be its own pair of endpoints with its own URLs written into the module. A provider under Oidc:Providers:{name} (Authority, ClientId, ClientSecret) now gets GET /api/auth/oidc/{name}/start and /callback, with its endpoints and keys read from the issuer's discovery document. The flow uses state, nonce and PKCE, and validates the id token's signature, issuer, audience and lifetime. GET /api/auth/providers gains an oidc array naming the providers that are on; the existing fields are unchanged. A provider account is tied to a user by issuer and subject, and by email only on its first sign-in and only when the provider says the address is verified. Microsoft Entra ID works as configuration, including the multi-directory issuer template; the module README has the settings. Apple is not supported. An upgraded database needs migrations/4.6.0/external-auth-identities.sql, which creates the empty mt_doc_external_identities table. Ships as BarakoCMS.ExternalAuth 4.4.0 (#786).

Added

A workflow email can carry public stored files.

The Email action takes an optional Attachments parameter: file ids or links to the download routes, usually a placeholder for a field of the entry ({{data.Programme}}), and a field holding a list attaches every file in it. A file is attached only when the entry the workflow is running for names it, it is stored in that entry's tenant, and it is public. A private file cannot be attached by a workflow yet; that waits for a file field that ties a file to its entry (#668). Anything that cannot be attached fails the action with the reason on the run, and nothing is sent. Workflows:Email:Attachments:MaxCount (5), MaxFileBytes (10 MB) and MaxTotalBytes (15 MB) cap what one email carries, and passing one fails the action naming the key. BarakoCMS.Email.Smtp 4.1.0 and BarakoCMS.Email.Resend 4.4.0 send them. BarakoCMS.Abstractions 4.6.0 adds IFileStore and StoredFileInfo (read a public file without referencing BarakoCMS.Files, which implements it), EmailAttachment, and two IEmailService members that take attachments. Their default throws NotSupportedException when there is an attachment, so an existing provider still compiles and such an email fails instead of going out without its files. GET /api/workflows/actions lists Attachments under the Email action's optional parameters, and the dry run shows that parameter as written. A stored workflow that already had an Attachments parameter on an Email action, ignored until now, starts attaching or failing after the upgrade. See docs/configuring-email.md (#806).

Added

A transition can require fields, such as a reason to reject.

A transition on a type's lifecycle takes requiredFields and optionalFields, each a list of the type's field names. PUT /api/contents/{id}/status takes their values in data beside transition, checks them the way an update does (field sensitivity, the type's validation, before-save hooks) and stores them with the move in one commit. A required field with no value in data is a 400 naming the field, and nothing changes; a value already on the entry does not count. data may carry only the fields the transition declares, and sending them needs the transition permission, not update. The permission checks still run first. A type whose transition names a field it does not declare is refused when saved or imported, and a stored one is skipped for that name and logged. Both lists and data are optional, and a transition that declares neither list answers as before. See docs/approval-by-configuration.md. BarakoCMS.Abstractions 4.6.0 adds StateTransition.RequiredFields and StateTransition.OptionalFields, and the core adds IContentTypeValidatorService.ValidateLifecycle(lifecycle, fields) with a default (#809).

Added

A form can verify an email field with a one-time code before it takes a submission.

PUT /api/forms/{contentType} takes verifyEmailField, the name of an email field a visitor can fill in. For such a form POST /api/public/forms/{slug}/email-code emails a six digit code to an address, and POST /api/public/forms/{slug} takes the submission only with that code in emailVerificationCode; anything else is a 400 named emailVerificationCode. A code is stored as a BCrypt hash, works for 10 minutes and for one accepted submission, dies after 5 checks, and is bound to the tenant, the form and the address it was sent to. Both routes take that address only as one bare mailbox of at most 254 characters, so a display name or a trailing dot is a 400. Sending is limited per client IP (5 per 10 minutes), per address (5 per hour) and per form (100 per hour), each answered with 429. The limits are settings under Modules:Forms:EmailVerification, and one set below 1 stops the host at startup naming it. An accepted submission adds a form.email.verified audit event naming the entry. The setting survives the form being turned off and on by a client that does not send it. A form that does not set verifyEmailField behaves as before, so ApiContract.Version does not move. The form definition and GET /api/forms gain verifyEmailField. An existing database needs migrations/4.6.0/forms-email-verification.sql for the two new tables, with rollback-forms-email-verification.sql beside it. BarakoCMS.Forms is 4.4.0. See its README (#811).

Added

A token field holds a random value the server generates when an entry is created, and no caller can set or change it.

For a claim stub, an unsubscribe link or a lookup code. The value is 32 characters by default (tokenLength, 16 to 128) picked by RandomNumberGenerator from the digits and lower case letters without i, l, o and u, five bits each; uniqueness rests on that randomness (at 16 characters and a million entries the chance of any two matching is about 4 in 10^13) and is not looked up. A value sent for the field is discarded and the stored one kept, on create, update, bulk import, bundle import, collection push, rollback and a transition, for every caller; the UpdateField workflow action fails instead. An entry written before its type had the field gets a token the next time its data is saved. The field is Hidden unless declared Sensitive and can never be Public, so it is left out of everything under /api/public/, feeds, webhook bodies and public forms, and read on the authoring API only by the roles that read a field of that level. The entries list search never matches it, a bundle export leaves it out, and a type cannot make it required, give it a default or a rule, name it in a transition, call it Slug, or add it under a name entries already hold a value under. A stored value that is not text of the alphabet, 16 to 128 characters, is replaced on the entry's next save. It is accepted by POST /api/content-types, POST /api/content-types/{name}/fields, a blueprint file and a bundle import, and listed by GET /api/meta/describe. Every field in a content type response now carries tokenLength, null unless declared. See docs/token-fields.md. BarakoCMS.Abstractions 4.6.0 adds FieldDefinition.TokenLength, and the core adds FieldTypeRegistry.IsServerGenerated and FieldTypeRegistry.ApplyTypeDefaults for a module that stores definitions or exports entries (#812).

Added

The entries list and both searches could not be narrowed by a field (#825, #930).

GET /api/contents (with contentType), GET /api/public/{type}/search and GET /api/public/{type}/semantic now take the delivery list's filter[field][op]=value parameters, with the same operators and the same cap of five filters. On both searches the filter runs in the query, ahead of the scan cap and limit, and only fields the type marks Public are accepted. On the entries list a filter is accepted on a field the caller reads unmasked, and a filtered list leaves out an entry whose document sensitivity withholds its data from the caller. A field name is checked against the type's declared fields and a value is a bound parameter. No index serves a field filter, so a filtered request compares every entry of the type. The parameters are optional and a request without them answers as before. BarakoCMS.Abstractions 4.6.0 adds IPublicContentFilterParser and IPublicContentFilter, so a module's anonymous route applies the same filters, and ISensitivityService.MaySeeFieldAsync and MaySeeDocumentAsync, both with a default that answers for Public only. BarakoCMS.AI now needs a core that registers IPublicContentFilterParser.

Added

A row-level condition can follow a reference (#827).

A condition key written Reference.Field, such as Class.InstructorUser, reads the field off the entry the row's reference field points at, so an instructor reads only the enrollments of the classes they teach without the instructor's id being copied onto each enrollment. It works on Read, Update and transition rules with _eq, _ne, _in and _nin against text. GET /api/contents with a contentType filters, pages and counts such a rule in the database. The condition denies unless the first name is a declared reference field holding the id of an entry in the same tenant, of the declared type, with Public document sensitivity, that the caller may read under their own rules, and the second name is a Public field that entry holds. One reference is followed, not two. A pass over many rows resolves a condition once to at most 1,000 referenced ids. Past that a list of a named type is filtered by a subquery where the database can answer the whole condition, a single entry is still read, and any other pass over many rows answers 403 with a reason naming the condition and the bound: it does not return a short list. See docs/access-control.md.

Added

A workflow message could not show a time, an amount, a duration, or address the person who did the action.

A placeholder in a workflow action parameter now takes a format, {{createdAt | date "MMM d, h:mm tt"}}, {{data.Amount | money}}, {{data.Name | upper}} and lower, and two durations, {{duration createdAt transition.at}} (8 hours 30 minutes) and {{hours createdAt transition.at}} (8.5). {{createdBy.name}} and {{createdBy.email}} name whoever created the entry, and on a transition trigger {{transition.name}}, {{transition.at}}, {{transition.by.name}} and {{transition.by.email}} name the transition that fired and who made it, read from the event and not from the entry. In a registered tenant a user is named only while an active member of it. Dates are shown in the TimeZone of the tenant's published site entry (a new field in the site blueprint, UTC when unset) unless the format names a zone, money puts the site's Currency in front, and every format uses the invariant culture on any server. Each value is encoded for where it lands exactly as a field value is. Saving or validating a workflow lists the placeholders it will send as written in a new warnings field (WorkflowValidationResult.Warnings), which never refuses the save, counts and does not quote a credential parameter, and says when an UpdateField or CreateTask would write a user's address into an entry; GET /api/workflows/variables lists the new names and a new formats list (TemplateVariableCollection.Formats). Added fields only, so ApiContract.Version does not move. See "Placeholders" in docs/approval-by-configuration.md (#828).

Added

A content type can say which values only one entry may hold at a time, such as one open time entry per teacher.

Nothing stopped a teacher clicking Time in twice and holding two open entries, and a workflow could not, because it runs after the save commits. A type now takes uniqueness, a list of rules each with a name, the fields it compares (fields of the type, or $createdBy) and an optional whenState, so that an entry leaving the state frees its values. Every entry write that goes through the content writer is checked inside its transaction under a PostgreSQL advisory lock on the values, and refused with 409 naming the rule, without naming the entry that holds them: create, update, status changes, transitions from the endpoint and from IContentTransitioner (which answers Conflict), rollback, collection push and sync, form submissions, workflow UpdateField and CreateTask, a spreadsheet import (a row error) and a bundle import (a refused record). Values are compared as PostgreSQL compares jsonb: text exactly, numbers by value, ids ignoring case, braces and dashes, and email addresses with A to Z lowered. Only Public fields holding one value may be named, not date, datetime or time, and a field a rule names cannot be raised from Public. A write waits at most five seconds for another write of the same values and is then refused with a 409 that says to try again. Rules are accepted by POST /api/content-types, a blueprint file and a bundle import of a new type, returned with the type, and set on a stored type with PUT /api/content-types/{name}/uniqueness, which counts the entries already sharing values and refuses unless force is sent; those entries stay editable and GET /api/content-types/{name}/uniqueness/{rule}/duplicates lists them. GET /api/meta/describe describes what a rule may compare under uniqueness. A type with no rule is written as before, and no schema changes. See docs/uniqueness-rules.md. BarakoCMS.Abstractions 4.6.0 adds ContentTypeDefinition.Uniqueness, UniquenessRule and ContentUniquenessException, and the core adds IContentTypeValidatorService.ValidateUniqueness with a default that applies the rule, and ContentWriter.CheckUniquenessAsync for code that stores an entry around the writer, which the accounting module's account writes now call (#830).

Added

A share link can open one entry or one page instead of the whole held site.

POST /api/contents/{id}/share-links makes a link to that entry, whatever its status, for a caller who may update it; with path it is a page link and carries that path. GET on the same route lists the entry's links and DELETE .../{linkId} revokes one. API keys do not reach these routes. The anonymous POST /api/public/site/share-links/open takes the key in its body and answers with the link's scope (site, entry or page) and, for an entry or page link, that one entry with its Public fields only. It opens no other entry, no entry or type public delivery would refuse, and nothing on another tenant, and POST /api/public/site/share-links/redeem answers 404 for such a key, so it never opens the whole site. Both anonymous routes are no-store on every answer and share one rate limit. Erasing an entry deletes its links. SiteShareLink in BarakoCMS.Abstractions gains EntryId, Path and Preview; a link stored before has none of them and stays a link to the site. No schema change. See docs/site-settings.md (#857).

Added

Upgrade note for share links.

Links to one entry are stored under a different hash from links to the site, so an older build sharing the database (a rolling deploy, an image rollback) cannot redeem one as a link to the whole site. It does list them under GET /api/site/share-links as if they were site links, can revoke them there, and counts them toward the site's 100 (#857).

Added

A webhook body did not say which tenant sent it.

Every Webhook delivery now carries tenant, the handle of the tenant the workflow fired in (default for the default tenant), including a Deleted delivery and a webhook inside a Conditional action. It is in the signed body, so a receiver serving several tenants behind one secret can refuse a delivery replayed at another tenant's URL by comparing it with the tenant it resolved for the request. The value comes from the run's tenant and cannot be set by an action parameter. The same value is sent in an unsigned X-Barako-Tenant header for routing. An added field, so receivers that ignore it are unaffected and ApiContract.Version does not move. See docs/webhooks.md (#868).

Added

A role of any name can be granted what HR used to get by its name.

Two capabilities, view_sensitive and view_hidden, open Sensitive and Hidden fields and entries that list no roles of their own, for reading and for writing. Neither implies the other, and Admin starts with neither, as before. BarakoCMS.Abstractions 4.6.0 adds SystemCapabilities.ViewSensitive and SystemCapabilities.ViewHidden, and the core adds barakoCMS.Core.RoleReferences, which turns the role names in a field's VisibleToRoles into role ids and back (#883).

Added

A role of any name can be given what the role name Admin used to decide for files, and a module declares its capability defaults once.

BarakoCMS.Files adds the manage_all_files capability: it downloads a private file somebody else uploaded and, together with upload_files, deletes one. The seeded Admin role is granted it at seed and SuperAdmin satisfies it through *. GET /api/capabilities lists it. BarakoCMS.Abstractions 4.6.0 adds CapabilityDefaults (CapabilityDefaults.For(...).GrantedTo(SystemRoles.Admin), with GrantAsync and LegacyRoles), SeededRole, SystemRoles.Admin and SystemRoles.LegacyNames. A grant finds a seeded role by its id and falls back to its seeded name where no role holds the id, skips a role that does not exist, only adds, and runs on every start as the grant by name did. The core adds CapabilityGate.ChecksCapability, which puts a capability a handler checks for itself in the vocabulary without gating the route, and a RequireCapability overload taking the legacy list as an IReadOnlyList<string>. Every first-party module that declares a capability declares its defaults this way (#886).

Added

A rate limit can be defined in configuration and named by a route.

A policy under RateLimiting:Policies:{name} has PermitLimit, WindowSeconds, QueueLimit and a Partition of Ip, User or ApiKey, and a route in a module or the host names it with RequireRateLimiting("{name}"). A route that names a policy nobody defined now stops the host at startup, naming the route and the policy, where it used to answer every request to that route with an error. The existing sections (Global, Auth, Batch, Registration, Renderer, SiteShare) keep their keys and their defaults (#888).

Added

Public delivery and API keys can each be given a limit of their own.

RateLimiting:Delivery counts the delivery routes per client IP, and leaves a request carrying the renderer key in the renderer bucket. RateLimiting:ApiKey counts every request an API key authenticated against that key, whatever address it comes from. Both are off until PermitLimit is set, so an upgrade refuses nothing it served before. The policy name delivery is now reserved by the core, beside auth, telemetry, registration, site-share and logout: a host or a module that registers its own policy of that name in code stops at startup with a message saying to rename it (#888).

Added

Forms: one form can have its own submit limit.

Modules:Forms:PerForm:{slug} takes PermitLimit and WindowSeconds, and submissions to that form are then counted per client IP against those numbers instead of the shared limit. A value left out takes the shared one, and a value that is not a whole number above zero stops the host at startup with the setting named. With nothing set every form stays on the shared five per ten minutes. BarakoCMS.Forms 4.4.0 (#888).

Added

Switching tenant takes tenant.

POST /api/me/switch only knew its target as club, the one place in the API where a tenant went by another name. The body is now { "tenant": "<handle>" }, and club keeps working as an alias through the same membership check. The messages now read "A tenant is required." and "You are not a member of this tenant.", and an error is reported against whichever of the two fields named the tenant. The new field is optional and additive. The requests this change now refuses are listed under Breaking (#891).

Added

A deployment can say it is multi-tenant, and then serves registered tenants only.

Tenancy:Mode is Single, the default and what every deployment did before the setting existed, or Multi. In Multi a request that names no tenant, a slug with no Tenant document or an inactive tenant gets a 404 at resolution, with one body for all three. No token is issued, and no API key is accepted, for the default partition or an unregistered slug, and a token issued for one before the switch is refused with 403. The workflow runner, the retention sweeps, the scheduled content sweep and the collection sync sweep leave the default partition and unregistered partitions as they are. Health, /metrics, /api/meta, the two tenant lookups and /api/auth/* still answer without a tenant, and nothing else does. Register a tenant and be a member of it before turning Multi on. A value that is not a mode stops the host at startup. With the default nothing changes, so ApiContract.Version does not move. docs/multi-tenancy.md has the list of routes and what happens to data already stored (#895).

Added

Migrations are recorded, and one command applies them.

SQL files under migrations/ were run by hand with psql and nothing recorded which had run. The host now keeps a ledger table, public.barako_migrations, and db-migrate (an argument to the image, like db-assert, run with the API stopped) runs every shipped file the ledger lacks, in order, each in a transaction with its ledger row, under an advisory lock so two runs cannot overlap. On a database migrated by hand before the ledger, a file whose change is already in place is recorded as baselined and not run, and each one is printed. A file that was run here and has since been edited stops the run before anything executes. Ctrl+C and SIGTERM cancel the statement at the database. db-migrate --status lists every migration and exits non-zero while one is pending. Modules ship their own files: the Files index, the two Forms files, the Email.Resend table and the ExternalAuth table are recorded under those modules and run only where the module is enabled. A start logs the pending ids, and the module schema preflight names them when it refuses a module. A host built from the packages ends Program.cs with the new app.RunBarakoCommandsAsync(args) to answer the command. See docs/migrations.md (#901).

Added

One contract number covered every HTTP surface, so an admin-only change moved the number for delivery too.

The delivery surface (the core routes under /api/public/ and the two anonymous tenant lookups) now has its own number. Every response carries it on X-Delivery-Contract-Version, beside X-Api-Contract-Version, and a browser may read both cross-origin. GET /api/meta adds deliveryContractVersion. The delivery number starts at 6, the value the single number had when the two were split, and an API from before the split sends no delivery header, so a consumer that finds it absent reads X-Api-Contract-Version in its place. X-Api-Contract-Version and apiContractVersion keep their name, type and value and now mean the admin surface, so a console needs no change. All additive, so ApiContract.Version does not move (#902).

Added

A module had nowhere to state the version of its own endpoints.

IBarakoModule gains HttpContractVersion, with a default of 0 for unstated, so an existing module compiles and behaves as before. It covers every route the module ships, a route under /api/public/ included, and neither core number covers a module route. Each entry of the modules part of GET /api/meta/describe adds httpContractVersion. That part goes to the callers it already went to and lists enabled modules only. The member ships in BarakoCMS.Abstractions 4.6.0 and does not move ModuleContract.Version. BarakoCMS.Pages 4.3.0 declares the number its bodies already carry as contract, and BarakoCMS.Forms 4.4.0, BarakoCMS.Files 4.4.0 and BarakoCMS.AI 4.3.1 declare 1 (#902).

Added

A client's own domain can work in a browser with no config edit or restart.

Both pieces are opt-in. With CORS:AllowTenantDomains set to true (off by default, so the allowed origins stay exactly CORS:AllowedOrigins until it is set), the API also allows an origin that is https:// plus a domain an active tenant holds, or its www. form, matched exactly: no http, port, path, wildcard, IP literal or other subdomain. Such an origin never gets Access-Control-Allow-Credentials, and a listed origin keeps it. With the setting on, every response that gets past the rate limiter and tenant resolution carries Vary: Origin, with or without an Origin on the request. A domain saved through PUT /api/tenants/{handle} passes on the next request on that instance and within Multitenancy:CacheDuration on others; if the domain map cannot be read, only the configured origins pass. A host that registered its own ICorsPolicyProvider before AddBarakoCMS keeps it. New anonymous GET /api/tenants/tls-ask?domain= is Caddy's on-demand TLS ask endpoint: 200 for an active tenant's domain, 404 for anything else, naming no tenant, answered in Multi mode without a tenant. The fixed tls-ask rate limit policy counts it by the name asked about, 60 a minute in each of 4096 buckets, and the route is outside the global per-IP limit, so a flood of made-up names from the proxy does not starve a real one. A RateLimiting:Policies:tls-ask setting now stops the host, as the other built-in names do. Additive, so ApiContract.Version does not move. See docs/deploy-in-production.md (#904).

Added

A lifecycle transition could only be made through PUT /api/contents/{id}/status.

The rules lived in that endpoint, so a webhook, a job or a module that had to move an entry either copied them or wrote around them. BarakoCMS.Abstractions 4.6.0 adds IContentTransitioner in barakoCMS.Core.Interfaces, with ContentTransitionActor, ContentTransitionOptions, ContentTransitionResult and ContentTransitionOutcome, and the endpoint now calls it. Its routes, JSON and status codes are unchanged, so ApiContract.Version does not move. The actor is stated: a user id is checked through the permission resolver against the roles the user holds in the tenant, and in a registered tenant needs an active membership; a named system actor holds no permissions and is refused unless the caller sets SkipPermissionChecks, which is off by default and skips tenant membership, read on the type, the transition permission and field sensitivity on the values sent. The lifecycle, required fields, validation and the before-save hooks apply either way. Lockout and token validity are not read for a user actor named from code. A system actor's move is recorded with Guid.Empty on the events and, on the content.transitioned audit row, no user and actor set to system:<name> in the metadata; a move made with the checks skipped carries permissionChecks: skipped there and nothing on the events. The transition log lines now come from the category barakoCMS.Infrastructure.Services.ContentTransitioner (#907).

Added

A module can store, read and delete files without referencing BarakoCMS.Files.

IFileStore in BarakoCMS.Abstractions 4.6.0 gains SaveAsync, FindAsync, OpenAsync, DeleteAsync and PublicUrlAsync, with FileToStore, FileSaveResult and FileDeleteResult. BarakoCMS.Files implements them. A save gets the checks an upload gets (allowed type, content matching the type, 10 MB, the virus scan when one is configured) and writes the same record. FindAsync, OpenAsync and DeleteAsync take the signed-in user and apply the rule of the matching Files route: a private file is read by the user it belongs to or an account holding Admin or SuperAdmin, and deleted by one of those who also holds upload_files. An API key, a principal that is not signed in and a token for another tenant read public files only. Every call works in the scope's tenant. A save and a delete commit through the scope's session and throw when work is already staged on it. A file saved with no owner lists with the empty id as uploadedBy. The new members have a default that throws NotSupportedException, so a store written against FindPublicAsync and OpenPublicAsync still compiles, and a host without a module that stores files throws on each of them naming BarakoCMS.Files. The existing two members and the Email action's public-files-only rule are unchanged. See MODULES.md (#911).

Added

Creating or changing a role, and creating or revoking an API key, left nothing in the audit log.

role.created and role.updated are new actions. The update row holds the capability lists before and after, what was added and removed, each permission as its content type, actions and the fields and operators its conditions test, and conditionsChanged when a condition differs, values included. A condition's value is never stored. apikey.created and apikey.revoked are new too, and a row holds the key's id, name, scopes and the user it acts as, never the key, its hash or its prefix. Creating a tenant records its creator as the first member in the new tenant's log, and switching a tenant off or on writes tenant.deactivated or tenant.activated there. Existing rows say more: role.deleted carries what the role held, the tenant.member.* rows carry role names beside ids and the status and roles held before (including when somebody already a member is added again), user.role.assigned and user.role.removed carry the role's name, a sensitivity change carries the role ids and names and the mask before and after, and contenttype.field_added carries the new field's level, role ids and role names. GET /api/audit returns the capability and permission detail of a role row only to a caller holding manage_roles, and the scopes of a key row only to one holding manage_api_keys; anyone else reading the log sees the action, the actor and the name. The table, the row shape and the grants that still write no row are in docs/access-control.md. Reads of Sensitive fields are not recorded (#916).

Added

A permission rule can say which fields it shows and which it lets the caller set (#917).

A Read rule takes readableFields and a Create or Update rule writableFields, so a teacher updates attendance and not grades, and a student reads their own notes and nobody else's. A set narrows what field sensitivity allows and never widens it. Across a caller's roles the sets of the rules that grant an entry are joined, and a granting rule with no set shows every field, so a role stored before this reads and writes as it did. A field a rule does not show is left out of GET, the entries list and history, cannot be filtered on (400), and is not matched by the entries list's search. An update that changes a field the Update rule does not let the caller set answers 403; sent with its stored value or left out, the field keeps its stored value. On an entry the caller may not read, nothing is compared: an update writes the fields its Update rule lets the caller set, every field when that rule holds no set, and puts every other one back. A create that gives a value to a field outside the Create rule's set answers 403, and a bulk create, an import or a push holding one is refused whole. A condition Reference.Field denies unless the caller is shown that field of the referenced type. POST /api/roles and PUT /api/roles/{id} answer 400 for a set on another rule, a set on a content type this tenant does not define, or a name the type does not declare as spelled; an update passes over a set the stored role holds unchanged, and a PUT that leaves a set out keeps it, so a console that does not know the members does not drop them. An empty list removes a set. These are new optional members, so ApiContract.Version does not move. BarakoCMS.Abstractions 4.6.0 adds PermissionRule.ReadableFields and WritableFields, and to ISensitivityService an ApplyAsync, an ApplyWriteAsync and an ApplyTransitionWriteAsync taking the stored entry, and MayReadFieldAsync, each with a default that keeps today's answer. The rollback response now applies the read rules. See docs/access-control.md.

Added

A permission condition could name one thing about the caller, their user id.

A rule can now compare a field against the caller's member profile in the current tenant, written $CURRENT_USER.<name>, for example { "Branch": { "_eq": "$CURRENT_USER.branch" } }. The in-memory evaluator and the compiled list predicate both resolve it. The profile is the new Membership.Profile (BarakoCMS.Abstractions), a map of text values set with the optional profile field of POST /api/tenants/members and PUT /api/tenants/members/{userId}, behind manage_tenant_members, and returned as profile on the roster. A caller with no active membership, no attribute of that name or an empty value matches nothing, under _ne as well as _eq, and so does a field that holds a list or an object. The variable is the whole value of _eq or _ne; inside a list it stays text. It is read from the membership on each request, not from the token. $CURRENT_USER alone still means the user id. One thing changes for stored rules: a rule whose _eq or _ne value already was text starting with $CURRENT_USER. used to be compared as that text and is now read as a variable, which matches nothing until a member is given the attribute. The fields are optional and added, so ApiContract.Version does not move. No schema change: the profile is a property inside the membership document. See docs/access-control.md (#918).

Added

A reference field can hold a list of ids, and delivery can resolve and filter it.

A relation with many targets (an event's speakers, a class's students) had to be an untyped array: nothing checked the ids, include left them as ids, and contains matched any id holding the text asked for. A reference field now takes "multiple": true. An entry write then needs a list of at most 100 distinct ids, each lower case with hyphens and each an entry of the declared type in the tenant, and refuses anything else with 400 naming the ids at fault. include resolves such a field to the list of entries the caller may read, in stored order, leaving the rest out, and refuses a page that would resolve more than 1000 distinct ids rather than resolving part of it. A new filter operator, has, matches an entry whose list (an array, or a choice or reference with multiple) holds the value as one whole element: an array element by its text (so has=5 finds the number 5), a reference id in any case, a choice value exactly. contains keeps its meaning. A reference with multiple takes eq, ne and has. A role condition written Reference.Field is refused on save when the reference holds a list, and denies if one is ever stored. A bundle import repoints listed ids of records in the bundle and takes records that list each other, through a new ContentCreateBatch.Expect(id, contentType). The UpdateField workflow action fails on a list reference, and a collection sync refuses a mapping onto one. Existing fields and entries are unchanged, and the OpenAPI delivery document describes the new field as a list of uuids. Additive on the admin and the delivery surface, so neither ApiContract.Version nor ApiContract.DeliveryVersion moves. See docs/delivery-api.md (#928).

Added

A client had to keep its own copy of what the API accepts.

GET /api/meta/describe answers any signed-in caller with the field types (name, aliases, editor hint and ruleNames, the validation rules a field of that type may declare) and the rules, read from the registries the API validates against. A caller holding manage_roles also gets capabilities, one holding manage_workflows gets workflowActions, and one holding view_modules gets modules, the names of the modules that run. Each of those three is null for a caller without the capability, and workflowActions is also null when the action registry cannot be read. Every response on the route is sent Cache-Control: no-store. An API key gets 403. A new endpoint, so ApiContract.Version does not move (#931).

Added

The durable work seams are in the package contract, with nothing behind them yet.

BarakoCMS.Abstractions 4.6.0 adds IDurableOutbox (queue a message, or schedule one for a time, in the caller's own transaction, and get its id back), IDurableRuns (start a run once per id, park it until a key is resumed or a deadline passes, resume it, each answering whether it did), IDurableMessageHandler<TMessage> with DurableMessageContext (a plain class that handles one message type and is told the tenant and the message's id) and DurableWorkConflictException (a unit of work that lost a key to another fails to commit with it). A message is queued in the tenant of the session it was staged in. No signature names a Marten, Wolverine, FastEndpoints or ASP.NET type. The host registers no implementation and nothing in the core calls them: resolving one fails until the implementation lands in #965. MODULES.md has the rules a module writes against (#964).

Added

The share links list reports the maximum expiry.

GET /api/site/share-links now carries maxExpiryDays on the page, beside items, including when the tenant has no links. It is read from the same constant the create validator enforces, so a console can offer expiry choices the API will accept without keeping its own copy of the number. Optional and additive, so ApiContract.Version does not move (#967).

Added

A field definition can say which editor it wants, which section it sits in and what it is to the entry, and a content type can say where its entries live on the site.

A console picked a field's editor from its name, and the RSS feed, the sitemap and the SEO block found the title, summary, date and link by guessing field names and reading server configuration. A field now takes editor (blocks, menu, links or image), section (free text, at most 60 characters) and role (title, summary or date), and a type takes routeTemplate, a path holding {slug} once such as /blog/{slug}. All four are optional and null on every type stored before this release. The feed reads an item's title, description and date from the fields holding the roles, and the SEO title falls back to the field holding title; the names guessed before are still tried when the type has no role or the field is empty. The feed and the sitemap build links from routeTemplate ahead of Feeds:Paths:{type} and /{type}/{slug}. A type with none of them is served as before. They are accepted by POST /api/content-types, POST /api/content-types/{name}/fields, a blueprint file and a bundle import (which, over a stored type, keeps a member the bundle does not carry), and set on a stored field or type with PUT /api/content-types/{name}/fields/{field}/presentation and PUT /api/content-types/{name}/route-template, both under manage_content_types and both in the audit log. GET /api/meta/describe lists the accepted values and the field types each is for under fieldEditors and fieldRoles. Every field in a content type response now carries editor, section and role, and every type routeTemplate, null unless declared. See docs/field-hints-and-roles.md. BarakoCMS.Abstractions 4.6.0 adds FieldDefinition.Editor, FieldDefinition.Section, FieldDefinition.Role and ContentTypeDefinition.RouteTemplate, and the core adds IContentTypeValidatorService.ValidateRouteTemplate, with a default that applies the rule, so an existing implementor still compiles and refuses the same templates (#970).

Changed

A retry is refused for a run that cannot run.

POST /api/workflow-runs/{id}/actions/{ordinal}/retry answers 409 when the run was cancelled, when its workflow is switched off, and when its workflow no longer exists. The first two are new states. The third is new for a workflow deleted through the API, and it also refuses a retry of a run whose workflow was removed from the database by hand, which used to be queued (#1009). That last answer is a status change for an existing request, and it is part of API contract 6, the move master already carries.

Changed

Creating a workflow bound the stored document as its request, and a run's status was an untyped string in the OpenAPI document.

POST /api/workflows takes a request type of its own with the fields a caller chooses: name, triggerContentType, triggerContentTypes, triggerEvent, triggerEvents, conditions, actions and enabled, each with the type and default it had. An id in the request is not read: a well-formed one was already replaced by the server's, and one that is not a GUID, which used to answer 400, is now ignored and the workflow is saved. POST /api/workflows/dry-run takes the same fields and the id its log is filed under. In the OpenAPI document, status on a run, status on each of its actions and the status filter of GET /api/workflow-runs are declared as enums that list every value. No response changes and no request that was accepted is refused, so ApiContract.Version does not move (#655, #692).

Changed

Upgrading needs migrations/4.6.0/event-correlation-metadata.sql (#691).

It adds two nullable columns to mt_events and replaces mt_quick_append_events with one that takes two more arguments. Like the 4.3.0 and 4.4.0 event store files it can be applied while the old build is still serving, then deploy: an older build writes events with an INSERT that names its own columns and does not call that function. An old instance that restarts after the file fails its start-up schema assertion, as with those files. rollback-event-correlation-metadata.sql, run with 4.6.0 stopped, puts the function and the table back and drops the ids stored since.

Changed

X-Correlation-ID is checked before it is used (#691).

The header used to be echoed on the response and written to the log as sent. An id of 1 to 64 letters, digits, ., _ and - is kept as before. Anything else is replaced by the request's trace id, or by a fresh id, and is never echoed. An id the API mints itself is now 32 hexadecimal characters, where it was a GUID with hyphens.

Changed

Public and private files shared one key layout in object storage, so no bucket policy or CDN could serve only the public ones.

A new upload is stored under public/ or private/ by its visibility, and a resized copy under the prefix of the file it came from, so anonymous read can be granted on public/* alone. A file stored earlier keeps its key and is read, resized and deleted by it; nothing is moved, so a grant narrowed to public/* no longer covers an old public file's direct URL. S3FileStorage.PutAsync now throws ArgumentException for a key that could land under the prefix of the other visibility, where it used to accept any key. The BarakoCMS.Files.S3 README shows the scoped policy for AWS S3 and for a CloudFront origin. Ships as BarakoCMS.Files 4.4.0 and BarakoCMS.Files.S3 4.2.0 (#779).

Changed

A stored workflow template that already holds one of the new placeholders as text now resolves it.

{{createdBy.name}}, {{createdBy.email}}, a {{transition.*}} name on a transition trigger, a known variable followed by | date, | money, | upper or | lower, and {{duration a b}} or {{hours a b}} over two dates (or with an empty one, which gives nothing) were sent as written until now, and are filled from this release on. {{ status | upper }} and | lower are also what Jinja and Nunjucks write, so a template stored for one of those to render downstream is now filled here first. Every other template resolves to the same text as before: a plain {{name}} is read by the same rule, and anything else between braces is still sent as written. A workflow dry run fills the author and the transition with sample values and reads no user (#828).

Changed

POST /api/preview is deprecated, and its token is now an entry share link.

The body, the status codes and the ?preview= query on GET /api/public/{type}/{slug} are unchanged, so a caller needs no change. The 200, the route's own 404s and its 401 for a token whose user is gone carry a Deprecation header; a 400, a 429, the 401 for no credentials and the 403 an API key gets do not. The token used to be a signed JWT checked by signature alone. It is now the key of a 30 minute link stored hashed in the tenant, so it stops working when its entry is erased, an entry keeps at most 20 live tokens (a mint past that drops the oldest), and it follows the entry if the slug is renamed. A token is not listed with the entry's links and minting writes no audit row. A JWT issued before the upgrade is still accepted until it expires. ApiContract.Version does not move (#857).

Changed

A new install is seeded with SuperAdmin, Admin and User, and no longer with HR (#884).

HR belongs to the attendance demo, so the seeder creates it only with the demo content (Seed:DemoContent), holding view_sensitive, and creates the hr_manager demo account only where that role exists. A database that already holds the HR role keeps it, and it still cannot be deleted. HR is no longer a reserved role name, so a site can call a role of its own HR. SystemRoles.HRRoleId and DataSeeder.HRRoleId are marked obsolete, for removal in 6.0.

Changed

A tenant's public profile is read from its site entry (#885).

GET /api/tenants/{handle}/public keeps its shape and GET /api/me/tenants keeps logoUrl. Both now answer from the tenant's published site entry, read the way GET /api/public/site delivers it. Field by field, the site decides where its type declares the field: a field that is not Public, or that the entry holds blank, answers empty. The value still on the tenant record answers where there is no published entry, the type does not declare the field, or the entry has no key for it. So a tenant whose site entry already said something about its Logo answers with that, and every other tenant answers as it did, before and after the migration. GET /api/me/tenants makes two more reads for each tenant on the page. migrations/4.6.0/tenant-profile-to-site.sql moves the stored values into each tenant's site entry and reports every tenant and value it leaves behind, and rollback-tenant-profile-to-site.sql fills the tenant record's blanks from the site entry for an earlier release. Both take barako.only_tenant to look at one tenant. docs/multi-tenancy.md has the rules.

Changed

The site blueprint has the profile fields.

A site type made from the blueprint gains optional About, Email, Location, LocationUrl, ContactUrl and SocialHandle fields. Applying a blueprint never touches a type that already exists: the migration above adds a field to an existing type only where it moves a value into it.

Changed

Tenant's profile members are obsolete.

LogoUrl, About, Location, LocationUrl, SocialHandle, Email, ContactUrl and Branding on barakoCMS.Models.Tenant are marked [Obsolete] and still read and store as before. The core no longer sets the first seven, and blanks one only when a tenant update sends it as an empty string. A host or module that reads them from a Tenant finds them empty for a tenant the migration moved, so read the site entry, or hold the migration back until that code has changed: the API does not need it to have run. Removal is planned for 6.0. Ships in BarakoCMS.Abstractions 4.6.0.

Changed

LegacyRoles on the module capability classes and the grant by role name are obsolete.

The public LegacyRoles field on AiCapabilities, AccountingCapabilities, AnalyticsCapabilities, DiagnosticsCapabilities, ResendEmailCapabilities, FeatureFlagCapabilities, FileCapabilities, FormsCapabilities, ImportCapabilities, PortabilityCapabilities and PwaCapabilities keeps its value and is marked [Obsolete]: each module's gates take the list from its own CapabilityDefaults now. ModuleCapabilities.GrantAsync(session, roleNames, capabilities) still works and is marked [Obsolete] in favour of CapabilityDefaults.GrantAsync. All are planned for removal in 6.0. A module's seeded grants are unchanged: the seeded Admin role, and the Accountant role for Accounting, start with the same capabilities as before. This ships as BarakoCMS.Analytics.Umami 4.3.2, BarakoCMS.Diagnostics 4.3.2, BarakoCMS.FeatureFlags 4.3.2 and BarakoCMS.Pwa 4.3.2, and in the versions of the other modules that 4.6.0 publishes for the first time (#886).

Changed

Settings and workflow parameters now decide what a credential name is with one rule (#889).

The settings endpoint and the workflow code each kept a word list of their own, and the lists differed. Both now call CredentialNames.IsCredential, which holds every word either list held. Workflow parameters are classified exactly as before, so stored workflows and what GET /api/workflows returns do not change.

Changed

A push to a branch with an open pull request runs CI once, not twice.

ci.yml listened to both push and pull_request, so each push ran every job twice on one commit. It now runs on pull_request and merge_group only, and a branch push starts ci-branch.yml, which calls ci.yml unless an open pull request into master sits at that commit and its pull_request run has started. It runs everything when it cannot tell, and a branch with no pull request yet still gets its push run. Push runs report as Branch / <job name>, so they can never satisfy a required check, and the merge queue's own branches no longer start a push run beside the merge_group one.

Changed

The wiki is published from CI, not by hand.

scripts/wiki-sync.sh generated the wiki from docs/ but only ran when somebody remembered, and by 24 September every synced page differed from docs/. .github/workflows/wiki-sync.yml now runs on every push to master that touches docs/ and pushes the result as one commit naming the source commit. The new scripts/wiki-publish.sh does the sync, the commit and the push, and exits non-zero when the sync fails, the push is refused or the wiki moved under it, so a stale wiki is a red run. A run with nothing to change exits zero and says so. With nobody reading the result before it is pushed, wiki-sync.sh is stricter about what it writes: it publishes only the docs git tracks, and it stops when a doc would overwrite a wiki page that no earlier sync wrote. It also accepts a wiki clone whose origin ends in .wiki, which is how actions/checkout leaves it.

Fixed

A tenant made from the site blueprint lost its Labels, HomePath and OptionStyles.

barakoPress reads all three from the site entry, but the blueprint did not declare them, so a value written to one was not delivered and the renderer printed its defaults, with no error. site now has an optional string field HomePath and optional json fields Labels and OptionStyles. Applying a blueprint never touches a type that already exists, so existing tenants are unchanged and add the fields by hand if they want them (#1005).

Fixed

A database upgraded from 4.0 or 4.1 had no file that created the share links table.

4.2.0 added mt_doc_site_share_links to migrations/4.0.0/3.x-to-4.0.sql and shipped no file of its own, so a database that had already run the 4.0.0 file never got it and db-assert reported the table as outstanding with every listed file applied. migrations/4.2.0/site-share-links.sql creates it, with rollback-site-share-links.sql beside it, and docs/upgrading-to-4.0.md lists both in order. scripts/upgrade-check.sh takes a 4.0 or 4.1 FROM_VERSION, and CI now runs it from 4.1.0 as well as from 3.21.0 (#1007).

Fixed

A manual collection sync run that overlapped another run of the same collection could answer 500 (#1008).

POST /api/collection-syncs/{slug}/run took no lock, so a run started while the sweep, or another caller, was filling the same content type could create the same entry stream and fail on its primary key (Postgres 23505 on mt_streams). A run now holds a session level advisory lock on its tenant and content type. The route waits up to five seconds for it (CollectionSyncs:RunLockWaitSeconds, 0 to 30, and a value that is not a number stops the API starting), then answers 409 with Retry-After and runs nothing. The sweep tries once and leaves a due sync whose collection is locked for a later tick; a sync it leaves is not one of the tick's twenty, so the next due sync runs in its place. The lock is per content type, so it also covers a different sync filling the same collection: running product-brew while the sweep is on product-cms used to answer 200 at once, and now waits for that run and answers 409 only if it is still going after the wait. A script that runs several syncs of one type in turn should retry on 409. A run that overlaps nothing answers as before. An overlap that used to complete with 200 can now answer 409, which is a status code change, so it is part of API contract 6: it shares the move of ApiContract.Version to 6 with the validation rules change and does not move it again. The sweep and the route now read the sync again once they hold the lock, so the sweep no longer reruns a sync that was run from the API a moment earlier, or saves an older copy over an edit made while it was busy with another sync.

Fixed

The inline workflow engine handed a Deleted action the whole entry.

IWorkflowEngine.ProcessEventAsync called with the Deleted event passed its caller's entry to the actions and ran workflows with conditions against it, where the queued path gives an action the id and the content type only and does not fire a workflow with conditions. It now does the same as the queued path. Nothing in the core or the modules calls the engine with Deleted; a host that does gets what the action contract already said (#1009).

Fixed

Rescheduling cleared an armed sensitivity change.

PUT /api/contents/{id}/schedule treated a request without scheduledSensitivity and scheduledSensitivityAt as a request to clear them, so a client that sent only the publish times removed a sensitivity change it could not see. Leaving both fields out now keeps the armed change. Sending both as null still clears it. A client that cleared an armed change by leaving the fields out must now send them as null.

Fixed

The backup, upgrade and schema-command checks in CI could fail because a port was already taken.

scripts/restore-check.sh, scripts/upgrade-check.sh and scripts/suite-db-commands-check.sh published Postgres and started the API on fixed host ports between 55433 and 58096, inside the range the kernel hands to outbound connections, so anything else on the runner could be holding one. Docker now chooses the Postgres port and the script reads it back with docker port, the API binds port 0 and the script reads the port from that process's own log, and Postgres is published on loopback only. PG_PORT, APP_PORT, NEW_PORT and OLD_PORT are still honoured when set. On exit each script removes the containers it started, by id, where it used to remove by name and could take out another run's. scripts/test-check-ports.sh holds a listener on each old default and runs in CI.

Fixed

A Conditional workflow action took the wrong branch on a real run.

The runner and the engine replaced {{...}} in Condition before the action evaluated it, and the action only reads a value from the entry when the token is still there, so it compared an empty string. Condition is now handed to the action as written, at the top level and for a Conditional nested in a branch, and it is compared against the entry. This changes what stored workflows do, so check every workflow with a Conditional:

  • == against a value, such as {{status}} == Published: always ran ElseActions. It now runs ThenActions when the condition holds.
  • != against a value, such as {{contentType}} != Post: always ran ThenActions. It now runs ElseActions when the two are equal.
  • A comparison with an empty value: {{data.Phone}} != "" always ran ElseActions and {{data.Phone}} == "" always ran ThenActions, whatever the field held. Both now follow the field, so a != "" workflow runs its ThenActions for the first time.
  • != against a field whose value holds == or !=: always ran ElseActions, because the value made the condition unreadable. It now runs ThenActions when the value differs.

Only the left side of a condition is read from the entry. A token on the right side is compared as text and is not replaced. One case used to compare field to field and no longer does: when the left token was one the resolver left in place (a field name with a character outside letters, digits, underscore and dot, such as {{data.first-name}} == {{data.nickname}}, or a field the entry does not have), the right side was still replaced. It is now compared as the literal text. The dry run response now shows a Conditional's Condition as written, not with values filled in.

Fixed

Testing a connector could overwrite an update made while the test was running.

POST /api/connectors/{slug}/test stored the whole connector as it had read it before the probe. It now writes only the last test time and result (#574).

Fixed

Taking a published entry down, or erasing one, fired no workflow.

A status change away from Published now fires the Unpublished trigger, and DELETE /api/contents/{id}/erase fires Deleted. A Deleted run carries the entry's id and content type and none of its data: a Deleted workflow with conditions does not fire, {{status}}, {{createdAt}} and {{updatedAt}} resolve to nothing, a Conditional on the status or the data fails, and a webhook body holds event, contentId and contentType only. A custom IWorkflowAction sees TriggerEvent set to Deleted and a Content whose members other than Id and ContentType are defaults, not stored values. An entry whose event stream records no earlier status fires no Unpublished on its first status change, and the API logs that. A workflow can also name several events in triggerEvents, beside triggerEvent, the way triggerContentTypes sits beside triggerContentType; an event fires it once however many entries match. Both fields are optional and a workflow stored without them fires as before, so ApiContract.Version does not move (#663, #664).

Fixed

A due workflow run could wait behind twenty runs that were not due.

The runner read the twenty oldest unfinished runs of a tenant and only then checked which were due, so twenty runs in backoff, or held by other nodes, hid every run queued after them. Due-ness is now part of the query, through a NextDueAt value kept on each run. The runner also lists tenant partitions once per idle cycle instead of once per action, and starts each pass after the tenant it served last, so a tenant that always has work no longer keeps the others waiting. Job retries are shortened by a random share of up to a quarter, never past Jobs:BackoffMaxSeconds, so jobs that failed together do not retry together. A tenant whose runs cannot be read is logged and passed over, and no longer stops the pass for the others. No schema change: NextDueAt is filtered in the query and not indexed.

Fixed

Semantic search answers were cacheable without Vary: X-Tenant.

GET /api/public/{type}/semantic sent Cache-Control: public alone, so a shared cache keyed on the URL could serve one tenant's results to another on a deployment that routes tenants by header. Every cached answer from that route now carries Vary: X-Tenant, the same as the core delivery routes (BarakoCMS.AI 4.3.1).

Fixed

A content type with two fields that differ only by case failed on the public list, and a filter could reach the wrong one.

GET /api/public/{type} answered 500 for a type declaring, say, Title and TitlE, both Public. It answers now, and a filter or sort on either name uses the first one declared. A filter finds its field without regard to case, so a name is now refused, for a filter and for a sort, when any field of that name in another casing is one the caller cannot read. Creating a content type still accepts such a pair (#825).

Fixed

Renaming a seeded role lost it on the next start.

The core seeder looked for SuperAdmin, Admin and User by name, and BarakoCMS.Accounting for Accountant. After a rename neither found the role, and each stored a new one under the same id, which replaced the renamed role and emptied its permissions. Both now find the role by its id first, and by its name only where no role holds the id, and add only the capabilities they added before. A rename now sticks: the role's holders keep what its capabilities open, and a gate's legacy role fallback sees the new name (#886).

Fixed

The Accountant role had no capabilities until the second start.

BarakoCMS.Accounting created the role and granted to it in one seed, and the grant read the database, where the role was not saved yet. The grant now finds a role staged in the same seed (#886).

Fixed

A transition was checked against the copy of the entry the request loaded, and applied to the entry as stored.

When another write moved the entry between those two points and the request sent no data, the move was answered 200 and recorded with the state the request had read. A transition now reads the entry again before writing, with or without data, and binds the write to the version it read. PUT /api/contents/{id}/status answers 409 with the message it already gave for a write that lost to another, so ApiContract.Version does not move. A transition without data makes up to three more queries for it (#907).

Fixed

Removing a role a user never held wrote a user.role.removed audit row.

DELETE /api/users/{userId}/roles/{roleId} still answers 200 for a role the user does not hold, and now writes a row only when a role was taken away. Revoking an API key that is already revoked answers 204 as before and writes no second row (#916).

Fixed

A field's validationRules were stored and never applied.

A type author who set { "max": 100 } got a 200 and every entry above 100 was stored anyway. Every entry write now checks min and max on number and date fields, and minLength, maxLength and pattern on text fields, and answers 400 naming the field and the rule. An optional string, text, richtext or markdown field sent empty is a cleared field and is not checked; a blank email, url, slug, uuid or time is refused by its type, as before. regex is read as pattern, and a field that sets both is refused. A pattern is matched anywhere in the value unless it is anchored with ^ and $, and a value that ends in a line break is refused. Patterns are matched without backtracking, so a lookahead, a lookbehind, a backreference or an atomic group is refused when the type is saved. \d matches any Unicode digit, and [0-9] is the ASCII form. requiredWhen makes a field required only while a condition on other fields of the entry holds, in the form permission conditions use: { "Kind": { "_eq": "Company" } }, with _eq, _ne, _in and _nin, and { "Age": { "_lt": 18 } }, with _lt, _lte, _gt and _gte comparing numbers and dates. A field that is left out is read as null by _ne and _nin, and makes every other comparison false. Saving a type refuses an unknown rule name, a bound of the wrong kind, a min above its max, a pattern that does not compile or is longer than 500 characters, a condition naming a $ property or $CURRENT_USER, and an _in or _nin whose value is not a list. Only the fields a request adds are checked, so a type that already stores a rule a save would refuse still accepts a new field. An import leaves a field alone when the target already stores it with the same type and rules, so a type exported from a tenant imports back into it. The entries in a bundle are still checked against the rules, so a stored entry that breaks a rule keeps reading and is refused on import, update and rollback until it is fixed. A stored rule a save would refuse, a stored min above its max, and a rule stored under two spellings (min beside MIN) are skipped on entry writes. On start the API logs a warning per tenant listing the types whose stored rules now apply, and each stored rule that is skipped as type, field and rule (#927, #810).

Fixed

Several docs and package pages said things the code does not do.

A completed idempotency key is kept for 24 hours, not indefinitely. BarakoCMS.ExternalAuth reads each provider from its own root section (Google:ClientId, GitHub:ClientId, LinkedIn:ClientId, Facebook:AppId), not from under ExternalAuth, so a host configured from the old README had every provider off. Files:S3:UsePublicReadAcl defaults to true. BarakoCMS.DeviceTrust now names DeviceTrust:Enforce, which is off by default, and the X-Device-Id header. SECURITY.md names Connectors:Key, docs/access-control.md lists manage_collection_syncs, manage_forms and analyze_spreadsheets, and DECISIONS.md marks D1, D3, D5 and D11 as implemented. The module READMEs no longer say barakoCMS 4.0.0 is enough: a module built since 4.3.0 also needs BarakoCMS.Abstractions. The corrected READMEs ship as BarakoCMS.Accounting 4.3.1, BarakoCMS.DeviceTrust 4.3.1, BarakoCMS.Email.Resend 4.4.0, BarakoCMS.Email.Smtp 4.1.0, BarakoCMS.ExternalAuth 4.4.0, BarakoCMS.Files 4.4.0, BarakoCMS.Files.S3 4.2.0 and BarakoCMS.Import 4.4.1. Some of these versions also carry code changes, each listed under its own entry in these notes (#994).

Security

Credentials on a Conditional action's child actions are now protected like any other.

The Secret, ApiKey, Password, Token and other credential-named parameters of the actions in ThenActions and ElseActions are encrypted when a workflow is saved, at any nesting depth, and the startup pass that encrypts stored workflows now covers them too. The API leaves them out of those two parameters and adds a SecretSet flag to each child instead, so the JSON in them is written out again rather than echoed as it was sent. A child's credentials are decrypted just before it runs, which also lets a child Webhook with a Secret sign and send. A branch that is not a JSON array of actions the Conditional can run (each an object, parameter values as text, no repeated property name) is stored as it was sent, is not run, and is not returned: the action's new unreadableBranches list names it instead, a dry run leaves it out of the preview and names it in the action's errorMessage, and the startup pass logs a warning naming it. The startup pass also encrypts a stored credential, on a child or on the workflow's own action, that begins with the enc:v1: marker and is not an envelope after it.

Security

With Tenancy:DatabaseEnforcement on, the startup pass that encrypts stored workflow credentials did nothing.

It listed tenants with a query the row level security policy refuses, logged the error and stopped. It now lists tenants the way the workflow runner and the retention sweeps do, so after a restart the credential parameters on a workflow's own actions are encrypted in every registered tenant, active or not, and in the default partition. It does not reach a partition with no Tenant document, which includes a single-tenant deployment whose partition is a slug taken from its host name: register that tenant and restart. docs/tenancy-at-the-database.md has the query that lists such partitions. With enforcement on the pass now logs how many partitions and workflows it read on every start, and a partition that fails is logged by name and skipped instead of ending the pass. With enforcement off the partitions visited are the same as before.

4.5.0

2026-09-29

Minor

Contributor

Breaking

Changing a connector's address kept its stored credentials.

PUT /api/connectors/{slug} keeps a secret the request leaves out, which is how the console saves an edit without showing the token. That now holds only while the base URL keeps its scheme, host and port (compared with the host lowercased and in punycode, and the default port made explicit). When any of those change, every stored secret has to be sent again or cleared in the same request, and the update answers 400 with one error per missing secret, named secrets.<Key>. A path change on the same origin keeps the secrets as before. A request definition whose path template resolves to a different scheme, host or port from its connector (an absolute or // URL) is now refused when it is composed. A connector's probePath has to start with a single / and hold no backslash, so health, //host/ and https://host/ are answered 400, and the test button refuses a stored probe path that resolves to another origin. These requests used to succeed, so X-Api-Contract-Version moves to 5, the same bump the platform role change makes, and the barakoBrew console has to accept contract 5 before it runs against this release.

Breaking

An upload whose bytes do not match its declared type is now refused.

POST /api/files answers 400 for a type that only starts with an allowed one (image/pngx) and for a file whose content is not the declared format, such as a PNG sent as image/jpeg. Both used to be stored. ApiContract.Version moves to 5.

Breaking

A portability import wrote types and entries that the create endpoints refuse (#933).

POST /api/portability/import now runs each content type through the checks POST /api/content-types runs, lifecycle included, and refuses a field declared twice. A new type keeps the bundle's lifecycle, where it used to drop it. A stored type keeps its own when the bundle has none, and a bundle that changes it, or adds one to a type with entries, is refused. On a stored type the import refuses a required field with no default when the type has entries, and a bundle that raises, lowers or leaves out a non-Public field; PUT /api/content-types/{name}/fields/{field}/sensitivity is how that changes. A bundle type matches a stored type under create's name normalisation, so Blog Post updates blog-post. Each entry goes through the same write path as POST /api/contents, now shared as IContentCreator: a field the importing caller may not see is dropped, the entry is validated against its type as the bundle leaves it (required fields, value types, references, unique slugs), the type's lifecycle hooks run, and the entry starts in the type's initial lifecycle state. The import runs in one database transaction with each entry written before the next is checked, so validation and lifecycle hooks see the entries before it in the bundle: journal entries are numbered in sequence and a page's parent from the same bundle resolves. Export now writes each entry's id, and a reference to another record of the bundle is pointed at that record as imported, whatever the order. A reference to anything outside the bundle has to exist in the target. A type that an earlier import stored with no display name or with field names that are not PascalCase no longer imports until it is fixed. The import is all or nothing: any refusal answers 400 naming each item as contentTypes[i] or contents[i], nothing is written, and a dry run answers the same. A bundle holds at most 5,000 entries (Portability:MaxImportRecords) and 500 types. A bundle that imported before and breaks one of these rules is now refused, so ApiContract.Version moves to 5, the same bump as the platform role change. IContentBatchRunner runs such a batch. ISensitivityService and IContentValidatorService gain an overload that takes the content type definition, with a default implementation that throws. BarakoCMS.Abstractions 4.5.0, BarakoCMS.Portability 4.4.0. POST /api/import/content moves onto the same path and the same transaction: a field the caller may not see is dropped instead of stored, the type's lifecycle hooks run and see earlier rows, and a request holds at most 5,000 records (Import:MaxRecords), refused with 400 past that. Its response shape and continueOnError are unchanged. BarakoCMS.Import 4.4.0.

Breaking

Only a platform administrator changes a user's global roles.

POST /api/users/{id}/roles and DELETE /api/users/{id}/roles/{roleId} change the roles a user holds in every tenant. They now answer 403 unless the caller's manage_user_membership comes from one of the caller's own global roles, so an Admin whose role comes from a tenant membership can no longer use them and manages roles inside that tenant through /api/tenants/members. Removing SuperAdmin now takes a SuperAdmin, and removing it from the last user who holds it answers 409. A role carrying *, manage_roles, manage_tenants, manage_users or manage_email_settings is now granted only by a SuperAdmin, through either /api/users/{id}/roles or /api/tenants/members (403 otherwise), and removed from a user's global roles only by one; /api/tenants/members/roles no longer offers it to anyone else. A tenant admin can still edit or suspend a member who already holds one. Each 403 carries a message saying why. These requests used to succeed, so X-Api-Contract-Version moves to 5, and the barakoBrew console has to accept contract 5 before it runs against this release.

Breaking

Switching tenant no longer issues a second session or renews the current one.

POST /api/me/switch used to store and return a new seven day refresh token, and every call, including one to the tenant the caller was already in, returned a fresh 15 minute access token. It now returns an access token for the target tenant only, that token expires when the presented one would have, and the presented token is revoked. refreshToken stays in the response but is empty and refreshTokenExpiry is the default value. The refresh token from sign-in, in the body or the refresh cookie, already covers every tenant the user belongs to: a refresh mints for the X-Tenant it is sent and re-checks membership. A client that stored refreshToken from the switch response has to keep its existing one when the value is empty, and has to use the returned token from then on. ITokenIssuer gains an overload taking a notAfter cap, with a default implementation that refuses a token it cannot cap. This moves X-Api-Contract-Version to 5.

Breaking

A tenant's administrator sees that tenant's data on the admin screens backed by a global table.

Client errors, email events and PWA installs are stored once for the deployment, and GET /api/client-errors, GET /api/email-events and GET /api/pwa/installs listed every tenant's rows to any Admin. A caller holding the capability through a tenant membership now sees the current tenant's rows; one holding it through a global role, as on a single-tenant deployment, still sees every tenant's. POST /api/client-errors/{id}/resolve answers 404 for another tenant's error. An email event now carries the tenant that sent the email, recorded against Resend's id when it is sent and kept 30 days (a new sent_emails table, created on an existing database by migrations/4.5.0/email-sent-emails.sql); an event with no recorded sender is visible through a global role only. Only mail sent on a tenant's behalf is recorded, which today is a workflow's email: a user's own account mail (sign-in codes, verification, lockout notices) belongs to no tenant. IEmailService gains SendForTenantAsync, whose default ignores the tenant, so an existing provider keeps working. The same fault reported from two tenants is now two rows, a report that names no tenant takes the tenant the request resolved to, and reported tenants are stored lowercased. /api/settings, GET /api/settings/email, everything under /api/feature-flags/admin and all six /api/analytics routes are shared by every tenant, so they now answer 403 to a caller holding manage_settings, manage_feature_flags, view_analytics or manage_analytics_websites through a membership only. These requests used to succeed, so this shares the move of X-Api-Contract-Version to 5 with the global-roles change.

Changed

Values placed into an email body were not HTML-encoded.

The sign-in code email wrote the device description (taken from the request's user-agent) and IP address into its HTML as they were, and a workflow Email action did the same with entry fields, which can come from a public form. Both are encoded now, as is the app name in every system email. In a workflow email, values in Subject and To have their line breaks replaced by a space, and a Conditional resolves its children's parameters when each child runs instead of substituting values into the branch JSON. This changes how a rich text or markdown field looks in a workflow email: a Body of {{data.Body}} holding <p>Hello <b>world</b></p> now shows the tags as text instead of a bold "world". A workflow email's To must now resolve to exactly one address; a list, or a value that is not an address, fails the action and sends nothing. The sign-in code email names the device only when it recognises the browser or system, and says "an unrecognised device" otherwise, instead of repeating the user-agent.

Changed

A portability export returned every entry, unmasked, whatever the caller could read.

GET /api/portability/export now applies the content List endpoint's per-entry read rule and the read endpoints' document and field sensitivity, keyed on the caller. An entry the caller may not read is left out and counted in the bundle's new contentsWithheld. A field the caller may not read comes out under its mask and is named in the record's new maskedFields, and import skips those fields, so a mask is never stored as a value. A caller needs a read rule for a type to export its entries, as on GET /api/contents; SuperAdmin still exports everything. A token whose user does not exist now gets 401, as on the List endpoint. Each record also carries the entry's sensitivity, and import creates the entry at that level, so restoring a backup no longer makes Hidden and Sensitive entries Public; a bundle without it imports as Public, as before. The portability.exported audit entry keeps contents as the number of entries considered and adds exported and withheld. BarakoCMS.Portability 4.3.1.

Changed

The BarakoCMS.Forms package page left out choice fields.

Forms has accepted a choice field since 4.3.0, and the form definition lists its options and multiple, but the README listed only the other field types. BarakoCMS.Forms 4.3.1 carries the corrected README; the code is unchanged.

Changed

Refresh tokens were stored as issued, and a refresh answered with the new token in the body even for a browser that sent only the cookie.

The server now stores a SHA-256 hash of each refresh token and looks tokens up by it. Rows stored before the upgrade still refresh once and are replaced by a hashed row, so nobody is signed out and the last of them expires within seven days. Apply migrations/4.5.0/refresh-token-hash-index.sql on an existing database for the index on the hash. A refresh sent only the barako_refresh cookie now sets the new token in the cookie and returns an empty refreshToken in the body; a refresh sent the token in the body still returns it there, and sign-in is unchanged. POST /api/auth/logout accepts the refresh cookie when there is no usable bearer: a live cookie revokes that user's refresh tokens, a spent or unknown one revokes nothing, and every one is cleared with the same 200, so a console whose access token expired can still sign out. The cookie is now set for /api/auth/logout as well as /api/auth/refresh. Logout has its own rate limit of 30 per minute per IP rather than the sign-in one.

Fixed

An upload's declared type was matched as a prefix and never compared with the file.

POST /api/files now parses the declared type and requires it to be exactly one of PNG, JPEG, GIF, WebP, AVIF or PDF, and requires the start of the file to be that format. A mismatch is 400. The type is stored as the bare lower case name, so IMAGE/PNG; x=y is kept as image/png, and the storage key's extension comes from that type rather than the uploaded file name. Both download routes send a sandboxing Content-Security-Policy, and redirect to an object store only for a file stored with one of those exact types; any other stored file is streamed through the API as application/octet-stream. The redirect also answers 302 now; it used to answer 204. BarakoCMS.Files 4.3.1.

4.4.1

2026-09-25

Patch
Fixed

A module built against 4.0 to 4.2 stopped the host from starting on 4.3.0 and 4.4.0.

4.3.0 moved the module contract, the models and the interfaces from barakoCMS into BarakoCMS.Abstractions without type forwarders, so a compiled module still looking for them in barakoCMS failed with a TypeLoadException. That hit every host using BarakoCMS.Email.Smtp 4.0.0, BarakoCMS.Pages 4.2.0 or BarakoCMS.Files.S3 4.1.1 (with BarakoCMS.Files 4.1.1), the latest of each on NuGet. barakoCMS now forwards all 101 moved types, so those binaries load unchanged. A module assembly that still cannot load no longer takes the host down: discovery skips it with a warning naming the assembly and the types it is missing, the endpoint scan leaves it out, and BarakoCMS:Modules:Enabled naming a module in it fails with an error that names the assembly. Adding such a module by hand is refused. An assembly that defines no module is scanned as before, so a broken type in the host's own libraries still stops startup. BarakoCMS.Email.Smtp 4.0.1, BarakoCMS.Pages 4.2.1 and BarakoCMS.Files.S3 4.1.2 are rebuilt against BarakoCMS.Abstractions, and Files.S3 now requires BarakoCMS.Files 4.3.0 or later.

4.4.0

2026-09-25

Minor
Added

A collection could be pulled from a source on a schedule but not pushed to by the source.

POST /api/collections/{type}/push upserts entries by slug in one transaction, so a repository's CI can push its changelog, contributors or docs when they change. Every entry goes through the same permission, sensitivity, schema and lifecycle checks as the content API, an unchanged entry writes nothing and fires no webhook, and archiveMissing archives published entries the push left out once every write has passed. The response counts created, updated, unchanged and archived entries and lists refused ones. At most 1,000 entries and 4 MB per push. API keys take an optional contentTypes list, which limits a key to pushing to those types and nothing else; existing keys name none and are unchanged. See docs/collection-push.md.

Added

A collection sync could only copy a value the item already held.

A roadmap needing a product name, a repository taken out of a URL, every label of an issue or a percent complete had to be filled by an import script run by hand. A sync definition now takes fieldRules beside fieldMap: a constant, a path with prefixStrip, regex or join, a contains test over an array path, and a ratio of closed over closed plus open as a rounded percent. exclude skips an item when a path is not empty or equals a value. Every rule is checked on save with a 400 naming the field. Both properties are optional, and a definition without them runs as before. See docs/collection-syncs.md.

Added

A sync could not take an entry off the page when its source dropped it.

A closed milestone or an issue somebody took stayed published. archiveMissing: true archives the published entries a sync owns that a complete run did not produce, and publishes one again if it comes back. It acts only when the run read everything: at least one item, no maxEntries cut, no next page in the Link header, no skipped item. Entries typed by hand are never touched, nor are another sync's, unless both syncs produce the same key and so write the same entry. Off by default, and the run result reports archived.

Added

Three values a synced page prints still needed code (#1003).

A field rule now takes replace, text to text applied after prefixStrip and regex (a package id BarakoCMS.Analytics.Umami becomes Analytics · Umami), and map, a lookup applied last that writes nothing for a value it does not name (a package id to the shelf a site files it on). A third source beside const, path and ratio is sum, two to ten paths added into an int or decimal field (a milestone's total from its closed and open counts). Each is checked on save with a 400 naming the field, and a definition that uses none of them runs as before.

Added

A tenant made from a blueprint could not set a page's HideTitle or the site's header and footer regions.

barakoPress reads them, but the blueprints did not declare them, so a kit had to add them with a script. page in blog and devsite now has an optional HideTitle, and site has HeaderPath, FooterPath, and HeaderTone and FooterTone as a choice of the renderer's six tones. Applying a blueprint never touches a type that already exists, so existing tenants are unchanged and add the fields by hand if they want them.

Added

A tenant made from the site blueprint could not edit the newer barakoPress settings in barakoBrew.

barakoPress 0.8.0 reads them, but the blueprint did not declare them, and public delivery sends only declared fields. site now has optional json fields Tokens, Tones, StyleRecipes, MenuLinks, HeaderActions and Plugins. Applying a blueprint never touches a type that already exists, so existing tenants are unchanged and add the fields by hand if they want them.

Changed

Marten moves from 9.37.0 to 9.38.0, which stops a failed batch leaving a permanent gap in the event sequence.

Under the default append mode a StartStream drew sequence numbers nothing read back, so when anything later in the same batch failed, the gap it left stalled the projection daemon for every tenant until the stale-sequence threshold passed. 9.38 sends stream creation through mt_quick_append_events, and replaces that function's body so a new stream's row is written once instead of twice. The app runs AutoCreate.CreateOnly, which never replaces an existing function, so an upgraded database needs migrations/4.4.0/marten-9-38-quick-append-events.sql applied while the old build is still serving. It touches no data. Rolling back needs the matching file in the same directory. JasperFx moves to 2.73.2 and Weasel to 9.32.0 with it.

Fixed

A collection sync mapping a datetime field rewrote every entry on every run.

The stored value and the fetched one were compared as text, and the two were never spelled the same, so each run appended a ContentUpdated to every entry and fired its workflows. A datetime is now compared as an instant, and a second run against an unchanged source reports every entry unchanged.

Fixed

A collection sync could drop a mapped field from a large JSON item.

The reader kept the first 200 paths of every item, and a real GitHub issue is about 200 paths, so a mapping of state_reason came back empty once an issue had one more label. It now keeps only the paths the mapping, rules and exclusions name.

Fixed

CollectionSyncs:Enabled=false did not stop the sweep in a host that adds configuration late.

The switch was read only when services were registered. The sweep now checks it again when it starts.

4.3.0

2026-09-21

Minor

Contributor

Added

A devsite blueprint: the shape barakocms.com needs.

page, post, category, author and a flat doc type carrying its own section, order and parent fields for a documentation tree, plus five types meant to be filled by a collection sync (#794) rather than typed by hand: package (NuGet), release and contributor (GitHub), up-for-grabs (open issues), milestone (open GitHub milestones, one per product, with no date field, matching the roadmap page it feeds). Every reference in it points at a type the same blueprint declares, so it applies on a fresh tenant with nothing else applied first, and applying it twice is refused with 409 like any other blueprint. Ships under the existing barakoCMS/Blueprints directory rather than a separate repository, since nothing outside this project reads a kit yet; moving it later is one JSON file. (closes #721)

Added

A collection's entries could only come from somebody typing them, so three sites each carried their own code to read NuGet, GitHub, Medium and an RSS feed at build time.

A collection sync is configuration now: /api/collection-syncs names the content type to fill, the source (a request definition sent through its connector, or an RSS or Atom URL), which response path becomes which content field, and which field is the stable key, so a re-sync updates an entry rather than adding another one. A field can be given a floor, keeping the greater of the stored and the fetched value, so a lagging registry index cannot walk a download count backwards. A background sweep runs each sync on its own interval, one instance at a time under a Postgres advisory lock, and a response that says the same thing writes nothing. A failed fetch leaves the last good entries in place and records the reason on the sync, where GET /api/collection-syncs/{slug} shows it along with the last success, so an operator can tell a stale page from an empty one. Entries are ordinary content, so blocks, delivery and search treat them like anything else. The schedule is on by default and CollectionSyncs:Enabled=false turns it off without disabling the syncs. Additive on both surfaces, so ApiContract.Version does not move. (#794)

Added

A sensitivity change can be scheduled, the way publish and unpublish can.

PUT /api/contents/{id}/schedule takes scheduledSensitivity and scheduledSensitivityAt (both or neither; the time has to be in the future and the level different from the current one), the entry and its history report what is armed, and the scheduled sweep applies it as a real ContentSensitivityChanged, so delivery, masking, change webhooks and the history see it the way they see a manual change. The entry stays Published throughout: an unpublish time is "gone from the public site at a date", this is "still published, but only these roles may read it from that date". Resolution is the sweep interval, one minute. Additive on the request and the response, so ApiContract.Version does not move. (#824)

Added

Forms accept a choice field.

The Forms module predates the choice type, so a join form asking for an area of focus or a race sign-up asking for an entry type and a shirt size had nowhere to put the answer. POST /api/public/forms/{slug} now takes a choice field among the ones it accepts, validated the same way any write is: a value not offered is a 400 naming what is accepted, and a multiple choice field takes a list, even of one. GET /api/public/forms/{slug} lists each choice field's options (value and label, in order) and whether it is multiple, so a widget can draw a select, radios or checkboxes (BaryoDev/barakoPress#21). Additive, so ApiContract.Version does not move. (closes #845)

Added

The site blueprint declares Collections, the setting barakoPress reads to render a tenant's configured lists.

Each entry names a content type, a route, a field map, references, a sort, and how the list behaves: whether it feeds, whether it is in the sitemap, a page size, a label and a noun, related items, a read time, and a choice field to colour it by (BaryoDev/barakoPress#5). A fresh tenant applying the site blueprint gets the field; a tenant that applied it earlier adds it by hand with POST /api/content-types/site/fields, the same as any field added since. Nothing changes for a tenant that leaves it unset. Additive, so ApiContract.Version does not move. (closes #873)

Added

Preflight now runs holdout on the change in hand, not just on its fixtures.

Until now the fixtures proved holdout still worked and nothing ran it against the pull request being prepared, so the check existed and was never asked anything. --body <file> points it at the pull request body. It is required when the diff touches production code and ignored when it does not, because a release, a changelog or a docs pass has no hunk to bind and demanding a declaration there is friction that buys nothing. A change that touches production with no --body fails rather than skipping: a check that quietly does nothing when its input is missing is the hole this script keeps closing one level at a time. Each holdout exit code maps to its own message, so a caught test, an unresolved binding and an inconclusive build do not collapse into one failure.

Added

scripts/holdout.sh decides whether a test added by a pull request notices the change that pull request made.

The method has required this since September 5 and nothing ran it: preflight had no revert step, no pull request body carried a binding, and the review checklist item that reads "mutation reverted and the named tests failed" was being answered yes over a revert that never happened. The script reads a fenced holdout block naming, for each new test, the production hunk it depends on; it holds that hunk out in a throwaway worktree, rebuilds, and requires the named test to fail, then restores and requires it to pass again. A hunk that is bound to no test and not listed under untested: with a reason fails the run, so an omission has to be written down rather than simply left out. A held-out tree that does not build is inconclusive rather than a pass, because a revert that breaks compilation would otherwise read as a test failing for the right reason forever. Exit codes are distinct (0 pass, 1 caught, 2 unresolved, 3 inconclusive) so a caller cannot collapse them into "non-zero, whatever". scripts/testdata/holdout/run-fixtures.sh runs a known one-hunk change past all eight cases, every failure path included, and preflight fails if any of them stops producing its exit code.

Added

The site blueprint declares the four settings barakoPress and barakoBrew read but could not be given.

Currency is the three-letter code the money binding format formats against, and without it a bound amount falls back to a plain number, because a default currency is one client's currency. Space and Text are the spacing and type scales a tenant overrides, which is what stops a theme from being pixel sizes written into blocks. Presets holds the saved blocks a designer builds in barakoBrew, which barakoPress reads and renders. Each is a field on the site singleton, so a deployment sets them per tenant with no release. Nothing changes for a site that leaves them empty: every one falls back to what the theme or the configuration already supplies. Additive, so ApiContract.Version does not move. (BaryoDev/barakoPress#33, BaryoDev/barakoBrew#140)

Changed

The package contract was a list in a document, and nothing checked it.

BarakoCMS.Abstractions now holds the module interfaces, the documents and events, the service interfaces and the three workflow extension points, in an assembly that does not reference the core. A type reaching back into the host is a compile error instead of something review has to catch. Namespaces are unchanged, so no using moves and no module needs an edit; every module and the core reference the new package alongside what they referenced before.

Changed

Marten moves from 9.30.0 to 9.37.0, and the event store gains the columns that make the projection daemon observable.

mt_event_progression now records, per shard, which node holds it and its heartbeat, whether it is paused and why, how far behind it is against a warning and a critical threshold, and the sequence, type and tenant of the event that failed it. Until now a projection that died took every workflow with it and said nothing beyond a health check we wrote ourselves, which is the failure WorkflowProjection documents against itself. mt_streams gains the compaction watermark Marten 9.32.0 added. Both are ALTER statements on tables that already exist, and the app runs AutoCreate.CreateOnly, so an upgraded database needs migrations/4.3.0/marten-9-37-event-store-columns.sql applied while the old build is still serving. On PostgreSQL 11 and later each statement is a catalogue change rather than a table rewrite, so it is fast on an event store of any size. Rolling back needs the matching file in the same directory, because a pre-4.3.0 build refuses to start against a database carrying the new columns. JasperFx moves to 2.72.0 with it.

Fixed

The 4.2.1 notes said the 4.2.0 release had skipped three modules. It had not.

The 4.2.0 run pushed BarakoCMS.ExternalAuth, BarakoCMS.Files and BarakoCMS.Portability at 4.1.1, a number none of them had been published under, so the 4.1.1 packages hold the 4.2.0 work and 4.2.1 is the same code under the version the release check expects. The changelog and the GitHub release say so now. The 4.2.0 and 4.2.1 sections were written by hand with their fragments left in changelog.d, so the next assemble would have announced sixty shipped changes as new; the entries those sections left out are added to 4.2.0 and 4.1.0, and the fragments are gone. The playground deploy runs db-assert against the pulled image before it recreates the app, which the 4.2.1 notes claimed and nothing did.

Fixed

A deployment that never set CORS:AllowedOrigins no longer accepts credentialed requests from localhost.

With no origins configured the CORS policy fell back to http://localhost:3000, http://localhost:3001 and https://localhost:7049 with AllowCredentials(), and it did that in every environment, not only Development. This API puts the refresh token in a cookie, so a page served on one of those three ports could drive any deployment that had forgotten the setting. Outside Development, no configured origins now means no cross-origin access at all: a deployment with no browser client needs none, and one with a client gets a CORS error naming the setting rather than a hole nobody looks for. Development is unchanged, so dotnet run and the quickstart behave exactly as before. If your deployment relied on the fallback, set CORS:AllowedOrigins (the CORS__AllowedOrigins environment variable) to the origins your console and site are served from. Two environment checks beside it were case-sensitive, so ASPNETCORE_ENVIRONMENT=development took Development's connection string but production's schema policy, no Swagger and HSTS; both now compare the way the rest of the code does.

Fixed

Holdout asked for 245 declarations on a refactor that had no bindings to make, and its production list counted files no test can be bound to.

Found by pointing it at the assembly split in #971: the unfiltered list named 286 files, 41 of them .csproj and packages.lock.json, which have no behaviour to hold out and change on every dependency bump. Build metadata is excluded now, as are pure renames, which have no hunk at all. The two copies of that file list, one for none: and one for the unclaimed check, are one definition, so they cannot drift apart. none: is also accepted when the change adds or edits no test file: there are no new tests, so there are no bindings to make. It cannot be used to dodge one, because touching a single test makes it fail again, and that is exactly the change for which a binding is owed. The guard was written with --diff-filter=ad copied from the production list, which excludes added paths and so could never notice an added test; the fixture caught it before it shipped.

Fixed

Three things the first real holdout runs found, and none: which the check had never supported despite its own pull request declaring it.

Running it against a feature branch, a merged bug fix and a synthetic one-line change surfaced: the script resolved the repository from its own file location, so a copy run from anywhere else reported "cannot find merge base" and read as a git problem rather than a path one; a file the change adds outright is a single hunk covering the whole file, and holding it out deletes the file, which never compiles, so the run spent two builds to reach an inconclusive it could have predicted from the diff; and a class whose every test fails on the clean tree is usually a stopped Docker rather than a broken branch, which the message now says when the daemon is unreachable. none: <reason> is now parsed, and a change declaring it while touching production files fails rather than passing, so it cannot be used to opt out. The fixture suite covers all four, eleven cases now.

Fixed

A holdout run with every hunk declared untested reported that all bindings had been held out and had failed as required, having held out nothing.

Found by pointing it at a pull request that changes a shipped blueprint and a changelog. The exit code was right, since declaring every hunk untested is a legitimate answer for a change that ships data rather than behaviour, but the sentence borrowed the words of a run that proved something. Pasted onto a pull request it would read as evidence of a check that never ran, which is the failure holdout exists to catch. It now names what happened and lists the untested hunks, and it stops before building a tree it has no binding to test. The success line counts the bindings it actually held out.

4.2.1

2026-09-18

Patch
Fixed

BarakoCMS.ExternalAuth, BarakoCMS.Files and BarakoCMS.Portability are published at 4.2.1.

The 4.2.0 release pushed all three at 4.1.1, a number none of them had been published under before, so nothing was skipped: the 4.1.1 packages already hold the 4.2.0 work, the OAuth state from RandomNumberGenerator (#754, closes #652), the corrected Files documentation (#748, closes #553) and the 200-field cap in Portability (#861, closes #650). What was wrong is the number. A module's version is the version of the release that publishes it, and scripts/check-module-versions.sh measures that against the v<version> tag, which did not exist until the release created it, so the check passed on the release pull request and went red on the next one. 4.2.1 republishes the three under the version they should have carried; the code is the same as 4.1.1. Unlike 3.12.1, 3.17.1 and the 4.1.0 Import and Portability skip (#749), no change was lost this time. The first version of this entry said --skip-duplicate had dropped them. It had not.

Fixed

A deploy carrying a schema change the target database will not accept is refused before the running container is replaced.

Production and playground run AutoCreate.CreateOnly, which creates a missing table and never alters an existing one, so a release carrying a delta on a table that is already there throws on start and crash-loops with the previous container already gone. 4.2.0 did exactly that on playground. db-assert was on the image and in the 4.0 upgrade guide, but nothing in the deploy path ran it. The production upgrade doc now puts it between the pull and the up, scripts/assert-schema-current.sh is that step for any compose stack, and the playground deploy script on the host runs the same check before it recreates the app. The doc also said "schema migrations run on start", which is true for a new table and not for a changed one, and now says which is which.

4.2.0

2026-09-18

Minor
Breaking

A locked account now answers like any other failed sign-in.

It used to be distinguishable, which told an attacker which usernames exist and which of them are worth waiting on. X-Api-Contract-Version moves to 4. The barakoBrew console refuses to start against a contract version it does not speak, so it has to be upgraded in lockstep with this release. (#867, closes #640)

Breaking

Erase and rollback now require their own API key scope.

A key that could write could previously also destroy. An existing key keeps working for everything except those two routes, which it has to be regranted for. (#863)

Breaking

Username and email uniqueness is enforced on the normalised values

, so two accounts differing only by case or surrounding whitespace can no longer both exist. Stored values are recomputed at startup rather than left to the migration's SQL. A deployment already holding a normalised collision keeps serving both, and the next edit of either is refused until one is changed. (#864, #879)

Breaking

A content type now caps how many fields it can hold.

A type already over the cap keeps serving; the next edit of it is refused until it is under. (#861, closes #650)

Breaking

Malformed and boundary input answers 4xx rather than 500.

Anything treating a 500 from these routes as retryable should be rechecked. (#758, closes #648)

Added

Pages: a module for the page tree, navigation and path resolution.

(#826, closes #718)

Added

Forms: a module so a public visitor can submit a form.

(#821, closes #720)

Added

A choice field type with ordered options.

(#820, closes #803)

Added

Site: holding mode and share links

, so a tenant can put a site behind a holding page and hand out links that see past it. (#860, closes #841)

Added

A site blueprint for a tenant's identity, theme and chrome

, with tenant domains settable through the API and tenant lookup by host. (#797, #796)

Added

Every rate limit is configurable

, plus a renderer partition. (#832)

Added

Workflow actions report a group, and optional and secret parameters.

(#783, #764)

Added

A signed-in viewer can fetch an entry by its slug

, with the same permission, tenant, status and field-masking rules as a read by id, and 404 for one it may not read. (#768)

Added

Resolving a client error can record a reference and a note

, returned on the list. (#791, closes #790)

Added

The page tree reports the fields it is built from

, under options. (#826, closes #842)

Added

A deployment guide for App Service, Fargate and Cloud Run.

(#755, closes #727)

Added

A first-module walkthrough, from clone to passing tests.

(#760, closes #729)

Security

Every credential-named workflow parameter is encrypted

, and stored ones are migrated. An encrypted credential is told from a plain one by a prefix rather than by its shape, which guessed wrong on values that happened to look encrypted. (#862, #880)

Security

The workflow execution log is redacted on write and on read

, and stored debugger logs are redacted too. (#859, #881)

Security

Security headers are kept on early error responses, and exception text is kept out of 500s.

(#874)

Security

Stored redirects are normalised when served

, and subdomain tenants resolve by host. Backslashes and control characters in redirect paths are normalised. (#872, #751)

Security

A password hash below the configured work factor is upgraded on successful sign-in.

(#866)

Security

OAuth state is minted from RandomNumberGenerator.

(#754)

Security

No-store on token and profile responses, no Server header, and no config keys in 503s.

(#757, closes #654)

Security

Only Caddy's address is trusted for forwarded headers in the production compose.

(#767)

Fixed

A slug the caller cannot read answers 404

, like a missing one, rather than revealing that it exists. (#875)

Fixed

A by-slug read of a type named erase or rollback is no longer treated as destructive.

(#882)

Fixed

Moving a page checks the whole subtree

, skips unchanged parents, and runs save hooks on workflow field updates. (#876)

Fixed

Navigation says when it has been truncated

rather than silently returning a partial tree. (#878)

Fixed

Workflows find partitions from the tenant registry

, so enforced database tenancy still runs them. (#943, closes #877)

Fixed

A workflow failure records whether it was permanent

, and retrying one is audited. (#865)

Fixed

A workflow trigger can name more than one content type.

(#776)

Fixed

A parent reference that points at itself or closes a cycle is refused.

(#772)

Fixed

Endpoints of a disabled module are no longer mapped.

(#773)

Fixed

Startup throwing before the host's handler exits 1

, so the image stops instead of spinning. (#777)

Fixed

The semantic search scan is bounded.

(#771, closes #620)

Fixed

A bodiless GET or DELETE sent with a JSON content type binds instead of answering 400.

(#756, closes #681)

Fixed

Job workers wait for the schema apply

, so a host no longer races itself into 42P07 on the jobs index. (#761, closes #686)

Fixed

The quickstart ships its own backup script

, so a folder copied out of the repository takes backups. (#753, closes #712)

Fixed

db-assert and db-patch work on the published image

, which runs the Suite host. (#759, closes #662)

Fixed

A release body over GitHub's limit is summarised, and checked before anything is published.

(#752, closes #660)

Fixed

BarakoCMS.Import and BarakoCMS.Portability 4.1.1 carry the singleton handling 4.1.0 skipped.

Both had been bumped to 4.0.1, a version already on NuGet, so the 4.1.0 release dropped them with --skip-duplicate; this release publishes them. (#749)

Fixed

ahmdkaml is credited for code, not ideas.

(#744)

Changed

Postgres is tuned for a 2 GB server

, query stats are recorded, and there is a delivery load script. (#822)

Changed

The upgrade gate runs against the Suite host

, and rollback drops the Files index. (#775)

Changed

Files.S3 tests against a maintained S3 server

instead of archived MinIO, and the env examples stop recommending MinIO and the Docker bridge range. (#774, #778, closes #619)

Changed

Architecture decisions D22 to D34 recorded.

(#819, #947)

Changed

The docs name the console image barako-brew.

(#762)

Changed

preflight.sh refuses to pass having tested nothing

, and checks the three pinned versions agree. (#743)

4.1.0

2026-09-12

Minor

Contributor

Breaking

A write carrying a slug another entry of the type already holds is now refused with 400.

That tightens request validation on POST /api/contents, PUT /api/contents/{id}, the rollback route and bulk import, so X-Api-Contract-Version moves to 2. The barakoBrew console refuses to start against a contract version it does not speak, so it has to be upgraded in lockstep with this release, not after it. A deployment that already holds duplicate slugs is not rewritten: it keeps serving them, the slug route now answers with the oldest of them every time rather than whichever one Postgres handed back first, and the next edit of one of the colliding entries is refused until the slug on it is changed. One caveat on bulk import: a row colliding with a stored entry is refused, but two rows of the same batch carrying the same slug as each other are still both accepted.

Breaking

A role named SuperAdmin, Admin, HR or User is now refused with 400.

That tightens request validation on POST /api/roles and PUT /api/roles/{id}, so X-Api-Contract-Version moves to 3. A seeded role keeps its own name, so renaming one to what it already is still works. The names were not previously reserved by anything, so an existing custom role holding one keeps working and keeps serving; the next edit of it through the role endpoint is refused until its name is changed. The barakoBrew console refuses to start against a contract version it does not speak, so it has to be upgraded in lockstep with this release. See the Security entry for why the names had to become unavailable rather than just unprivileged.

Added

A client site's own values had nowhere to live.

Address, phone, an emergency number, opening hours and footer text are content, but a content type holds any number of entries, so nothing stopped an editor creating a second set of them. ContentTypeDefinition gains IsSingleton, default false, settable on POST /api/content-types and reported back on the type. A second entry of a type that sets it is refused, whatever case the type name is spelled in, including two rows of one bulk import. Editing the entry that is already there still works, which is the point of the flag. The cap counts entries of every status, so archiving the one entry does not free the slot, and a Portability bundle import is not capped.

Added

A module could not serve public content without copying four security checks.

The projection behind /api/public was internal to the delivery slice, so a module adding its own anonymous route had to re-implement published status, document sensitivity, the content type's delivery opt-in and the field allowlist. IPublicContentProjector in Core/Interfaces now exposes it: hand it a Content and its definition, get back the same shape /api/public/{type}/{slug} returns, or null when the entry must not be served. The core routes call the same code, so there is one copy of the checks. Project also refuses a definition that is not the entry's own content type, since another type's schema would apply another type's field allowlist. See docs/delivery-api.md.

Added

A content lifecycle hook could not tell which entry it was guarding.

ContentLifecycleContext carried the content type, the data and the stored version, but not the id, so a rule about the entry's own identity had nothing to start from: it could not refuse an entry that names itself as its own parent, nor walk the ancestor chain looking for a cycle. The context now carries EntryId, the entry's id on update and null on create, and every caller of IContentLifecycleRunner.RunBeforeSaveAsync passes it.

Added

A content type's fields were fixed the moment it was created.

POST /api/content-types/{name}/fields adds one to a type that already exists. The only route to a new field was the SEO endpoint, which performs the same mutation for one hardcoded set, so a client asking for one more field on a type already holding their content had no answer that did not involve recreating the type. It refuses a name the type already has rather than overwriting it, refuses a required field with no default on a type that already has entries, and puts the merged field list through the same validator POST /api/content-types uses. Additive, so the API contract version does not move.

Added

Where each product is extended was folklore, and got re-argued twice in one day.

D20 records it: barakoCMS by modules, barakoPress by widgets, BaryoVM by release manifests, and barakoBrew by nothing. It also records why the console deliberately has no plugin model, since one console serves every deployment and cannot load a third party's code without becoming a different console per site, and the ladder a developer climbs, where a module is the last resort rather than the first move.

Added

Owning the software was being confused with running the servers.

D21 records that barakoCMS is a container and a Postgres database, so it runs on a VM, on App Service, on Fargate, on Cloud Run or on the Kubernetes manifests already in k8s/, and that BaryoVM is one deployment option rather than the path. It also writes down something nobody had: scaling out is already safe, because SchemaApplyLock takes a blocking advisory lock and projections are leased per projection, so several instances starting at once serialise rather than race.

Changed

A pull request red only because its base is old now fixes itself.

When master moves, any open pull request that is behind it and failing gets its branch updated and CI runs again. minio/minio being removed from Docker Hub failed the integration suite on every branch at once, and after the fix landed two pull requests stayed red for a reason that was already fixed until somebody worked that out. Nothing is merged and no job is retried: a retry hides a flake, where rebuilding on a newer base rules out one cause and leaves a real failure visible. Green-but-behind is left alone, and the no-self-heal label opts a branch out.

Changed

Sensitivity:Mode=All is now refused at startup instead of running inert.

It was declared but never implemented: scrubbing branches on Off and nothing else, so All behaved exactly as SensitiveOnly while accepting the setting and starting cleanly. An operator who sets it has decided they need strict lockdown, which is the one case where getting SensitiveOnly silently is worst. Refused the same way Erasure:Mode=CryptoShred is, and for the same reason. Use SensitiveOnly, which is the default.

Fixed

The public slug route could serve either of two entries sharing a slug.

It resolved with an unordered FirstOrDefaultAsync, so on a deployment that already holds duplicates the same URL answered with either page and could change its mind between requests. It resolves oldest first now, with the entry id as the tiebreak, so the answer is at least stable while the duplicates are cleaned up.

Fixed

Two entries of one content type could share a slug.

Nothing checked, and the public slug route resolves with FirstOrDefaultAsync, so the same URL could serve either entry and change its mind between requests. A create, an update and a version rollback now refuse a slug another entry of the type already holds, in any status, matched case-insensitively the way the route matches it.

Fixed

The Caddyfile registered the literal string ${ACME_EMAIL} as its Let's Encrypt contact.

Caddy reads a config-time placeholder as {$VAR}, which line 6 of the same file already used correctly. Compose does not template a bind-mounted file, and nothing in the tests or CI parses the Caddyfile, so every deployment following deploy-in-production.md had no real ACME contact address.

Fixed

Six documentation claims that were checkably false.

The 4.0 rollback guide said it restores two columns; it drops nine tables, and the email provider key and connector credentials in them cannot be read back first because nothing decrypts them for display. It also called the migration safe to re-run when four statements carry no guard. compliance-posture claimed a CycloneDX SBOM per package and per image attached to each release; there is one solution-wide SBOM kept as a 90-day workflow artifact. delivering-a-client-project had Auth:LegacyRoleFallback defaulting to true when 4.0 defaults it false, still described the cross-tenant audit read that Features/Audit/List closed, and listed DOMAIN_ADMIN as required after the console moved out. SECURITY.md still said 4.0 had not shipped.

Fixed

Every CI run started failing because an upstream image disappeared.

MinIO archived the project and the minio/minio repository on Docker Hub now answers "pull access denied ... repository does not exist" to an anonymous pull, so S3FileStorageTests could not start its container and every merge queue run failed with it. The same tag is still served from quay.io, where MinIO published in parallel, so BarakoCMS.Tests/S3FileStorageTests.cs pulls from there instead. A registry change, not a version change.

Fixed

delivering-a-client-project said a custom role could not reach four admin surfaces, and listed seven shipped features as missing.

All four take a capability, and the page now says which; only the media library screen is still absent. (#742)

Security

A caller holding manage_roles could grant itself a full authorisation bypass.

PermissionResolver granted every capability to any role whose Name was SuperAdmin, and role create put no guard on the name a caller supplied, so POST /api/roles {"name":"SuperAdmin"} followed by assigning it bypassed every capability gate, erase_content included, which is deliberately withheld from Admin. The resolver now identifies the seeded role by its id, which SystemRoles already documents as the key. That alone was not enough: TokenIssuer puts role names into the JWT and SensitivityService reads one back with IsInRole("SuperAdmin") to skip field scrubbing, and a claim carries no id, so the same fake role also switched off sensitivity masking for its holder. Reserving the four seeded names on both role write paths closes that second route, which is the only point both paths pass through.

4.0.1

2026-09-09

Patch

Contributor

Changed

The module template points a new module at 4.0.1.

dotnet new barakocms-module defaulted to the core version it shipped beside, and that default has to move with the release or every module generated after today compiles against the previous one. BarakoCMS.Templates ships 4.0.1 for it. TestingVersion stays at 4.0.0, because BarakoCMS.Testing did not change and is not being republished.

Fixed

Two instances starting against the same database could fail on 42P07.

Marten asks the database what exists and then issues the DDL, and those two steps are not atomic, so two hosts starting together both saw an object missing, both created it, and the loser's whole batch failed. Marten guards its own ApplyAllDatabaseChangesOnStartup with an advisory lock; this application replaced that call to get the schema in before the seeders, and the lock came off with it. Schema apply and the module preflight now run under a Postgres advisory lock, so a second host waits and then finds the work done.

Fixed

A deployment that set App:BaseUrl got a working feed and a sitemap that failed.

GET /api/public/sitemap.xml read Feeds:SiteUrl on its own and answered a bare 500 without it, while the feed beside it resolves through CanonicalHost and falls back to App:BaseUrl. The sitemap resolves the same way now, and refuses with a message naming the setting instead of an empty 500.

Fixed

The module version gate failed on clean master and blocked every pull request.

It measured a module's changes from the commit that set the version in its .csproj, not from where that version was released. Those are different commits, and everything between them is in the published package, so the release commit that touched BarakoCMS.Templates after #645 set its version was reported as a change that would be skipped at publish. It would not: 4.0.0 was built from it. The gate measures from the v<version> tag now, falls back to the old reference when no such tag exists, and CI asks for tags by name so a missing one cannot silently reinstate the old behaviour.

Fixed

A browser could not read the ETag the concurrency work emits, so nothing could send it back.

#565 gave Content optimistic concurrency through ETag and If-Match, and the header was correct on the wire, but CORS never sent Access-Control-Expose-Headers. Script sees only the seven safelisted response headers cross-origin, so response.headers.etag was undefined in the console and two editors still overwrote each other. AllowAnyHeader() governs request headers, which is a different list, and the two are easy to conflate. ETag and X-Api-Contract-Version are exposed now, on both branches of the policy.

Fixed

The 3.x to 4.0 migration never created the background job queue's table.

mt_doc_jobs and its three indexes were missing from migrations/4.0.0/3.x-to-4.0.sql, because the queue (#106) landed after that file was generated and it was not regenerated. No deployment broke, because the host creates a missing object at startup under AutoCreate.CreateOnly, but the upgrade gate asserts that the migration alone brings the schema up to date, and it did not. The table and its indexes are in the migration now, and the rollback drops them.

Fixed

The changelog assembler filed every entry under the release that had just shipped.

It looked for its ### Fixed heading with an unscoped search, and a release empties Unreleased, headings included, so the first match in the file belonged to the previous version. Assembling 4.0.1's fragments wrote them into 4.0.0's notes and printed "Assembled 5 into Fixed", which is the shape of failure this repository keeps meeting: a gate that says it worked. Searches are scoped to the Unreleased section now, a missing heading is created in section order, and scripts/test-changelog-assemble.sh pins all of it against a fixture in CI.

Security

The shipped blog blueprint steered every new site into an unsanitised HTML field.

Neither richtext nor markdown is sanitised: both store and return the string that was saved. That is survivable for markdown, whose ordinary renderer drops raw HTML, and not for richtext, which exists to become HTML, so anyone who could edit content had a script tag on every page that showed it. Every body and bio field in the shipped blueprints is markdown now, richtext stays valid for a deployment that sanitises on its own side, and the contract is written down on the field type, in docs/blueprints.md and in docs/delivery-api.md. The blog blueprint also stored a ReadingTimeMinutes that drifted from the body beside it; it is gone, and reading time is computed at render.

Security

A workflow action's credential was only redacted when it was called "Secret".

Action parameters are free-form, so a credential arrives under whatever name the third party uses, and anything called Password, Token, ApiKey or the like was stored on the run record verbatim and served to anyone who can read workflow runs. Redaction now matches credential-bearing names case-insensitively, and errs towards hiding: a parameter named TokenUrl is hidden too, which costs a lookup rather than a credential.

Security

A caller could forge a log line.

The request path is URL-decoded before anything reads it, so a request for a path containing an encoded newline arrived with a real one, and against a one-line-per-entry sink that became a second, attacker-written entry. Control characters in the values the middleware logs are replaced with a space and the value is capped.

4.0.0

2026-09-07

Major
Breaking

No endpoint returns a stored document as its wire contract.

Role, UserGroup, Tenant, WorkflowDefinition, ContentTypeDefinition and the rollback endpoint's Content all went out as the Marten document. That froze every stored property name as API and published any property added later to every client the moment it was saved. Each endpoint owns its response shape now. Field names are unchanged, so a client reading the documented fields is unaffected; what changes is that SearchText and other stored-only properties no longer appear.

Breaking

Every package retargets from net8.0 to net10.0.

Host applications have to be on .NET 10. This is the largest break in 4.0 and no migration helps with it.

Breaking

updatedAt is gone from the content history response.

GET /api/contents/{id}/history returned both updatedAt and timestamp built from the same event timestamp. updatedAt was produced by DateTimeOffset.DateTime, which discards the offset rather than converting, so on a UTC+8 server the two fields described one event eight hours apart and the client had no way to tell which was right. timestamp is correct, is normalised to UTC, and is the field the admin already rendered. A client reading updatedAt was reading a wrong value, so this removes a field rather than a capability, and 4.0 is where a wire change like this belongs.

Breaking

The core package no longer injects appsettings.json into consumer projects.

The published 3.21.0 really does carry content/appsettings.json and contentFiles/any/net8.0/appsettings.json, verified against the artifact on nuget.org, so referencing BarakoCMS dropped the host's own configuration into every consumer to collide with theirs at build and publish.

Breaking

The feature slices are internal.

188 types under Features/ were public only by accident, which under the stability rule froze every endpoint's Request and Response records until 5.0 and turned renaming a field into a compatibility event. IWorkflowAction and IWorkflowEngine stay public, because custom actions are a documented extension point. What the rule covers is now written down in CLAUDE.md section 6 rather than left to the broadest possible reading.

Breaking

IUserRepository and MartenUserRepository are internal.

Breaking

Registering the same module twice is refused rather than skipped.

BarakoModuleBuilder.Add dropped a module whose type was already registered and said nothing, so a host that deliberately added two configured instances got one of them and no explanation, and a test registering more than one module quietly lost one. It throws now, naming the type, which is what the duplicate-name check in ModuleOrder already did for the same class of mistake. DiscoverFrom still skips a type already registered: discovery is a sweep, so adding a module by hand and then scanning the assembly it lives in is a normal combination rather than an error.

Breaking

Enums cross the wire as names, not numbers.

ContentStatus and SensitivityLevel were 0/1/2, and the admin had the numbering transcribed into its own source to cope. Inserting a member renumbered every client. Requests may still send a number, so an existing caller keeps working when it posts; responses are names.

This is the HTTP contract only. Documents are still stored with Status as a number, because mt_doc_contents_idx_status indexes ((data ->> 'Status')::integer) and names there would break the index cast and every query that filters on status.

Breaking

Signing in fails with 401, not 400.

Login and all six refresh failure paths returned 400, which standard client middleware classifies as a caller bug rather than an authentication failure. Account lockout returns 423.

Breaking

sortBy is gone from every paginated request.

It was accepted everywhere, documented in Swagger, and honoured nowhere. On /api/public/{type} it was actively harmful: that endpoint deliberately rejects ?sort= because accepting and ignoring it "would be a silent wrong answer", while ?sortBy= was skipped as an unknown key and returned exactly that. sortOrder stays.

Breaking

The content-type list is GET /api/content-types.

/api/schemas keeps working as a deprecated alias and goes in 5.0. The resource was read at one route name and written at another.

Breaking

GET /api/diagnostics/typecheck is removed.

It returned an anonymous type built by reflection to debug a Marten upgrade, which cannot be expressed in the spec and should not be frozen API.

Breaking

{Id} in two routes is now {id}

, matching the other thirty-odd. Cosmetic at runtime, but it lands verbatim in the OpenAPI paths.

Breaking

Every collection endpoint returns the same envelope.

Nine endpoints returned a bare array (/api/schemas, /api/user-groups, /api/tenants, /api/api-keys, /api/workflows, /api/me/tenants, /api/accounting/accounts, /api/devices, /api/pwa/installs) and two returned an ad-hoc wrapper (/api/settings was {settings: [...]}, /api/contents/{id}/history was {versions: [...]}). All of them now return {items, page, pageSize, totalItems, totalPages, hasNextPage, hasPreviousPage}.

This had to happen in a major or never: a bare array cannot gain pagination compatibly, because the root JSON changes from [ to {. The default page size for the newly paginated endpoints is the maximum, 100, so a deployment small enough not to have noticed still does not.

/api/public/{type}/search keeps {results, count, query} on purpose. It echoes a query rather than paging a set, and the reason is recorded on PublicSearchResponse.

Breaking

/api/pwa/installs no longer silently caps at 1000 rows.

The envelope is the bound now.

Three modules ship the envelope change: Accounting, DeviceTrust and Pwa. Every module published 4.0.0 alongside the core rather than continuing its own 0.x line, so the suite carries one number.

Breaking

Every error the core returns is now ProblemDetails.

Four shapes shipped from an API configured for RFC7807: ProblemDetails, a hand-rolled {message} with the field errors flattened into one string, a hand-rolled {errors: [...]}, and bodyless. POST /api/content-types emitted two of them from one endpoint depending on which check failed. Clients reading message or errors[].message off a 400 need to read errors[].reason.

Breaking

PUT /api/contents/{id}/status requires newStatus.

It was a non-nullable enum, so omitting it or spelling the field wrong bound to 0, which is Draft, and the validator accepted it. A caller sending {"status": 1} moved its content to Draft and was told "Content status changed to Draft". Omitting the status is now a 400.

Breaking

Success responses no longer carry error fields.

Content/Create.Response and Content/Update.Response drop Message; ContentType/Create.Response drops Errors. A generated client no longer sees success types with mysterious nullable error members.

Breaking

Four obsolete members are removed from Events/ContentEvents.cs

, as their attributes promised for "the next major version", which 4.0.0 is. The narrower ContentCreated and ContentUpdated constructors go together with their paired Deconstruct overloads, because removing one without the other only fixes half the break.

Breaking

A 3.x database needs one SQL migration before 4.0 will boot.

Marten moved from 8.37 to 9.30 and four database objects changed. Production runs AutoCreate.CreateOnly, which never alters an existing object, so the first boot against a 3.x database refuses and exits non-zero without writing anything. Apply migrations/4.0.0/3.x-to-4.0.sql first. Full procedure, including rollback, in docs/upgrading-to-4.0.md. scripts/upgrade-check.sh runs the whole sequence in CI against a database created by the released 3.21.0 image.

Breaking

A missing database connection string fails at startup outside Development

, naming the setting, rather than substituting a dummy that points at localhost. Development keeps the dummy, which the codegen pass needs.

Breaking

/metrics needs a scrape key.

The Prometheus endpoint was mapped with no authentication and no network restriction, so on any deployment that publishes the API it handed anonymous callers a list of every route, per-endpoint request counts and latencies, error rates and process internals. It now refuses unless Metrics:ScrapeKey (env Metrics__ScrapeKey) is set and the caller presents it, either as Authorization: Bearer, which is what Prometheus sends from authorization in a scrape config, or in X-Metrics-Key.

A deployment that upgrades without setting the key loses scraping: with nothing configured the endpoint returns 404, because an unset credential has to mean refuse rather than allow. A wrong key against a configured one returns 401, so the two cases are told apart from the status code alone. docs/upgrading-to-4.0.md has the Prometheus config.

Breaking

Feature flags are private until published, and GET /api/feature-flags no longer lists the catalogue to anonymous callers.

The endpoint is anonymous on purpose, since a public page rendering with flags has no user to authenticate, and targeting already evaluated a restricted flag to false for a stranger. But it built its dictionary from every flag before evaluation narrowed anything, so every key came back regardless: unreleased feature names, migration plans, and customer names wherever a flag targets one account.

FeatureFlag gains IsPublic, defaulting to false. An anonymous caller receives only the flags marked public, and a private one is absent from the response rather than returned as false, which would hand over the name anyway. An authenticated caller still receives everything. Existing flags read back as private, so upgrading discloses nothing, and anyone relying on client-side flags on a public page has to publish those flags deliberately: POST /api/feature-flags/admin with "isPublic": true. FeatureFlagService.EvaluateAllAsync takes a FlagAudience; the overload without one returns the public subset, so a caller that has not thought about who is asking cannot leak a key by omission. FeatureFlags 4.0.0.

Breaking

Audit IPs and rate-limit buckets no longer come from a client-supplied X-Forwarded-For.

DeviceContext read that header directly and returned its first hop, so any caller could write its own address into the audit log and the OTP email just by sending one. The rate limiter never read it at all, so behind a reverse proxy every client shared a single bucket and the per-IP limit on /api/auth/login throttled the proxy instead of the attacker.

The header is now applied by the ASP.NET ForwardedHeaders middleware, which honours it only from a hop the operator named. That middleware is off unless ForwardedHeaders:Enabled is true, and turning it on without ForwardedHeaders:KnownProxies or ForwardedHeaders:KnownNetworks stops the host at startup: an empty trusted set either does nothing or trusts every upstream, and both look like working configuration.

What changes for a deployment already behind a proxy: until those keys are set, audit entries and rate-limit buckets record the proxy's address rather than the header value. For an honest client that is a worse answer than before, and for a dishonest one it is a much better one, because the old value was whatever the caller typed. For a proxy container on the compose network:

"ForwardedHeaders": {
  "Enabled": true,
  "KnownNetworks": ["172.16.0.0/12"]
}

Turning it on also applies X-Forwarded-Proto, so UseHttpsRedirection sees the scheme the client used rather than the proxy-to-app hop.

Breaking

A production first run no longer seeds demo content.

The demo AttendanceRecord content type, its sample records and its "Attendance Confirmation Email" workflow were seeded unconditionally, so every production instance came up holding an attendance schema it did not ask for and a workflow stored active that mails whatever address a record's Email field holds. Once an operator configured Resend, that demo fixture became an outbound mail path in their system.

Seed:DemoContent (env Seed__DemoContent) decides it now. Unset, it follows the environment: on in Development, off everywhere else. Roles and the configured InitialAdmin stay unconditional.

What an existing deployment sees on upgrade: nothing is removed. Each of those seeders already skipped when its document existed, so an instance that has the demo content keeps it, and deleting it is a manual choice. What changes is that a new instance outside Development no longer gets it, and neither does an existing one whose demo documents were already deleted by hand. The quickstart runs as Production, so SEED_DEMO_CONTENT=true in .env is how a developer asks for the sample content there.

Breaking

k8s/06-service.yaml is a ClusterIP behind an Ingress, not a LoadBalancer.

It was a LoadBalancer commented "easy access for local testing", which on a managed cluster provisions a public load balancer pointing straight at the app with no TLS and no proxy. Anyone who was reaching the app through that address needs k8s/08-ingress.yaml (new), or kubectl -n barako-cms port-forward svc/barako-cms-service 8080:80.

Breaking

The Kubernetes Deployment reads its database password from barako-secrets.

It inlined Password=postgres while k8s/03-postgres.yaml took POSTGRES_PASSWORD from the secret whose placeholder operators are told to replace, so following the manifests' own instructions handed Postgres a password the app never got. k8s/02-secret.yaml gains ConnectionStrings__DefaultConnection, InitialAdmin__Username and InitialAdmin__Password; set all of them before applying. The manifests could not be applied at all before this, so no running deployment is affected.

Breaking

HSTS is sent, and the policy it sends changed.

The application configured Strict-Transport-Security twice, once through UseHsts and once by appending the header by hand, and a browser processes only the first value it receives, so the effective policy was the framework's 30 day default rather than the year the hand-written copy asked for. There is one policy now, in HstsPolicy: 90 days, includeSubDomains off, no preload. Operational consequence: a browser that reaches a deployment over HTTPS refuses plain HTTP to that host for 90 days and cannot be told otherwise before then, so confirm the host is staying on TLS before taking this. Hsts:MaxAgeDays and Hsts:IncludeSubDomains tune it, and includeSubDomains should go on only once every subdomain is on HTTPS, because it covers subdomains that do not exist yet and cannot be recalled. Nothing is sent in Development, where the hand-written copy had been pinning developers' browsers against https://localhost.

Breaking

Absolute URLs come from configuration rather than the Host header.

The RSS feed and the OAuth redirect_uri were built from Request.Host whenever nothing was configured, and AllowedHosts ships as "*", so the caller chose the origin of links this application hands to crawlers and identity providers. Operational consequence for a deployment that has configured neither Feeds:SiteUrl or App:BaseUrl nor a real AllowedHosts: the feed answers 503 and the external-auth start endpoints throw, each naming the setting that fixes it. Set App:BaseUrl to the deployment's public URL, or set AllowedHosts to the hostnames it answers on, after which the request host is vetted and usable again. AllowedHosts itself still defaults to "*", so nothing else changes on upgrade. One trap if you narrow it: a Kubernetes httpGet probe sends the pod IP as Host, so a list of real hostnames makes the probes 400 and the pod never goes ready unless the probe carries a Host header.

Breaking

Models.ContentType is removed.

A public document type written and read by nothing but the seeder, in a table no query touched. The content types the API serves are ContentTypeDefinition, and always were. An existing mt_doc_contenttype table is left where it is, which is safe under AutoCreate.CreateOnly.

Breaking

A compose stack no longer ships a usable admin password.

docker-compose.yml, docker-compose.hub.yml and .env.example defaulted ADMIN_PASSWORD to changeme-in-production, so a stack brought up with no .env had a SuperAdmin login published in this repository. The default is gone. Set InitialAdmin__Password and nothing changes; leave it unset and the seeder generates one and prints it once to the console, which is a change for anyone who was relying on the shipped literal. The seeder used to skip the account entirely when no password was configured, so removing the default without this would have left a first run with no way in (#271).

Added

CI runs the admin against the real API, with nothing mocked.

Every other admin job mocks the API with page.route, so it proves the admin behaves correctly given fixtures the same person wrote and cannot prove those fixtures match the server. scripts/smoke-check.sh stands up Postgres, the API and the admin, seeds content through the API, and runs admin/smoke, which refuses to contain a route mock. It covers signing in, the error shape, the list envelope, string enums on the wire and the History panel.

Added

Scheduled publishing is reachable from the admin.

A Schedule tab on a content entry arms or clears the publish and archive times, and shows what is armed. The server has had PUT /api/contents/{id}/schedule and the background sweeper for a while, and the README advertised arming any item, but nothing in the admin called it. GET /api/contents/{id} now returns scheduledPublishAt and scheduledUnpublishAt so a client can read back what it set.

Added

A tenant can have a second member.

One thing created a Membership: POST /api/tenants, provisioning the creator as an Active admin. There was no supported way to add anyone else, change what they hold, or remove them, which made a multi-tenant CMS into a single-operator one. Five endpoints under Features/Tenants/Members/ close it: GET /api/tenants/members (roster, active and suspended, newest first), POST /api/tenants/members (add by email), PUT /api/tenants/members/{userId} (roles or status), DELETE /api/tenants/members/{userId} (mark Removed) and GET /api/tenants/members/roles (what an administrator may assign).

The tenant is the caller's current one rather than a route parameter. TenantAccessMiddleware already refuses a request whose token was minted for another tenant, and TokenIssuer puts the caller's effective roles for that tenant into the token, so Roles("SuperAdmin", "Admin") reaching a handler already means an administrator of this tenant. A handle in the route would mean re-deriving that in every endpoint.

SuperAdmin is never assignable here, whatever the caller holds, on both the add and the edit path. Removal marks Removed and never deletes, so history and audit survive. An unknown email creates an OTP-only account (no password, they sign in with an emailed code), a known one reuses the existing user, and re-adding somebody removed reactivates the membership they already had rather than writing a second row. The tenants page in the admin grows a members section for all of it.

Added

A workflow action can report that it failed.

IWorkflowAction gains RunAsync, which returns a WorkflowActionResult. It has a default implementation that calls the existing ExecuteAsync and reports success, so an action written against the old contract compiles and behaves exactly as before; ExecuteAsync is marked [Obsolete] and is removed in 5.0. This is not a break: nothing existing has to change. WorkflowActionResult is a new public type under Features/Workflows, added to CLAUDE.md section 6 and to the public-surface allowlist, because an extension point cannot return a type a module author cannot name.

Every live workflow run is now recorded as a WorkflowExecutionLog with a per-action outcome, so GET /api/workflows/{id}/debug shows which actions ran, which failed and why, instead of only dry-runs. WebhookAction reports its real outcomes through it: a missing URL, a URL the outbound guard refuses, a non-2xx response, and a delivery that could not be made were all log lines and nothing more, which is how a webhook could answer 500 for a week without the workflow ever looking unhealthy.

Added

A Workflow Projection health check and a barakocms_projection_lag_events gauge.

The workflow projection runs in Marten's async daemon, and an unhandled exception there stops the shard: every workflow silently stops firing while database, disk and memory checks all stay green. The check compares the projection's progress against the event high-water mark and reports it at GET /api/monitoring/health. It reports Degraded, never Unhealthy, because /health is what the liveness probe reads and restarting a pod does not restart a stopped shard. Tunable with HealthChecks:MaxProjectionLagEvents.

Added

docs/operating-workflows.md

covers when a workflow action can fire twice, what the run records say, and what to do when workflows stop firing. It also states what a projection rebuild would actually cost, which is where two code comments were wrong.

It documents the rolling-deploy window in particular: HotCold and the scheduled-content lock both need every node to be running the new code, and during a rollout the old node is not, so a workflow action can fire twice for the length of the deploy. No code can prevent that, since the half that does not participate has already shipped. k8s/05-deployment.yaml says so at the strategy, with the Recreate alternative for deployments that cannot tolerate a duplicate.

Added

The content list reports status and sensitivity.

The single-item GET returned them and the list did not, so an entries table could not show which rows were Drafts without a request per row. The admin list has a status column again.

Added

docs/event-sourced-content-types.md

explains what turning on event sourcing commits a content type to: the history becomes the record, stale saves get a 409, the choice is permanent even across delete-and-recreate, and non-Public fields are refused. Written for the admin making the choice, and published ahead of the toggle itself (#230, #331), which has not shipped.

Added

Content can reference other content.

A reference field names the content type it points at, in referenceType, and a write is refused if the target does not exist or is of a different type. ?include=Field on public delivery resolves references in one batched request instead of leaving every consumer to fetch each one. Resolved entries go through the same projection the list uses, so published state, document sensitivity, type opt-in and the field allowlist all apply: resolving is not a second way into a Draft. A target that does not survive that projection has its field removed rather than left as an id, which is also what a dangling reference does.

Added

Public delivery can sort by a field value.

?sort=Price and ?sort=-Price on /api/public/{type}, composing with the existing filters and paging. Only fields the content type marks Public are sortable, for the same reason only those are filterable: ordering by a field the caller cannot read is an oracle. Numbers sort as numbers, entries missing the field sort last in both directions, and CreatedAt breaks ties so paging a sort with duplicate values cannot show one entry twice and skip another.

Added

Content records who created it, and a permission rule can require ownership.

Content.CreatedBy is set from ContentCreated, which has always carried it, and a rule can now say {"$createdBy": {"_eq": "$CURRENT_USER"}} for "own records only". Document properties are named with a $ prefix so a schema field cannot collide, since a field name has to start with an uppercase letter. A record with no owner is denied rather than granted, and a SuperAdmin still sees everything.

Added

An answer to the right-to-erasure question, and a way to act on it.

Erasure:Mode decides how a deployment handles an erasure request. Delete, the default, removes a content item's events, its stream and its document in one transaction through DELETE /api/contents/{id}/erase (SuperAdmin, audited). None requires an explicit acknowledgement. CryptoShred is recognised and refused at startup, because it needs an answer to which field identifies the data subject and a CMS has no natural one; a setting that reads as a policy while no policy is in force is the exact failure this decision exists to prevent. Reasoning in DECISIONS.md D9, and in docs/compliance-posture.md for anyone answering a privacy review.

Added

A support and end-of-life policy.

SECURITY.md had a table that stopped at 3.x and no statement of what "supported" means. It now carries a 4.x row, a rule rather than a date (a major is actively supported until twelve months after its successor ships), what each status includes, and how module packages inherit the core's window.

Added

A compliance posture

in docs/compliance-posture.md, linked from SECURITY.md and the README. States what exists with somewhere to verify each item, states plainly that there is no SOC 2, no ISO 27001 and no third-party penetration test, and answers the largest part of a typical security questionnaire by naming which questions self-hosting moves to the operator.

Added

A software bill of materials.

CycloneDX for the .NET solution and the admin's npm tree, generated during the release build and uploaded as a 90-day artifact. verify-packages fails if either is missing or lists no components, so the release cannot claim an SBOM it did not produce.

Added

Accessibility checks.

The 28 jsx-a11y rules eslint-config-next leaves off are enabled in the existing lint step, and an axe scan runs over the sign-in page, the content list, the content types list and the entry form in the existing e2e pack. Serious and critical fail the build.

Added

db-patch, db-assert and db-apply on the host, so a schema change can reach an existing database as a reviewed SQL file instead of having no route at all.

Added

docs/delivery-api.md.

The parts of the public contract a consumer needs most existed only as C# comments: the page/pageSize bounds and the response envelope, the filter[field][op]=value syntax with its seven operators and five-filter cap, sort=field / sort=-field, include= for resolving references, and which status each refusal returns (#295).

Added

The release tags the repository and writes a GitHub Release.

Sixty-seven versions reached nuget.org while the newest git tag stayed at v3.2.0, so the repository's front page advertised a release from many versions back and git log v3.21.0..master did not resolve. The release now tags the commit it published and creates a Release whose body is that version's CHANGELOG.md section, read by scripts/release-notes.sh, which fails when the section is missing or empty rather than publishing a blank note. The historical tags are not backfilled (#155).

Added

GET /health/build reports the commit an image was built from.

Anonymous, like the other probes, and a separate path so /health keeps the body every dashboard already parses. The commit is stamped in through the BARAKO_BUILD_SHA build argument, since .git is in .dockerignore and cannot be read inside the image. An image built without it answers unknown (#157).

Added

CI reads the Kubernetes manifests.

Nothing ever did, which is how memory: "128Mw" sat in k8s/05-deployment.yaml through several releases. A job stands up a throwaway kind cluster and sends every manifest to a real API server with --dry-run=server --validate=strict. The two cheaper options were measured against the manifests as they stood before that bug was fixed and both accepted them: kubectl --dry-run=client --validate=strict and kubeconform -strict. A resource quantity is a string in the OpenAPI schema, so only the API server parses it. scripts/testdata/k8s-known-bad/ keeps those manifests, and CI fails if they are ever accepted (#383).

Added

CI fails when a tracked lockfile is watched by nothing.

A directory Dependabot does not cover produces no error and no pull request, so site/ drifted unwatched. scripts/check-dependabot-coverage.sh compares every tracked package-lock.json against the npm entries in .github/dependabot.yml (#153).

Added

SWAGGER_ENABLED on the shipped compose files.

docker-compose.yml sets it true, which is what it already did through the environment; docker-compose.hub.yml sets it false. Swagger follows ASPNETCORE_ENVIRONMENT when Swagger:Enabled is unset, and the hub file defaults that to Development, so the compose that runs the published images turned the whole API surface on without anyone choosing it. Saying it explicitly means changing the environment no longer changes what is published as a side effect (#271).

Added

A width parameter on the file downloads.

GET /api/public/files/{id}?w=640 and GET /api/files/{id}?w=640 answer with a resized copy of a PNG, JPEG or WebP, made on the first request that asks for it and kept as a derived StoredFile row pointing at its parent. Every consumer was downloading a full-size upload and resizing it client side, or the editor was being asked to upload three sizes.

The cap is the part worth reading. ?w= above Files:Images:MaxWidth (default 2048) is refused with a 400, and requests inside it are snapped onto a ladder of seven widths rather than honoured literally. Honouring an arbitrary width on an anonymous route means anyone can walk ?w=1 through ?w=2048 on one public image and leave two thousand stored blobs behind, which is a cap on the cost of a request and no cap at all on what the cache costs. Dimensions are read from the image header before any pixel is decoded, so a ten megabyte PNG that decodes to tens of gigabytes is served at full size rather than resized.

A variant is reachable exactly when its original is. The access check runs on the original before a resize is considered, so a private file with a ?w= on the public route is a 404 that did no work, and a cached variant is not addressable by its own id on either route, including for an admin. That is deliberate: an addressable variant would need access rules of its own, and a second copy of an access rule is one that can drift out of step with the file it came from.

Anything that is not a resizable image is served unchanged with the parameter still on the URL, so a frontend that appends ?w= to every asset does not break on the one that is a PDF. Setting Files:Images:MaxWidth to 0 turns the whole thing off. docs/image-variants.md has the rest.

The variant is stored with its parent's public flag, and on a store with ACLs that is an access control rather than bookkeeping: S3 turns a public put into a PublicRead object with a URL that is then persisted on the row and redirected to. So a variant of a private file stored public would be a private file anonymously fetchable at the bucket, whatever the API answered.

Concurrent decodes are bounded by processor count. The pixel limit bounds one decode and the rate limiter caps one address, so N simultaneous misses on the same uncached width were N simultaneous bitmaps in memory. Work queues now instead.

A file the resizer cannot handle is served unchanged at any width, including one above the cap. It used to be a 400 there, which broke the promise that a frontend can put ?w= on every asset URL.

Added

A server-sent event stream of content changes.

GET /api/public/events streams content.published, content.updated and content.unpublished for the tenant, filterable with ?type=. Every payload is produced by the same projection the REST reads use, so a Sensitive field is masked in the stream for the same reason it is masked on GET /api/public/{type}/{slug}, and a subscriber on one tenant never receives another tenant's change. Off by default: Delivery:Events:Enabled turns it on, Delivery:Events:MaxConnections (100) caps open streams per instance, and a keepalive goes out every 15 seconds. Fan-out is in process, so with several API instances each streams only the writes it handled; docs/delivery-api.md says so. Closes #105.

Added

A job queue whose enqueue shares the request's transaction.

QueueJobAsync stages the job in the request's scoped Marten session, so a request that throws or fails to commit leaves no job and a request that commits leaves one in its tenant. The queue owns retry: a record carries the attempt count, the next attempt time and the last error, waits with exponential backoff (Jobs:BackoffBaseSeconds, capped by Jobs:BackoffMaxSeconds) and is dead-lettered after Jobs:MaxAttempts. A claim holds for Jobs:LeaseSeconds, which is also the handler's execution limit. GET /api/jobs lists a tenant's jobs behind the new view_jobs capability, which Admin holds by default. Nothing migrates onto the queue yet; one logging command proves it runs. See docs/background-jobs.md.

Added

Content type blueprints: a site starts from a named set of types instead of an empty schema.

GET /api/content-types/blueprints lists them and POST /api/content-types/blueprints/{name} creates every type one declares in the caller's tenant. Four are built in: blog (post, category, author, page), events (event, venue, speaker, with a geopoint location), portfolio (project, client) and docs (article, section). Every addressable type has a slug field, and fields that are for the team rather than the public are marked Sensitive or Hidden.

Applying is additive and all or nothing: a type that already exists refuses the whole blueprint with a 409 naming the clash, and types the blueprint does not mention are left alone. Gated on manage_content_types, like the create it stands in for.

Set Blueprints:Path to a directory and its *.json files are listed alongside the built-ins. Each file is validated when listed, with the same validator the create endpoint runs, and a broken file shows its errors in the list rather than failing at apply time.

Added

A content type can opt into the SEO fields every client site needs.

POST /api/content-types/{name}/seo-fields adds meta title, meta description, canonical URL, social image and a no-index flag. Ordinary fields marked Public, so delivery, validation and scrubbing already handle them, and all five optional so opting in does not invalidate existing entries. Additive and idempotent: a field the type already has is left exactly as it was.

Public delivery now carries a resolved seo block, omitted entirely for a type that has not opted in. An unset meta title falls back to the entry's own title rather than emitting an empty tag, using the same field names the admin uses to label an entry. An empty title tag is worse than none: a search engine shown one indexes the page with nothing to display.

An entry marked no-index is left out of the sitemap, because listing a page and then telling the crawler to go away when it arrives is a contradiction Search Console reports as an error. Title and description lengths are guidance rather than validation, since search engines truncate on pixel width and a hard limit would be wrong in both directions.

Added

URL redirects, so a rebuild does not break every existing link.

GET/POST /api/redirects and DELETE /api/redirects/{id} manage them, POST /api/redirects/import takes a CSV for a migration, and GET /api/public/redirects/resolve?path= is the anonymous lookup a frontend makes on its 404 path. One indexed equality comparison, no wildcards, cached for five minutes, because that path is when a site can least afford anything else.

Loops are refused when a rule is saved rather than when a visitor hits one: a path redirecting to itself, a rule closing a circle with rules already stored, and a chain longer than ten hops even when it terminates. An import checks each line against the stored rules and against the lines above it in the same file, which is the loop no single line creates and nobody can find afterwards. A bad line is rejected by number and the rest still import.

permanent defaults to false, so a redirect is a 302 unless asked otherwise. A browser caches a 301 indefinitely, so one entered by mistake is not fixed by deleting the rule.

Added

Alt text, a caption and a where-used list on files, for a media library.

PATCH /api/files/{id} stores alt and caption with a file, GET /api/files/{id}/meta and the new GET /api/files list return them, and GET /api/public/files/{id}/meta hands them to a frontend for a public file only, 404 otherwise like the bytes next door. The list takes ?q= for a name substring and ?contentType=image/ for a type prefix, is paginated, and leaves out cached resizes.

GET /api/files/{id}/usage lists the entries whose data references the file, matched by the id and by the storage key so a bare id, a download URL with or without ?w=, and an object store's public URL are all found. DELETE /api/files/{id} is new and refuses with a 409 naming the first ten usages while any entry references the file; ?force=true deletes anyway, along with the cached resizes and every blob behind them. A usage row's title goes through the same read permission and sensitivity checks as GET /api/contents, so a file used by a Sensitive entry still blocks a delete without telling the editor what the entry says. All of it is gated on the module's upload_files capability. The console half (grid, picker) is barakoBrew's.

Added

The seven modules that shipped with no tests have them.

ExternalAuth, DeviceTrust, Portability, FeatureFlags, Email.Resend, Import and Analytics.Umami were built, packed and pushed to NuGet on every release with no assertion anywhere covering them, and two of the seven are authentication surface. 52 tests, each one checked by breaking the thing it covers and watching it go red.

What is pinned is the behaviour that would hurt if it broke rather than a coverage number. An account with MFA enrolled gets a challenge from a social sign-in and never a token, which is the bypass 0.1.5 shipped. An OAuth callback with a missing or mismatched state mints nothing, and one that matches its own state signs a verified account in. A device-bound token is refused from any other device, a token with no did claim is deliberately left alone, revoking a device kills its refresh tokens and nobody else's, and nobody can revoke a device they do not own. An exported bundle imports into a clean tenant with its schema and content intact, twice over without duplicating the type, into the calling tenant only. A percentage rollout gives the same person the same answer every time. The Resend API key travels as a bearer header and appears in no URL, body or exception. A bad row stops an all-or-nothing import before anything is written. The Umami account never reaches the browser, and data requests to Umami carry the exchanged token rather than the credential.

BarakoCMS.Tests now references DeviceTrust, Import and Analytics.Umami as well, so all seven are reachable from a test at all, which four of them were not.

Added

A provider outage during registration is now pinned as invisible from outside.

POST /api/auth/register answers one sentence whatever happens, so that an address somebody else already registered cannot be told apart from a free one. A send that escaped as a 500 would have put that difference back without anyone editing the message. Three tests cover it: the answer is byte for byte the same with the provider down as with it up, the failure reason never reaches the response, and a sign-in code request answers the same thing for a registered address, an unknown address, and a registered address during an outage.

Added

BarakoCMS.Email.Smtp, an SMTP email provider.

Email no longer means signing up for one particular SaaS: any relay works, which is what a self-hoster already has from their host, from Google Workspace, from SES or from a corporate mail server. MailKit does the sending, not the System.Net.Mail.SmtpClient Microsoft tells you not to use in new code.

The module reads its own Modules:Email.Smtp section (host, port, user, password, from, TLS mode), because the existing settings surface resolves an API key and a from address and SMTP needs neither shape. A from address typed into the admin still wins, since that field is not provider-specific.

With no host configured it registers nothing at all, so adding the package to an existing deployment and configuring nothing leaves whatever was sending before still sending. The TLS default will not fall back to plaintext: unset means implicit TLS on port 465 and STARTTLS everywhere else, and a relay that does not offer STARTTLS fails the send rather than getting the password in the clear. A failed send names the relay and quotes its reason, with the password redacted out of it, because a relay that echoes the credentials it just rejected would otherwise put them in an admin screen and a support ticket.

Added

A screen for importing a spreadsheet.

The Import module had two endpoints and no interface, so turning an .xlsx or CSV into entries meant calling the API by hand. Settings now has one: choose a file, say which row holds the headings, match each column to a field on the target content type, and import.

Entries are created as drafts, so nothing an import gets wrong is published. Every row is attempted and the refusals are reported by their position in the sheet, rather than the first bad row ending the run and leaving an editor to work out how much of it landed. A column matched to nothing is left out rather than sent blank: a sheet usually carries a column nobody wants, and sending it would either fail validation or invent a field the type never declared.

Added

Devices and Export/import have admin screens.

Both modules shipped a backend with no interface, so their features existed only for whoever was willing to call the API by hand. Settings > Devices lists the browsers and apps signed in to your own account and revokes one, with the confirmation saying something different when it is the device you are sitting at. Settings > Export and import downloads every content type and entry as one JSON file, and takes one back in, with a preview that runs the import as a dry run first. The preview names the entries whose content type is in neither the bundle nor the CMS: those import successfully and then never appear in public search, so a plain success message would be true and misleading.

Added

A geopoint field type and a proximity filter on delivery.

A field can now hold { "lat": number, "lng": number }, validated as a real coordinate pair rather than free text, and GET /api/public/{type}?filter[Location][near]=lat,lng,radiusKm returns the entries within the radius. Each item then carries distanceKm, and sort=distance orders by it. No PostGIS: the query is a bounding box then the haversine, both in SQL over the stored JSONB, and it sits in the same chain as every other filter so a Draft inside the radius stays invisible. The radius is capped by Delivery:MaxRadiusKm (default 1000) so the prefilter always applies. Distances are great-circle, right for "within 10 km" and not for geodesy. The console's map editor is barakoBrew's side.

Added

CODING_STANDARDS.md

, a signpost to CLAUDE.md, which is the coding standard and was effectively invisible under a filename no human contributor has a reason to open. CONTRIBUTING.md and the pull request template now point at it too. The standard itself gains the rules two contributor pull requests showed were missing: developer-machine files stay out of the repository, a config default preserves existing behaviour, a list endpoint is bounded, prefer an existing pattern over a new one, and an assertion over a collection has to assert the collection is not empty first. Closes #145.

Added

Swagger shows /api/public/students, not just /api/public/{type}.

A content type is created by a user at runtime, so nothing built at compile time can ever name it. The generated OpenAPI document is now merged with a projection of the content types on its way out, adding the list, search and slug paths for every type marked IsPubliclyDeliverable along with a schema built from its fields. No route is added and no delivery code changes: /api/public/{type} keeps matching exactly as it did, and it is still in the document.

A schema is disclosure, so this is an allowlist and it emits exactly what the anonymous delivery endpoint would return. A type that is not publicly deliverable does not appear, not even by name. A field whose sensitivity is not Public is absent from the schema, because a field name is itself information: naming guardianContactNumber tells a reader what to probe for even when every value comes back masked. ValidationRules and DefaultValue are never published. The document does not vary by caller, so a cached copy cannot show one caller what another may see, and it is cached per tenant and invalidated when a content type is created or its delivery is switched. Closes #159.

Added

A field's sensitivity was fixed at the moment the content type was created.

There was no update path at all, so a field marked Public by mistake stayed readable, and a field that should never have been masked stayed masked, until somebody edited the database by hand. PUT /api/content-types/{name}/fields/{field}/sensitivity changes one field's level, admin only, and rebuilds the derived search text for every existing entry of the type so that raising a field stops its value being matched by anonymous search and not only stops it being returned. Lowering is a disclosure of data written under the old level, so it is refused unless the request sets acknowledgeDisclosure, and it is recorded under its own audit action. Raising stops the value being served and does not remove it from storage, backups or the event stream.

Added

Modules are found by reference and chosen by configuration.

AddBarakoCMS now discovers every IBarakoModule in the application's dependency context, so dotnet add package plus a restart is the whole install and BarakoCMS.Suite/Program.cs names no modules at all. Only libraries that reach BarakoCMS through their dependencies are loaded, only public top-level types with a parameterless constructor count, discovered modules are ordered by type name, and a type the host already added is skipped. modules.Discover = false on the builder, or BarakoCMS:Modules:Discover=false in configuration, keeps the explicit list only. A host that references a module package without adding it now runs that module; turn discovery off to keep the old explicit-only behaviour.

BarakoCMS:Modules:Enabled, an array or a comma-separated string (BarakoCMS__Modules__Enabled=Accounting,Files), decides which of the modules found run. Unset runs all of them and logs one warning saying how to set it, so an existing deployment changes nothing on upgrade; an empty string is core only; a name that matches nothing refuses startup and lists the names available. Disabling a module leaves its data in place. GET /api/modules now lists every module seen with an enabled field, so "installed but off" and "not installed" can be told apart. Fixes #170 and #172.

Added

A module author starts from a template and tests on a packable host.

dotnet new install BarakoCMS.Templates then dotnet new barakocms-module -n Acme.Notes produces a module that builds, registers and passes its own tests: one endpoint gated on a capability the module declares and grants to Admin at seed, one document type, options bound from Modules:Notes, a README in the house structure, an icon placeholder, packaging metadata inherited from a shared props file with the barakocms-module tag, and a test project. The tests run on BarakoCMS.Testing, a new package holding BarakoTestHost: the real host over a Testcontainers PostgreSQL with the modules you name registered, the system roles and the admin seeded, every module's seeder run, a client signed in as the admin, a client for a named role, a tenant helper and a Marten session. Both packages are proved from outside the solution by scripts/check-module-template.sh, which CI runs and the release runs against the artifact it is about to publish. MODULES.md gains the section a third party needs: what the host checks at startup and what it does not, that a module is trusted in-process code, and how to name, version and describe a published one. Fixes #174.

Added

GET /api/modules reports which modules an instance actually booted with.

Read straight off the container: AddBarakoCMS registers each opted-in module as a singleton, so the answer is what the host runs rather than a list somebody maintains beside it. Two fields per module, the registered name and the declared contract version, and nothing else. A module knows its configuration section and its assembly paths, and none of that is a fact about the module.

Ordered by name, always. An instance running core alone answers with an empty list rather than a 404: a 404 is what a route that never shipped looks like, and telling those apart is the reason a client asks at all. Admin and SuperAdmin only.

Every first-party module currently reports contract version zero, since none of them override the property. So this confirms a module was picked up, and does not yet say which contract version it thinks it is talking to.

Added

docs/delivering-a-client-project.md, the path from a clean machine to a handed-over client site.

Everything else documented here answers what barakoCMS can do; this answers what you do, in what order, and with which endpoints and config keys. It sequences standing an instance up, creating the tenant, modelling content inside it, adding the client's people, giving them a role, pointing a frontend at the delivery API, deploying and handing over. The step order matters: content types are tenant-scoped, so modelling before the tenant exists leaves the model on default where the client's tenant cannot see it, and moving it then needs the Portability bundle. Tenant member management (#184) shipping is what made the onboarding step writable.

Added

It names what is not solved, because a delivery document that overstates is worse than none.

Two administrative surfaces reach past the tenant and are both open to the seeded Admin role, so the document says not to give that role to a client's staff: GET /api/audit treats ?tenant= as a caller-chosen filter rather than a boundary, and POST /api/users/{userId}/roles writes the global User.RoleIds and, unlike POST /api/tenants/members, does not refuse the SuperAdmin role id. It also records that Tenant.Domains and Tenant.Branding are returned by the API and writable only in the database, that an invited member cannot set their own password because POST /api/me/password verifies a current one they do not have, and that the switch-tenant request field is spelled club.

Added

docs/multi-tenancy.md

is in the repository, rewritten against the code. It was gitignored and described a design that had since shipped, in several places the opposite way round from how it was actually built: roles, refresh tokens, OTP codes and trusted devices are global rather than tenant-scoped, the X-Tenant header is accepted from any caller by design, the membership check runs when a token is issued rather than in middleware, and User.RoleIds was kept and unioned with membership roles rather than moved. Closes #211.

Added

A test refuses to let an event type reach an API response.

The event stream is internal and history goes out as a projected, versioned view, and until now that held by luck: the history endpoint projects to a DTO because whoever wrote it projected out of ordinary API hygiene. The moment one response carries an event type the record's shape is public API and reshaping it behind an upcaster is a wire break. EventSurfaceTests takes the response types off the endpoints themselves rather than from a list, so a response added later is covered, and follows property types, constructor parameters, public fields, array elements and generic arguments, because a List<ContentCreated> is the same leak one level down. It found no existing violation. The rule is DECISIONS.md D4.

Added

A content type had no way to say its entries are event sourced, so the choice could only be made for all of them or none.

A type can now be created with eventSourced: true, which makes its event stream the source of truth instead of its Content document. It defaults to false, which is what every type has always been: the document is the record, events are still appended for history, audit and workflows, and nothing changes for a deployment that does not ask for this. The decision is recorded against the type NAME rather than on the definition, so deleting a type and creating it again inherits the original answer instead of arriving at the opposite one, and there is no code path that changes or deletes it in either direction. Two rules come with it. An event-sourced type may not hold non-Public fields, refused at creation and at any later attempt to raise one, because erasing a value out of an append-only stream is not something this server can do. And an event-sourced type has to be chosen before its first entry, because a stream written under the old rules is not a history the stream can claim to be the source of truth for. New table mt_doc_content_type_sourcing_policies, in migrations/4.0.0/3.x-to-4.0.sql, empty on arrival.

Added

Connectors had a backend and no interface, so a third party's credentials could only be entered with curl.

There is a screen now at Settings, Connectors: the list, add, edit, delete, and the test button, gated to SuperAdmin and Admin the way the endpoints are.

Added

A credential is write only on the screen because it is write only in the API.

No endpoint returns a stored value, so the box starts blank every time and blank means "keep what is stored". Deleting a credential is a separate checkbox rather than an empty box, which is what SaveConnectorRequest already encodes: an absent key changes nothing, an empty value deletes. The alternative, showing asterisks and posting them back, would overwrite the token with asterisks the first time somebody corrected a base URL. The only values this screen ever sends are ones typed into it in that session.

Added

The list answers the question an operator came with: did the last probe work.

LastTestResult is prose the server wrote ("HTTP 200 in 34 ms"), not a boolean, so the screen reads the status out of it and calls 200 to 299 a success, matching IsSuccessStatusCode, which is what the server used to decide it. A 302 to a login page counts as failing, which is what ProbePath exists to fix. It also names the gap before a probe is run, and says which gaps ConnectorSender really refuses on: no stored credential is one, and so is a missing HeaderName on an API key connector. A missing Username on a Basic connector is not. The sender defaults it to empty and sends base64(":password"), so the screen says that instead of promising a refusal that never happens.

Added

The slug is typed, not rewritten under the operator's cursor.

It is derived from the name until the operator edits it, and after that the box keeps exactly what they typed. The save is gated on ^[a-z0-9][a-z0-9-]{0,62}$, which is ConnectorRules.IsSlug, and the form says so when the slug would be refused. That matters more here than on most forms, because UpdateConnectorEndpoint overwrites the slug on a PUT with the stored one, so a slug entered wrong can only be fixed by deleting the connector and entering the credential again.

Added

Deleting asks first, and says what goes with it.

The credentials go in the same transaction and any request definition naming that slug stops working, so the confirmation names the slug rather than asking a generic "are you sure".

Added

The queries screen.

/api/queries had no interface, so a saved query could only be created by hand against the API. The admin now lists, builds, previews and deletes them under Queries.

The form offers exactly the shape the model accepts and no more: a content type, up to ten typed filters, a sort, a limit and an explicit field projection. There is nowhere to type an expression, because there is nowhere in the model to put one. Only fields the content type marks Public are offered to filter on, sort by or return, which is the same allowlist the runner enforces and for the same reason: filtering on a field the rows cannot show is a way to read that field without ever printing it.

The preview is the part that makes it useful. Pressing it saves any pending edits and then runs the stored definition, and the rows come back as a table of the projected fields in the order the projection names them. That is what a workflow action carrying the query would send. A run the server refuses shows the server's own reason, so a field raised to Sensitive after the query was written surfaces here rather than in a payload.

Added

A screen for outbound requests.

The request endpoints had no interface, so composing an outbound call meant POSTing JSON by hand. Settings now has one: the list, an editor for the connector, method, path, headers, body template and success rule, and the dry run.

The dry run leads the screen, because it is how an operator finds out what a template produces while they can still change it. It composes the call against a real entry and renders exactly what came back: the finished URL, every header and the body, laid out when it is JSON and left as composed when it will not parse, since that is the case worth seeing. A refusal shows the reason instead, which is what happens when a template names a field that is not Public, or reads a named query, which the composer cannot do yet.

Nothing on the screen sends anything, and it says so where a verdict could be misread: the result panel is headed "Dry run. Nothing was sent.", the verdict reads "Would be sent" rather than "Sent", and the button says compose rather than send. Header blocks are pasted as "Name: value" lines and a line the parser cannot read is refused rather than dropped, because a dropped line is a header the operator believes they set.

Added

A screen for workflow runs.

The run endpoints have been there since the outbox split and had no interface, so the only way to find out whether a workflow actually fired was to query Postgres. /workflow-runs lists every run newest first, filtered by status, with the status carried by a tinted badge rather than a word in a column, and opening one shows its actions in execution order with the attempt count, how long each took, the response status and the error when there is one.

A retry button appears on a failed action and on an unknown one, and nowhere else. Unknown is a timeout, where the request may well have arrived and only the response was lost, so retrying it is a decision to accept possible duplicate delivery and a person has to make it. Succeeded, Running, Pending and Skipped get no button at all: POST .../retry refuses a succeeded action with a 409 because sending it twice is the hazard the idempotency key exists for, and offering a control that can only answer 409 teaches an operator to distrust the screen.

Pressing retry refetches the run rather than rendering what the endpoint returned. The response is the run as it stood at the moment of the write, and the runner can claim the attempt a tick later, so painting that body on screen would show a Pending action that is already Running.

Added

Connectors: one place to hold a third party's credentials, encrypted and write only.

Calling Jira or Twilio or a plain REST API meant a module with its own config keys and its own code. A connector is configuration instead: a base URL, an auth mode, non-secret settings, and credentials an admin enters through POST /api/connectors. There is a test button, because a credentials screen that cannot tell you whether it worked moves the failure to the first real workflow run.

Added

A secret is not on the connector document, which is the design rather than an omission.

Credentials live in a separate ConnectorSecret, encrypted with AES-GCM, and the read path never joins them, so a bug that returns a connector over the API cannot leak a token: there is nothing in the object to leak. The response carries the names of the secrets held, which is what a screen needs to say one is set without handling it. Nothing reads a secret back out, from any endpoint.

Added

Connectors:Key is its own key, with no fallback

, unlike Mfa:Key which falls back to the JWT signing key. SECURITY.md records that coupling as a lesson, and this enforces it: a key that matches JWT:Key, Mfa:Key or Secrets:Key is refused before the host is built, as is one shorter than 32 characters. Rotating an encryption key makes everything under it unreadable, so it has to be one decision at a time rather than one that retires every integration and every enrolled second factor together. An absent key is not a startup error, it means the feature is off, and the endpoints say so naming the setting rather than storing a credential in the clear.

Added

The address guard runs when the socket opens, not when the URL is saved.

A base URL is checked on save as an early refusal, but the check that counts is the one in the ExternalApi client's connect callback, which resolves once and dials an address that answer survived, with redirects off. A name that resolves publicly at save time and privately later is the case that matters, and it is the only one a save-time check cannot see.

Added

A test result carries the status code and the round trip, never a response body.

A 401 from an OAuth provider frequently contains the credential that was sent, so quoting the response is how a token reaches a log aggregator, an error tracker and a support ticket in one step.

Added

The unique slug is scoped per tenant.

Marten does not infer that from a document being multi-tenanted, so without TenancyScope.PerTenant the index is global and the first tenant to name a connector "company-jira" stops every other tenant using that name, refused with a 409 about something they cannot see. Found by the 3.x upgrade check, which compares the shipped migration against the schema Marten expects.

Added

Conjoined multi-tenant, role gated and audited.

A connector belongs to the tenant that added it. Configuring one is SuperAdmin or Admin, because it is credential management rather than content editing. Creating, updating, testing and deleting are audit events recording the slug, the base URL and the names of the secrets held, never a value. Deleting a connector removes its credentials in the same transaction, so nothing decryptable is left belonging to something nobody can see.

Added

Requests: what to send through a connector, held as configuration.

A connector says where and who; a request says what. Together they replace the C# somebody would otherwise write per integration. A definition names a connector, a method, a path template, header templates and a body template using the same {{...}} variables workflow actions already use, and a workflow fires it with one parameter: { "Type": "Request", "Parameters": { "Request": "post-to-facebook" } }.

Added

A field the schema marks Sensitive or Hidden cannot leave, even when a template names it.

Refused rather than redacted: the operator wrote {{SSN}} on purpose, and a request that silently posts three asterisks where they expected a value looks like it worked. The message names the field and its level, while they still have the template open.

Added

A value cannot rewrite the request around it.

Each hole is escaped for where it lands, so a title of ","admin":true,"x":" becomes a title rather than an extra field, and one containing a slash cannot address a different endpoint from the path. That is injection in a different costume, and it is why substitution is not delegated to ITemplateVariableExtractor.ResolveVariables, which returns a finished string with no point at which one value can be escaped for its context. The composed body is parsed before sending, so a malformed template is a refusal here rather than a 400 from a provider describing their own parser.

Added

Success is a rule, not a status code.

Several providers answer 200 with an error in the body, so TwoHundredAndJsonPathAbsent fails the call when a named path is present. Choosing that rule without a path is refused, because it would otherwise behave exactly like the plain 2xx rule and appear to be in force while changing nothing.

Added

POST /api/requests/{slug}/dry-run/{contentId} composes everything and returns the exact method, URL, headers and body without sending.

No credential appears in it, and not because it is redacted: the connector's secrets are attached by the sender afterwards, so the dry run never held one. {{PublicUrl}} is new and resolves from App:BaseUrl rather than a request host, because this composes inside a workflow where there is no request.

Added

The method is an allowlist.

TRACE against some proxies echoes request headers, including the Authorization header the sender attaches, which would be a way to read a credential back out of a connector built specifically never to return one.

Added

{{query.*}} resolves a named query (#328, #573).

A request definition names a query in QuerySlug; {{query.rows}} and {{query.SomeField}} compose from what it returns, and a hole naming a query that does not exist, or a field the query does not select, is refused rather than sent as a literal. Posting the text {{query.rows}} to a third party looks like a delivery and is a defect.

Added

Queries: fetch the rows a payload needs beyond the entry that triggered it.

"Email all subscribers" starts from one blog post, and the recipient list is not on it. A query names a content type, typed filters, a sort, a limit and the fields that leave, and an operator builds one without writing code.

Added

Not a query language, on purpose.

No SQL, no expression strings, no caller-supplied predicates. The moment it accepts an expression it is an injection surface and an unbounded-cost surface at once, and the person editing it is configuring a marketing workflow. It is built on the same foundation as the anonymous delivery filters, which bind the field name as well as the value so neither reaches the SQL text. Joining two content types or filtering on something computed are the obvious next asks, and the answer to both is a reporting feature rather than growing this one.

Added

A field that is not Public can be neither filtered on, sorted by, nor returned.

Filtering on a field the result cannot show is an oracle: a workflow author could binary-search a Sensitive salary by watching how many rows come back, without the value ever appearing in a payload. The refusal reads the same as for a field that does not exist, because saying which would let somebody enumerate a type's Sensitive fields from here.

Added

Validated when it runs, not only when it is saved.

A field that was Public when the query was written can be raised to Sensitive afterwards, and a save-time check cannot see that: the query would go on feeding it into third-party payloads with nothing saying so.

Added

The projection is an allowlist and cannot be empty.

Only the named fields leave, even the Public ones, so a schema change that adds a personal-data field next year does not silently start including it. A query with no projection is refused rather than defaulting to everything.

Added

The limit has a ceiling of 1000, applied when it runs as well as when it is saved.

A query with no bound inside a workflow action is an accidental way to email everyone twice, and the ceiling is what stands between a misconfiguration and that, so it does not rely on the save path having run.

Added

POST /api/queries/{slug}/preview runs one and shows the rows

, so an operator can see what a payload would carry before anything is sent.

Added

A type that is not event-sourced can stop writing its changes to history.

EventSourcing:DocumentTypesAppend, true by omission, which is what every deployment before 4.0 did. Set it false and a document-sourced type writes only its current version. That is what issue #331 asked for, and it is a setting rather than the new behaviour because it takes three things away with it: GET /api/contents/{id}/history returns nothing for those types, the rollback endpoint has nothing to roll back to, and workflows on those types stop firing, since a workflow is triggered by reading a committed history entry. An event-sourced type is not affected, because for it the history is the record. docs/event-sourced-content-types.md says all of that in those words.

Added

A content type can declare its own states and the named moves between them.

ContentStatus is Draft, Published, Archived, in the core, for every type, which is right for a blog post and wrong for an invoice. A type may now carry a Lifecycle with its own states, an initial state and named transitions, and PUT /api/contents/{id}/status takes a transition name for such a type instead of a status. A transition out of the wrong state is refused server side, and an incoherent lifecycle is refused at declaration rather than left to strand entries later. Lifecycle:EnforceTransitions defaults to on and can be turned off for a deployment whose existing entries predate its rules, which logs the violation rather than passing over it. A type that declares no lifecycle behaves exactly as it did, which is every type that exists today, and ContentStatus is untouched by a transition because it is what public delivery reads.

Added

A workflow can trigger on a named transition, so an approval routes and an edit does not.

TriggerEvent was Created, Updated, Deleted or Published, and "when an invoice becomes Approved" is none of them. Routing on Updated fires on every save, so the supplier was sent the invoice on every edit before approval and again after, which is the feature not existing rather than a rough edge. A trigger may now be transition:Approve, naming a transition on the triggering type's own lifecycle. It keys on the transition name and not the state it lands in, because "status is now Approved" also describes an administrator correcting a mistake, and a supplier notification is the thing that most needs to not fire on that. A transition is not folded into Updated, so existing Created and Updated workflows are unaffected.

Added

A workflow naming a transition its content type does not declare is refused when saved

, with a message naming what was asked for and what the type declares. Stored and never fired was the other option, and a workflow that never fires looks identical to one that fires and fails. A trigger naming a content type that does not exist is refused for the same reason, rather than passed over because there was nothing to check against. The trigger is stored spelled as the type declares it, since the engine matches it with an equality query and transition:approve against a transition named Approve would otherwise save and then never fire.

Added

A content type's lifecycle is now on the API response.

GET /api/content-types did not return it, so nothing outside the database could discover a type's transitions, and a transition is what both a permission and a workflow trigger name. The admin workflow builder offers the selected type's transitions as triggers because of this.

Added

Email is configured in the admin, not in the deployment.

Provider credentials came from IConfiguration, so somebody had to edit appsettings or an environment variable, which a process owner standing up their own instance cannot do. A SuperAdmin sets them at Settings, Email, and they take effect on the next send with no restart. There is a test send, because a configuration screen that cannot tell you whether it worked moves the failure to the first real invoice. It goes to the caller's own address and nowhere else, and it refuses with the provider's own reason rather than reporting a send that went nowhere, including when no provider module is registered and the mock would have silently swallowed it.

Added

The stored key is encrypted at rest and never returned.

AES-GCM through a new ISecretProtector, so a database dump does not hand over a working sending credential. The response says whether a key is set and where it came from, and has no field that could carry the key itself, so the admin form cannot prefill it into a browser cache or a screen share. The consequence worth knowing: there is no way to read the key back, from the API or the admin. Secrets:Key is its own key, separate from Mfa:Key, so rotating one does not retire the other, and rotating either makes what it encrypted unreadable. See docs/configuring-email.md.

Added

Stored settings beat configured ones, per field.

An operator will otherwise set one and watch the other win. Configuration remains how a deployment with no database row yet is seeded, and a stored From address does not switch off a configured API key, because that cliff would stop email working the moment somebody filled in one box.

Added

POST /api/settings refuses a key that looks like a credential.

Everything in that store is held in plaintext and returned in full by GET /api/settings, which is right for a feature flag and wrong for an API key, and a box labelled Value next to a key called Resend:ApiKey was going to collect one. The refusal names the endpoint that encrypts.

Added

Changing email settings is audited

as settings.email.changed, recording which fields changed and never their values. It sits at SuperAdmin rather than Admin: redirecting where the system's mail comes from redirects every password reset and every verification token in the deployment.

Added

The entries list can be searched and filtered by status, and every row shows its version.

GET /api/contents takes search and status, and ContentResponse carries Version. Search matches any string value in an entry's data, not the derived SearchText the anonymous delivery search uses: that one holds only the values of fields the type declares Public, so an admin searching a reference number kept in a Sensitive field would get an empty page with no way to tell that from the entry not existing. Matching more than the caller may read is safe, because the per-item permission check and the sensitivity scrub both still run on whatever comes back. The version is read in one batched query for the page rather than one call per row.

Added

Workflow runs are swept, and failures outlive successes.

Every firing leaves a run behind and nothing removed them. Workflows:Retention:Succeeded (7 days) and Workflows:Retention:Failed (90 days) are the two windows, because a successful run answers "did that go out" for a while and a failed one is interesting until somebody deals with it. PartiallyFailed is kept on the failure window, since it holds an action nobody has handled.

A Pending or Running run is never removed, whatever its age. That is a rule rather than a consequence of the window: a run whose provider has been unreachable for a fortnight may still be an email that somebody is waiting for. Zero or less on either setting keeps that class forever, which is the safer of the two readings "0 days" has.

The sweep takes an advisory lock so one instance does it, and deletes in bounded batches. docs/workflow-runs.md covers the settings and says plainly that this is not an audit trail.

Added

A misspelled capability on a role was accepted and granted nothing, and nothing said so.

POST /api/roles and PUT /api/roles/{id} stored systemCapabilities verbatim, and no endpoint listed the names a role could hold, so after #443 an operator had no way to find the right spelling of manage_analytics_websites short of reading the source.

GET /api/capabilities lists every name this instance understands: core's set plus every name a registered module's endpoints ask for, read off the routing table rather than off a list a module maintains, so a module you have not installed contributes nothing and a module needs no new contract member to be listed. Each entry carries its source (core or the module's name), and * carries a note saying it satisfies everything. Gated on manage_roles, the same as reading roles.

A role write now checks its names against that list. By default the role still saves, the unknown names are logged and come back in the response as unknownCapabilities, so a console can show them and a module installed later that declares the name starts working without a re-edit. Set Roles:RefuseUnknownCapabilities=true and the write is refused with a 400 naming each unknown name and pointing at GET /api/capabilities. * is known both ways. See docs/access-control.md.

Added

The schema a module wants is checked before it runs.

On boot, before the schema is applied and before anything seeds, the host asks Marten for the migration it would apply, attributes every object in it to a module by the assembly its document type ships in (or to core), and logs one line per module saying which objects are new and which existing ones would change. When the store is AutoCreate.CreateOnly and a module wants a change to an existing object, startup stops with a message naming the module, the object, the policy that refuses it and what would allow it, instead of Marten's error several layers down. A change to a core object is attributed to every enabled module that overrides the deprecated ConfigureMarten, the only hook that can reach one. BarakoCMS:Modules:SchemaPreflight switches it: unset is on for a CreateOnly store and off otherwise, false keeps the old behaviour. GET /api/modules gains schemaState (ready, needs-migration, unknown) and schemaChanges per module from the same check. Fixes #519.

Added

A page that walks the invoice approval scenario end to end against the API.

A lifecycle per type, a permission on a transition, a workflow that fires on one and email from settings were each on master and tested, and nothing in docs/ mentioned any of them. docs/approval-by-configuration.md declares the invoice type, gives one role create and another the approve transition, shows the raiser refused on approve and on their own submit (and the Lifecycle:AllowSelfTransition switch), attaches an email workflow to the approve transition and sets the sender from settings, one curl per step with the status code each answers. docs/access-control.md links to it from the permissions section. Until the next release the page needs BARAKO_TAG=master in the quickstart's .env, since :latest predates lifecycles.

Added

The HTTP surface is a public contract now, and it has a version.

CLAUDE.md section 6 used to exclude everything under Features/* on the grounds that nothing compiled against it. That stopped being true once barakoBrew moved to its own repository and its own release cadence: it reads the JSON over HTTP without ever compiling against the classes that produce it. Section 6 now says what counts as a breaking change to that JSON (a removed or renamed field, a changed type, a changed status code, tightened validation) and what does not (an added optional field). The Endpoint, Request and Response types stay internal; only the wire shape is promised.

GET /api/meta reports ApiContractVersion alongside the existing Version, and every response, including a 401, carries it on the X-Api-Contract-Version header, so a console can tell whether it fits before signing in and again mid-session after a rolling upgrade. The API does not declare a minimum supported console version; the console is the side that breaks, so it carries the range it works with. Closes #630, closes #637.

Added

Every webhook delivery is logged.

"Did it fire?" was answered only by the application log. A WebhookDelivery row is written for every attempt, sent or refused: workflow, run, redacted URL, event, request headers minus the signature, response status, the first 4 KB of the response body, duration, the error when nothing answered, and the attempt number. GET /api/webhook-deliveries lists them, filtered by workflow and by status class, gated on view_workflow_runs. Webhooks:DeliveryLogRetentionDays (30) sweeps them hourly; zero or less keeps them. Retry stays with the runner until the job queue (#106) takes it. A Webhook with a Secret must use https, refused at create and at delivery; Webhooks:AllowInsecureSignedUrls (false) lets a lab sign over http. docs/webhooks.md covers all of it.

Changed

Every module version moves to the core's number.

The modules had drifted onto their own 0.x tracks (Accounting at 0.6.0, Portability at 0.3.1) while the core sat at 3.21.0, and the release gate reads the core's <Version> alone, so module bumps queued up invisibly until a core bump flushed them. Everything queued is compiled against net10.0, Marten 9 and core 4.0, but a consumer watching BarakoCMS.Accounting move from 0.3.1 to 0.6.0 reads a routine bump, and 0.x gives them no way to express "this one needs core 4". All thirteen modules are 4.0.0, so the number answers which core a package needs and the packed dependency range says the same thing (#294).

Changed

The playground deploy runs before anything is published.

The order was: tests pass, fourteen packages become permanent, and only then does anything get deployed and looked at. NuGet has no delete, only unlist, and a version someone has already resolved stays resolved, so the irreversible step now runs last. The deploy also proves the playground is running the commit being released, by reading /health/build, because a 200 and a version string are what the previous build returns too: a deploy that pulled nothing passed both (#157).

Changed

Publishing packages waits for a person.

publish-packages names a nuget GitHub environment, and its job name carries the version so the prompt asks about a specific number rather than about publishing in general. Because an environment with no protection rules approves everything in silence, and naming one that does not exist creates it that way, the version gate now refuses to start a release unless that environment has a required reviewer (#203).

Changed

Delivery API: the routes under /api/public now have a written stability and deprecation policy, and no version segment.

#107 asked for URL versioning after 3.20.0 changed behaviour for every site in a minor release. The conclusion is that a second code path is the wrong cost for a project this size and would not have prevented 3.20.0 anyway. docs/delivery-api.md now says what counts as breaking, that a break lands only in a major, that it is announced under a Delivery API lead in this changelog at least one minor ahead, and that the old behaviour keeps working until then, a security fix being the one exception. D14 in DECISIONS.md records the alternative rejected and what would reopen it.

Changed

The admin mark is the coffee bean.

It was a mug glyph in a filled purple tile, while the sign-in page had been drawing the bean since the Signal design landed, so the two front doors of the same product did not look like the same product. BrandMark now renders the same component the sign-in page uses, at the footprint the tile had.

Changed

Comments that contradicted the code they sat above.

RevokeAllUserTokensAsync said a full implementation would query and revoke the user's refresh tokens, on top of code that does exactly that, and logged a warning on every call for a feature working as designed. Eight comments in all, including a registration handler recommending BCrypt one line above the call to BCrypt, an unresolved "or should we fail?" left in the content validator, and a Kubernetes monitor describing namespace handling it does not do. No behaviour changes. Closes #128.

Changed

NuGet lock files are committed and restores run in locked mode.

Every project now writes packages.lock.json (RestorePackagesWithLockFile in Directory.Build.props) and the files are committed. CI, the release workflow, both Dockerfiles and the upgrade, restore and smoke scripts restore with locked mode on, so a version bump that does not carry its lock file diff fails with NU1004 instead of being quietly regenerated. A transitive bump is now a reviewable diff, and GitHub attributes the dependency graph to this repository. The README gains a "What it runs on" section with the pinned versions, and names Umami and Caddy as deployed alongside rather than referenced.

Changed

Every OpenAPI operation is tagged from the namespace its endpoint lives in.

FastEndpoints tags by path segment and every route here starts /api/, so all but three operations carried one tag, Api. Generators group methods by tag, so a generated client was one class with every method on it. The tag now comes from the endpoint's namespace (barakoCMS.Features.Content.Create becomes Content, BarakoCMS.Analytics.Umami.Features becomes Analytics.Umami), so a new endpoint is grouped correctly by existing where it belongs. No endpoint file changed. The three endpoints that set their own tag keep it, and a test asserts no operation carries Api and pins the tag set, so a namespace rename cannot silently rename a consumer's method group. Closes #181.

Changed

The release refuses to run if a test project is not covered.

release.yml names one test project by path. That is correct while there is one and a silent hole the moment somebody adds a second: the new suite would sit in the repo, never run, and the packages would publish anyway. The workflow now enumerates *.Tests.csproj and fails if what it finds is not what it runs. The suite-actually-ran floor moves from 500 to 900.

Changed

A content event says when the change happened, so a rebuild reproduces the timestamps exactly.

Two clocks answered that question and they were not the same: the writer stamped DateTime.UtcNow as it applied an event to the document, while Marten stamped the transaction time on commit. A replay could only see the second, so a rebuilt document's CreatedAt and UpdatedAt differed from the original by the write latency. For an audit trail that is not acceptable.

Every content event carries OccurredAt now, set once by the writer, and both the live write and the rebuild read that same value. Domain time drives the projection; storage time still drives ordering, which is what matters on a multi-instance deployment where application clocks skew and the database clock does not.

Additive. The previous constructors are kept and obsolete, so code that has not moved across still compiles and behaves as it did, stamping the clock at construction. An event written before 4.0 carries no such field and falls back to the commit time, exactly as a rebuild did for everything until now, rather than rebuilding the document at year one.

Changed

Workflow actions no longer run inside the projection.

WorkflowProjection runs in Marten's async daemon, which processes a shard sequentially, so an action that posted to Facebook, emailed a list and then tweeted held that shard for the duration of three third-party calls: one slow provider stalled workflow processing for every tenant and a hanging one stopped it. The projection now writes a WorkflowRun with an attempt per action and returns, and a background runner does the I/O. It is the outbox pattern, and the event stream was already half of it.

Changed

Every attempt is recorded, so a configured integration that stops working is visible.

GET /api/workflow-runs, GET /api/workflow-runs/{id} and POST /api/workflow-runs/{id}/actions/{ordinal}/retry. The stored outcome carries the status code, a truncated reason and the timing, and the response shape has nowhere to put a response body or a resolved parameter: a 401 from an OAuth provider frequently contains the credential that was sent.

Changed

A timeout is Unknown, not Failed, and is never retried automatically.

The request may have arrived and the response been lost, so retrying it is how a customer gets two invoices. An operator can retry one by hand, having decided, and the audit entry records that they did.

Changed

Retrying an action that already succeeded is refused.

The reason a run records each action separately is so that retrying a failed third does not re-send the first two. A manual retry does not reset the attempt count either, because an action that keeps failing should still stop.

Changed

Two nodes cannot execute the same attempt.

Attempts are leased with an expiry rather than locked: a lock serialises every node onto one attempt at a time, while a lease lets them work in parallel and releases the work of a node that died without anything having to notice. Optimistic concurrency on the run is what refuses the second claim.

Changed

A partly successful run says so.

PartiallyFailed is a real status rather than a rounding of Failed: three independent actions where the mail server was down is not the same as three that all failed, and it is exactly what somebody deciding whether to retry needs to know. A later action still runs when an earlier one fails, since the actions are usually independent.

Changed

Retries are bounded and jittered.

Five attempts, exponential backoff capped at ten minutes, then it stops. A run that retries forever is a self-inflicted denial of service against a third party who answers by banning the account, which takes down every other integration pointed at them.

Changed

A projection rebuild no longer re-fires everything.

A run is not queued twice for the same workflow, content and event sequence, so replaying the stream records what already happened instead of re-sending every email and webhook this instance has ever sent.

Changed

A permanent failure is not retried.

WorkflowActionResult.PermanentFailure is new, for the cases that are the same on the fifth attempt as the first: a malformed webhook URL, a missing required parameter, an action type the host was not built with. They go straight to Failed instead of spending the attempt budget, so the operator is told now rather than after ten minutes of backoff, and a third party is not sent five copies of somebody's typo. Failure still means retryable, so an existing action behaves exactly as it did.

Changed

GET /api/workflows/{id}/debug now shows dry runs only.

Real runs record against WorkflowRun and are served by /api/workflow-runs, which has per-action status, the reason and a retry. A dry run is genuinely a different thing from a run, so the older record keeps that job rather than being deleted.

Changed

The event-sourced flag was recorded and nothing read it, which is a setting that does nothing.

IContentWriter now branches on it, in one place rather than in the six slices that write content. For an event-sourced type the document is produced by folding the stream, so a value that reached it by any route other than an event does not survive the next write, and the whole read model can be discarded and rebuilt from the streams through POST /api/content-types/{name}/rebuild. That rebuild is refused for a type that is not event-sourced, whose document is the record and whose stream is an audit trail. Concurrency differs by type, deliberately: an update to an event-sourced entry has to say which version it was read at and gets 409 if it cannot or if the stream has moved, while every other type keeps the last-write-wins behaviour it has today. IContentWriter gains CreateAsync and AppendAsync, since reading a type's policy needs an await; Create and Append still work, still take the document path, and are obsolete from 5.0.

Changed

Changelog entries are one file per change.

Every pull request used to edit CHANGELOG.md directly, so every branch conflicted on that one file after every merge, and resolving it by hand put conflict markers on master once and duplicated three entries once, both invisible to every other check because nothing reads Markdown. Add a file to changelog.d/ instead; the release folds them in. Two branches adding two files do not conflict.

Changed

Verified #394 rather than assuming it.

docker manifest inspect on barako-cms:3.21.0, barako-cms-decaf:3.21.0 and barako-admin:3.21.0 confirms all three are linux/amd64 only. release.yml's platform gate already checks the pushed manifest (not the build config), runs for both images this repo builds, blocks tag-release on failure, and CI already proves it fails on 3.21.0 and passes on latest (#510). No workflow hole found. docs/deploy-in-production.md now also names barako-admin, which has the same amd64-only versioned tag but is built and released by BaryoDev/barakoBrew, outside this gate.

Changed

The image platform gate had never been seen to fail.

The release workflow refused to publish an image that did not serve both linux/amd64 and linux/arm64, but the check had only ever passed, and 3.21.0 (amd64 only) predates it. The assertion is now scripts/check-image-platforms.sh, which release.yml calls, and CI runs it against barako-cms:3.21.0 and passes only when the script refuses that tag for being amd64 only, then against latest and passes only when it accepts it. The versioned tags themselves stay amd64 only until the next release publishes through the gate.

Changed

The admin wears the Signal theme.

Bootswatch Yeti is gone: square corners, 300-weight Open Sans headings and #008cba blue on white read as a template rather than a tool, which is what prompted the redesign. Signal is indigo #5A46D6 on a #FAFAFC page with white panels, 14px cards and 11px controls, Sora for display and Manrope for body. The rule that does most of the work is typographic: every machine-produced value is JetBrains Mono with tabular-nums, and human prose is not. Counts, versions, slugs, timestamps, durations, ids and API paths line up in a column.

Changed

Dark mode is pinned off rather than half-converted.

A Signal dark palette has not been drawn, and an inversion is not a substitute, so next-themes is forced to light and the two toggles that set a theme nobody drew were removed. The .dark block stays in globals.css as the starting point. Restoring it means drawing it, which is the open question on #407.

Changed

A third contrast remediation, of the same shape as the two already recorded there.

The handoff's faint at #6E7387 is 4.25:1 on the #F2F3F9 sunken tint, and a table column head is exactly where that lands. It ships four points darker in lightness at #696E81, same hue and saturation: 4.57 on the tint, 4.86 on the page, 5.06 on white. Every other pair in the token set was measured too, and the axe gate passes on all twelve of its cases.

Changed

The admin sidebar is a rail.

248px wide with 16px of padding, sitting on the page background rather than in a panel of its own, so the content beside it is inset on three sides and reads as a card. The four everyday destinations (Overview, Entries, Content types, Workflows) are one unlabelled group at the top; Access, Modules and System follow it, smaller, under uppercase mono headings. The active item is a white card lifted off the background with an accent icon, not a tint. Role filtering is unchanged: a non-SuperAdmin still does not see Tenants.

Changed

Counts and badges, from the API or not at all.

Entries, Content types and Workflows carry a right-aligned mono count, and Errors carries a red pill of unresolved client errors. Each is one request for a single row, read from the pagination envelope's total, cached for a minute, and fetched only when role filtering left that destination on screen. A response with no total renders nothing rather than a zero. Email events shows bounces in the last 24 hours, counted from /api/email-events, which has no read state to make an unread count out of.

Changed

Two numbers the design draws are deliberately missing.

"Modules, 5 installed" and the "Add a module" row both need #185, which would give the admin a module list and somewhere for that row to go. There is no /api/meta/modules, so the group heading is plain "Modules" and the row is not there. A count with nothing behind it would be worse than no count.

Changed

Search moved into the rail.

The command menu is unchanged; its trigger is now a 40px field at the top of the rail with the ⌘K hint, and it is no longer duplicated in the header.

Changed

Collapsing the rail hides it rather than shrinking it to icons.

The design draws no collapsed state and no icon rail, so the affordance stays (the header toggle, and Ctrl or Cmd + B) but what it does is slide the rail out. On a phone it is still a sheet.

Changed

The entries table wears Signal.

A live count pill reading the server's own totalItems, a tinted column head in 10.5px uppercase, the entry title at 700, and type and timestamp in mono with tabular-nums so machine-produced values line up in a column.

Changed

A Private pill on entries whose content type is not publicly deliverable.

Joined from useSchemas() on isPubliclyDeliverable, and only when the schema list positively answers false. An unknown type and an absent flag both mean the server did not say, and a lock icon is a claim about who can read an entry, so it is left off rather than guessed.

Changed

The entry title is a link, so the row is reachable from the keyboard.

It was a tr with a click handler and nothing focusable inside it.

Changed

Search and the status segmented control from the design are not shipped, because the API cannot answer them.

GET /api/contents takes page, pageSize, sortOrder and contentType. There is no search parameter, no status parameter, and no version on ContentListItem. Filtering the twenty rows a page happens to hold and labelling the result with the server's total is a control that lies about what it searched, so the three controls are absent and #410 records what the endpoint would need.

Changed

Status badges no longer render white on white.

The tone classes built the background from an alpha wash and took the text colour from --warning-foreground, which is white because it exists for white-on-solid buttons, so a warning badge was white text on a 10% wash of white. They now use the measured Signal tint pairs: 4.73:1 success, 5.35 warning, 6.27 danger, 7.89 accent, 7.37 muted. Nothing caught it because the axe case for the content list stubs an empty page, so no badge had ever rendered under the gate.

Changed

The sign-in page wears Signal, and every button on it now does something.

A centred 340px column on the page tint with the bean bleeding off the corner, a white 16px-radius card, and the real lockout policy stated underneath: five failed attempts locks for 15 minutes, a new device asks for an emailed code.

Changed

"Email me a sign-in code" is wired.

POST /api/auth/otp/request has existed the whole time and the admin never called it, so the emailed-code route back into an account was reachable only by failing a device check first. It opens a field for the email address rather than reusing the username box, because that endpoint and its verify half both look the account up by email. It repeats the server's own wording, which is the same whether or not the address is registered, so the screen cannot become an account-enumeration oracle the endpoint deliberately is not.

Changed

Social sign-in renders from GET /api/auth/providers instead of a hardcoded button.

BarakoCMS.ExternalAuth is optional and a provider with no client id is off even when it is installed, so a fixed "Continue with GitHub" is a dead control on the default deployment. A 404, a 500 and an unreachable API all mean the same thing here and all render nothing. Google, LinkedIn and Facebook come along, since the module ships all four.

Changed

The "Forgot?" link in the design is not shipped.

Features/Auth/ holds Login, Logout, Mfa, Otp, Refresh and Register, and a repo-wide search for forgot-password, reset-password and ForgotPassword returns nothing. Shipping the link means shipping password reset, which has its own threat model and belongs with #268 and #271. The emailed code is the route back in that exists.

Changed

Scheduled is a real content status.

A draft with a publish time on it was a draft, and every screen that wanted the distinction worked it out again from ScheduledPublishAt. ContentStatus gains a fourth member, appended so no existing row changes meaning, and arming a publish time appends a ContentStatusChanged next to the ContentScheduled so the move is in the history and visible to workflows. A published entry carrying a future unpublish time stays Published, because it is published. The migration moves existing drafts that carry a publish time, and the rollback moves them back. Entries scheduled before the upgrade have no status-change entry behind them, so replaying one gives Draft with the date still armed, which the sweeper handles. See DECISIONS.md D12.

Changed

The entries list stopped issuing two queries per row.

PermissionResolver read the caller's roles once per permission check, and the entries list checks every entry it loaded, so a tenant with fifty thousand of them issued a hundred thousand queries to return a page of twenty. The decision cache above it does not help, because its key includes the item id, so a first pass over a list misses on every row. The roles are read once per request now. The content type, status and search filters are also pushed into the database query, which is safe where a permission filter would not be: they can only remove rows, never grant one.

Changed

The content-type endpoints ask for a capability instead of a role name.

/api/content-types (and its /api/schemas alias) and the rebuild require manage_content_types; setting public delivery and setting a field's sensitivity require manage_public_delivery. A role created at runtime can be granted either.

Two names, though both gates were the same role pair and one name would have covered them with no seeded role noticing. Designing a schema and deciding what an anonymous caller can read are different jobs: sensitivity decides whether a value is scrubbed on the way out, public delivery decides whether the route answers at all. A role that models content without also choosing what leaves the building is an ordinary thing to want, and one name makes it unexpressible.

Admin holds both by default, because Admin reached all five routes already. Nothing is narrowed, and Auth:LegacyRoleFallback still honours the old role names while it is on.

Changed

The last two core routes on a role name ask for a capability, and the count is pinned at zero.

GET /api/modules asks for view_modules, named for reading because it answers with two fields per module and manages nothing. POST /api/content-types/{name}/seo-fields asks for manage_content_types, since adding fields to a content type is exactly what that capability is, rather than inventing a name for one endpoint. Admin holds both by default, matching what it reached before.

Both were added while #443 was in progress, in #185 and #111, and nothing noticed. RoleGateTests now asserts that no core route gates on a role name, counting a route that carries both a capability and a role list, so the next one fails the suite instead of waiting for a reader.

Changed

Every module endpoint asks for a capability instead of a role name.

Accounting, AI, Analytics, Diagnostics, Email, Feature flags, Files, Portability and PWA: 23 routes, twelve capability names. No endpoint in core or in a first-party module gates on a role name any more, which is what issue #443 set out to do.

A module declares its own names, because core does not reference a module and a third-party one is not in this repository at all. Each module grants them at seed time to the roles its old gate listed, so turning Auth:LegacyRoleFallback off does not take a module away from the Admin role. Additive and idempotent, and a role the host never seeded is skipped rather than invented.

Three gates that were one role list become two capabilities. Accounting separates reading the books from writing to them, so an auditor can read a ledger without posting to it. Analytics separates reading the numbers from creating a website in the upstream Umami account. Portability separates export from import, because reading a whole tenant out and writing a whole tenant in are opposite risks that one name could not tell apart.

A Accountant role reached the whole accounting module by its name alone. It now reaches what it is granted, which after seeding is the same thing, and which an operator can now see and change.

Changed

The last of the core endpoints ask for a capability instead of a role name.

Monitoring, redirects, saved queries, request definitions, connectors, workflows, workflow runs, the content rollback and the content erasure are all gated on a capability now, so a role created at runtime can be granted any of them without a code change. Eleven names: view_monitoring, manage_redirects, manage_queries, manage_requests, view_connectors, manage_connectors, manage_workflows, view_workflow_runs, retry_workflow_actions, rollback_content and erase_content.

Three areas are split rather than given one name each. Connectors split read from write, because a connector is the only document in core holding a third party's credentials: the reads return the configuration and the names of the secrets, the writes take secret values, and the probe spends them against the configured base URL. Workflow runs split reading from retrying, because a retry queues a real attempt and the mail is actually sent, while "did the notification go out" needs the run list and nothing else. The rollback and the erasure are separate because their old gates differed, Roles("SuperAdmin", "Admin") against Roles("SuperAdmin"), and one name would have had to widen one of them.

Queries and requests are deliberately one name each, preview and dry run included. The dry run composes a call without making it and holds no credential; the preview shows the author rows a saved query would have sent to a third party anyway, bounded to fields whose sensitivity is Public.

Admin's defaults gain everything migrated here except erase_content, which was Roles("SuperAdmin") and destroys content and its history irrecoverably. Nothing is narrowed, and Auth:LegacyRoleFallback still honours the old role names while it is on.

Two core routes stay on role names on purpose, GET /api/modules and POST /api/content-types/{name}/seo-fields, and RoleGateTests pins that list so it cannot drift.

Changed

The settings endpoints ask for a capability instead of a role name.

GET/POST /api/settings and GET /api/settings/email now require manage_settings; PUT /api/settings/email and POST /api/settings/email/test require manage_email_settings. A role created at runtime can be granted either, which is the whole point: a name granted nothing before and still does not.

Two names rather than one, because the gates being replaced were not the same. Reading settings was Admin and SuperAdmin; changing where the deployment's mail comes from was SuperAdmin alone, since that redirects every password reset and every verification token in the deployment. One manage_settings covering both would have handed that to every Admin, which is a widening nobody asked for. The seeded Admin role gains manage_settings and not the other.

Auth:LegacyRoleFallback still honours the old role names while it is on, so nothing changes for an existing deployment until it is turned off.

Changed

The entries list stopped loading a whole collection to return a page.

Permission conditions compile to a SQL predicate where they can, so GET /api/contents for a named content type pages and counts in the database. A tenant with fifty thousand entries used to deserialise all of them to return twenty. Nothing moved into the database except the filtering: the predicate is built from the same rules the resolver reads, and the per-item check still runs over the page that comes back, which is what would notice the two disagreeing.

Where a rule cannot be compiled faithfully the compiler declines and the endpoint behaves exactly as it did before. It declines $status (the evaluator compares the enum name while Marten stores a number), any expected value that is not a string or list of strings, and unknown operators. IPermissionResolver.ReadPredicateAsync has a default returning "no predicate", so a module with its own resolver compiles and behaves unchanged.

Changed

The README no longer implies Postgres enforces tenant isolation.

It said a database per tenant buys "isolation that row-level scoping plus a token check already gives". That scoping is a tenant_id filter the application adds, not row-level security, and docs/multi-tenancy.md says in as many words that row-level security is not implemented and a slipped filter has nothing underneath it. The two now agree, in the file people read first: what is enforced, what is not, and that database-per-tenant remains the escape hatch for anyone who needs isolation a bug cannot cross.

Changed

The client-layer decision is in DECISIONS.md, where anybody can read it.

Six issues cited a design document that .gitignore excludes, so it existed on one machine and in no commit. Two of those issues carry help wanted, which meant pointing a contributor at a file they cannot open. D13 records what was decided (a hand-written base plus generated slices, one per tag, and a configured invocation of an existing generator rather than one of our own), what it rules out, and what is still open. The working notes stay ignored: they are notes.

Changed

A role name no longer opens a gate on its own.

Auth:LegacyRoleFallback was true through 3.x, so the capability gates also honoured the role names they replaced and an upgrade kept working while roles had no capabilities yet. From 4.0 it defaults to false.

Nothing to do on a deployment that runs the seeder: every core and module endpoint gates on a capability now, and the seeder adds the capabilities a system role is missing rather than only filling an empty list, so those roles reach what they always did. A deployment that curates its roles by hand, or is mid-upgrade, sets Auth__LegacyRoleFallback=true and nothing changes for it. The flag is still there and still supported; only the default moved.

This is a behaviour change on upgrade, and it is the one 4.0 makes deliberately: a role somebody creates can be granted administrative access, and a role called Editor gains nothing from being called that.

Changed

barakoCMS is the API only.

The console under admin/ now lives at BaryoDev/barakoBrew and still publishes ghcr.io/baryodev/barako-admin; the marketing site under site/ has its own repository. Gone with them: the Admin UI, Site and "Admin against the real API" CI jobs, the admin image in the release and its SBOM, the admin-only playground deploy, the admin and site Dependabot entries, the admin service in every compose file and the quickstart, the DOMAIN_ADMIN Caddy route, scripts/smoke-check.sh and assets/admin. The API's own surface is Swagger, and the quickstart now passes SWAGGER_ENABLED through. Nothing in the packages or the API changed (#505).

Changed

Events stream: a per-client connection cap under the instance cap.

Delivery:Events:MaxConnections counted every stream on the instance and nothing keyed on the caller, so one anonymous client could hold every slot and every other tenant on the instance got 503 from GET /api/public/events. Delivery:Events:MaxConnectionsPerClient (5) caps open streams per client address, resolved the way the rate limiter resolves it (the socket peer, or the forwarded client when ForwardedHeaders names the proxy). The next stream from that address gets 503 with a body naming the per-client limit while another address still connects, and the slot comes back when the stream closes. Zero turns the per-client cap off. Closes #520.

Changed

Module READMEs teach the package reference as the install.

Each BarakoCMS.* README and docs/delivering-a-client-project.md and docs/configuring-email.md now say that dotnet add package plus a restart installs a module and BarakoCMS:Modules:Enabled decides whether it runs, with modules.Add(...) shown once as the override. Every module gets a patch bump so the README on nuget.org changes too. #521

Changed

Inbound idempotency is documented.

IdempotencyFilter has honoured an Idempotency-Key header on POST, PUT and PATCH since before this entry, but the only header named Idempotency-Key anywhere in docs/ was the outbound one on webhook deliveries, a different thing entirely. Nobody sending the header meant the protection sat unused. docs/idempotency.md now covers the header name, the verbs it applies to, the exact 409 a replay gets, how long a completed key is remembered (indefinitely; a failed one is released immediately), and what happens when two requests race on the same key. Linked from the README's documentation list.

IdempotencyTests now also posts to /api/contents twice with the same key and checks that only one entry landed, not only that the second call's status code was 409.

Changed

The free-module promise now names the publisher rather than the repository.

It read "every module in this repository is free, forever", which scoped a promise about what BaryoDev publishes to one git repository, and modules already live outside it. It now covers every module BaryoDev publishes under the barakocms-module tag, wherever it lives. The roadmap also says plainly that other vendors may charge for their own modules, that core is gaining a licensing primitive so they can, and that a paid third-party module in the module list is the ecosystem working rather than the promise bending. README.md said there was no support contract while ROADMAP.md said BaryoDev sells support; the software carries no SLA, and hosting and support are a separate commercial relationship.

Changed

New icons for the fourteen module packages.

Each keeps the ground colour it already had, with a white glyph and the bean device in the lower right, so a package stays recognisable in a NuGet search result while the set reads as a family. BarakoCMS.Templates and BarakoCMS.Testing are tooling rather than feature modules and keep the icons they had. Directory.Build.props already packs each project's assets/icon.png as its PackageIcon, so no packaging wiring changed.

Changed

POST /api/import/analyze asks for a capability.

It had no gate at all, so any authenticated caller could hand the server a spreadsheet to parse, and parsing is the expensive half. It now requires analyze_spreadsheets, which the module grants to Admin at seed time.

One name covering the preview only. The bulk create next door is authorized on the target content type's own create permission, which is the right question for a write because it depends on what is being written. The preview has no target yet, since the mapping that names one is built from the preview it is about to return, so it asks the narrower question of whether you may use the import tool at all.

Changed

CI runs on the merge queue.

ci.yml gains a merge_group trigger, without path filters, because the queue is the last gate before master and a required check only counts when it reports on that event. Without it the queue waits forever for checks that never start.

Changed

Every published package is now versioned 4.0.0.

Module versions had drifted apart, from BarakoCMS.DeviceTrust at 4.0.1 to BarakoCMS.Files at 4.4.2, so a reader had no way to tell which module versions belong together. They are now set to a single number and move together from here. BarakoCMS.Suite and BarakoCMS.Tests are not packable and have no version of their own.

Changed

Three decisions recorded before the 4.0 tag, in DECISIONS.md.

D16 extends expected-version concurrency to document types, because moving from last-write-wins to a 409 is a breaking change and 4.0 is the last moment it costs nothing; Content:Concurrency:Require keeps the 3.x upgrade path working and flips in 5.0. D17 settles that a money value stays a plain number, with currency, scale and rounding declared on the field definition, so the stored shape and the delivery contract do not change. D18 states what module authors are promised: a replacement for ConfigureMarten before 5.0 removes it, a default implementation and a deprecation window for every added member, and IWorkflowAction documented as the extension point it already is.

Changed

scripts/preflight.sh, scripts/sync-master.sh and scripts/needs-review.sh replace the manual PR checklist.

Preflight does a locked-mode restore first, before any build, then builds with --no-restore, runs the named test classes and fails if a class matches zero tests, then checks changelog fragments, module versions, and dashes/banned words and workflow YAML for duplicate keys, both scans covering untracked files too, failing on the first problem with a one-line reason. Sync-master merges origin/master, regenerates lock files when a .csproj or Directory.Packages.props changed in the merge, and exits 1 naming either the conflicting files or a dirty working tree, whichever blocked it. Needs-review is advisory only: it always exits 0 and prints one line per rule the diff against origin/master fires, for a reviewer to read.

Changed

The roadmap describes numbered releases instead of a weekly train.

It carried six dated sections from 3.22.0 to 3.27.0, two of which shipped and four of which were superseded by the 4.0 work. The CLI, starter templates, the MCP server and the typed client were not cancelled, they moved into 4.1.0 and 5.0.0 where they sit against the rest of the work rather than against a date that would have passed a few days after the tag. The file also now states the pairing with the console: barakoBrew 1.0.0 goes with barakoCMS 4.0.0, 1.1.0 with 4.1.0, 2.0.0 with 5.0.0.

Changed

The 3.x support window is anchored to the 4.0 tag, not to a date.

SECURITY.md said "30 August 2027, 12 months after 4.0", worked out from a 4.0 that was expected in August 2026 and has not shipped. The same document says the policy is "a rule rather than a date, so it does not go stale in this table", and that row was the one place it did. It now reads "4.0 ships, plus 12 months", and says plainly that until 4.0 is tagged, 3.x is the current line and is actively supported.

Changed

Image assets ship without embedded provenance metadata.

Design tools stamp C2PA content credentials into what they export, naming the tool that produced the file, and Directory.Build.props packs assets/icon.png into every module package, so an unstripped export would have carried that stamp to nuget.org. scripts/strip-asset-provenance.py removes it by filtering the optional PNG chunks and the SVG <metadata> element, which leaves the image data byte for byte identical rather than re-encoding it. scripts/preflight.sh now fails if any asset still carries a stamp.

Removed

IBackupService and BackupService.

Registered in DI and called by nothing, repo-wide, so the codebase read as though the application backed itself up.

Removed

The X-XSS-Protection header.

Every current browser ignores it, and while it was honoured its filter was an information leak of its own: with mode=block a cross-origin attacker could infer page content from which loads it refused. The Content-Security-Policy is what carries this (#271).

Removed

fly.toml.

It hardcoded app = 'barako-cms-api-baryo', and a Fly app name is unique across the whole platform, so anyone running fly deploy from a clone either collided on the name or deployed into the maintainer's app. fly launch generates the file; .gitignore now keeps it local, and .agent/workflows/deploy-fly-io.md carries the settings it held (#271).

Fixed

Registration accepted a username and an email of any length.

Username had a minimum and no maximum and Email had a shape check and no length at all, and both carry a unique btree index on the users document. Under roughly 2.7KB that meant a value stored, indexed and string-compared on every sign-in; over it, postgres refuses the index entry and an anonymous endpoint answers 500. Capped at 64 and 254 (#271).

Fixed

The content update endpoint answered 500 to a malformed UserId claim.

It used Guid.Parse where the create endpoint used Guid.TryParse, so a token carrying something other than a Guid in that claim threw a FormatException the exception handler turned into a server error. Not reachable with a token this server minted, and Configure() already refuses a missing claim, but answering "server error" to a malformed request sends an operator looking in the wrong place. Both write endpoints now answer 400 (#271).

Fixed

Module ordering recursed, so a deep dependency chain killed the process.

ModuleOrder.Sort traversed recursively, which bounded dependency depth by the call stack rather than by anything the method checked: a long enough chain overflowed instead of reporting a cycle or a missing dependency, and an overflow cannot be caught. The traversal keeps its own stack on the heap now. Nothing caps depth, because any number picked would refuse a legal graph, and the existing guarantees are unchanged: stable order for independent modules, a missing dependency refused by name, a cycle refused with the cycle printed.

Fixed

Liveness and readiness were the same probe, so a database blip restart-looped every API pod.

Both pointed at /health, which runs every check including the database one, and the ready tag already on the database check was filtered by nothing. One Postgres restart therefore failed liveness on every replica at once and Kubernetes killed a whole deployment of healthy application processes, turning a blip into an outage plus a cold-start stampede.

There are three endpoints now. /health/live runs the checks tagged live (Memory, the one a restart actually clears) and backs the liveness probe. /health/ready runs the checks tagged ready (Database, Disk Space, Memory, Startup Seeding) and backs the readiness probe. /health is unchanged and still reports everything. k8s/05-deployment.yaml also gains a startupProbe so the boot-time schema apply is not counted as a liveness failure.

Fixed

The core host reported itself ready before roles and the initial admin were seeded.

The seed ran on a detached task that slept five seconds first while the app was already accepting traffic, so sign-in failed in that window and a registration landing in it was stored with an empty RoleIds. Under a rolling deploy it repeated on every new node. The seed still runs in the background, so /health and /health/live keep answering while it works, but readiness stays closed until it finishes.

Fixed

The Kubernetes monitor disabled itself permanently on the first init failure.

A static flag was set once and never cleared, and the service is a singleton, so a single API-server hiccup at pod start (normal in the environment the feature targets) left monitoring off until the process restarted. The client is rebuilt on a later call now, on exponential backoff after a failed attempt and on a slow fixed interval when there is simply no cluster to talk to.

Fixed

The Kubernetes manifests could not be applied.

k8s/05-deployment.yaml asked for memory: "128Mw", which the API server rejects outright, so nothing else in the directory had been exercised either. Also fixed: the app pod now consumes k8s/01-configmap.yaml through envFrom, so a Kubernetes deployment actually runs in Production mode; the image tag is pinned instead of latest; InitialAdmin is wired to the secret, so a first boot creates an admin rather than silently creating none; and the Grafana dashboard moved to k8s/observability/, where kubectl apply -f k8s/ no longer trips over it. kubectl apply -f k8s/ was run against a real cluster.

Fixed

Re-publishing already-published content fired every Published workflow again.

PUT /api/contents/{id}/status appended a ContentStatusChanged without checking whether the status had actually changed, and the projection fires on any such event whose new status is Published. A double-clicked publish button, a client retry after a timeout or a form that resubmits the current status sent the confirmation email twice, called the webhook twice and created the task twice. It also wrote transitions that changed nothing into the stream, which is the source of truth for history and replay. The endpoint now short-circuits an unchanged status, the way the update slice always has. A real transition back to Draft and out again still fires the workflow both times.

Fixed

The workflow code named a manual rebuild as the remedy for a halted projection.

No such command exists, and running one as the projection is written would re-run every action for every event ever stored: every confirmation email re-sent, every webhook re-fired. The comments say what a rebuild would cost, and docs/operating-workflows.md says what recovery actually looks like until the side effects are separated from the projection.

Fixed

The shipped Kubernetes Deployment asked for 128Mw of memory.

Not a valid quantity, so the manifest was rejected on apply.

Fixed

Two tests that could not fail are gone, and the cross-tenant join is covered.

One built a workflow and ended on await Task.CompletedTask with no act and no assert; the other constructed a workflow engine, never called it, and asserted that the list it had just built contained the item it had just put in. Both ran on every build. Replaced with tests that drive the real engine, plus the first test to put two authenticated users in different tenants against the content API: tenant isolation was proven in two halves that never met, and the guard between them is one if that nothing was checking.

Fixed

Assigning a role or a group to an unknown user id fabricated a user.

Both assign endpoints carried a "load or create user (for testing, we'll create if not exists)" branch into production. On a miss they stored a User with a synthesized user_{guid}@example.com and no password hash, holding the role, and answered "Role assigned to user successfully". A mistyped id therefore left a ghost identity row behind while the real account still lacked the role, and the caller was told it had worked. The role and group ids were never checked at all, so a mistyped one also reported success and granted nothing. All four cases are 404s now, and nothing is written.

Fixed

Create and Update accepted status and sensitivity values no enum member names.

POST /api/contents with "status": 7 bound cleanly and stored content with an undefined status, invisible to the scheduler, to status-filtered lists and to delivery, with no error anywhere. ChangeStatus has validated this since it was written. Both slices do now. A defined value sent as a number still works, so a 3.x client posting "status": 1 is unaffected.

Fixed

A PUT that omitted Status silently un-published the content.

An absent status bound to 0, which is Draft, and the endpoint treated any difference from the stored status as a transition. A consumer sending only id, data and version, which is what a data-only edit looks like, un-published the item and emitted a ContentStatusChanged saying so. Status is nullable now and absent means unchanged.

Fixed

An update reported a version it computed before the append.

The reported version was the stream state read before the append plus the number of events appended. When version is 0 the staleness check is deliberately bypassed, so another writer can advance the stream in that window and the sum then under-reports. The client echoes the reported version into its next update, so an under-report turned an ordinary follow-up edit into a 412 blaming a conflict that never happened. The version is read back after the commit.

Fixed

Expired OTP codes were never deleted.

TokenCleanupService swept RefreshToken, RevokedToken and IdempotencyRecord, and no deletion path for OtpCode existed anywhere. OtpService only marks outstanding codes Consumed when a new one is issued, so every sign-in request left a permanent row and the "this email, not consumed" scan in send and verify degraded with the table. The ExpiresAt index was already registered. All four passes are now a single DeleteWhere each, one DELETE statement per document type, instead of loading the full expired set and deleting row by row.

Fixed

The anonymous slug route loaded every published entry of the type.

GET /api/public/{type}/{slug} queried all published, Public content of the type and matched the slug in memory, so a blog with 20k posts deserialized 20k documents to return one and a 404 probe cost exactly the same. The match runs in Postgres now, reusing the case-insensitive jsonb key lookup the delivery filters already had. It stays case-insensitive, and _ and % in a slug are still ordinary characters.

Fixed

Three endpoints checked a claim that could never exist.

Content/List, Content/History and Content/Get looked up the literal string System.Security.Claims.ClaimTypes.NameIdentifier, which is the name of a constant and not its value, so it matched nothing on any token this project issues and the UserId fallback beside it was always what ran. No behaviour change, but it read as though a second identity source was being consulted.

Fixed

WebhookAction never disposed its HttpResponseMessage

, on a path a workflow can fire on every content change.

Fixed

OllamaEmbeddingClient.EmbedAsync swallowed cancellation.

A bare catch turned OperationCanceledException into null, so an abandoned search reported "no results" rather than stopping and the caller could not tell an empty index from a request that never finished. An unreachable backend still degrades to null.

Fixed

The install command in every release announcement named a version that does not exist.

The announce step interpolated the gate's version, which is the core's, into dotnet add package BarakoCMS.Accounting --version …. No module has ever shared the core's number, so the command has failed for every release so far: at 3.21.0 it asked nuget.org for BarakoCMS.Accounting 3.21.0, where the highest published is 0.3.1. It names the core package now, which is the one id guaranteed to exist at that version, because the publish job just pushed it (#294).

Fixed

No release ever published a symbol package.

Directory.Build.props has set IncludeSymbols and SymbolPackageFormat=snupkg since Source Link went in, and pack has been writing out/*.snupkg all along, but the artifact upload matched out/*.nupkg and the publish job pushes from that artifact and nothing else. Every symbol package was discarded between the two, and no step went red about it, so the whole Source Link investment shipped nothing. The upload takes both now, and verify-packages fails unless all fourteen packages have a .snupkg beside them (#294).

Fixed

The project still advertised .NET 8 in fourteen NuGet storefront pages.

The move to .NET 10, Marten 9 and FastEndpoints 8 changed Directory.Build.props, global.json and the Dockerfiles and almost nothing else. The core package Description (the text NuGet search results render), the core README and eleven module READMEs all said .NET 8, and four of the module READMEs also claimed barakoCMS ≥ 2.2.0, so every package page would have been wrong twice over the moment 4.0.0 published. README.md, llms.txt, CLAUDE.md, .cursorrules, the site copy, the bug-report template and the quickstart's BARAKO_TAG pin are corrected too. CLAUDE.md mattered most of these: agents are pointed at it as the working agreement and would have followed its ".NET 8, one target framework" when adding a package (#295).

Fixed

F5 could not launch the project.

.vscode/launch.json pointed at bin/Debug/net8.0/barakoCMS.dll, which no build has produced since the retarget (#295).

Fixed

The blog-starter example failed at both of its steps.

Step 1 said to import blog-schema.json through the admin, which has no schema import. Step 2 fetched /api/contents?contentType=blog-post with no auth; that is the authoring API, so it answered 401 and the example's catch rendered an empty blog rather than saying anything. The schema is now a valid POST /api/content-types body (isRequired rather than required, slug/url/array in place of the media and list types no validator accepts, and isPubliclyDeliverable: true, without which delivery 404s), the README shows the request that creates it, and the fetch uses the public delivery route and reports a failure instead of hiding it (#295).

Fixed

Turning on device trust locked every administrator out.

With DeviceTrust__Enforce on, the API answers a password login from an unapproved device with requiresDeviceApproval and emails a code. The admin showed a toast and stopped, so there was nowhere to type the code and no way back in. The quickstart advertises that setting. The login page has the approval step now, and hands off to the authenticator step rather than signing in when the account also has MFA enabled, because a mailbox is a first factor and cannot stand in for the enrolled second one.

Fixed

The admin History panel had been showing nothing since the list envelope changed.

It read versions off GET /api/contents/{id}/history, and that endpoint has returned the paginated items envelope since #291. The panel rendered an empty list rather than failing, and the e2e suite could not catch it because it mocks the route and the mock was written to match the client. It also understands the entry types the history now reports, so a status change is labelled as one and is not offered a Restore button it cannot honour.

Fixed

The admin decided which roles are undeletable by name, and the server decides by id.

Rename a system role and the admin offered a delete the server refuses; create a custom role called "HR" and the admin locked one the server would remove. The roles API reports isSystem now, derived from the seeded ids that the delete rule already keys on, and the admin asks instead of re-deriving.

Fixed

Content history reports every event, not two of five.

GET /api/contents/{id}/history mapped ContentCreated and ContentUpdated and returned null for ContentStatusChanged, ContentScheduled and ContentSensitivityChanged, and the nulls were filtered out, so publishing a document left no trace in its own history and nothing in the response said the list had been shortened. Every event is now an entry carrying a changeType, and an entry that does not record a document version carries the value that changed (status, the scheduled times, sensitivity) instead of data. An event type the endpoint does not recognise still appears, under its own name, rather than being dropped.

Fixed

The published images serve both amd64 and arm64.

The release built for whatever architecture its runner happened to be, so barako-cms:3.21.0 and its siblings were amd64 only and could not run on Graviton, on Ampere, or on this project's own playground VM. Each architecture is now built natively, on a runner of that architecture, and joined into one manifest list. Pushes carry no tag until the join succeeds, so a half-finished build cannot leave :latest pointing at one architecture, and the release fails if a published image does not serve both.

Fixed

Three real accessibility defects, found by the new scan on its first run.

The primary button colour gave white text 3.85:1 against WCAG AA's 4.5:1, so every primary button in the light theme failed; muted text was 4.45:1 on the sidebar; and the content-type selects had no accessible name, one of them because a visible label was never associated with its control.

Fixed

Every deployment path takes a backup, and CI proves one can be restored.

The hardened backup script was wired into the development compose file only, so the deployments holding real data had none. docker-compose.prod.yml and the quickstart stack now run that same script, and the k8s CronJob carries the same logic inline because a CronJob has no repository to mount. Each writes to its own volume rather than Postgres's. scripts/restore-check.sh takes a backup, destroys the database, restores it and boots the app against the result, on every pull request. Runbook in docs/backup-and-restore.md.

Fixed

The k8s backup CronJob could not run, and would not have worked if it had.

It mounted postgres-data, but the StatefulSet's volumeClaimTemplates creates postgres-data-postgres-0, so the pod stayed Pending forever. Its dump also piped straight into gzip and checked gzip's exit code, which is the failure the compose script was rewritten to remove.

Fixed

The admin rendered every validation failure as "[object Object]"

, including "Invalid credentials" on the login page. It read message off ProblemDetails entries, which carry name and reason.

Fixed

A fatal startup failure now exits 1.

It exited 0, so a broken deploy reported success to CI, a docker run wrapper, systemd and a Kubernetes Job container. Anything that depended on the old behaviour to get past a failing start will now stop.

Fixed

The workflow daemon lost the event's tenant.

It resolved the workflow engine from a scope sitting on the platform default tenant, so a tenant's workflow definitions were invisible to it and a default-tenant workflow's writes landed in the wrong partition.

Fixed

The scheduled publish sweep read every due item in one query.

No limit, so the sweep's memory and the size of its transaction were whatever had accumulated: nothing on a healthy deployment, and the entire backlog after downtime or a bulk import that carried schedules. It works in batches of 200 now, up to 25 batches per tick, committing each batch. A caller expecting one SweepTenantAsync to drain everything still gets that up to 5000 items per tenant, and the remainder is applied by the next tick a minute later. The method gained an overload taking the batch size and the cap; the three-argument one is unchanged and uses the defaults.

Fixed

A revoked permission could come back.

Permission-cache invalidation bumped a version counter that formed part of the cache key, and that counter was itself an entry in the same cache: same five minute expiry, same size limit, same eviction under pressure. Once it was gone the next invalidation read zero, wrote one, and rebuilt a key that was already cached, so the revoked decision was served again and the log said "Invalidated permission cache" either way. Invalidation now uses expiration tokens held outside the cache, so cancelling one evicts every decision that registered against it, and there is no version arithmetic left to lose.

Fixed

Rollback skipped every gate a normal update runs.

Restoring a version wrote the historical data straight into a new event, so it could put back data the current schema rejects, change a field the caller is not allowed to change, or break an invariant introduced after that version. It now runs write-path sensitivity, schema validation and the lifecycle hooks, and refuses with a message naming the reason. An operator can be refused a rollback for a reason that predates them, which is the correct answer: the alternative is a write path that launders rejected data back in.

Fixed

A sensitive field escaped masking on a casing mismatch.

Validation and public delivery match schema field names case-insensitively, and delivery documents that as normal. Masking matched ordinally, so a record holding salary against a field declared Salary was validated as that field, delivered as that field, and not hidden as that field. All three now agree.

Fixed

An OTP code could be verified twice.

RefreshToken and MfaSecret both carry optimistic concurrency to close exactly this race and OtpCode did not, so two requests with the same code could both see it unconsumed and both mint tokens. Device approval and passwordless sign-in both rest on that path.

Fixed

A system proxy silently bypassed the webhook address guard.

With a proxy in use the connect callback dials the proxy, and the proxy then resolves and connects to the target, so the guard was inspecting the wrong hop. UseProxy is off on that client now. A system proxy can arrive from an environment variable nobody deploying chose, which is what makes it worth failing closed on. An operator whose egress needs one sets Webhooks:AllowProxy and has to apply the same destination policy at the proxy, because nothing here can.

Fixed

The production CSP no longer allows 'unsafe-inline' on style-src.

script-src had dropped it outside Development, which is the half that defeats XSS mitigation, but styles kept it app-wide as a documented partial fix pending a check nobody had run. CSS injection cannot execute script, so this is the lower-severity half, but attacker-controlled inline styles still exfiltrate through selectors and background-image requests.

The allowance survives only on the health-checks dashboard, and only while HealthChecksUI:Enabled is on. That dashboard genuinely needs it: its shipped bundle renders three dozen React style props, so its elements carry inline style attributes and the page renders wrong without it. Nothing else this host serves outside Development emits an inline style, and the Next.js admin is a separate application with its own headers, so its rendering is unaffected either way.

Fixed

The token revocation check failed open.

Any exception from the revocation query returned "not revoked", so a revoked token was accepted for as long as the store was unreachable, and it said so at Debug, which production does not emit. A logged-out session came back during a database blip and nothing recorded it. A missing table still answers "not revoked", because with no table nothing has ever been revoked and that is the case the original catch was written for. Everything else refuses the request.

Fixed

Refresh-token rotation dropped the device binding.

The replacement token carried no DeviceId, so the binding survived exactly one refresh and device trust had nothing to enforce against from the second onward. The symptom appeared one rotation after the cause, which is why it lasted.

Fixed

An OTP email that failed to send was reported as sent.

On the device approval path, where the password has already been proved, the response now says the code could not be emailed instead of sending somebody to wait for a message that was never sent. The unauthenticated request-a-code route deliberately still answers identically whether the address exists, because reporting the failure there would tell a caller which addresses are real.

Fixed

The API images run as a non-root user.

barako-cms and barako-cms-decaf ran as root while the admin image did not, which is what an omission looks like rather than a decision. Both now drop to the base image's app user (uid 1654) before the entrypoint. Nothing needs privilege: 8080 is above 1024, and the app writes nothing to the container filesystem at runtime. No compose file in this repository mounts a host path into the API, so no shipped configuration changes. Anyone who has added their own bind mount needs it writable by uid 1654.

Fixed

Social sign-in accepted an email the provider never verified.

The email was the only join key, so an unverified assertion was a login for whichever local account held that address, including a seeded SuperAdmin whose address is {username}@company.com and therefore guessable. PasswordHash is not consulted on that path.

Google and LinkedIn now require email_verified. GitHub uses only the verified primary from /user/emails; it previously preferred the unflagged profile email whenever it was set, so the careful branch was the one nobody reached. Facebook exposes no verification flag at all and is now refused unless Facebook:TrustUnverifiedEmail is set, which is an operator's explicit decision. IssueAsync takes the flag as a required argument, so the next provider cannot omit it quietly. ExternalAuth 4.0.0.

The module had no test project reference and therefore no tests, which is why none of this was caught (#120). It has both now.

Fixed

A password login against an account with no password returned 500, not 401.

Social sign-in creates users with an empty PasswordHash, and BCrypt throws on one rather than returning false. That was a username oracle on the one endpoint that had taken care to avoid one, next to its own dummy-hash timing defence. It now burns the same dummy verify and returns the same 401.

Fixed

Any authenticated account could read any file in the tenant, and upload without a role.

Both Files endpoints had authentication and neither had authorization. Download is now the uploader or an admin, refusing with 404 rather than 403 so a leaked id cannot be used to probe for others. Upload now carries the same role gate as every other write in the module set. Files 4.0.0.

Fixed

The seeder no longer writes anything shaped like a Social Security number.

The demo AttendanceRecord rows carried 123-45-6789, 987-65-4321 and 456-78-9012. The first is a well-known placeholder that data-loss-prevention and compliance scanners treat as a real SSN, and all three planted realistic sensitive values in every fresh install of a CMS that markets field-level sensitivity. The sample rows now use SAMPLE-NOT-A-REAL-SSN-n, names that read as placeholders, and mail at example.com, which RFC 2606 reserves for documentation.

Seeded mail addresses moved off company.com for the same reason: it is a registered domain, so a password reset or an OTP for the seeded admin, HR or standard account left the building. A seeded admin's address changes from {username}@company.com to {username}@example.com on next start.

A test asserts the shape rather than the new values, so a future edit that swaps in three different realistic numbers fails too.

Fixed

docker-compose.yml no longer ships three defaults that are unsafe to copy.

It is labelled local-development-only, but that is a comment rather than a control, and people copy what works.

The app container bind-mounted ${HOME}/.kube, handing every context and token in the developer's kubeconfig to anything running inside it; the mount is gone, and the Kubernetes monitor is off by default anyway. The postgres and backup services hardcoded the password, so setting a variable left the three services out of step while the built-in value kept working; all three now read DB_PASSWORD, matching .env.example and the other compose files. Postgres was published on every interface, which with a default password is an open database on any host that is not a private laptop; it binds 127.0.0.1 now, so psql from the host still works and nothing else can reach it.

The file still starts with no .env at all.

Fixed

The webhook SSRF guard checked one address and connected to another.

WebhookAction resolved the target host, checked the answer, then handed the name to HttpClient, which resolved it again when it opened the socket. A name whose DNS answer changed in between passed the check on a public address and connected to 169.254.169.254. Resolution now happens once, inside the client's connect callback, and the socket is opened to an address that answer survived, so there is no second lookup to poison. A name that answers with one public and one blocked address is refused outright rather than connected to the public one. Redirects stay off, since a redirect is a second resolution by another route.

Fixed

The webhook posted the whole content data object.

Every stored field went to the target URL, including fields a read masks, so anyone who could configure a workflow could send a Hidden field to an external address. The payload now carries only the fields the content type marks Public, through the same projection the public read path uses, and a document that is itself Sensitive or Hidden contributes no data at all. A content type with no definition sends no data rather than all of it.

Fixed

The redirects index in the 3.x upgrade script is named the way Marten names it.

It was mt_doc_url_redirects_uidx_frompath; Marten derives mt_doc_url_redirects_uidx_from_path from the property name. An upgraded database ended up with a unique index that behaved identically and had the wrong name, so every start-up schema assertion wanted to drop and recreate it.

Fixed

An unmapped content event no longer puts its class name in the history response.

The mapper fell back to @event.GetType().Name for an event it did not recognise, so adding an event and forgetting the switch would have published its CLR type name, which is the leak #229 forbids. No reflection guard can catch it, because by the time it reaches the wire it is a string. It reports Unknown now, the entry still appears so the count keeps matching the stream, and a behavioural test pins it.

Fixed

DATABASE_URL keeps its own sslmode, and defaults to Require rather than Disable.

The URL was parsed and then SSL Mode=Disable was appended regardless, so a managed Postgres that requires TLS refused every connection, and one that merely allows it got an unencrypted link nobody asked for. An sslmode the URL names is honoured, an unrecognised one is refused by name rather than ignored, and credentials and the database name are percent-decoded.

Fixed

The connection string is built rather than interpolated.

Decoding the credentials makes a case reachable that was not before: a semicolon is legal in a Postgres password, percent-encoding it is how a URL expresses one, and decoded into an interpolated string it ends the Password key, so everything after it is read as another setting. That surfaces as an unknown keyword rather than as a bad password, which is a long afternoon. NpgsqlConnectionStringBuilder quotes it.

Fixed

A URL with no port gets 5432

rather than Port=-1, which is what Uri.Port returns when none was given.

Fixed

The workflow tests no longer fight the hosted runner.

The runner polls every five seconds and claims any Pending attempt that is due, plus any Running one whose lease has expired, which includes one that a test seeded and is about to assert on. Seeded runs are now parked out of its reach (a future next-attempt time, or a live lease held by another node) rather than the runner being taken out of the test host, which is what broke every workflow-firing test: those poll for the hosted runner to do the work. A test drives one drain directly and asserts the seeded runs are untouched, so the parking fails loudly if it stops working instead of showing up as a flake in a full suite.

The other side of keeping the runner in the host: a test that drives it can no longer treat "this pass claimed nothing" as "the work is finished", because the hosted runner may have claimed the attempt first and still be executing it. WorkflowTenantIsolationTests waits for the outcome with a deadline instead, which is the difference between a test that is slow when something is wrong and one that fails at 201ms with the work still in flight.

Fixed

The background scheduler no longer runs inside the test host.

ScheduledContentService waits thirty seconds after startup and then sweeps every minute, so a class run in isolation finished before it ever fired and a six minute suite got six sweeps, any of which could publish a test's draft between its arrange and its act. That is why the sweep-versus-editor concurrency test failed only in CI and passed every time locally. Every scheduling test drives SweepTenantAsync directly, so removing the timer takes nothing away, and the fixture throws if the registration ever stops matching rather than quietly restoring it. The delivery test that had been weakened to work around the same sweeper asserts on the scheduled item again.

Fixed

The admin no longer offers Editor a screen the API refuses.

GET /api/content-types stopped granting Editor when #373 landed, but the sidebar kept listing it, so the link rendered and the API answered 403. The test that should have caught it asserted the stale behaviour in its own name, "gives Editor the content types screen the API lets them reach", which is how it survived the server-side fix. It now asserts the general rule instead: a role the server has never heard of reaches no gated destination.

Fixed

POST /api/content-types no longer excludes SuperAdmin.

It gated on Roles("Admin") alone, the only gate in the codebase that left SuperAdmin out, so a principal holding only that role could read content types, toggle public delivery and change a field's sensitivity but could not create the type those settings belong to. A structural test now asserts that any role gate naming Admin also names SuperAdmin, because nothing had ever presented a SuperAdmin-only principal to a gate: the seeded admin holds both roles, so the omission was invisible to the suite.

Fixed

A permission decision no longer outlives the item state it was based on.

A per-item decision was cached for five minutes keyed on the item's id, and nothing on the content write path invalidated it. Decisions can depend on the item's contents (a rule can test status, last modified by, created by or any data field), and status and last modified by both change on an ordinary write. A rule granting update only while an entry is a draft kept granting for up to five minutes after it was published.

It failed open, which is the direction that matters: a stale denial is an inconvenience, a stale grant is an authorisation check that has stopped checking.

Item decisions are now answered fresh, every time. Keying on the item's version would also close it, but the version is not on the document (the document is the fold, the version belongs to the stream), so reading it costs a query per check. There is little to give up: the key included the item id, so a list was a cache miss on every row already, and what makes a list cheap is the role memoisation on the resolver, which is untouched. The type-level decision, which has no item state in it, is still cached.

Fixed

GET /api/audit compares from and to in UTC.

CreatedAt is stored in UTC, but the two query values were compared straight against it with whatever Kind the model binder gave them. A caller filtering in a non-UTC zone had their window shifted by the offset, silently missing rows at both edges. Fixed the same way as the Forms module's submissions list (AsUtc): a value with an offset is converted from local to UTC, a value already tagged UTC passes through, and a bare value with no zone is taken as UTC, which is what ListRequest.From and ListRequest.To already documented.

Fixed

The redirects resolve endpoint's output cache now actually caches.

It called Options(x => x.CacheOutput(...)), but nothing registered AddOutputCache/UseOutputCache, so the policy was metadata nobody read and every resolve hit Postgres. Output caching is registered now, placed after authentication and authorization so it never serves a response to a caller who should not see it, and the cache key is varied by tenant so one tenant's cached answer cannot be served to another.

Fixed

Public delivery responses now carry Vary: X-Tenant.

TenantResolutionMiddleware resolves the tenant from the X-Tenant header before it looks at Host, and the response is built entirely from that tenant's content, but Cache-Control: public, max-age=60 went out with no Vary. A shared cache keyed on the URL alone could serve one tenant's response to another, on any deployment where more than one tenant is reachable through the same hostname and path (header- or path-routed multi-tenancy; hostname-per-tenant was already safe, since Host is part of the URL). PublicDelivery.SetCache sets Vary now, which covers the list, search, slug, feed and sitemap routes in one place. Vary is necessary but not sufficient: docs/deploy-in-production.md now says which deployment shapes are safe to put a shared cache or CDN in front of, and what the CDN itself has to be configured to do on the ones that are not.

Fixed

Content now catches a concurrent write instead of silently losing it.

Two editors saving the same entry used to leave one edit gone with no error, and the history recorded the surviving write as though the other never happened. Content gets Marten's own optimistic concurrency, GET /api/contents/{id} returns the entry's version as an ETag, and PUT accepts it back as If-Match, answering 412 when it does not match. Two writers racing with no version sent at all now also get one success and one 412, rather than a second write nobody could see coming. Content:Concurrency:Require (default false in 4.x) decides whether a write that sends no version is refused instead; a 3.x client upgrading in place sends none, so the default keeps that path working. Same shape as Lifecycle:EnforceTransitions. Event-sourced content types are unaffected: they already refuse a stale or missing version on the stream (D3).

Fixed

SmsAction and EmailAction no longer report success when nothing was sent.

Both actions implemented only the obsolete ExecuteAsync, so the default RunAsync always returned WorkflowActionResult.Success() after calling it, whatever the underlying provider did. On a stock install the default ISmsService and IEmailService are mock providers that log and return without sending anything or throwing, so a workflow with an SMS or Email action recorded success for a message nobody received.

Both actions now implement RunAsync directly. A send against the mock provider returns PermanentFailure, since retrying will not change anything until a real provider is registered; a provider throwing is caught and returned as a retryable Failure naming the exception type, never the exception message, which routinely names the recipient. The error text stored on the run record never carries a phone number, email address or provider credential.

Fixed

A Request action now carries the workflow run's idempotency key through to the connector.

WorkflowRunner has always put a stable key on every action's parameters, and WebhookAction has always sent it as Idempotency-Key, but RequestAction dropped it: neither it nor RequestComposer mentioned idempotency at all, so a retried call to a connector, the path an operator actually configures to reach a payment or accounting provider, carried no protection against being applied twice.

The header name is a connector setting (Settings["IdempotencyHeader"]), not a request setting, because the spelling a provider wants is a property of the provider, and every request definition against the same connector should agree on it without repeating the choice. Unset defaults to Idempotency-Key. The literal value off switches it off, for a provider that rejects an unknown header; an empty setting falls back to the default rather than silently disabling the protection, so turning it off has to be spelled out.

The key is sent unchanged, the same value WebhookAction sends, and goes through the same control-character check every templated header already passes. An action invoked outside the runner (a dry run, a test) has no key to send, and composes without the header rather than being refused.

Fixed

UpdateFieldAction no longer applies its change twice when an attempt is reclaimed.

The action wrote content in its own transaction, separate from the write that records the attempt's outcome. When a node ran past its lease, another node reclaimed the attempt and the first node's outcome was discarded on purpose (see the comment in WorkflowRunner.TryRunAsync), trusting the idempotency key to absorb the duplicate call downstream. An in-process field update has no downstream: the content change had already committed, the outcome was dropped, and the second node applied the change again with no record that it had run twice.

The write now reloads the target immediately before deciding anything, and checks a marker on the content itself, keyed by the run's IdempotencyKey and the attempt number the runner injects. Two executions of the same attempt (a reclaim) find the mark already there and write nothing a second time; a genuine retry after a real failure carries the next attempt number, finds no matching mark, and still applies. The write goes through IContentWriter.AppendOptimisticAsync rather than a plain Store, so it does not depend on last-write-wins either.

Fixed

ConditionalAction no longer reports success when one of its child actions fails.

Each child ran inline and its result was logged as a warning and dropped, so a conditional whose branch failed to send anything still reported Success(). The run record said the workflow did something it did not do.

A failing child now feeds into the conditional's own result. If nothing in the branch has succeeded yet, the failure is retryable, since retrying only re-runs children that never had an effect. The moment one child has succeeded alongside a failing one, the conditional reports a non-retryable failure instead: children still run with no attempt record and no idempotency key of their own (that reshape is 4.1), so a retry re-runs every child from the top, and offering one here would resend whatever the earlier child already sent. The aggregated error names which child action types failed, never the child's own error text, which can carry what it was sending.

Fixed

A request definition can now use a query.

#328 closed a feature that refused itself: every {{query.*}} hole in a request's path, headers or body was refused with "queries are not implemented yet (#328)", whatever RequestDefinition.QuerySlug named, because nothing on the request path called IQueryRunner. A query could be defined, previewed and run through the API, and a request definition still could not use one.

{{query.rows}} now composes to a JSON array of the named query's rows, one object per row, holding exactly the fields the query selects, bounded by its own Limit (itself capped at QueryDefinition.MaxLimit, 1000). It is inserted unescaped in a JSON body, since it is already valid JSON: quoting it would hand the recipient a string full of JSON instead of an array. {{query.SomeField}} composes to that field from the first row.

The refusal is unchanged for a hole naming a query that does not exist or a field the query does not select: posting the literal text {{query.rows}} to a third party is worse than not running, and that has not stopped being true. A single field naming a query that matched no rows is refused too, rather than composing empty: "the query matched nothing" and "the field is genuinely empty" must not produce the identical value with nothing in the sent request to tell them apart afterwards. {{query.rows}} does not need this; an empty array is still a real answer to how many rows matched.

A query is resolved through the same tenant-scoped session as everything else a request composes against, so a request never sees another tenant's query even when both hold the identical slug.

Fixed

migrations/4.0.0/rollback-to-3.x.sql parses.

The DROP FUNCTION for mt_quick_append_events carried DEFAULT NULL::integer over from the function's own definition, which DROP FUNCTION does not accept in its argument list. Applied with --single-transaction as the docs say, this meant nothing before the failing line landed either: the documented rollback did nothing at all. scripts/upgrade-check.sh now applies the rollback after the forward migration and boots 3.21.0 again against the result, so a future break here fails CI instead of an operator mid-incident.

Fixed

CITATIONS.cff said Apache-2.0 and carried a stale version.

The project has been MPL-2.0 since 3.1.1. license now reads MPL-2.0, and the stale version/date-released fields are removed rather than left to go wrong again on every release.

Fixed

Two pull request scratch files, body517.md and body518.md, were committed in the repository root.

Both are deleted. scripts/preflight.sh now refuses a diff that adds a top-level Markdown file not on a known list, so the next one fails before it merges.

Fixed

Two places pointed at the console as though this repository still owned it.

The issue template's console redirect went to barakoBrew/issues/new/choose, which offers no chooser because that repository has no templates yet (barakoBrew#29); it now points at the plain form and says so. The README and docs/deploy-in-production.md said barako-admin is "still published" or "built and released by" barakoBrew's own workflow; nothing has published it since the split (barakoBrew#23), so the wording now says where the image comes from without claiming a pipeline that does not exist, and names the amd64-only 3.21.0 tag as the last one built, with no 4.0 tag coming from here. quickstart/.env.example and quickstart/docker-compose.yml now say why ALLOWED_ORIGINS defaults to port 3000 when this repository's quickstart starts no console on it (#632, #633).

Fixed

Logging out threw, and revocations were never cached.

AddMemoryCache sets a SizeLimit, and an entry stored without a Size raises InvalidOperationException. TokenRevocationService set both of its cache entries without one, so POST /api/auth/logout failed outright and every revocation check fell through to a database query on every authenticated request. There were no logout tests, which is why it survived. Found while building the session epoch, whose own cache write threw the same way and was invisible because the middleware catches and serves.

Fixed

A capability added after a deployment upgraded now reaches its seeded roles.

The backfill filled only an empty capability list, so a deployment that upgraded once had an Admin whose list was not empty, and every area migrated afterwards never arrived. Nothing broke while Auth:LegacyRoleFallback was on, since the gate still honours the role names it replaced. Turning the fallback off, which is the point of the migration, is where that Admin would have silently lost every area migrated after its own upgrade.

The defaults are unioned in on each seed instead. The cost, stated rather than hidden: a default an operator has deliberately removed from a seeded system role comes back on the next restart, because nothing records that the removal was deliberate. Removing one for good means not running the seeder. A role you created is untouched either way, since the defaults are keyed on the names the seeder creates.

Fixed

The health canary pins the shape of the /health body instead of asserting the app is healthy.

It exists so a dashboard or a kubelet parsing that body sees what it always saw, and its own comment already said the assertion was about the shape rather than about when seeding ends. It asserted the status word was Healthy anyway, which made it depend on the startup seed finishing inside a fixed window on a shared CI runner. It now accepts any of the three status words and still fails on a new field, a renamed property or added whitespace, which is what it is for. No production code changed.

Fixed

POST /api/import/analyze refuses a spreadsheet it will not parse, before decompressing it.

The parser reads a whole sheet into memory before the 500-row preview cap can apply, so the cost of a request followed the expanded size rather than the uploaded size. An xlsx is a zip, and repetitive sheet XML compresses roughly fifteen to one, so the 10 MB request body limit did not bound the work.

Measured: a 3.2 MB upload, well inside the body limit, expanded to 46 MB of sheet XML and took 98 seconds and 968 MB to answer, returning a preview of 500 rows. The same file is now refused in 0.15 seconds and 20 MB. The global rate limit of 100 requests a minute per address does not bound something that costs what the first figure costs.

The limit is on the expanded size the archive declares, read from the zip's central directory without decompressing anything. Default 8 MB, configurable as Import:MaxExpandedBytes, and a refusal names the setting so an operator with a genuinely large file knows what to change. A CSV is not an archive and is unaffected: its expanded size is its uploaded size, which the body limit already bounds.

Security

A pre-release hardening sweep closed the low-severity findings from the bug hunt.

The individual changes are the entries marked (#271) under Breaking, Added, Removed and Fixed.

Two of the checklist's items were deliberately left as they are. The device-approval login response still returns the account email: it is written on one response only, the one reached after the password has already been verified, and /api/auth/otp/verify is keyed on the address while the sign-in form collects a username, so removing it would break device approval without withholding anything the caller had not already proved. A test now pins that the address never appears on the failure path, which is the boundary that reasoning rests on. The second, BARAKO_BACKUP_DIR, needed no work because BackupService was deleted earlier in this release.

Security

Uploads can be scanned for malware before they are stored.

Set Files:Scanner:Address to a clamd daemon and every upload is scanned before the bytes reach storage. Off by default, which is what every deployment does today. An infected file is refused with 422 and the signature name; a scanner that cannot be reached refuses the upload with 503, because an outage is not evidence that a file is safe, and that choice is documented rather than accidental. Neither outcome stores the file: what is kept is an audit entry naming the file, its size, who sent it and what the scanner found, in the hash-chained log that already has a screen. docs/scanning-uploads.md covers the container, the memory it needs, and how to check it works with the EICAR test file.

Security

48 core endpoints declared a role gate and 41 of them had no test that the gate refuses anyone.

Only three had the full treatment, so deleting an endpoint or widening its roles was invisible to the suite. Every endpoint that calls Roles(...) in Configure() now gets the three cases WorkflowMetadataAuthTests established: anonymous refused with 401, a signed-in caller holding the wrong role refused with 403, and an admin still served. The route inventory those tests run over is compared against the gates the running host actually declares, so adding a gated endpoint without refusal coverage, or dropping a gate from one that had it, fails the suite by name instead of quietly reducing coverage. Closes #231.

Security

Registration accepted any email address and created a live account against it.

Nothing proved the registrant could read the mailbox, so anyone could take an address they did not own. That also reopened external sign-in from the other side: SocialSignIn matches a provider's verified email to a local account by address alone, so a squatted address handed its real owner's Google sign-in to whoever registered it first. POST /api/auth/register now records a pending registration and emails a single-use token (24 hours) instead of creating a user, and the account appears at the new POST /api/auth/register/verify when the token comes back. No user document ever holds an address nobody proved. Registering an address that already exists answers exactly as a new one does, byte for byte, and tells the mailbox owner rather than the caller. Set Auth:RequireEmailVerification to false to keep the old behaviour; a deployment that does must also set Auth:AcknowledgeUnverifiedRegistration, or it refuses to start.

Security

Administrative endpoints gate on a capability the caller's roles carry, not on a role name in C#.

Roles are runtime data and the gates were literals, so the two could never be reconciled: a role created through POST /api/roles could not be granted access to anything without a release, and a role someone named Editor picked up whatever Editor was written into. Role.SystemCapabilities existed for exactly this and nothing read it, which is a security control that looks present and does nothing. Definition.RequireCapability(...) replaces Roles(...) on Features/Roles/*, Features/Tenants/* and Features/Tenants/Members/*, with manage_roles, manage_tenants and manage_tenant_members as the first three names in the vocabulary. Everything else still gates on Roles(...) and keeps working, including third-party modules, which compile unchanged.

Security

Revoking a capability takes effect on the next request.

Capabilities are resolved per request from the caller's roles rather than stamped into the token, so there is no window where a token issued before the change still carries the old answer. Putting them in the token would have meant up to 15 minutes of stale access with nothing to say so, which is the case that matters: someone removing an administrator's access during an incident. CachedPermissionResolver absorbs the lookup and already evicts on the role and membership changes that can alter it.

Security

Nothing to do on upgrade.

The seeder backfills the four system roles with the capabilities matching what they could already reach, and leaves alone any list an operator has curated. Access does not depend on that having run: the gate also honours the role names it replaced, so a host that never calls the seeder is unaffected. Set Auth:LegacyRoleFallback=false (env Auth__LegacyRoleFallback) to turn the names off once your roles carry capabilities. Admin was never in the Roles("SuperAdmin") gate on roles and tenants and does not acquire it here.

Security

RoleGateTests reads capability gates too.

Its structural half compares the live routing table against its inventory, and it only knew about Roles(...), so migrating an endpoint would have dropped it out of scope and quietly taken its 401/403/served coverage with it. A second structural test refuses a capability that the vocabulary does not declare, so a typo cannot ship as an endpoint nobody can reach. Closes #272.

Security

Approving is a different right from editing.

A status change checked the Update permission, so whoever could edit an invoice could also approve it and separation of duties could not be expressed at all. ContentTypePermission carries a rule per named transition now, and a transition that a role does not declare is refused rather than falling back to Update, because that fallback is the defect wearing the fix's clothes: it grants approval to everyone with edit rights. A transition does not require Update either, so a manager can approve an amount they may not change. The person who raised a record cannot move it on unless Lifecycle:AllowSelfTransition:{Name} says so, and that applies to an administrator too, because a separation of duties an administrator can ignore is not one.

Security

A transition path now requires read on the content type.

Dropping the shared Update check left the refusals below it naming the type's declared transitions and the entry's lifecycle state, which any authenticated token could read off a 400 and a 409. Read is the floor rather than Update, because requiring Update is the coupling this change exists to remove. The permission check also runs before the state check, so a caller who can never perform a transition is told that rather than to come back later.

Security

A transition rule saved in a different casing than the lifecycle declares now matches.

Transitions is built with StringComparer.OrdinalIgnoreCase and that comparer does not survive persistence: System.Text.Json constructs a fresh dictionary with the default comparer when Marten deserialises the role, so a rule stored as approve stopped matching a transition named Approve once the document was reloaded, and the symptom was a 403 on a permission the admin UI showed as granted. The resolver compares the key itself rather than trusting the comparer.

Security

The guard that keeps event types off API responses covered the core and was blind to the modules.

It read response types out of the core assembly, so every module endpoint, which lives under its own root in its own assembly, was outside it, and a guard covering part of the surface reads as covering all of it. The rule is now checked against the live routing table of a running host: 13 assemblies, 93 response types and 191 types reached through them, with floors asserted on all three so a discovery path that stops finding modules fails instead of passing on a smaller set. Reading the routing table rather than reflecting over assemblies means no module project has to grant InternalsVisibleTo, and what gets checked is what the host actually serves. Proven by putting a ContentCreated on a module response and watching it go red by name. No module violated the rule: Accounting, Portability and Import reference barakoCMS.Events and all three construct events in order to write them, which is the correct use. The rule is DECISIONS.md D4. Closes #426.

Security

Turning public delivery on or off for a content type is audited.

The switch serves every published entry of a type to anonymous callers at once and recorded nothing, which made it the larger half of a pair whose smaller half, a field sensitivity change, was already audited. Both directions are recorded, with the actor and the number of published entries the change affects, because "public delivery enabled" and "public delivery enabled, 4,000 entries now anonymous" are different sentences to whoever reads the trail later. Drafts are not counted: they stay invisible to anonymous callers whatever the setting says, and a number that overstates is one nobody trusts the second time. A request that changes nothing records nothing.

Security

PublicDelivery:RequireAcknowledgement makes enabling it a two-step decision

, refusing the request unless it carries acknowledgeExposure and naming the count in the refusal. Off by default, which is what this endpoint has always done: it is the documented way back from the 4.0 change that stopped delivering every existing type, and a default that refuses until clients are updated would turn the recovery path into a second outage. Disabling never needs it, because asking somebody to confirm the safe direction trains them to confirm without reading.

Security

Features/Users/* and Features/UserGroups/* gate on capabilities, not role names.

The thirteen routes there still matched SuperAdmin or Admin by name, so a role created at runtime could reach none of them. They now ask for a capability the caller's roles carry, using the mechanism from #272. It is three names rather than one because the old gates were not uniform: GET /api/users and the password reset were Roles("SuperAdmin") while assigning roles and groups was Roles("SuperAdmin", "Admin"), and a single manage_users would have had to pick one of those. So manage_users is the narrow set (list accounts, reset a password), manage_user_membership is a user's roles and groups, and manage_user_groups is the groups themselves. The seeded Admin role is backfilled with the second and third and not the first, which is asserted through the gate: a role holding exactly Admin's defaults reaches every route Admin reached before and neither of the two it did not. The legacy role names still open each migrated gate they used to, under Auth:LegacyRoleFallback, so nothing changes on upgrade. Step 1 of #443.

Security

API keys and the audit log follow.

POST, GET and DELETE /api/api-keys now require manage_api_keys, and GET /api/audit requires view_audit_log. Both areas gated on the same SuperAdmin, Admin pair, so a single name would have covered them; they are split because a role that should read the audit trail without being able to mint credentials is the ordinary auditor case, and one name makes that unexpressible. Admin's defaults gain both, matching what it already reached. Step 2 of #443.

Security

Postgres can enforce tenant isolation as a second boundary.

Tenancy:DatabaseEnforcement, off by default. On, Marten puts a row level security policy on every conjoined document table, so one tenant's session cannot read or write another's even if the application's own filter is missed. mt_events and mt_streams are outside Marten's support and stay application-filtered.

Turning it on is not a settings change. A Postgres superuser bypasses row level security entirely and every deployment here connects as one, so the policies alone would be applied and inert. migrations/tenancy/001-app-role.sql creates a NOSUPERUSER role and transfers ownership, and the application refuses to start if enforcement is on while it is still connecting as a superuser, rather than running while appearing to be protected.

It does not catch a session opened with no tenant at all. Marten represents that as the default tenant, so such a session sees the default partition exactly as it does today. docs/tenancy-at-the-database.md covers the setup, the connection-footprint cost and the PgBouncer constraint.

Security

A rollback now needs the update permission, not just the role on the route.

POST /api/contents/{id}/rollback/{versionId} gated on Roles("SuperAdmin", "Admin") and ran sensitivity, validation and lifecycle hooks, which is why the comment there claimed parity with an update. An update runs a fourth gate it did not: CanPerformActionAsync(..., "update", ...). So an Admin whose role granted no update on a content type could still rewrite an entry of that type by restoring an old version, while being refused the history that lists what there is to restore. A write they could perform over a read they could not. Authorisation also runs before the event stream is read, so a caller who may not write cannot tell a real version from an invented one by comparing the status codes, and the server no longer reads every event in the stream on the way to refusing them.

Security

DATABASE_URL no longer turns on Npgsql error detail outside Development.

It was set unconditionally, and Npgsql puts parameter values into exception messages when it is on, so a failed write copied the row's personal data into the log store, which has its own retention policy and its own access list. This is the production path: managed providers set DATABASE_URL, while a local stack sets ConnectionStrings__DefaultConnection and never reaches it. The last of the four defects #284 named.

Security

A Secret parameter is now protected on every workflow action type, not only Webhook.

WorkflowActionResponse already hid the Secret parameter and reported secretSet regardless of action type, so a custom action reusing that parameter name was shown as protected while it was actually stored in clear. ProtectSecrets now encrypts Secret for every action, the same way it already did for Webhook, closing that gap.

Security

A stored secret that predates encryption now refuses with a message that says what to do about it.

A Webhook action carrying a plaintext Secret from before it was ever protected already refused to send rather than sign or deliver anything with it. The failure used to read the same as a rotated Secrets:Key: "could not be decrypted, enter it again". That is the wrong instruction here, because entering the same secret again produces the same unprotected value; the fix is to recreate the workflow. The two cases are now told apart and the row says which one applies.

Security

DELETE /api/files/{id} now requires being the uploader or an admin, matching the download route.

A holder of upload_files could delete any file in the tenant, including one uploaded by another account, while the download route already refused that same account with a 404. Delete could destroy a file it could not read. The two gates now agree: upload_files still opens list, describe and edit for every file in the tenant, but delete and download both also need the uploader, or an account holding Admin or SuperAdmin. docs/access-control.md covers the split. A bespoke role holding only upload_files and used to tidy up orphaned uploads, a departed employee's files for instance, can no longer delete somebody else's upload after this upgrade, and needs an Admin or SuperAdmin account for that instead.

Security

A composed request header carrying a line break is now refused, closing a pre-existing injection.

Any value substituted into a request definition's header template reached Escaping.None with nothing stripping or refusing a carriage return or newline, then reached ConnectorSender's TryAddWithoutValidation unchecked. A content field of "safe\r\nX-Injected: evil" composed verbatim and sent as two headers, which is a way to forge a header on an outbound call made with the connector's own credentials attached, for anyone who can write a content field a request template names. Wiring queries into requests widened what reaches the same sink, so the fix covers both: any value landing in a header, from content or from a query, is checked.

Refused rather than stripped, naming the header and never the value: stripping the control character would send a request the operator did not write, silently, the same reason a Sensitive field is refused rather than masked.

Security

Workflow action failures no longer persist exception messages.

Failed actions retain the exception type in their run record while the full exception remains available in server logs, preventing provider error bodies from exposing credentials through the API or admin UI.

Security

A webhook URL redacted for a run record or failure message kept its path, and that is where Discord, Slack and Teams put the secret.

WebhookAction.Redact kept scheme, host, port and path, dropping only userinfo and the query string, on the reasoning that those two are where most providers put a credential. Discord (/api/webhooks/{id}/{token}) and Slack (/services/{a}/{b}/{secret}) put theirs in the path instead, so a webhook that answered 500 once wrote a replayable secret into a run record or a WebhookDelivery, readable by anyone holding ViewWorkflowRuns. Redaction now keeps only the scheme, host and port; the path is always dropped. Run and webhook-delivery records written from now on will show a shorter URL than before; that is the fix, not a regression.

Security

A webhook delivery's response body needed only view_workflow_runs, the same capability that reads every workflow run.

Two other places in this codebase refuse to carry a response body at all, because a 401 from an OAuth provider frequently echoes the credential that was sent; the delivery log was the one place that reasoning had not reached. GET /api/webhook-deliveries now needs a second capability, view_webhook_response_bodies, to read the responseBody field. Nothing else on the row is gated further: a caller holding only view_workflow_runs still sees every delivery, its status, its error and everything else, with responseBody: null. docs/access-control.md covers the split. A holder of view_workflow_runs who is not also granted view_webhook_response_bodies loses the ability to read a delivery's response body on upgrade. Admin's defaults do not include the new capability, since Admin never held this access before the split; only SuperAdmin (via *) and a role an operator grants it to explicitly can read a body. Grant view_webhook_response_bodies to whichever role should keep debugging webhooks.

Security

The response body now expires on its own.

Webhooks:ResponseBodyRetentionHours (default 24) clears responseBody on rows older than the window, on the same hourly sweep that already prunes the delivery log at Webhooks:DeliveryLogRetentionDays (default 30, unchanged). The row survives; only the body is cleared, and responseBodyClearedAt is stamped so a cleared body reads differently from one that was empty to begin with (nothing answered, or the body has not expired yet). docs/webhooks.md covers both windows.

Security

An access token issued before a security event is now refused.

Revoking refresh tokens stopped a session being renewed and did nothing to an access token already issued, which stays valid for up to fifteen minutes, so a password change, an administrator reset or enabling MFA all left a stolen session working for the rest of that window. User.TokensValidFrom is bumped by RevokeRefreshTokens.ForUserAsync, so it moves wherever sessions are already being invalidated rather than at three call sites that have to remember, and TokenValidationMiddleware refuses a token issued before it. Cached for thirty seconds, which is what the remaining exposure is across instances; on the instance that made the change it is zero. TokenIssuer now sets iat explicitly, because the check has nothing to compare against without it. Closes #82.

Security

Webhook deliveries are signed.

A receiver could not tell a genuine delivery from anyone who learned the URL. A Webhook action takes an optional Secret, stored encrypted with ISecretProtector and never returned by any read (secretSet stands in for it). Every delivery carries X-Barako-Delivery, X-Barako-Timestamp and, with a secret, X-Barako-Signature: sha256= over HMAC-SHA256 of "<timestamp>.<body>", so a replay is detectable. Without a secret the delivery goes out unsigned as before. A secret that can no longer be decrypted after a key rotation refuses to send rather than sending unsigned.

Security

Any tenant admin could read every tenant's audit log.

The audit trail is one global table, so the tenant-scoped session gave GET /api/audit no isolation and the ?tenant= filter was caller-chosen. A tenant admin now sees only their own tenant's entries; reading across tenants is a SuperAdmin action.

Security

The RSS feed passed authored HTML through unescaped.

A feed item's description wrapped the field value in a CDATA block, and many readers render a description as HTML, so a Body of <img src=x onerror=...> became stored XSS in every subscriber's reader. The description is now entity-encoded like the title, so authored markup shows as text and never executes.

Security

A deployment could boot on the placeholder JWT signing key.

The startup check enforced only a minimum length, and the key shipped in k8s/02-secret.yaml is a length-valid placeholder, so an operator applying the manifests unedited ran a signing key that is public in the repository and anyone could forge tokens. Startup now rejects the shipped placeholder as well as a short one.

Security

An Admin could grant itself the SuperAdmin role.

POST /api/users/{id}/roles is reachable with manage_user_membership, which the Admin role holds, and it assigned any role including SuperAdmin with no check, so an Admin stepped outside the capability model entirely. Granting SuperAdmin now requires the caller to already be SuperAdmin, matching the guard the per-tenant membership endpoint already had.

3.21.0

2026-08-23

Minor
Security

A sensitive field was masked under one spelling and returned under another.

Content data is a plain case-sensitive dictionary and nothing at the write boundary rejects a key that differs from a schema field only by case, because validation walks the schema's fields rather than the data's keys. So "Salary" and "salary" could both be stored, and the mask removed the first and handed over the second. A writer who could not set a field could also set it under another casing. Public delivery already treated the two as one field; the authenticated path does now.

Security

A content type's name is unique per tenant, and a duplicate create answers 409 instead of 400.

Uniqueness was a read followed by a write with nothing in the database behind it, so two requests close enough together both read nothing and both inserted. The name is a lookup key for the validator, the sensitivity service and the search-text backfill, and each of them resolved the ambiguity differently, which for a type carrying Sensitivity and Mask decides what gets masked. The index is per tenant, so one customer's "article" does not block another's.

On upgrade the index is not created for you. Production runs AutoCreate.CreateOnly, which never alters an object that already exists, so an existing database keeps the old read-then-write behaviour until migrations/4.0.0/3.x-to-4.0.sql is applied by hand. That file carries the statement and the query that finds the duplicates first, because CREATE UNIQUE INDEX fails while one exists and the duplicates have to be merged or renamed before it will run.

Security

A status change that loses a race answers 409.

PUT /api/contents/{id}/status appends under an expected-version check now, so a request built on a copy another writer has already moved past is refused rather than applied over the top. 409 rather than the 412 the update endpoint returns: nothing about this request was conditional on a version the client sent, so there is no precondition to have failed.

Security

A scheduled publish and a concurrent edit could silently undo each other.

The writer stored the document the request had loaded at its start, with the new events applied on top, and the expected-version check covered only the stream from the append onwards. So the scheduler published a due draft and committed, an edit that had loaded the earlier copy appended cleanly afterward, and the document it stored said Draft. Nothing recorded the reversal: replaying the stream gave Published while the read model said Draft, permanently, and delivery stopped serving an item that had been published. The mirror interleaving reverted the editor's data instead. The document is now rebuilt from the committed state before the events are applied, and the scheduler sweep saves one item at a time under the same check, leaving anything another writer overtook for the next tick rather than overwriting it.

Security

A role held only through tenant memberships could be deleted.

The referential-integrity guard read User.RoleIds, which is not where a tenant member's roles live: MembershipRoles .EffectiveRoleIdsAsync unions the membership list into the global one, and creating a tenant writes the membership list when it seeds that tenant's admin. So a role granted to every member of a tenant passed the check, the delete succeeded, and each membership was left holding an id that resolves to nothing, which the permission resolver treats as denied. The 409 now names the tenants the role is held in, so a blocked delete says where to go and unassign it.

Security

The seeder created a content type the API could not see.

It wrote a Models.ContentType into a table nothing else reads, so a freshly seeded instance logged "Created AttendanceRecord content type" while GET /api/content-types returned an empty envelope and the schema editor showed nothing. The demo entries validated against no schema at all, because a content type with no definition means loose mode. It seeds a ContentTypeDefinition now, with the demo SSN field marked Sensitive, and the demo content is committed before the search-text backfill runs so a first boot leaves it indexed rather than waiting for the next one.

Security

A failed SearchText backfill looked exactly like a completed one.

The seeder runs in an un-awaited Task.Run whose catch only logs, so a backfill that runs out of memory or time on a large corpus leaves the application serving traffic with public search empty for every pre-existing document, indefinitely, and nothing distinguishes that from a run that finished. It logs per batch now, and a run that does not reach the end says so and says how far it got before rethrowing.

Security

An import dropped a content type's public-delivery flag.

Every other attribute of a type carried across and that one did not, so a bundle exported from one instance and imported into another created the type and the content correctly and left them off the public API, with the import reporting success. Records whose content type is in neither the store nor the bundle are also counted and named in the report now, rather than being created with empty search text and discovered later as content that never appears in search.

Security

Losing the OTP race answered 500.

Giving OtpCode optimistic concurrency stopped two requests from consuming one code, but left the loser's save throwing into the global handler, so a code another request had just used came back as a server error instead of "Invalid or expired code." The verify endpoint and the send path now both treat a lost race as a refusal. The save that mints the tokens is the one that matters: losing it refuses, rather than returning the tokens it had already computed.

Security

Two endpoints granted access to a role that does not exist.

/api/content-types and the Files upload endpoint both named "Editor" in their Roles(...) gate and nothing has ever seeded it. It granted nothing, because a token only carries roles its user holds, but it misdescribed the permission model to anyone reading the line, and it would have started granting silently the day somebody created a role by that name for an unrelated reason. A test now refuses any gate naming a role nothing creates, in the core or in a module.

Security

A production compose file that runs the published images.

docker-compose.prod.yml used to build the API and the admin from source, so nobody holding only the images we publish could use the file that has the production shape, and every deploy compiled a .NET solution and a Next.js app on a box that should need only Docker. It now runs ghcr.io/baryodev/barako-cms and ghcr.io/baryodev/barako-admin behind Caddy, with ${VAR:?} guards so the stack refuses to start without a real database password, JWT key, admin password and pinned tag, and with FRONTEND_ORIGINS asked for at deploy time rather than discovered as a browser CORS error. This is the only production compose file; the headers of the other three now say what each is for (#307, #309).

Security

.env.prod.example and docs/deploy-in-production.md.

The variables with the command that generates each one, and the deploy itself: DNS first, what a healthy stack answers, how a frontend reaches the delivery API, and the rough edges an operator hits on day one instead of finding them alone.

Security

CI resolves every compose file.

A compose job runs docker compose config on all four, asserts the production one resolves to no build step and to the published image names, and asserts it refuses to resolve at all with JWT_KEY unset. A production compose file nobody has run is what #307 was about.

Security

A comment claiming Next.js needs the API URL at build time, which cost the production deploy its images.

It is true of Next.js in general and false of this image: admin/entrypoint.sh regenerates public/env-config.js from any NEXT_PUBLIC_* variable at container start and getApiUrl() reads that first, which is why docker-compose.hub.yml and the quickstart already passed it as a plain runtime variable. The comment is gone, NEXT_PUBLIC_API_URL is a runtime environment: entry in the production compose, and the published barako-admin image can be pointed at any domain (#309).

Security

docs/ was gitignored, so documentation shipped nowhere.

The rule was docs/* plus a growing allowlist, which meant a new file was ignored by default: git add docs/whatever.md did nothing and said nothing, and the 4.0 readiness pass concluded documentation was largely absent partly because the directory looked almost empty on GitHub. It is inverted. docs/ is tracked, the few paths that stay out are named individually with the reason next to each, and docs/access-control.md, docs/device-trust.md and docs/workflow-engine-rethink.md are readable without a checkout for the first time (#312).

Security

The admin no longer keeps either token in localStorage.

The refresh token is an httpOnly cookie the page cannot read; the access token is a variable in memory and is gone on reload, which a silent refresh replaces. Any script on the origin could read both before, and the refresh token is seven days and renewable, so one cross-site scripting bug or one compromised dependency in the admin build was a week of account takeover rather than fifteen minutes. The API still returns the refresh token in the response body, so the generated clients and anything not in a browser are unaffected: what changed is that the admin stops persisting it. Reasoning, the two things you will notice, and what the cookie needs from your deployment topology are in docs/session-and-token-storage.md.

Security

A fresh deployment's first backup always failed.

db-backup started as soon as Postgres was healthy and took its proof backup immediately, racing the API creating its tables, so every first deployment logged archive is only 369 bytes. The size guard did its job and nothing was written, but the stack had no recovery point until somebody noticed, and a failure logged on every first deploy is a good way to teach people to ignore the backup log. It waits for the application schema now, asked of Postgres rather than of the API so it needs no second service to be reachable.

Fixed

SearchText backfill loaded every document at once and failed silently; it now batches and reports.

Fixed

GET /api/content-types is removed. It queried a document type nothing in the codebase ever wrote, so it always returned an empty list while POST to the same route stored a different type. POST /api/content-types and PUT /api/content-types/{name}/public-delivery are unaffected.

Added

GET /api/meta

, authenticated, reporting the running API version and whether the instance serves Swagger.

Added

An About dialog in the admin

, off a version line in the sidebar footer: API version, admin version, API address, documentation, this instance's own API reference when enabled, release notes, issues, Discord and sponsor. Nothing opens on its own.

Added

Modules declare a contract version.

A module built against an incompatible core is refused at startup with a message naming the supported range, rather than failing later in a way that is hard to trace.

Added

Public search across delivered content.

Changed

A release now ships the build that was tested.

The pipeline compiles the solution once, packs from that same output, and publishes the resulting artifact. The publishing job has no checkout step at all, so it cannot rebuild even by accident. Previously the test and publish jobs compiled independently, and "we ship what we tested" held only while two separate builds happened to agree.

Changed

Every package is installed before it is published.

A job between pack and push adds all fourteen to a scratch project from a local feed, builds, and asserts each one delivered a net8.0 assembly. A package that restores cleanly but ships nothing now fails the release.

Changed

The test gate proves the suite ran.

dotnet test exits 0 when it discovers nothing, so the gate in front of fourteen published packages used to be satisfied by a command that did nothing. It now parses the result file and refuses an unreadable or implausible count.

Changed

The admin sidebar shows only what your role can reach.

Every account previously saw all nineteen destinations, most of which answered with a permission error on arrival.

Changed

The "what's new" indicator reads the running API's version

instead of a hand-maintained constant, which had sat at 3.1.2 while the product shipped 3.20.1.

Changed

Node 22 in the admin image.

Node 20 is past end of life.

Changed

The post-deploy smoke test and the playground verification now assert /api/schemas returns 401. An unmapped route answers 404, so a 401 proves the API layer routed the request and still refuses anonymous callers.

Changed

Modules read their own configuration section rather than the application root.

Fixed

seeding a chart of accounts could create two accounts sharing one code

AccountService.UpsertAsync looked for an existing account with a database query, so accounts stored earlier in the same uncommitted unit of work were invisible to it. UpsertManyAsync is a loop over that method and is how a host seeds a whole chart in one transaction — precisely where a repeated code is most likely to appear. The second appearance became a second account: one code split across two documents, with lookups picking between them arbitrarily and balances divided between them.

It now checks the session's pending changes before the database. Accounting module 0.2.2.

Other

Accounting test coverage: 49.6% → 85.4%

The module's own HTTP surface (POST /api/accounting/journal-entries, the accounts endpoints), the one-shot AccountingMigration, and AccountService had no tests between them, while carrying the money. Three new suites cover them, each checked by reintroducing the bug it claims to catch — balance tolerance, totals accumulated through double, a migration that moves instead of copies, a dropped idempotency guard, and a widened role gate.

Two of those checks found weak tests rather than weak code, and both were rewritten: a one-line journal entry is rejected for being unbalanced, not for having too few lines, so the line-minimum rule was only pinned once an entry with no lines was tested; and a (decimal)(double) round trip is lossless at these magnitudes, so the shape that actually bites — the running totals declared as double — is what the fractional-amount test now pins.

AccountService was the surprise. Nothing inside barakoCMS calls it, so it read as dead code, but a host application uses it in seven places. Whole suite: 71.1% → 74.4%.

3.20.1

2026-08-15

Patch

Contributor

Fixed

the opt-in had no way to be turned on for a type that already existed

3.20.0 made public delivery opt-in and added the endpoint to change it, but the admin only offered the toggle when creating a content type. Every existing type — which is every type anyone upgrading has — had no interface at all, so the documented upgrade step was "call the API by hand".

The content type screen now has the switch, with copy that says what each state means and names the exact URL that will or will not answer. There are no core code changes; this releases the admin image.

3.20.0

2026-08-15

Minor

Contributor

Breaking

public delivery is now opt-in per content type

Read this before upgrading. Content served at /api/public/* goes dark until you opt each type in.

Public delivery used to be opt-out. GET /api/public/{type} served any content type as long as the entry was Published and its sensitivity Public — and both of those are the defaults, for documents and for fields alike. So modelling members, orders or a ledger as content handed you an anonymous, unauthenticated endpoint for them without anyone ever deciding to publish anything.

That is the wrong way round. Publishing is a decision, and it should have to be made.

It was not hypothetical either: on a live deployment this served a club's member roster — names, member numbers, emails, phone numbers, addresses — and its chart of accounts, including per-member receivables, to anyone who supplied the club's handle. No token required.

ContentTypeDefinition gains IsPubliclyDeliverable, defaulting to false. The gate covers every anonymous read path — the list, search and slug routes, the RSS feed, and semantic search in BarakoCMS.AI 0.1.4. An un-opted-in type and an unknown type both answer 404, deliberately: a different answer would confirm which types exist.

Field-level sensitivity is unchanged and still applies on top. Opting a type in never implies every field on it is public.

Upgrading

Existing types deserialize with the flag false, so anything you currently serve publicly stops being served until you turn it on. For each type your site reads anonymously:

PUT /api/content-types/{name}/public-delivery
{ "enabled": true }

Admin or SuperAdmin. There is also a toggle on the content type screen in the admin.

That endpoint is new, and it is why this could ship at all: content types had no update endpoint, so without it the opt-in would have been a one-way door — every existing type undeliverable, with no supported way back short of editing the database.

If you are unsure which types are affected, the honest answer is every type your frontend fetches from /api/public/. There is no safe way for the CMS to infer that for you, which is exactly why this is a major-flagged change rather than a silent default flip.

3.19.0

2026-08-09

Minor

Contributor

Fixed

the Next.js upgrade that was never actually broken

The admin moves to Next 16.3, and npm audit now reports zero vulnerabilities — the next, postcss and sharp advisories that SECURITY.md had listed as unfixable are all gone.

They were never unfixable. Upgrading Next had been reverted once because it "broke" 28 end-to-end tests, and the failures looked like a routing regression: after a mocked action the URL stayed at /login?. The real cause is that Next 16.1 began blocking cross-origin requests for dev-server assets. The end-to-end suite drives http://127.0.0.1:3100 while the dev server treats localhost as its origin, so every /_next/* chunk was refused, the app never hydrated, and any test that clicked something failed. One line — allowedDevOrigins: ["127.0.0.1"] in next.config.ts — and the full pack passes on 16.3.

Development only; a production build serves its own assets and is unaffected. No product code changed, which is the point: the harness was misconfigured, not the application.

3.18.1

2026-08-09

Patch

Contributor

Fixed

3.18.0 shipped only half its images

The 3.18.0 release published to NuGet and pushed the full suite image, then failed building the Decaf image, which skipped the admin image and the playground deploy with it. So 3.18.0 exists as a package but was never deployed; playground stayed on 3.17.1.

The Decaf Dockerfile copied only the .csproj before restoring, which stopped working when central package management moved TargetFramework into Directory.Build.props — NETSDK1013: The TargetFramework value '' was not recognized. Dockerfile.suite was unaffected because it copies the whole build context, which is why only one of the two images failed.

No code changes; this exists to re-run the release now that the image builds.

3.18.0

2026-08-09

Minor

Contributor

Changed

enabling MFA now ends other sessions and tells the account owner

Closes the last two findings from the MFA security review.

Turning on two-factor authentication revokes the account's other refresh tokens, and sends the owner an email saying it happened. Both exist for the same case: an attacker who has hijacked a session on an account without MFA could enrol their own authenticator and keep the account — the enrolment was silent, and their session survived it. Now no session that predates MFA outlives it, and if the owner did not do this, they hear about it through a channel the attacker does not control.

The email is best-effort: a send failure is logged, not surfaced, since failing the request would undo an enrolment the user did ask for. Users will be asked to sign in again after enabling, which is also a useful confirmation that their authenticator works.

A bounded gap remains, stated plainly. Revoking refresh tokens stops a session being renewed; it does not invalidate an access token already issued, which stays valid until it expires — at most 15 minutes. So an attacker's stolen session ends within 15 minutes of MFA being enabled rather than immediately. Closing that properly needs a user-level "tokens issued before this moment are invalid" timestamp checked during authentication. That is worth doing — it would also close the same window on password change and on logout-everywhere, where RevokeAllUserTokensAsync has always been refresh-token-only — but it belongs in its own change, because it runs on every authenticated request and a mistake there locks everybody out.

3.17.1

2026-08-08

Patch

Contributor

Fixed

the social sign-in MFA gate was never published

BarakoCMS.ExternalAuth 0.1.6 ships the change written for 3.15.0 that stops Google, GitHub, Facebook and LinkedIn sign-in from minting tokens for an account that has MFA enrolled. The code landed in 3.15.0 but the module's own <Version> was left at 0.1.5, and the release pushes with --skip-duplicate, so the package was silently skipped — anyone consuming 0.1.5 still has the bypass, where a provider-account takeover sidesteps the second factor entirely.

If you use BarakoCMS.ExternalAuth with MFA, take 0.1.6. Core is bumped only to get past the release gate, which reads core's version alone; there are no core changes in 3.17.1.

This is the second time an unbumped module version has swallowed a shipped fix (see 3.12.1). The underlying gap is that nothing checks whether a module's source changed without its version moving.

3.17.0

2026-08-06

Minor
Added

MFA in the admin UI

Settings → Security

— enroll with a QR code (rendered locally, so the secret never travels to a third-party QR service) or by typing the key, confirm with a code, and get the one-time recovery codes with a copy button. Turning MFA off requires a current code, so a hijacked session can't silently remove it.

Added

MFA in the admin UI

Login

— a second step that accepts an authenticator code or a recovery code. The field uses autocomplete="one-time-code", so password managers and iOS autofill offer the code directly.

3.16.0

2026-08-05

Minor

Contributor

Added

browser error capture (the other half of Diagnostics)

Uncaught errors and unhandled promise rejections, via global listeners installed in the root layout.

Added

browser error capture (the other half of Diagnostics)

React render errors, via a root global-error boundary (those never surface through window.onerror, so they were invisible to any listener-only approach).

Added

`telemetry` rate-limit policy

POST /api/client-errors is anonymous by design (faults happen before sign-in) and fans out to one lookup per item in the batch, so under the global 100/min budget it allowed roughly a 20x amplification against the database. It now has its own tighter policy: 20 batches per minute per IP, far above real client behaviour. BarakoCMS.Diagnostics 0.1.3 applies it.

3.15.0

2026-08-05

Minor

Contributor

Added

TOTP multi-factor authentication

POST /api/auth/mfa/setup (auth) — start enrollment; returns a secret + otpauth:// URI to show as a QR code, once.

Added

TOTP multi-factor authentication

POST /api/auth/mfa/enable (auth) — confirm with a code; returns one-time recovery codes, once.

Added

TOTP multi-factor authentication

POST /api/auth/mfa/verify — complete a two-step login: exchange the challenge from /login plus a TOTP (or recovery code) for the usual access + refresh tokens.

Added

TOTP multi-factor authentication

POST /api/auth/mfa/disable (auth) — requires a current code, so a hijacked session can't strip it.

Added

TOTP multi-factor authentication

GET /api/auth/mfa/status (auth).

Fixed

every sign-in path honors MFA

Enrolling MFA now protects every way to obtain tokens, not just password login. The email one-time-code path (/api/auth/otp/verify) and all four social providers (BarakoCMS.ExternalAuth: Google, GitHub, Facebook, LinkedIn) treated mailbox/provider possession as a complete login and minted tokens without the second factor — an inbox or OAuth-account compromise would have sidestepped MFA entirely. They now return the same MFA challenge and require /api/auth/mfa/verify to finish. MFA-issued tokens also carry the device-binding claim, matching the password and OTP paths.

Note: the AES key for MFA secrets derives from Mfa:Key if set, otherwise the JWT signing key. Set a dedicated Mfa:Key in production and do not rotate it without re-encrypting stored secrets.

3.14.1

2026-08-05

Patch

Contributor

Fixed

3.14.0 startup crash on existing databases

3.14.0 added two Marten indexes (on the new scheduled-publish fields) to the Content document. On a fresh database that is harmless, but on an existing one it is a delta to mt_doc_contents, which the prod/playground AutoCreate.CreateOnly policy refuses at startup — so the container crash-looped (Cannot derive schema migrations for TableDelta). The indexes are removed: the scheduler sweep leads with Status (already indexed), so they were never load-bearing. No API or behavior change from 3.14.0. See H.40 for the missing online-migration step that would let index additions ship safely.

3.14.0

2026-08-05

Minor

Contributor

Added

scheduled publish / unpublish

ScheduledPublishAt — a Draft is promoted to Published at/after this time.

Added

scheduled publish / unpublish

ScheduledUnpublishAt — a Published item is Archived at/after this time.

Fixed

publish workflows now actually fire

PUT /api/contents/{id}/status constructed a ContentStatusChanged event and updated the read model but never appended the event to the stream, so the async WorkflowProjection — which is driven off the stream and already maps a Published transition to the Published trigger — never ran. The endpoint now appends the event (matching the Update and rollback endpoints), so workflows configured on Published finally execute. Scheduled transitions go through the same path.

3.13.0

2026-08-05

Minor

Contributor

Added

RSS feeds for public content

Feeds:SiteUrl — the site the links resolve against (falls back to the request host).

Added

RSS feeds for public content

Feeds:Paths:{type} — a per-type link template like /blog/{slug} (defaults to /{type}/{slug}).

Added

RSS feeds for public content

Feeds:Titles:{type} — the channel title (defaults to the type name).

3.12.2

2026-08-03

Patch

Contributor

Fixed

every module rebuilt against current core

All module packages are republished so they are compiled against 3.12.x. They had drifted badly — most were last built against core 3.2.x, nine minor versions back — because a module is only rebuilt when its own <Version> changes, and none had.

This was not theoretical. A host taking new core with the previously published modules got real failures: import endpoints returning 403, and ledger and file-attachment posts returning 400. The same host built against matching source passed. If you are on core 3.12.x, take these module versions too; mixing 3.12.x core with the older module packages is not a supported combination.

No functional changes in this release beyond the rebuild. See H.40 in the roadmap for the pipeline gap that let the drift accumulate silently.

3.12.1

2026-08-03

Patch

Contributor

Fixed

BarakoCMS.Portability 0.1.2 — ships the audit-log capture for export and import that was written for 3.12.0 but never published: the module's version was unchanged, and the release pushes with --skip-duplicate, so the package was silently skipped and stayed at 0.1.1. Core is bumped only to get past the release gate, which reads core's version alone; there are no core changes in 3.12.1.

3.12.0

2026-08-03

Minor

Contributor

Added

audit log

GET /api/audit (Admin) — filter by actor, action, date range and tenant, paginated.

Added

audit log

Captures auth events (login succeeded/failed/blocked, account lockout, logout, token refresh and refresh-token reuse detection) and sensitive administrative actions (role and user-group deletion, role/group assignment and removal, content archival, portability export/import).

Added

audit log

Entries are hash-chained: each one carries the previous entry's hash, so editing or removing a past entry breaks every hash after it. This is tamper-evidence, not tamper-prevention — someone with direct database access can still rewrite the chain forward. Known limitation: the previous-hash lookup and the insert are not one atomic operation, so two audit-worthy actions racing in the same tenant can chain off the same previous hash. That shows up as a detectable fork, and no entry is lost.

Added

audit log

Admin gains an "Audit log" page with the same shape as the Errors page.

Added

per-content-type domain rules (`IContentLifecycleHook`)

Schema validation can express "Amount is a decimal"; it cannot express "total debits must equal total credits", or "assign the next sequence number". Previously a domain with real invariants had to be given its own bespoke write endpoint, which put it outside the generic content pipeline.

A module now registers an IContentLifecycleHook the way it registers a workflow action, and core runs it on create and update without knowing the module exists. Hooks can reject a write or enrich it, and they receive the request's Marten session, so anything they store commits in the same transaction as the entry.

Changed

decimals in schemaless data are no longer doubles

Whole numbers still come back as long, so ids and counts are unaffected.

Changed

decimals in schemaless data are no longer doubles

Values outside decimal's range still fall back to double rather than throwing.

Changed

decimals in schemaless data are no longer doubles

If your code casts a stored number straight to double, it will now throw InvalidCastException.

Use Convert.ToDecimal/Convert.ToDouble instead.

Changed

`BarakoCMS.Accounting` 0.2.0 — accounts and journal entries are content types

New AccountService so hosts keep working with the Account domain type instead of hand-building content dictionaries. Replace session.Query<Account>() and session.Store(new Account { … }) with AccountService.GetAllAsync/GetByCodeAsync/UpsertAsync.

Changed

`BarakoCMS.Accounting` 0.2.0 — accounts and journal entries are content types

The /api/accounting/* endpoints are unchanged for callers, but now read and write content.

Changed

`BarakoCMS.Accounting` 0.2.0 — accounts and journal entries are content types

AccountingMigration.RunAsync copies existing typed Account/JournalEntry documents into content. It copies rather than moves and is idempotent, so the originals stay on disk and a bad run can be repeated rather than being the step that loses a ledger.

Fixed

BarakoCMS.Diagnostics is wired into the Suite image, so the shipped Suite's admin "Errors" page has a backend instead of returning 404.

Fixed

CI now fails on Critical/High vulnerable dependencies instead of only reporting them, and Dependabot is configured for NuGet, npm and GitHub Actions.

Fixed

CSP no longer allows 'unsafe-inline' in script-src outside Development. style-src still does — see the roadmap for the remaining nonce work.

3.11.0

2026-07-30

Minor

Contributor

Added

draft preview

POST /api/preview — an authenticated editor mints a short-lived (30 min) signed token for one draft. The caller must have read access to that content type (the same permission check as the authoring read endpoint), so you can only mint a link for a draft you're allowed to see.

Added

draft preview

GET /api/public/{type}/{slug}?preview=<token> returns the draft when the token is valid. The token is signed with the JWT key and bound to the exact tenant + type + slug, so it can't be forged or reused for another entry. Preview lifts only the published gate: a document-Sensitive entry is still refused, only Public fields are emitted, and the response is no-store. An invalid or expired token falls back to the normal published-only behavior, revealing nothing.

3.10.0

2026-07-28

Minor

Contributor

Added

AI semantic search (BarakoCMS.AI module)

POST /api/ai/index/{type} (admin) builds a type's vector index in the current tenant, embedding each Published, document-Public entry from its Public fields only.

Added

AI semantic search (BarakoCMS.AI module)

GET /api/public/{type}/semantic?q=…&limit=… (anonymous, cacheable) ranks the index by cosine similarity, then re-verifies each hit is still Published and document-Public before returning it — so a draft, a Sensitive document, a Sensitive field, or an entry unpublished since indexing never surfaces.

3.9.0

2026-07-28

Minor

Contributor

Added

public content search

GET /api/public/{type}/search?q=…&limit=… returns the top public matches for a query. It projects each entry to its public shape first and only then matches, so it searches exclusively over allowlisted Public fields — a draft, a document-Sensitive entry, or a value in a Sensitive field can never surface a result. A title/name hit outranks a body hit. It scans a bounded recent window (swap in Postgres full-text search for larger corpora). Anonymous and cacheable, like the rest of public delivery.

Fixed

admin runtime config under a basePath

The admin loaded its runtime env-config.js from the origin root, so when hosted under a basePath on a different origin than it was built for, the config 404'd and the admin fell back to the build-time API URL — sending auth cross-origin. The script now loads from the basePath.

3.8.0

2026-07-28

Minor

Contributor

Added

password change and admin reset

POST /api/me/password — the signed-in user changes their own password. It re-verifies the current password, enforces the password policy, and rejects a no-op change.

Added

password change and admin reset

POST /api/users/{userId}/password — a SuperAdmin resets another user's password (recovery or rotation), enforcing the same policy.

3.7.0

2026-07-28

Minor

Contributor

Changed

navigation menus are now a content type

Removed the Menu document and the /api/menus admin endpoints (create/update/delete/list) and the /api/public/menus/{slug} read endpoint.

Changed

navigation menus are now a content type

A menu is a menu content type with a Name and an Items field of type json that holds the nav tree ({ label, url, openInNewTab, children[] }). It is served by the generic public delivery at GET /api/public/menu/{slug}, so the same published-and-Public rules and field allowlist apply.

Changed

navigation menus are now a content type

Existing menus tables are left orphaned and untouched (safe under AutoCreate.CreateOnly).

3.6.0

2026-07-27

Minor

Contributor

Added

pluggable file storage, an S3 provider, and public media

A storage abstraction (IFileStorage) moves file bytes behind an interface while metadata stays in Postgres. The default keeps bytes in the database.

Added

pluggable file storage, an S3 provider, and public media

A new opt-in BarakoCMS.Files.S3 provider stores bytes in a bucket. One code path serves AWS S3, Cloudflare R2, and MinIO; only the endpoint and public URL differ. Configure it under Files:S3; with no bucket set it stays dormant and Postgres keeps serving.

Added

pluggable file storage, an S3 provider, and public media

Public media for a website frontend: uploads can be marked public, and GET /api/public/files/{id} serves a file anonymously only when it is public. Private and missing files are both a plain 404, so ids cannot be probed. A public file on an object store is served from its own direct, CDN-friendly URL; a public file in Postgres is proxied through the API.

3.5.0

2026-07-27

Minor

Contributor

Added

site navigation menus

GET /api/public/menus/{slug} returns a menu for public rendering.

3.4.0

2026-07-27

Minor

Contributor

Added

public content delivery API

GET /api/public/{type} returns a paged list of Published entries of a content type.

Added

public content delivery API

GET /api/public/{type}/{slug} returns a single Published entry addressed by its slug.

Added

public content delivery API

Only Published entries are ever returned. Drafts and archived content are never exposed.

Added

public content delivery API

A document marked Sensitive or Hidden is never delivered, even when Published.

Added

public content delivery API

Only fields the content type marks Public leave the API. Field masking is an allowlist, so a field removed or renamed in the schema, or a value stored under a differently-cased key, cannot leak.

Added

public content delivery API

Each request is scoped to one tenant, resolved from the X-Tenant header or host.

Added

public content delivery API

Responses carry Cache-Control: public, so a CDN can absorb traffic.

3.3.0

2026-07-26

Minor

Contributor

Added

API keys for machine callers

Content surface only.

A key can read and write content, content types and schemas, and nothing else. It can never manage users, roles, tenants, or other keys. That stays behind a human sign-in, so a leaked key can't escalate into platform administration.

Added

API keys for machine callers

Scoped.

content:read, content:write, contenttype:read, contenttype:write, or *. A read-only key is refused when it tries to write.

Added

API keys for machine callers

Tenant-bound.

A key operates in one tenant and can't reach another's data. It stops working the moment its owner's membership is removed or the tenant is deactivated, the same check the login path uses.

Added

API keys for machine callers

Revocable immediately.

Revoking a key refuses it on its next request, not at expiry.

3.2.4

2026-07-25

Patch

Contributor

Fixed

dashboard crash on partial metrics

The admin overview formatted the error-rate metric without guarding for a missing value, so if the monitoring endpoint returned a partial object the whole dashboard threw (Cannot read properties of undefined (reading 'toFixed')) and rendered a blank error page. Guarded it — a missing metric shows —, like the other cards already did. Found while writing the end-to-end tests, not in production.

Other

Pipeline and tests (internal)

Not user-facing, but part of the same release: CI now runs the whole browser end-to-end pack (not a subset) plus a secret and dependency scan; every deploy runs a smoke test that logs in, creates content, and confirms validation still rejects bad input; a one-button rollback workflow was added; and the field types from 3.2.3 gained backend integration tests that exercise the real API over a real database.

3.2.3

2026-07-24

Patch

Contributor

Added

richer content-type field types

Content types now support properly-typed fields instead of everything being text: email, url, slug, uuid, money, time, plus richtext, markdown, and json (and a date/datetime split). Each is validated at the API — an email field rejects a value that isn't an email rather than silently storing it — and the admin renders a matching control for each type (date/time pickers, number input for money, a JSON editor for structured data).

Behind it, the allowed field types now live in one FieldTypeRegistry that every validator reads from. Three validators had drifted apart — one accepted text/number, another rejected them, and a doc comment advertised types no validator accepted — and a parity test now fails the build if they ever diverge again.

3.2.2

2026-07-24

Patch

Contributor

Fixed

fresh installs boot on an empty database

Production now runs Marten's recommended CreateOnly: it creates missing objects (so a fresh database and any unregistered document type work) but never updates or drops an existing one, so it still won't attempt the failing single-to-conjoined event-store migration that None was chosen to avoid.

Fixed

fresh installs boot on an empty database

The schema is applied explicitly at startup, before the seeders run, so their first query always finds its tables.

Fixed

fresh installs boot on an empty database

The full-suite host now seeds the core roles and the initial admin. Previously it ran only the module seeders, so a fresh suite install had no user to sign in as.

3.2.1

2026-07-22

Patch

Contributor

Security

cross-tenant token issuance

Refresh re-checks on every rotation

, so revoking a membership takes effect within ~15 minutes instead of lingering for the refresh token's 7-day life.

Security

cross-tenant token issuance

Login denials return "Invalid credentials"

— the same message as a wrong password, since "right password, wrong tenant" confirms both the account and the tenant exist.

3.2.0

2026-07-21

Minor

Contributor

Other

One licence across the suite: MPL-2.0

LICENSE replaced with the Mozilla Public License 2.0

Other

One licence across the suite: MPL-2.0

LICENSE.txt (BSD 3-Clause, left over from an unrelated 2023 project) removed

Other

One licence across the suite: MPL-2.0

core switched from PackageLicenseFile to PackageLicenseExpression, so NuGet renders the licence inline and it matches how the modules already declared theirs

Other

One licence across the suite: MPL-2.0

README and CONTRIBUTING updated

Other

All modules republished

Eight modules were live on NuGet but missing from its search index — installable if you knew the exact ID, invisible if you didn't. Every module gets a patch release so the whole suite re-indexes and depends on core 3.2.0.

3.1.1

2026-07-20

Patch

Contributor

Security

Stability Hardening

Upgraded Marten 8.16.1 → 8.37.0

, fixing a critical full-text-search injection advisory (GHSA-vmw2-qwm8-x84c).

Security

Stability Hardening

Locked down anonymous endpoints

: content version history now requires authentication + per-content read permission and applies sensitivity redaction; GET /api/schemas, /api/diagnostics/typecheck, and /api/monitoring/k8s are restricted to admin roles (previously publicly readable).

Security

Stability Hardening

JWT signing key is validated at startup

— the app fails fast if it is missing or shorter than 32 characters (no insecure default).

Security

Stability Hardening

Removed committed credentials

from base config; the initial admin password and dev JWT key now live only in appsettings.Development.json, and seeded sample accounts are gated to Development.

Security

Stability Hardening

SSRF protection

on workflow webhook actions (loopback, link-local incl. cloud metadata, and private ranges are blocked).

Security

Stability Hardening

Added a global exception handler (no stack-trace leaks), request body-size limits, and a minimal (non-enumerating) health response.

Security

Stability Hardening

Fixed a latent bug that silently disabled token revocation

: UTC DateTime comparisons in LINQ queries threw under Npgsql and were swallowed, so revoked tokens were treated as valid. Revocation now works.

Security

Stability Hardening

Content rollback

now updates the read model (previously appended an event but left GET/LIST serving stale data) and records the acting admin.

Security

Stability Hardening

Optimistic concurrency

on content updates is now enforced via Marten AppendOptimistic; responses expose a Version field to echo back for conflict detection (HTTP 412). Create/Update/ChangeStatus commit their event and read-model document in a single transaction.

Security

Stability Hardening

Refresh-token rotation

is race-safe (optimistic concurrency) with reuse detection that revokes the entire token family on replay.

Security

Stability Hardening

Login lockout counter

uses an atomic increment, closing a race that allowed lockout bypass.

Security

Stability Hardening

Permission cache

is invalidated immediately on role/permission/user-role changes instead of serving stale decisions for up to 5 minutes.

Security

Stability Hardening

ConfigurationService no longer throws on malformed admin-editable settings (falls back to defaults).

Security

Stability Hardening

Workflow execution is decoupled from the request path and runs via the async projection — a slow or failing action can no longer block or fail a content save.

Security

Stability Hardening

Fault isolation

: per-action and per-workflow error handling prevents one failing action from stalling the engine/daemon.

Security

Stability Hardening

Template variables are now resolved in live runs

(previously only in dry-run), with a single-pass resolver that prevents second-order injection between fields.

Security

Stability Hardening

Status transitions now fire Published-triggered workflows; workflows are validated on creation (trigger event, action types, required parameters).

Added

SVG coffee-bean logo (assets/logo.svg) and README Security & Stability section.

3.1.0

2026-07-20

Minor

Contributor

Added

Multi-tenant admin

— auto-scopes to your tenant on sign-in, plus a switcher to move between the tenants you belong to (/api/me/tenants, /api/me/switch). The X-Tenant header is derived from the token's own claim and survives refresh.

Added

Installed modules surface in the admin

— sections appear when their module is present: Accounting (accounts/balances/ledgers), Feature flags (view/toggle), Email events (Resend bounces/complaints), Errors (client-error log + resolve), Analytics, PWA installs.

Added

BarakoCMS.Pwa module

— POST /api/pwa/report (anonymous or tied to the signed-in user) and GET /api/pwa/installs, so the admin shows who installed the app. Pairs with @baryodev/pwa-kit's reportPwaStatus.

Added

Analytics (Umami)

— device / OS / browser breakdowns; a site status endpoint powering install detection (an "add the snippet" banner + a Verify step); a visitors panel on the dashboard.

Added

Email.Resend

— an /api/email-events list endpoint.

Added

Quickstart bundle

— quickstart/ runs the full suite + admin + Postgres from one documented .env.

Fixed

Global roles kept when switching tenants

— MembershipRoles now unions a user's global roles with their tenant membership roles, so a platform SuperAdmin keeps Users/Roles access inside a tenant.

3.0.0

2026-07

Major

Contributor

Added

Multi-tenancy on a shared database

(Marten conjoined tenancy). Identity is global (users, roles, tokens, settings, devices are single-tenanted); only domain content and event streams are tenant-scoped. The default tenant maps to Marten's default partition — no data migration for existing single-tenant deployments.

Added

Tenant registry + Membership (a global user's roles within a tenant); tenant resolution via X-Tenant header/subdomain; TenantAccessMiddleware. New endpoints: /api/tenants*, /api/me/tenants, /api/me/switch, /api/club/*.

Added

Field-level sensitivity

— mark content-type fields Sensitive or Hidden; masked per role on read (remove / redact / show last 4); a role that can't see a field can't write it either.

2.1.0

2025-12-16

Minor

Contributor

Other

Phase 2 Week 4: Plugin System Completion & Documentation

6 Built-in Workflow Action Plugins

:

  • EmailAction - Send email notifications
  • SmsAction - Send SMS messages
  • WebhookAction - HTTP POST to external services
  • CreateTaskAction - Create tasks in the system
  • UpdateFieldAction - Update content fields dynamically
  • ConditionalAction - If/then/else logic
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Workflow Tool Endpoints (5 new API endpoints)

:

  • GET /api/workflows/actions - List all available action plugins
  • POST /api/workflows/validate - Validate workflow JSON schema
  • GET /api/workflows/{id}/debug - Get execution history for debugging
  • POST /api/workflows/dry-run - Test workflow without side effects
  • GET /api/workflows/variables - Get available template variables
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Plugin Infrastructure

:

  • IWorkflowPluginRegistry - Auto-discovery of workflow actions
  • ITemplateVariableExtractor - Template variable resolution ({{data.Field}})
  • IWorkflowSchemaValidator - JSON schema validation
  • IWorkflowDebugger - Execution logging and debugging
  • WorkflowActionMetadataAttribute - Plugin metadata for documentation
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Plugin Development Guide

(docs/plugin-development-guide.md):

  • Step-by-step tutorial for creating custom actions
  • Examples for all 6 built-in plugins
  • Best practices and patterns
  • Template variable usage
  • Troubleshooting guide
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Workflow Migration Guide

(docs/workflow-migration-guide.md):

  • Migration from hardcoded to plugin system
  • Before/after code examples
  • Migration checklist
  • FAQ section
  • No breaking changes - fully backward compatible
Other

Phase 2 Week 4: Plugin System Completion & Documentation

13 Integration Tests

(WorkflowToolsApiTests.cs):

  • All 5 workflow tool endpoints tested
  • Real database integration with Testcontainers
  • 100% passing
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Unit Tests

:

  • WorkflowPluginRegistryTests.cs (5 tests)
  • WorkflowSchemaValidatorTests.cs (8 tests)
  • TemplateVariableExtractorTests.cs (8 tests)
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Performance Optimization

:

  • Template variable resolution: 50-70% faster (StringBuilder)
  • Database queries optimized with .Take(1)
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Security Hardening

:

  • Type-safe WorkflowEvents constants (no magic strings)
  • Input validation complete
  • Null-safety throughout
Other

Phase 2 Week 4: Plugin System Completion & Documentation

Documentation

:

  • Complete XML documentation on all public APIs
  • Error handling in all 5 endpoints
  • Structured logging with context
Other

Phase 2 Week 4: Plugin System Completion & Documentation

IReadOnlyList

return types for immutability

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Enhanced error messages in validation

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Cancellation token support in validator

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Workflow plugin discovery: < 100ms for 6 plugins

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Schema validation: < 5ms per workflow

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Template variable resolution: 50-70% faster than before

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Updated README with workflow system features

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Added plugin quick start example

Other

Phase 2 Week 4: Plugin System Completion & Documentation

Links to development and migration guides

2.0.0

2025-12-11

Major
Other

Major Release: Advanced RBAC System (Phase 1)

POST /api/roles - Create role with granular permissions

Other

Major Release: Advanced RBAC System (Phase 1)

GET /api/roles - List all roles

Other

Major Release: Advanced RBAC System (Phase 1)

GET /api/roles/{id} - Get specific role

Other

Major Release: Advanced RBAC System (Phase 1)

PUT /api/roles/{id} - Update role

Other

Major Release: Advanced RBAC System (Phase 1)

DELETE /api/roles/{id} - Delete role

Other

Major Release: Advanced RBAC System (Phase 1)

POST /api/user-groups - Create user group

Other

Major Release: Advanced RBAC System (Phase 1)

GET /api/user-groups - List all groups

Other

Major Release: Advanced RBAC System (Phase 1)

GET /api/user-groups/{id} - Get specific group

Other

Major Release: Advanced RBAC System (Phase 1)

PUT /api/user-groups/{id} - Update group

Other

Major Release: Advanced RBAC System (Phase 1)

DELETE /api/user-groups/{id} - Delete group

Other

Major Release: Advanced RBAC System (Phase 1)

POST /api/user-groups/{groupId}/users - Add user to group

Other

Major Release: Advanced RBAC System (Phase 1)

DELETE /api/user-groups/{groupId}/users/{userId} - Remove user from group

Other

Major Release: Advanced RBAC System (Phase 1)

POST /api/users/{userId}/roles - Assign role to user

Other

Major Release: Advanced RBAC System (Phase 1)

DELETE /api/users/{userId}/roles/{roleId} - Remove role from user

Other

Major Release: Advanced RBAC System (Phase 1)

POST /api/users/{userId}/groups - Add user to group

Other

Major Release: Advanced RBAC System (Phase 1)

DELETE /api/users/{userId}/groups/{groupId} - Remove user from group

Other

Major Release: Advanced RBAC System (Phase 1)

Permission System

: Content-type-specific CRUD permissions with JSON conditions

Other

Major Release: Advanced RBAC System (Phase 1)

Role Model

: Support for permissions and system capabilities

Other

Major Release: Advanced RBAC System (Phase 1)

UserGroup Model

: User organization and group-based permissions

Other

Major Release: Advanced RBAC System (Phase 1)

ConditionEvaluator

: Dynamic permission conditions ($CURRENT_USER, $eq, $in)

Other

Major Release: Advanced RBAC System (Phase 1)

PermissionResolver

: Service for checking user permissions

Other

Major Release: Advanced RBAC System (Phase 1)

Comprehensive RBAC documentation in README.md

Other

Major Release: Advanced RBAC System (Phase 1)

CLA (Contributor License Agreement) requirement

Other

Major Release: Advanced RBAC System (Phase 1)

CLA Assistant integration

Other

Major Release: Advanced RBAC System (Phase 1)

Workflow automation guide with template variables

Other

Major Release: Advanced RBAC System (Phase 1)

AttendancePOC workflow examples

Other

Major Release: Advanced RBAC System (Phase 1)

Pre-publication review artifacts

Other

Major Release: Advanced RBAC System (Phase 1)

Production readiness assessment

Other

Major Release: Advanced RBAC System (Phase 1)

ROADMAP.md with 5-phase plan

Other

Major Release: Advanced RBAC System (Phase 1)

Enhanced DataSeeder with comprehensive AttendancePOC data:

  • 4 roles: SuperAdmin, Admin, HR, User
  • 3 sample users with different roles
  • AttendanceRecord content type with sensitivity configuration
  • Email confirmation workflow
  • 3 sample attendance records
Other

Major Release: Advanced RBAC System (Phase 1)

Updated User model with RoleIds and GroupIds lists

Other

Major Release: Advanced RBAC System (Phase 1)

Workflow documentation expanded with multiple examples

Other

Major Release: Advanced RBAC System (Phase 1)

Contributing guidelines updated with CLA requirement

Other

Major Release: Advanced RBAC System (Phase 1)

All RBAC endpoints secured with role-based authorization

Other

Major Release: Advanced RBAC System (Phase 1)

SuperAdmin role for role management

Other

Major Release: Advanced RBAC System (Phase 1)

Admin role for user group management

Other

Major Release: Advanced RBAC System (Phase 1)

Production configuration checklist provided

Other

Major Release: Advanced RBAC System (Phase 1)

Security audit passed (zero vulnerabilities)

Other

Major Release: Advanced RBAC System (Phase 1)

18 new integration tests (100% passing)

  • 7 Role API tests
  • 7 UserGroup API tests
  • 4 User Assignment tests
Other

Major Release: Advanced RBAC System (Phase 1)

Pre-publication testing complete

Other

Major Release: Advanced RBAC System (Phase 1)

Regression testing passed (no Phase 1 regressions)

Other

Major Release: Advanced RBAC System (Phase 1)

All RBAC operations use async/await

Other

Major Release: Advanced RBAC System (Phase 1)

Efficient Marten LINQ queries

Other

Major Release: Advanced RBAC System (Phase 1)

Stateless API design (horizontally scalable)

1.2.1

2025-12-08

Patch

Contributor

Added

Idempotency

: Added IdempotencyFilter to prevent duplicate requests on POST/PUT/PATCH via Idempotency-Key header.

Added

Content History

: Implemented full audit trail of versions containing Data, Timestamp, and ModifiedBy.

Added

Rollback

: Added ability to revert content to any previous version.

Added

Workflows

: Added event-driven workflow engine supporting Email actions on Created and Updated events.

Added

Documentation

: Added standalone release notes RELEASE_NOTES_v1.2.0.md.

Security

Hardening

Secrets Management

: Removed hardcoded secrets from appsettings.json. Migrated to User Secrets/Env Vars.

Security

Hardening

Infrastructure

: Secured Swagger UI (Development only) and added strict CORS policy.

Security

Hardening

Logging

: Redacted sensitive data (SMS content) from logs.

Security

Hardening

Auth

: Enforced strong password policy (Min 8 chars, Upper, Lower, Number, Special).

Security

Hardening

Code Quality

: Enforced strict analysis level (latest) and build-time style enforcement.

1.1.0

2025-12-05

Minor

Contributor

Added

Runtime Validation

: Implemented comprehensive validation for Content Types and Content Data.

  • Enforces field types (string, int, bool, datetime, decimal, array, object).
  • Enforces PascalCase naming convention for fields.
  • Validates content data against schema on Create and Update.
Added

Validation Configuration

: Added StrictValidation and ValidationOptions to appsettings.json.

Added

Documentation

: Added RELEASE_PROCESS.md and updated DEVELOPMENT_STANDARDS.md with validation details.

Fixed

Integration Tests

: Resolved Marten async query issues in validators.

Fixed

JSON Handling

: Fixed ContentDataValidator to correctly handle JsonElement types.

1.0.3

2024-01-01

Patch

Contributor

Added

AI Adoption

: Added llms.txt and .cursorrules to improve AI agent compatibility.

Added

Community

: Added CONTRIBUTING.md and CODE_OF_CONDUCT.md.

Added

Production

: Added Dockerfile and updated docker-compose.yml with health checks.

Added

Health Checks

: Added /health endpoint.

Added

Documentation

: Added CITATIONS.cff for research citation.

Changed

Licensing

: Changed license from custom restrictive license to Apache License 2.0.

Changed

NuGet

: Updated package tags to include ai-native and vibe-coding.

Changed

Error Handling

: Enabled global exception handling with UseProblemDetails().

Fixed

Improved docker-compose.yml reliability with depends_on and health checks.