feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

@@ -0,0 +1,331 @@
Feature: SPA foundation for roles and permissions (slice 5)
As a signed-in person with a custom or built-in role
I want the app to know exactly what my role allows, from the server's answer
So that every screen, route, control and error speaks in permissions, never in fixed role names
Background:
Given the backend contract of features/roles_permissions_{foundation,enforcement,content_scope,roles_api,users_api}.feature
And GET /v1/auth/me answers access {role: {id, name, built_in, locked}, permissions: [{key, scope}]} and no top-level role string
And the 34-key catalogue, scopes none|own|all, typed entries "<key>:<type_key>" (article for articles) that replace the wildcard for that type, a "none" entry denying
And toggle keys arrive with scope "all"; owner-only keys arrive only for the Owner role
And a refusal is a plain-text 403 body: missing_permission with X-Missing-Permission (base key), escalation with X-Escalation-Key, or role_locked, own_role, role_built_in, built_in_name, role_conflict (409), session_required ("This action needs a real session.")
And own means created_by equals the caller's user id; created_by 0 is owned by nobody
And the ownership data the SPA can see today is: entries carry created_by (GET /v1/content-type/{key}/entry and /{id}); articles did not (Article.CreatedBy was json:"-"); this slice exposes it to signed-in callers, see the backend Rule; media items have no owner and the media delete, merge and purge routes require scope all
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR copy in i18n/messages.ts, no comments in code
And the test runner is web/vitest.config.ts (jsdom) run by `task test:web`; it already exists, this slice runs a baseline first
Rule: The session carries access, and can() decides
Scenario: getMe maps the access object
Given GET /v1/auth/me answers 200 with access.role {id: 3, name: "Writer", built_in: true, locked: false} and 9 permission entries
When useApi().getMe() resolves
Then the session holds role {id 3, name "Writer", built_in true, locked false} and the 9 entries in order
And the session has no string role field and no isOwner / canManageContent getter exists
Scenario Outline: can(key, options) resolves the effective scope
Given the session grants <grants>
When can("<key>", <options>) is evaluated
Then the result is <result>
Examples:
| grants | key | options | result |
| content.update=all | content.update | {} | true |
| content.update=own | content.update | {} | true |
| content.update=own | content.update | {scope: "all"} | false |
| content.update=own, session id 7 | content.update | {ownerId: 7} | true |
| content.update=own, session id 7 | content.update | {ownerId: 8} | false |
| content.update=own, session id 7 | content.update | {ownerId: 0} | false |
| content.update=all | content.update | {ownerId: 8} | true |
| content.update=none | content.update | {} | false |
| key absent | content.update | {} | false |
| content.update=own, content.update:events=all | content.update | {typeKey: "events", scope: "all"} | true |
| content.update=all, content.update:events=none | content.update | {typeKey: "events"} | false |
| content.update=all, content.update:events=none | content.update | {typeKey: "venues"} | true |
| content.read=all | content.read | {typeKey: "article"} | true |
| settings.view=all | content.explode | {} | false |
| no session | media.view | {} | false |
Scenario: scopeOf exposes none, own or all
Given the session grants content.read=all and content.read:events=own
Then scopeOf("content.read") is "all"
And scopeOf("content.read", {typeKey: "events"}) is "own"
And scopeOf("schema.view") is "none"
Scenario: Live refresh after a refused action
Given the session grants content.delete=all
And the server now answers 403 missing_permission with X-Missing-Permission "content.delete" and GET /v1/auth/me then lacks content.delete
When the SPA receives that 403
Then GET /v1/auth/me is requested once more (not more than once every 5 seconds)
And can("content.delete") becomes false without a reload
Scenario: Live refresh when the tab comes back
Given the last /v1/auth/me answer is 31 seconds old
When the document becomes visible
Then the session is refreshed
And when the last answer is 10 seconds old, it is not
Scenario: A failed refresh keeps the previous grants
Given GET /v1/auth/me fails with a network error
Then the session keeps its last grants and shows no error
Rule: Every fixed-role check is replaced by a permission
Scenario Outline: Permission replaces the role string
Given the session grants only <grants>
When <place> is rendered
Then <effect>
Examples:
| grants | place | effect |
| users.invite | Users, Invite action | the Invite button is shown |
| users.view | Users, Invite action | the Invite button is hidden |
| apikeys.view=all | API keys list | the Created by column is shown |
| apikeys.view=own | API keys list | the Created by column is hidden |
| media.delete=all | Media, Duplicates | the purge control is available |
| media.delete=own | Media, Duplicates | the purge control is hidden (purge routes need scope all) |
| settings.edit_general | Cache, Variants, Media cleanup | controls are editable |
| settings.view | Cache, Variants, Media cleanup | read-only with the ReadOnlyBanner |
| activity.view=all | Home recent activity, Activity page | full view |
| activity.view=own | Home recent activity, Activity page | own events only |
| activity.view=none | Home recent activity | the card is not shown |
| authors.create | Authors, Add author button, MetaSection | shown / inline create enabled |
| authors.view | Authors | read-only list and detail |
| system.apply_updates | Settings, Software card, Home banner | Apply and Restart are actionable, cue shown |
| settings.view | Settings, Software card | the Apply action is disabled with the "Owner only" tag |
| system.edit_retention | Settings, Retention card | editable |
| settings.view | Settings, Retention card | visible, locked, "Owner only" tag |
| settings.manage_sites | Sidebar, Settings children | the Sites child is shown |
| settings.manage_sites | Sites, Delete | Delete is disabled with the "Owner only" tag |
| settings.manage_sites, settings.delete_site | Sites, Delete | Delete is enabled |
Scenario: Sidebar lists items per permission
Given the session grants only content.read, media.view, authors.view, activity.view=own
Then the sidebar shows Home, Articles, Media, Authors, Activity and Integration
And it shows no Users, no Schema, no Settings
And the mobile drawer shows the same items
And the Access / Roles entry is not added in this slice
Scenario: The Writer built-in sees the Writer menu
Given the session is the built-in Writer (9 grants)
Then the sidebar shows Home, Articles, Media, Authors, Schema, Activity, Integration and no Users and no Settings
Scenario: The Admin built-in loses only the owner-only actions
Given the session is the built-in Admin (29 keys)
Then Sites is reachable and Delete site is disabled with the "Owner only" tag
And Apply update is disabled with "Only the owner can apply updates."
And the Retention card reads "Admins can read the log. Only the owner can change how long it is kept."
Rule: Route guard by permission
Scenario Outline: A direct link to a page the role lacks shows the page-level 403 state
Given the session grants <grants>
When the person opens <route>
Then the URL stays <route> and no redirect happens
And the page shows the icon-lock state titled "You don't have access to this page"
And the description is "Your role, <role>, doesn't include it. Ask an owner or admin if you need it."
And the single action is "<action>"
Examples:
| grants | role | route | action |
| content.read=all, no schema | Contributor | /schema | Go to Articles |
| content.read=all | Contributor | /schema/new | Go to Articles |
| content.read=all | Contributor | /users | Go to Articles |
| content.read=all | Contributor | /settings/sites | Go to Articles |
| content.read=all | Contributor | /integration/api-keys | Go to Articles |
| content.read:events=none | Contributor | /content/events | Go to Articles |
| media.view only | Media | /schema | Go to Home |
Scenario Outline: A page the role can use renders normally
Given the session grants <grants>
When the person opens <route>
Then the page renders and no 403 state shows
Examples:
| grants | route |
| schema.view | /schema |
| schema.edit | /schema/new |
| users.view | /users |
| settings.manage_sites | /settings/sites |
| content.read:events=own | /content/events |
| content.create:events=all | /content/events/new |
| apikeys.view=own | /integration/api-keys |
| settings.view | /settings/media/cache |
Scenario: The guard waits for the session
Given the session status is "checking"
When the person opens /schema
Then neither the page nor the 403 state flashes
And once the status is "ready" the matching state shows
Scenario: Unverified and signed-out rules are unchanged
Given an unverified session
Then the person is sent to /register/pending
And a missing session goes to /login with redirect and reason=expired
Scenario: Grants lowered while a page is open
Given the person is on /schema with schema.view
And a refresh then returns no schema.view
Then the page switches to the page-level 403 state without reload
Rule: Error handling in useApi
Scenario Outline: A 403 with a code
When the server answers 403 with body "<body>" and header <header>
Then the call rejects with ApiError status 403, code "<body>"<details>
And <toast>
Examples:
| body | header | details | toast |
| missing_permission | X-Missing-Permission: content.delete | permission "content.delete" | an error toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." |
| missing_permission | X-Missing-Permission: users.invite | permission "users.invite" | the toast names "Invite users" |
| missing_permission | (none) | permission undefined | the toast reads "Your role doesn't include this action. Ask an owner or admin." |
| escalation | X-Escalation-Key: content.publish:events| escalationKey kept | no global toast; the screen shows "You can't grant that" / "You can only grant what your role has." |
| role_locked | (none) | code kept | no global toast; copy "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." is available to screens |
| own_role | (none) | code kept | no global toast; copy "You can't change your own role" / "Ask another owner or admin." |
| role_built_in | (none) | code kept | no global toast; copy "Built-in roles can't be deleted" / "Duplicate it to make a custom role." |
| built_in_name | (none) | code kept | no global toast; copy "Built-in roles keep their name." |
| session_required | (none) | code kept | no global toast |
Scenario: A caller can opt out of the toast
Given a request sent with onMissingPermission "passthrough"
When the server answers 403 missing_permission
Then the ApiError carries the code and permission and no toast shows
And the session is still refreshed
Scenario: The unverified redirect still wins
Given the session is unverified
When any call answers 403
Then the person is sent to /register/pending, as before
Scenario: Unchanged statuses
Then 401 still redirects to /login, 429 still carries Retry-After, network failures still set isOffline
Scenario: 409 role_conflict and user_role_changed are mapped, not toasted
When the server answers 409 "role_conflict" or "user_role_changed"
Then ApiError.code carries it and useApi shows no toast
And a screen without its own handling shows "This changed somewhere else. Reload and try again."
Scenario: The old role-specific toasts are retired
Given the Sites or Authors write answers 403 missing_permission
Then the generic missing_permission toast shows
And "Only the owner can manage sites" no longer shows anywhere
Rule: Copy follows the new Admin rights
Scenario Outline: Owner-only copy
Then "<screen>" no longer says "<old>"
And it says "<new>"
Examples:
| screen | old | new |
| Sites 403 page | Only the owner can manage sites… | the page-level state |
| Schema 403 page | Only owners and admins can change content types… | the page-level state |
| Settings, Software | Ask the owner of this site to update RenewCMS. | Only the owner can apply updates. |
| Settings, Retention | Only owners can change it. (hidden for admin) | Admins can read the log. Only the owner can change how long it is kept. |
| Settings, Media cleanup | Only owners and admins can change this. | You can read these settings but not change them / Ask the owner to change them, or ask for a role that can edit settings. |
Rule: Own-scope and role-aware controls on content (design: roles.md section 6)
Scenario Outline: Entry list rows under each scope for content.update and content.delete
Given the session is user 21 with content.update:events=<update>, content.delete:events=<delete>
And entry 1 has created_by 21, entry 2 has created_by 9, entry 3 has created_by 0
When /content/events lists the three entries and the row menu opens on <row>
Then the "View only" mark with a lock icon is <mark>
And Edit is <edit>, with the reason "<editReason>"
And Delete is <del>, with the reason "<delReason>"
Examples:
| update | delete | row | mark | edit | editReason | del | delReason |
| own | own | entry 1 | absent | enabled | | enabled | |
| own | own | entry 2 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
| own | own | entry 3 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
| all | all | entry 2 | absent | enabled | | enabled | |
| all | none | entry 1 | absent | enabled | | disabled | Your role can't delete entries. |
| none | none | entry 1 | shown | disabled | Your role can't edit entries. | disabled | Your role can't delete entries. |
Scenario: The entry list explains the own scope
Given the session has content.create:events=all and content.update:events=own
Then the Events list reads "You can create entries and edit your own."
And a note reads "Entries by others are view only" / "Your role lets you edit entries you created."
And the New entry button is shown
And with content.create:events=none the New entry button is hidden
Scenario: Direct link to an entry the role can only view
Given the session has content.update:events=own and entry 2 has created_by 9
When the person opens /content/events/2
Then the entry is shown with the "View only" mark and no Save action
And the exact layout beyond the mark is a design gap (not specified in roles.md)
Scenario Outline: Article controls under all and none (ownership not needed)
Given the session has content.publish:article=<publish>, content.delete:article=<delete>, content.update:article=<update>
When the Articles list and the editor of an article render
Then Publish is <publishState>
And the status reads <status>
And Delete in the row menu is <del>, with the reason "<delReason>"
And Edit is <edit>
Examples:
| publish | delete | update | publishState | status | del | delReason | edit |
| all | all | all | available | Draft or Published as today | enabled | | enabled |
| none | none | all | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | enabled |
| none | none | none | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | disabled |
Scenario: The Writer built-in Articles list
Given the session is the built-in Writer
Then the Articles header reads "Articles" with "You can write and edit all articles. Publishing is done by an editor."
And the row menu shows Edit enabled and Delete disabled with "Your role can't delete articles."
And the editor shows Save draft, Status "Draft" and "Ask an editor to publish." and no Publish
Scenario Outline: Article controls under own scope
Given the session is user 21 with content.update:article=own, content.delete:article=own, content.publish:article=own
And article 1 has created_by 21 and article 2 has created_by 9, both exposed by GET /v1/article/
When the Articles list renders
Then on article 1 Edit, Delete and Publish are available
And on article 2 Edit and Delete are disabled with "You can only edit content you created." and Publish is hidden with "Ask an editor to publish."
Scenario Outline: Media controls
Given the session grants <grants>
When Media renders
Then <effect>
Examples:
| grants | effect |
| media.view | the library is read-only: no upload control, no Rename, no Delete, no Merge, no purge |
| media.view, media.upload | upload and Rename available; Delete, Merge and purge hidden |
| media.view, media.delete=all | Delete, Merge and purge available |
| media.view, media.delete=own | Delete, Merge and purge unavailable: media has no owner and the server requires all |
| no media.view | the page-level 403 state |
Scenario: A stale menu still ends well
Given the person sees Delete on an article and the role was just lowered
When Delete answers 403 missing_permission
Then the toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." shows
And the session refreshes and the control becomes disabled with its reason
Rule: Shapes of the changed API are documented in the catalogue
Scenario: The catalogue samples match the backend
Then the /auth/me sample shows access.role and access.permissions and no role string
And the /user list sample shows role {id, name, built_in, locked}
And the invite create request documents the optional role_id (absent means Writer) and the response carries role
And docs/llms output is regenerated by `task build:llms` from the catalogue, never edited by hand
Rule: The backend exposes created_by on articles to signed-in callers (Go, Go test suite)
Scenario Outline: created_by on article responses
When <caller> calls <route>
Then the article objects <result>
Examples:
| caller | route | result |
| a signed-in session | GET /v1/article/ | carry created_by (the user id) |
| a signed-in session | GET /v1/article/{id} | carry created_by |
| a signed-in session | PATCH /v1/article/{id} (response) | carry created_by |
| a signed-in session | POST /v1/article (response) | carry created_by, the caller's id |
| an API key | GET /v1/article/ | carry created_by (a key acts as its creator, authenticated) |
| an anonymous caller | GET /v1/article/ and GET /v1/article/{id} | carry no created_by field at all |
| an unverified or stale session | GET /v1/article/ | carry no created_by field (treated as anonymous, as in the public read rule) |
And created_by 0 is returned as 0 for a signed-in caller
And the same article encoded for an anonymous caller and a signed-in caller differ only by that field
And docs/api.md and docs/api/articles.md document the field, the audience and the SPA use; the Go suite covers every row with the existing optionalAccess middleware deciding authenticated versus anonymous
@@ -0,0 +1,71 @@
# QA procedure: SPA foundation for roles and permissions (slice 5)
Spec: features/roles_permissions_spa_foundation.feature. Everything below is Vitest + Vue Test Utils under `task test:web` with `/v1` mocked through the existing kits. No live server, no real SMTP.
## Honesty rules
0. The runner already exists (web/vitest.config.ts, `test:web` in Taskfile.yml, vitest and @vue/test-utils in web/package.json). First run `task test:web` on the unmodified tree and record exit code and failing specs. That is the baseline.
1. If the runner cannot execute (bun missing, install failing), every SPA item is reported NOT RUN, never passing. A green Go suite never counts for the SPA.
2. Report each edited spec, and which ones already failed in the baseline.
3. The backend change (created_by on article responses for signed-in callers) is part of this slice and is covered by Go tests in test/; own-scope article specs are normal specs, not todo.
4. The sentence in CLAUDE.md saying the runner is added by the first SPA slice is stale; the coordinator updates it.
## Shared helper
`web/src/test/access.ts` (new) builds the Owner (34, all), Admin (29, no owner-only key), Writer (9 grants of roles_permissions_foundation.feature), custom and none presets. Kits keep their `role` option and call it; no spec writes a raw role string. Legacy mapping: owner, admin, member and user become Owner, Admin, Writer and no-grants; undefined becomes no `access`.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| stores/__tests__/permissions.spec.ts (new, replaces sessionRoles.spec.ts) | every row of the can() table; scopeOf; no session; typed none; wildcard for a type without row; unknown key false |
| composables/__tests__/useApi.spec.ts (extend or new) | getMe maps access and drops role; 403 matrix (missing_permission with and without header, escalation, role_locked, own_role, role_built_in, built_in_name, session_required); passthrough option; refresh debounce 5 s and visibility refresh 31 s vs 10 s with fake timers (no real dates); failed refresh keeps grants; unverified redirect wins; 401, 429, network unchanged; 409 codes mapped without toast |
| lib/__tests__/permissionNames.spec.ts (new) | all 34 keys have an EN and FR human name; content.delete is "Delete entries"; a typed key shows the base-key name |
| router/__tests__/routerGuard.spec.ts (extend) | each route of the guard table denied and allowed; status checking shows nothing; unverified and signed-out unchanged; typed param route /content/events; lowered-while-open switch; URL unchanged |
| components/__tests__/NoAccessState.spec.ts (new) | title, description with role name, single action Articles vs Home, no "See what I can do" |
| components/__tests__/OwnerOnlyTag.spec.ts, ReadOnlyBanner.spec.ts, ViewOnlyMark.spec.ts (new) | lock icon, texts EN and FR, tooltip reason |
| components/__tests__/DashboardShell.spec.ts (update) | Writer, Admin, Owner, custom menus; drawer equals sidebar; no Roles entry |
| views/__tests__/UsersView.spec.ts, UsersRowActions.spec.ts | users.invite shows Invite; role object rendered through RoleBadge (Owner locked, Admin built-in, custom, null) |
| views/__tests__/ApiKeysView.spec.ts | apikeys.view all vs own for Created by |
| views/__tests__/Settings{Updates,UpdateDownload,Restart,ContainerUpdates,Retention,MediaCleanup}.spec.ts, HomeUpdate*.spec.ts, stores/__tests__/update.spec.ts | system.apply_updates, system.edit_retention, settings.edit_general gating; Owner only tag; Admin Retention state |
| views/__tests__/SitesView.spec.ts, Schema*.spec.ts, composables/__tests__/useSchemaGate.spec.ts | page-level state replaces the old forbidden copy; Delete site disabled for Admin |
| views/__tests__/Authors*.spec.ts, EditorAddAuthor.spec.ts, CachePage.spec.ts, VariantsPage.spec.ts, Activity*.spec.ts, Home*.spec.ts | kits emit the new access body; owner and admin behaviour unchanged; permission-driven cases |
| views/__tests__/ContentList*.spec.ts, EntryEditor*.spec.ts (extend) | the entry table of the feature: View only mark, Edit and Delete disabled with their exact reasons, own note, New entry hidden without create, direct link to a viewed entry shows View only and no Save |
| views/__tests__/Articles*.spec.ts, Editor*.spec.ts, composables PublishControl / useArticleRowActions specs (extend) | all/none table: Publish hidden with "Ask an editor to publish.", Delete disabled with "Your role can't delete articles.", Writer header copy, stale-menu 403 toast then refresh and disabled control |
| same | own-scope article rows (created_by from the API) |
| views/__tests__/MediaView.spec.ts, MediaUpload.spec.ts, mediaTestKit | the media table: upload, rename, delete, merge, purge by grants; own behaves as none; no media.view shows the page state |
| data/__tests__/apiCatalogue.spec.ts | sample shapes; no role string in me or user list; role_id documented |
| i18n/spa.spec.ts (update) | new keys exist in EN and FR |
## Visual (`task visual:check -- <screen>`, hard gate 10 %)
Step 1. After migrating the `/auth/me` mocks in web/visual/states.ts (about 48) to access/helper bodies, run every screen of `visualStates`. Everything not listed below must still PASS; clock-sensitive states stay `skip: 'clock-sensitive'`.
Step 2. States whose rendering changes by design and whose board frames still carry the old or different copy. The user approved per-state exceptions in the slice 5 acceptance. Each is recorded in web/visual/states.ts as `exception` with `approvedBy: 'user (slice 5 acceptance)'` ONLY if the run prints FAIL for it (do not add an exception for a state that passes). Candidates, from a static read of states.ts:
| Screen | State | Why it may fail |
|---|---|---|
| sites | Forbidden (403) | old "You don't have access to Sites" copy replaced by the page-level state |
| schema | Forbidden (403) | old "Only owners and admins can change content types" copy replaced |
| variants | Forbidden | the permission guard shows the page-level state instead of the local one |
| activity | Forbidden (403) | same, only if the mock session now lacks activity.view |
| content | Forbidden (403) | same, only if the mock now lacks content.read for the type |
| settings | Retention for an admin (board shows it hidden for non-owners), Software for an admin (Owner only tag) | new Owner-only treatment |
| settings / cache / variants | Media cleanup read-only | new read-only copy and ReadOnlyBanner |
| users | Invite action gating, role badge from a role object | badge rendering from RoleRef |
Step 3. New or changed rendering with no board frame: NoAccessState (reference frame "Page: no access" in roles.md section 7), ReadOnlyBanner and OwnerOnlyTag ("Admin: read-only settings", "Admin: owner-only settings"), the View only mark and disabled row menus ("Own scope: entry list", "Own scope: row menu", "Writer: Articles list", "Writer: row menu", "Writer: entry editor"). If the board file does not expose them as states of an existing screen, record "no board frame, manual" and list it in the report as a design-doc gap. Do not invent a screen entry.
## Manual checks
1. Keyboard: Tab order and visible focus on NoAccessState action; disabled row-menu items are reachable and announce their reason; View only mark has an accessible name and the tooltip opens on focus.
2. Two real browsers, two roles: change a role through PUT /v1/user/{id}/role; the other browser's next refused action shows the toast and the page updates within 30 s of returning to the tab.
3. As a Writer open /schema, /users, /settings/sites by link: the page-level state, URL unchanged.
4. As a Contributor with content.update:events=own: another person's entry shows View only, Edit and Delete disabled with reasons; own entry editable.
5. Media as a role with only media.view: no write control anywhere.
6. French: all new copy translated. Dark mode and mobile for NoAccessState, OwnerOnlyTag, ReadOnlyBanner, ViewOnlyMark.
7. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree.
8. The TestRouterRoutesAreInTheCatalogue Go failure is pre-existing and reported, not fixed.
## Backend items blocking or limiting this slice (for the coordinator)
- B1 (done in this slice, signed-in callers only): `Article.CreatedBy` is `json:"-"`. GET /v1/article/, GET /v1/article/{id} and write responses must expose `created_by`. Privacy decision: anonymous callers should probably not get it.
- B2 ("Created by" column of the Writer Articles frame): needs names for created_by ids; GET /v1/user/lookup?ids= exists and answers for roles with no grants, so it can serve, but the column is not part of this draft unless asked.
- B3 (info): media has no owner; delete, merge and purge need scope all, so `own` on media.delete cannot be honoured in the UI.
- B4 (info): entries already expose created_by; no backend change.
- B5 (design gap): the design shows Publish and Status for entries, but the backend has no entry publish route.