Keep a Changelog, semantic within a major
Every break, with the reason it was worth it
Public members are not removed or resignatured inside a major version; the old form is kept and marked obsolete with a removal version at least one major away. Read the source at CHANGELOG.md.
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.
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).
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).
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).
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).
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).
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.
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.
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.
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 toPOST /api/tenantsandPUT /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
contactUrlandlocationUrlare fullhttporhttpsaddresses is no longer checked on these writes, since no value is accepted. It is applied where the profile is read:GET /api/tenants/{handle}/publicanswerslogoUrl,locationUrlandcontactUrlfrom the site entry, andGET /api/me/tenantsitslogoUrl, only when the value is an absolutehttporhttpsaddress, 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.
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.
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.
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.
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.
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).
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.
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).
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).
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).
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).
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).
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).
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).
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).
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.
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.
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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.
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.
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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.
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).
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).
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).
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).
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).
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).
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.
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).
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.
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.
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).
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).
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).
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.
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.
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.
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.
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).
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.
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.
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.
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).
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).
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.
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).
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.
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.
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 ranElseActions. It now runsThenActionswhen the condition holds.!=against a value, such as{{contentType}} != Post: always ranThenActions. It now runsElseActionswhen the two are equal.- A comparison with an empty value:
{{data.Phone}} != ""always ranElseActionsand{{data.Phone}} == ""always ranThenActions, whatever the field held. Both now follow the field, so a!= ""workflow runs itsThenActionsfor the first time. !=against a field whose value holds==or!=: always ranElseActions, because the value made the condition unreadable. It now runsThenActionswhen 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.
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).
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).
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.
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).
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).
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).
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).
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).
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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)
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)
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)
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)
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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)
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)
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)
Malformed and boundary input answers 4xx rather than 500.
Anything treating a 500 from these routes as retryable should be rechecked. (#758, closes #648)
Pages: a module for the page tree, navigation and path resolution.
(#826, closes #718)
Forms: a module so a public visitor can submit a form.
(#821, closes #720)
A choice field type with ordered options.
(#820, closes #803)
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)
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)
Every rate limit is configurable
, plus a renderer partition. (#832)
Workflow actions report a group, and optional and secret parameters.
(#783, #764)
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)
Resolving a client error can record a reference and a note
, returned on the list. (#791, closes #790)
The page tree reports the fields it is built from
, under options. (#826, closes #842)
A deployment guide for App Service, Fargate and Cloud Run.
(#755, closes #727)
A first-module walkthrough, from clone to passing tests.
(#760, closes #729)
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)
The workflow execution log is redacted on write and on read
, and stored debugger logs are redacted too. (#859, #881)
Security headers are kept on early error responses, and exception text is kept out of 500s.
(#874)
Stored redirects are normalised when served
, and subdomain tenants resolve by host. Backslashes and control characters in redirect paths are normalised. (#872, #751)
A password hash below the configured work factor is upgraded on successful sign-in.
(#866)
OAuth state is minted from RandomNumberGenerator.
(#754)
No-store on token and profile responses, no Server header, and no config keys in 503s.
(#757, closes #654)
Only Caddy's address is trusted for forwarded headers in the production compose.
(#767)
A slug the caller cannot read answers 404
, like a missing one, rather than revealing that it exists. (#875)
A by-slug read of a type named erase or rollback is no longer treated as destructive.
(#882)
Moving a page checks the whole subtree
, skips unchanged parents, and runs save hooks on workflow field updates. (#876)
Navigation says when it has been truncated
rather than silently returning a partial tree. (#878)
Workflows find partitions from the tenant registry
, so enforced database tenancy still runs them. (#943, closes #877)
A workflow failure records whether it was permanent
, and retrying one is audited. (#865)
A workflow trigger can name more than one content type.
(#776)
A parent reference that points at itself or closes a cycle is refused.
(#772)
Endpoints of a disabled module are no longer mapped.
(#773)
Startup throwing before the host's handler exits 1
, so the image stops instead of spinning. (#777)
The semantic search scan is bounded.
(#771, closes #620)
A bodiless GET or DELETE sent with a JSON content type binds instead of answering 400.
(#756, closes #681)
Job workers wait for the schema apply
, so a host no longer races itself into 42P07 on the
jobs index. (#761, closes #686)
The quickstart ships its own backup script
, so a folder copied out of the repository takes backups. (#753, closes #712)
db-assert and db-patch work on the published image
, which runs the Suite host. (#759, closes #662)
A release body over GitHub's limit is summarised, and checked before anything is published.
(#752, closes #660)
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)
ahmdkaml is credited for code, not ideas.
(#744)
Postgres is tuned for a 2 GB server
, query stats are recorded, and there is a delivery load script. (#822)
The upgrade gate runs against the Suite host
, and rollback drops the Files index. (#775)
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)
Architecture decisions D22 to D34 recorded.
(#819, #947)
The docs name the console image barako-brew.
(#762)
preflight.sh refuses to pass having tested nothing
, and checks the three pinned versions agree. (#743)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
IUserRepository and MartenUserRepository are internal.
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.
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.
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.
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.
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.
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.
{Id} in two routes is now {id}
, matching the other thirty-odd. Cosmetic at runtime, but it lands verbatim in the OpenAPI paths.
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.
/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.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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" } }.
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.
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.
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.
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.
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.
{{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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
IBackupService and BackupService.
Registered in DI and called by nothing, repo-wide, so the codebase read as though the application backed itself up.
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).
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
The shipped Kubernetes Deployment asked for 128Mw of memory.
Not a valid quantity, so the manifest was rejected on apply.
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.
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.
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.
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.
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.
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.
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.
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.
WebhookAction never disposed its HttpResponseMessage
, on a path a workflow can fire on every content change.
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.
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).
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).
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
A URL with no port gets 5432
rather than Port=-1, which is what Uri.Port returns when none
was given.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
.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.
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.
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).
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).
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.
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.
SearchText backfill loaded every document at once and failed silently; it now batches and
reports.
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.
GET /api/meta
, authenticated, reporting the running API version and whether the instance serves Swagger.
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.
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.
Public search across delivered content.
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.
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.
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.
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.
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.
Node 22 in the admin image.
Node 20 is past end of life.
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.
Modules read their own configuration section rather than the application root.
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.
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%.
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.
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.
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.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.
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.
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
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.
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.
browser error capture (the other half of Diagnostics)
Uncaught errors and unhandled promise rejections, via global listeners installed in the root layout.
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).
`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.
TOTP multi-factor authentication
POST /api/auth/mfa/setup (auth) — start enrollment; returns a secret + otpauth:// URI to show as a
QR code, once.
TOTP multi-factor authentication
POST /api/auth/mfa/enable (auth) — confirm with a code; returns one-time recovery codes, once.
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.
TOTP multi-factor authentication
POST /api/auth/mfa/disable (auth) — requires a current code, so a hijacked session can't strip it.
TOTP multi-factor authentication
GET /api/auth/mfa/status (auth).
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.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.
scheduled publish / unpublish
ScheduledPublishAt — a Draft is promoted to Published at/after this time.
scheduled publish / unpublish
ScheduledUnpublishAt — a Published item is Archived at/after this time.
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.
RSS feeds for public content
Feeds:SiteUrl — the site the links resolve against (falls back to the request host).
RSS feeds for public content
Feeds:Paths:{type} — a per-type link template like /blog/{slug} (defaults to /{type}/{slug}).
RSS feeds for public content
Feeds:Titles:{type} — the channel title (defaults to the type name).
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.
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.
audit log
GET /api/audit (Admin) — filter by actor, action, date range and tenant, paginated.
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).
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.
audit log
Admin gains an "Audit log" page with the same shape as the Errors page.
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.
decimals in schemaless data are no longer doubles
Whole numbers still come back as long, so ids and counts are unaffected.
decimals in schemaless data are no longer doubles
Values outside decimal's range still fall back to double rather than throwing.
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.
`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.
`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.
`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.
BarakoCMS.Diagnostics is wired into the Suite image, so the shipped Suite's admin "Errors" page has
a backend instead of returning 404.
CI now fails on Critical/High vulnerable dependencies instead of only reporting them, and Dependabot is configured for NuGet, npm and GitHub Actions.
CSP no longer allows 'unsafe-inline' in script-src outside Development. style-src still does —
see the roadmap for the remaining nonce work.
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.
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.
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.
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.
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.
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.
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.
password change and admin reset
POST /api/users/{userId}/password — a SuperAdmin resets another user's password (recovery or
rotation), enforcing the same policy.
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.
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.
navigation menus are now a content type
Existing menus tables are left orphaned and untouched (safe under AutoCreate.CreateOnly).
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.
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.
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.
site navigation menus
GET /api/public/menus/{slug} returns a menu for public rendering.
public content delivery API
GET /api/public/{type} returns a paged list of Published entries of a content type.
public content delivery API
GET /api/public/{type}/{slug} returns a single Published entry addressed by its slug.
public content delivery API
Only Published entries are ever returned. Drafts and archived content are never exposed.
public content delivery API
A document marked Sensitive or Hidden is never delivered, even when Published.
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.
public content delivery API
Each request is scoped to one tenant, resolved from the X-Tenant header or host.
public content delivery API
Responses carry Cache-Control: public, so a CDN can absorb traffic.
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.
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.
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.
API keys for machine callers
Revocable immediately.
Revoking a key refuses it on its next request, not at expiry.
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.
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.
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.
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.
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.
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.
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.
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.
One licence across the suite: MPL-2.0
LICENSE replaced with the Mozilla Public License 2.0
One licence across the suite: MPL-2.0
LICENSE.txt (BSD 3-Clause, left over from an unrelated 2023 project) removed
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
One licence across the suite: MPL-2.0
README and CONTRIBUTING updated
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.
Stability Hardening
Upgraded Marten 8.16.1 → 8.37.0
, fixing a critical full-text-search injection advisory (GHSA-vmw2-qwm8-x84c).
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).
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).
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.
Stability Hardening
SSRF protection
on workflow webhook actions (loopback, link-local incl. cloud metadata, and private ranges are blocked).
Stability Hardening
Added a global exception handler (no stack-trace leaks), request body-size limits, and a minimal (non-enumerating) health response.
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.
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.
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.
Stability Hardening
Refresh-token rotation
is race-safe (optimistic concurrency) with reuse detection that revokes the entire token family on replay.
Stability Hardening
Login lockout counter
uses an atomic increment, closing a race that allowed lockout bypass.
Stability Hardening
Permission cache
is invalidated immediately on role/permission/user-role changes instead of serving stale decisions for up to 5 minutes.
Stability Hardening
ConfigurationService no longer throws on malformed admin-editable settings (falls back to defaults).
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.
Stability Hardening
Fault isolation
: per-action and per-workflow error handling prevents one failing action from stalling the engine/daemon.
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.
Stability Hardening
Status transitions now fire Published-triggered workflows; workflows are validated on creation (trigger event, action types, required parameters).
SVG coffee-bean logo (assets/logo.svg) and README Security & Stability section.
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.
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.
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.
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.
Email.Resend
— an /api/email-events list endpoint.
Quickstart bundle
— quickstart/ runs the full suite + admin + Postgres from one documented .env.
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.
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.
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/*.
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.
Phase 2 Week 4: Plugin System Completion & Documentation
6 Built-in Workflow Action Plugins
:
EmailAction- Send email notificationsSmsAction- Send SMS messagesWebhookAction- HTTP POST to external servicesCreateTaskAction- Create tasks in the systemUpdateFieldAction- Update content fields dynamicallyConditionalAction- If/then/else logic
Phase 2 Week 4: Plugin System Completion & Documentation
Workflow Tool Endpoints (5 new API endpoints)
:
GET /api/workflows/actions- List all available action pluginsPOST /api/workflows/validate- Validate workflow JSON schemaGET /api/workflows/{id}/debug- Get execution history for debuggingPOST /api/workflows/dry-run- Test workflow without side effectsGET /api/workflows/variables- Get available template variables
Phase 2 Week 4: Plugin System Completion & Documentation
Plugin Infrastructure
:
IWorkflowPluginRegistry- Auto-discovery of workflow actionsITemplateVariableExtractor- Template variable resolution ({{data.Field}})IWorkflowSchemaValidator- JSON schema validationIWorkflowDebugger- Execution logging and debuggingWorkflowActionMetadataAttribute- Plugin metadata for documentation
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
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
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
Phase 2 Week 4: Plugin System Completion & Documentation
Unit Tests
:
WorkflowPluginRegistryTests.cs(5 tests)WorkflowSchemaValidatorTests.cs(8 tests)TemplateVariableExtractorTests.cs(8 tests)
Phase 2 Week 4: Plugin System Completion & Documentation
Performance Optimization
:
- Template variable resolution: 50-70% faster (StringBuilder)
- Database queries optimized with
.Take(1)
Phase 2 Week 4: Plugin System Completion & Documentation
Security Hardening
:
- Type-safe
WorkflowEventsconstants (no magic strings) - Input validation complete
- Null-safety throughout
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
Phase 2 Week 4: Plugin System Completion & Documentation
IReadOnlyList
return types for immutability
Phase 2 Week 4: Plugin System Completion & Documentation
Enhanced error messages in validation
Phase 2 Week 4: Plugin System Completion & Documentation
Cancellation token support in validator
Phase 2 Week 4: Plugin System Completion & Documentation
Workflow plugin discovery: < 100ms for 6 plugins
Phase 2 Week 4: Plugin System Completion & Documentation
Schema validation: < 5ms per workflow
Phase 2 Week 4: Plugin System Completion & Documentation
Template variable resolution: 50-70% faster than before
Phase 2 Week 4: Plugin System Completion & Documentation
Updated README with workflow system features
Phase 2 Week 4: Plugin System Completion & Documentation
Added plugin quick start example
Phase 2 Week 4: Plugin System Completion & Documentation
Links to development and migration guides
2.0.0
2025-12-11
Major Release: Advanced RBAC System (Phase 1)
POST /api/roles - Create role with granular permissions
Major Release: Advanced RBAC System (Phase 1)
GET /api/roles - List all roles
Major Release: Advanced RBAC System (Phase 1)
GET /api/roles/{id} - Get specific role
Major Release: Advanced RBAC System (Phase 1)
PUT /api/roles/{id} - Update role
Major Release: Advanced RBAC System (Phase 1)
DELETE /api/roles/{id} - Delete role
Major Release: Advanced RBAC System (Phase 1)
POST /api/user-groups - Create user group
Major Release: Advanced RBAC System (Phase 1)
GET /api/user-groups - List all groups
Major Release: Advanced RBAC System (Phase 1)
GET /api/user-groups/{id} - Get specific group
Major Release: Advanced RBAC System (Phase 1)
PUT /api/user-groups/{id} - Update group
Major Release: Advanced RBAC System (Phase 1)
DELETE /api/user-groups/{id} - Delete group
Major Release: Advanced RBAC System (Phase 1)
POST /api/user-groups/{groupId}/users - Add user to group
Major Release: Advanced RBAC System (Phase 1)
DELETE /api/user-groups/{groupId}/users/{userId} - Remove user from group
Major Release: Advanced RBAC System (Phase 1)
POST /api/users/{userId}/roles - Assign role to user
Major Release: Advanced RBAC System (Phase 1)
DELETE /api/users/{userId}/roles/{roleId} - Remove role from user
Major Release: Advanced RBAC System (Phase 1)
POST /api/users/{userId}/groups - Add user to group
Major Release: Advanced RBAC System (Phase 1)
DELETE /api/users/{userId}/groups/{groupId} - Remove user from group
Major Release: Advanced RBAC System (Phase 1)
Permission System
: Content-type-specific CRUD permissions with JSON conditions
Major Release: Advanced RBAC System (Phase 1)
Role Model
: Support for permissions and system capabilities
Major Release: Advanced RBAC System (Phase 1)
UserGroup Model
: User organization and group-based permissions
Major Release: Advanced RBAC System (Phase 1)
ConditionEvaluator
: Dynamic permission conditions ($CURRENT_USER, $eq, $in)
Major Release: Advanced RBAC System (Phase 1)
PermissionResolver
: Service for checking user permissions
Major Release: Advanced RBAC System (Phase 1)
Comprehensive RBAC documentation in README.md
Major Release: Advanced RBAC System (Phase 1)
CLA (Contributor License Agreement) requirement
Major Release: Advanced RBAC System (Phase 1)
CLA Assistant integration
Major Release: Advanced RBAC System (Phase 1)
Workflow automation guide with template variables
Major Release: Advanced RBAC System (Phase 1)
AttendancePOC workflow examples
Major Release: Advanced RBAC System (Phase 1)
Pre-publication review artifacts
Major Release: Advanced RBAC System (Phase 1)
Production readiness assessment
Major Release: Advanced RBAC System (Phase 1)
ROADMAP.md with 5-phase plan
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
Major Release: Advanced RBAC System (Phase 1)
Updated User model with RoleIds and GroupIds lists
Major Release: Advanced RBAC System (Phase 1)
Workflow documentation expanded with multiple examples
Major Release: Advanced RBAC System (Phase 1)
Contributing guidelines updated with CLA requirement
Major Release: Advanced RBAC System (Phase 1)
All RBAC endpoints secured with role-based authorization
Major Release: Advanced RBAC System (Phase 1)
SuperAdmin role for role management
Major Release: Advanced RBAC System (Phase 1)
Admin role for user group management
Major Release: Advanced RBAC System (Phase 1)
Production configuration checklist provided
Major Release: Advanced RBAC System (Phase 1)
Security audit passed (zero vulnerabilities)
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
Major Release: Advanced RBAC System (Phase 1)
Pre-publication testing complete
Major Release: Advanced RBAC System (Phase 1)
Regression testing passed (no Phase 1 regressions)
Major Release: Advanced RBAC System (Phase 1)
All RBAC operations use async/await
Major Release: Advanced RBAC System (Phase 1)
Efficient Marten LINQ queries
Major Release: Advanced RBAC System (Phase 1)
Stateless API design (horizontally scalable)
Idempotency
: Added IdempotencyFilter to prevent duplicate requests on POST/PUT/PATCH via Idempotency-Key header.
Content History
: Implemented full audit trail of versions containing Data, Timestamp, and ModifiedBy.
Rollback
: Added ability to revert content to any previous version.
Workflows
: Added event-driven workflow engine supporting Email actions on Created and Updated events.
Documentation
: Added standalone release notes RELEASE_NOTES_v1.2.0.md.
Hardening
Secrets Management
: Removed hardcoded secrets from appsettings.json. Migrated to User Secrets/Env Vars.
Hardening
Infrastructure
: Secured Swagger UI (Development only) and added strict CORS policy.
Hardening
Logging
: Redacted sensitive data (SMS content) from logs.
Hardening
Auth
: Enforced strong password policy (Min 8 chars, Upper, Lower, Number, Special).
Hardening
Code Quality
: Enforced strict analysis level (latest) and build-time style enforcement.
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.
Validation Configuration
: Added StrictValidation and ValidationOptions to appsettings.json.
Documentation
: Added RELEASE_PROCESS.md and updated DEVELOPMENT_STANDARDS.md with validation details.
Integration Tests
: Resolved Marten async query issues in validators.
JSON Handling
: Fixed ContentDataValidator to correctly handle JsonElement types.
AI Adoption
: Added llms.txt and .cursorrules to improve AI agent compatibility.
Community
: Added CONTRIBUTING.md and CODE_OF_CONDUCT.md.
Production
: Added Dockerfile and updated docker-compose.yml with health checks.
Health Checks
: Added /health endpoint.
Documentation
: Added CITATIONS.cff for research citation.
Licensing
: Changed license from custom restrictive license to Apache License 2.0.
NuGet
: Updated package tags to include ai-native and vibe-coding.
Error Handling
: Enabled global exception handling with UseProblemDetails().
Improved docker-compose.yml reliability with depends_on and health checks.