zz-meridian 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/dist/adopt.js +5 -1
  2. package/dist/migrations.js +28 -0
  3. package/package.json +1 -1
  4. package/payload/CHANGELOG.md +86 -0
  5. package/payload/CONTRIBUTING.md +1 -1
  6. package/payload/app/(dashboard)/(overview)/page.tsx +5 -2
  7. package/payload/app/(dashboard)/analytics/page.tsx +5 -2
  8. package/payload/app/(dashboard)/customers/page.tsx +22 -4
  9. package/payload/app/(dashboard)/health/page.tsx +4 -1
  10. package/payload/app/(dashboard)/keys/actions.ts +5 -3
  11. package/payload/app/(dashboard)/keys/page.tsx +26 -1
  12. package/payload/app/(dashboard)/layout.tsx +7 -3
  13. package/payload/app/(dashboard)/members/README.md +20 -13
  14. package/payload/app/(dashboard)/members/actions.ts +24 -10
  15. package/payload/app/(dashboard)/members/page.tsx +19 -2
  16. package/payload/app/(dashboard)/requests/[id]/page.tsx +21 -1
  17. package/payload/app/(dashboard)/requests/page.tsx +5 -2
  18. package/payload/app/(dashboard)/settings/actions.ts +41 -0
  19. package/payload/app/(dashboard)/settings/page.tsx +48 -3
  20. package/payload/app/api/assistant/route.ts +22 -6
  21. package/payload/app/sign-in/README.md +13 -8
  22. package/payload/app/sign-in/actions.ts +30 -2
  23. package/payload/app/sign-in/gated-panel.tsx +26 -3
  24. package/payload/app/sign-in/panel.tsx +61 -9
  25. package/payload/app/system/(atlas)/page.tsx +7 -7
  26. package/payload/decisions/0012-roles-and-feature-access.md +36 -0
  27. package/payload/docs/agents.md +3 -3
  28. package/payload/docs/benchmark.md +8 -0
  29. package/payload/docs/start-a-dashboard.md +19 -3
  30. package/payload/package.json +1 -1
  31. package/payload/scripts/assistant.ts +58 -46
  32. package/payload/scripts/audit.ts +7 -1
  33. package/payload/scripts/check.ts +34 -2
  34. package/payload/scripts/contrast.ts +1 -49
  35. package/payload/scripts/interactions.ts +8 -2
  36. package/payload/scripts/keyboard.ts +7 -1
  37. package/payload/scripts/lib/budgets.ts +6 -0
  38. package/payload/scripts/lib/chrome.ts +29 -6
  39. package/payload/scripts/lib/contrast-pairs.ts +53 -0
  40. package/payload/scripts/lib/deep.ts +16 -5
  41. package/payload/scripts/live.ts +54 -21
  42. package/payload/scripts/navigate.ts +10 -3
  43. package/payload/scripts/rail.ts +53 -0
  44. package/payload/scripts/shot.ts +43 -11
  45. package/payload/scripts/tokens.ts +21 -5
  46. package/payload/scripts/verify.baseline.json +22 -22
  47. package/payload/scripts/verify.config.ts +46 -1
  48. package/payload/scripts/verify.ts +109 -5
  49. package/payload/scripts/vitals.ts +8 -4
  50. package/payload/skills/zz-meridian/references/cache.md +178 -14
  51. package/payload/skills/zz-meridian/references/customize.md +28 -4
  52. package/payload/skills/zz-meridian/references/existing-project.md +11 -6
  53. package/payload/skills/zz-meridian/references/standard.md +1 -1
  54. package/payload/skills/zz-meridian/references/validation.md +11 -4
  55. package/payload/src/app.config.ts +15 -11
  56. package/payload/src/components/base/motion/README.md +1 -0
  57. package/payload/src/components/base/shell/README.md +1 -1
  58. package/payload/src/components/base/shell/index.tsx +31 -4
  59. package/payload/src/components/base/shell/preview.tsx +2 -2
  60. package/payload/src/components/base/text-roles/README.md +8 -0
  61. package/payload/src/components/base/text-roles/index.tsx +11 -0
  62. package/payload/src/components/charts/column-chart/README.md +2 -2
  63. package/payload/src/components/charts/column-chart/index.tsx +9 -2
  64. package/payload/src/components/charts/meridian/README.md +1 -1
  65. package/payload/src/components/charts/trend-chart/README.md +7 -4
  66. package/payload/src/components/charts/trend-chart/index.tsx +42 -18
  67. package/payload/src/components/charts/trend-chart/preview.tsx +12 -0
  68. package/payload/src/components/patterns/assistant/README.md +1 -1
  69. package/payload/src/components/patterns/assistant/index.tsx +10 -0
  70. package/payload/src/components/patterns/assistant/preview.tsx +1 -1
  71. package/payload/src/components/patterns/command-palette/README.md +1 -0
  72. package/payload/src/components/patterns/command-palette/index.tsx +3 -2
  73. package/payload/src/components/patterns/data-table/README.md +2 -1
  74. package/payload/src/components/patterns/data-table/index.tsx +24 -18
  75. package/payload/src/components/patterns/data-table/preview.tsx +4 -2
  76. package/payload/src/components/patterns/data-table/use-query-state.ts +15 -4
  77. package/payload/src/components/patterns/featured-metric/README.md +2 -0
  78. package/payload/src/components/patterns/featured-metric/index.tsx +2 -2
  79. package/payload/src/components/patterns/featured-metric/preview.tsx +7 -1
  80. package/payload/src/components/patterns/filter-bar/preview.tsx +13 -2
  81. package/payload/src/components/patterns/form-section/README.md +1 -1
  82. package/payload/src/components/patterns/form-section/index.tsx +2 -1
  83. package/payload/src/components/patterns/incident-card/index.tsx +1 -2
  84. package/payload/src/components/patterns/metric-tile/README.md +4 -2
  85. package/payload/src/components/patterns/metric-tile/index.tsx +14 -11
  86. package/payload/src/components/patterns/metric-tile/preview.tsx +8 -0
  87. package/payload/src/components/patterns/rail/README.md +8 -4
  88. package/payload/src/components/patterns/rail/index.tsx +57 -6
  89. package/payload/src/components/patterns/rail/preview.tsx +44 -1
  90. package/payload/src/components/patterns/shell-tools/README.md +1 -1
  91. package/payload/src/components/patterns/shell-tools/index.tsx +3 -3
  92. package/payload/src/components/patterns/shell-tools/preview.tsx +2 -2
  93. package/payload/src/components/patterns/status-list/preview.tsx +2 -1
  94. package/payload/src/components/ui/banner/index.tsx +3 -2
  95. package/payload/src/components/ui/card/README.md +2 -0
  96. package/payload/src/components/ui/card/index.tsx +3 -6
  97. package/payload/src/components/ui/empty-state/README.md +9 -3
  98. package/payload/src/components/ui/empty-state/index.tsx +8 -7
  99. package/payload/src/components/ui/empty-state/preview.tsx +7 -0
  100. package/payload/src/components/ui/key-value/preview.tsx +5 -4
  101. package/payload/src/components/ui/select/index.tsx +2 -2
  102. package/payload/src/components/ui/sheet/preview.tsx +1 -1
  103. package/payload/src/components/ui/table/README.md +1 -1
  104. package/payload/src/components/ui/table/index.tsx +3 -2
  105. package/payload/src/components/ui/table/preview.tsx +2 -1
  106. package/payload/src/components/ui/toast/index.tsx +7 -3
  107. package/payload/src/data/access.ts +165 -13
  108. package/payload/src/data/analytics.ts +26 -0
  109. package/payload/src/data/collections.ts +59 -4
  110. package/payload/src/data/features.ts +57 -0
  111. package/payload/src/data/member-mutations.ts +242 -0
  112. package/payload/src/data/roles.ts +54 -0
  113. package/payload/src/data/sample.ts +2 -2
  114. package/payload/src/data/view-as.ts +23 -0
  115. package/payload/src/lib/assistant/scoped.ts +55 -0
  116. package/payload/src/lib/assistant/tools.ts +5 -2
  117. package/payload/src/lib/collection.ts +47 -0
  118. package/payload/src/lib/color.ts +22 -0
  119. package/payload/src/lib/format-date.ts +13 -2
  120. package/payload/src/lib/format.ts +31 -8
  121. package/payload/src/lib/shared-context.ts +8 -4
  122. package/payload/src/styles/base.css +20 -0
  123. package/payload/src/styles/motion.css +6 -0
  124. package/payload/src/styles/tokens.css +85 -6
  125. package/payload/src/system/card-stage.tsx +37 -5
  126. package/payload/src/system/fixtures/sample-members.ts +8 -3
  127. package/payload/src/system/hero.tsx +1 -1
  128. package/payload/src/system/markdown.tsx +1 -1
  129. package/payload/src/system/page-stage.tsx +28 -11
  130. package/payload/src/system/token-view.tsx +37 -21
  131. package/payload/src/system/tokens-data.ts +1 -1
  132. package/payload/src/views/console-chrome.tsx +74 -10
  133. package/payload/src/views/keys.tsx +14 -8
  134. package/payload/src/views/members-context.ts +21 -4
  135. package/payload/src/views/members.tsx +164 -47
  136. package/payload/src/views/no-access.tsx +58 -0
  137. package/payload/src/views/overview.tsx +2 -2
  138. package/payload/src/views/request.tsx +7 -3
  139. package/payload/src/views/requests.tsx +3 -2
  140. package/payload/src/views/roles-matrix.tsx +98 -0
  141. package/payload/src/views/settings.tsx +49 -18
  142. package/payload/src/views/tools.ts +29 -6
  143. package/payload/tests/action-needs.test.tsx +45 -0
  144. package/payload/tests/assistant-access.test.ts +122 -0
  145. package/payload/tests/assistant-panel.test.tsx +3 -2
  146. package/payload/tests/assistant-settings.test.tsx +4 -1
  147. package/payload/tests/assistant-thread.test.tsx +8 -0
  148. package/payload/tests/assistant-tools.test.ts +18 -1
  149. package/payload/tests/bypasses.test.ts +70 -0
  150. package/payload/tests/color.test.ts +8 -0
  151. package/payload/tests/console-chrome.test.tsx +19 -4
  152. package/payload/tests/customers-masthead.test.tsx +8 -3
  153. package/payload/tests/data-access.test.ts +3 -2
  154. package/payload/tests/data-write.test.ts +14 -1
  155. package/payload/tests/demo-policy.test.ts +102 -0
  156. package/payload/tests/feature-access.test.ts +93 -0
  157. package/payload/tests/feature-definitions.test.ts +57 -0
  158. package/payload/tests/format.test.ts +31 -1
  159. package/payload/tests/keys-gate.test.tsx +67 -0
  160. package/payload/tests/member-mutations.test.ts +140 -0
  161. package/payload/tests/member-roles.test.ts +37 -0
  162. package/payload/tests/member-row-access.test.tsx +52 -0
  163. package/payload/tests/members-optimistic.test.tsx +72 -20
  164. package/payload/tests/no-access.test.tsx +37 -0
  165. package/payload/tests/page-gates.test.tsx +79 -0
  166. package/payload/tests/roles-context.test.ts +38 -0
  167. package/payload/tests/roles-matrix.test.tsx +48 -0
  168. package/payload/tests/roles.test.ts +38 -0
  169. package/payload/tests/settings-access.test.tsx +36 -0
  170. package/payload/tests/settings-disconnect-undo.test.tsx +4 -1
  171. package/payload/tests/setup.ts +8 -0
  172. package/payload/tests/shared-context.test.tsx +3 -2
  173. package/payload/tests/sign-in-persona.test.ts +38 -0
  174. package/payload/tests/trend-chart.test.tsx +45 -2
  175. package/payload/tests/verify-personas.test.ts +45 -0
  176. package/payload/tests/view-as.test.ts +39 -0
  177. package/payload/tests/view-tool-needs.test.ts +46 -0
package/dist/adopt.js CHANGED
@@ -74,9 +74,13 @@ function routes(root, dir) {
74
74
  const label = (route) => (route === '/' ? 'Home' : route.split('/').pop().replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase()));
75
75
  function appConfig(pages) {
76
76
  const template = readPayload('src/app.config.ts');
77
- const items = pages.map((r) => ` { href: ${JSON.stringify(r)}, label: ${JSON.stringify(label(r))}, icon: LayoutGrid },`).join('\n');
77
+ const items = pages.map((r) => ` { href: ${JSON.stringify(r)}, label: ${JSON.stringify(label(r))}, icon: LayoutGrid, needs: 'public' },`).join('\n');
78
78
  return template
79
79
  .replace(/^import \{[\s\S]*?\} from 'lucide-react';/m, "import { LayoutGrid, type LucideIcon } from 'lucide-react';")
80
+ // The project's pages have no feature entry yet, so every item is `public` — the honest default — and step 4 of the
81
+ // Start guide points each at its own feature once it has one. The template's own `FEATURES` import would name a file
82
+ // this project's `src/data/` need not have, and a nav item without a `needs` is a type error since 0.13.0.
83
+ .replace(/^import \{ FEATURES \} from '\.\/data\/features\.ts';\n/m, '')
80
84
  .replace(/export const nav: NavGroup\[\] = \[[\s\S]*?\n\];/, `/** Written by zz-meridian adopt from the project's pages: give each its own icon and group them. */\nexport const nav: NavGroup[] = [\n {\n items: [\n${items}\n ],\n },\n];`);
81
85
  }
82
86
  function verifyConfig() {
@@ -238,7 +238,35 @@ export const RELEASE_MIGRATIONS = [
238
238
  instructions: 'Read "A page spec" in references/customize.md. Under `## Agents` in the page\'s README.md, add `### What the agent reads`: what the page\'s shared context tells each agent, with a real line of it, and what the MCP App and the console\'s assistant each receive. `scripts/check.ts` requires it.',
239
239
  checks: ['gate'],
240
240
  },
241
+ {
242
+ id: 'nav-needs',
243
+ since: '0.13.0',
244
+ applies: navItemsWithoutNeeds,
245
+ summary: 'Every item in the project\'s `nav` carries a `needs`, so the rail shows only the destinations a person may reach.',
246
+ instructions: 'Read "Roles and access" in the Start guide (docs/start-a-dashboard.md). For each item in `src/app.config.ts`\'s `nav`, add `needs: \'public\'` when everyone may open that page, or point it at its feature (`needs: FEATURES.<id>.needs`); step 4 of that guide has the four steps, and `scripts/check.ts` fails the project\'s gate, naming the href, on an item without one. `adopt` writes `needs: \'public\'` for a project it sets up from now on.',
247
+ checks: ['gate'],
248
+ },
241
249
  ];
250
+ /** The project's own nav, with any item still missing the `needs` that 0.13.0 requires of it. */
251
+ function navItemsWithoutNeeds(root) {
252
+ const rel = 'src/app.config.ts';
253
+ const body = text(root, rel);
254
+ if (body === null)
255
+ return null;
256
+ const nav = /export const nav\b[^=]*=\s*\[([\s\S]*?)\n\];/.exec(body)?.[1];
257
+ if (nav === undefined)
258
+ return null;
259
+ // One item per line, as `adopt` writes them and as a hand-kept nav keeps them, so the item is the line naming its
260
+ // `href`: a group's opening line — every project groups its nav — names no `href` and carries no `needs` of its own,
261
+ // so counting it as an item would report a change no edit can settle. Anything the file does beyond that is the
262
+ // team's own shape, and none of it is read.
263
+ const items = nav.split('\n').filter((l) => /\bhref\s*:/.test(l));
264
+ // A project of 0.12.3's vintage also declares `NavItem` itself, without the property: an item that names a `needs` its
265
+ // own type does not have is the type error the gate prints next, so the change is not done until the type carries it.
266
+ const declaredType = /export type NavItem[^;]*\};/.test(body);
267
+ const typeHasNeeds = /export type NavItem[^;]*\bneeds\s*:/.test(body);
268
+ return items.some((l) => !/\bneeds\s*:/.test(l)) || (declaredType && !typeHasNeeds) ? [rel] : null;
269
+ }
242
270
  /** The page specifications under the console and embed folders that lack the part the 0.9.0 check requires. */
243
271
  function pagesWithoutAgentReads(root) {
244
272
  const files = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,92 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.13.0] · 2026-10-11
6
+
7
+ ### Added
8
+
9
+ - **A navigation check may carry its own budget.** An entry in `scripts/verify.config.ts`'s `navigationChecks` gains an optional `budget` — `{ shellMs: { phone: 300 } }` — which stands in for the shared `budgets.navigation` figure on that one metric and device for that one route; every figure it does not name, and every other route, keeps the shared one, and the shared figure itself is unchanged. The template uses it once: `/system/start/start-a-dashboard`, the longest reading page in the repository, whose phone shell measures 243-282 ms on the 4x-throttled profile against the shared 250. Beside it in the config is what that cost is not — this route's payload is smaller than the commit before the access work (152,388 bytes against 157,080), and `585349a` measures the same with byte-identical HTML — and the measurement lives in the initiative's review Backlog. Additive: a config with no `budget` behaves exactly as before.
10
+ - **The consistency gate fails a nav item whose `needs` is not a need.** `node scripts/check.ts` reads `nav` from `src/app.config.ts` and fails, naming the href, when an item's `needs` is neither `'public'`, nor a grant (`object:read|create|update|remove`), nor `{ allOf: [...] }` of grants — including the empty and the nested `allOf`. It checks the shape of a need and never the role table, so it passes whatever grants a project declares, and it holds in a product as well as in the template because `src/app.config.ts` is every project's own file.
11
+ - **`pnpm verify --as <persona>` signs the browser in as someone else, and is opt-in.** `scripts/verify.config.ts` declares five personas (`owner`, `admin`, `member`, `key-manager`, `viewer`) with the member id each stands for, its hand-written `expectedRoutes` and the protected string each of its denied routes must not leak. The run compares the rail with those routes, presses the role-dependent controls (`/keys`, `/members`, `/settings`) at 1440px, and opens every other rail route expecting NoAccess. An unknown persona exits non-zero naming the declared ones and `--as` cannot combine with `--perf`, both before anything is built. Without `--as`, `pnpm verify` is unchanged. `scripts/lib/chrome.ts`'s `Page.open` gains an optional `cookie` (set before the first request), `scripts/rail.ts` is new (it prints the rail's links as the browser drew them), and `scripts/audit.ts`, `scripts/interactions.ts` and `scripts/keyboard.ts` gain `--as`; `interactions.ts` also gains `--width`. All additive.
12
+ - **The Members page shows a Roles & access matrix, generated from the tables.** `src/views/roles-matrix.tsx` is new: `matrixRows()` and `RolesMatrix()` build a card whose columns are `MAIN_ROLES` then `ADD_ONS` and whose rows are the non-public features (their `title`) then the actions (their `label`), each ticked where `whoCan(need)` says that role's own grants satisfy the whole need, with a legend saying that an add-on adds to a main role and that the member rules still apply. It renders on `/members` inside the page's own stack, so the page's gate is its gate, and the table scrolls inside its own frame at a phone's width. Nothing is listed by hand — editing `src/data/roles.ts` or `src/data/features.ts` edits the section.
13
+ - **Every member write passes one boundary.** `src/data/member-mutations.ts` is new: `membersFor(scope)` binds the members collection so its `create`, `update` and `remove` all pass the boundary; `assignMemberRole({ id, role, addOns })` is the one assignment path; `memberAccess(id)` answers a row's capabilities, its role and add-on choices and its target-only reason; `invitableRoles()` is the invite choices. It re-resolves the caller from the request's own scope, enforces the Owner, self, give-Owner and last-active-Owner rules with the fixed messages, and runs every write in one promise queue per workspace.
14
+ - **The gate is one idiom.** `src/data/access.ts` gains `gate(feature)` and `requireNeed(need)`: a dashboard page opens with the gate — a person who may not open it gets the NoAccess view as a normal 200, and a request with no session is sent to `/sign-in` — and a view tool calls `requireNeed` before it reads. Both ask `may`, and `may` answers from `effective(role, addOns)`, the one union rule, so the policy and the role table cannot drift.
15
+ - **The empty state has a no-access kind.** `EmptyState` gains `kind="no-access"`: a lock mark on the neutral disc (`fill-track`, `ink-3`), `data-no-access` on its root, and no `role="alert"` (only `error` is an alert). `src/views/no-access.tsx` generates its copy from the role table — `noAccessCopy(need, title)` names the feature and the roles whose own grants satisfy the whole need, and says a main role plus an add-on is needed when no single role does.
16
+ - **A member's role and add-ons are data, not a fixture list.** The role table moved into a new `src/data/roles.ts` (`MAIN_ROLES`, `ADD_ONS`, `GRANTS`, `effective(role, addOns)`, `whoCan(need)`), and `ROLES` left the member fixture and `src/data/sample.ts`. A member record now carries `role` (a main role, default `Member`) and `addOns` (default `[]`, duplicates removed), so the members collection refuses an unknown role or add-on and an update that does not name either leaves it as it was. Anything importing `ROLES` from `@/data/sample` takes `MAIN_ROLES` from `@/data/roles` instead.
17
+ - **The demo signs in as whoever the View as cookie names.** `src/data/access.ts` reads the `zz_meridian_view_as` cookie on every request and answers grants from the member's own record: no cookie signs in as Maya Chen (`members_1`), the five frozen personas are `PERSONAS`, and an unknown or non-Active id gives no session — never the Owner. The scope keeps its three fields but its values change: `subjectId` is a member id and `authorizationKey` is `demo:<id>:<role>:<add-ons deduped and sorted>`, so code that hard-coded `subjectId: 'owner'` or `demo:1` must read the scope instead. `src/data/view-as.ts` (`'use server'`) adds `chooseViewAs(id)`, which sets the cookie for an Active persona and throws a readable message otherwise.
18
+ - **The demo sign-in offers the personas.** Without `DEMO_PASSWORD`, the sign-in page lists the five personas above the product's sign-in panel — each with their name and role, a non-Active one disabled — and a click opens the console as that person through the View as cookie; with `DEMO_PASSWORD`, "Open the demo" gains an "Open as" select (default Maya Chen) beside the password and sets both the gate and the View as cookie. A persona outside the list, or one whose record is not Active, sets nothing, and the choice never bypasses the password.
19
+
20
+ ### Changed
21
+
22
+ - **The Members page offers each row only what that person may do.** A row's menu is drawn from the boundary's answer for that row and that target: withheld controls are not rendered at all, a row that offers nothing says why, and a caller without the member grants sees no row menu, no Invite member control and one line saying who can (`Owners and Admins manage members.`). New: a `Change role` row action opening a sheet whose role select and add-on choices are exactly what that row allows, submitting through `assignRole`; the Activity line is written by the boundary, not the page. The phone card now carries the role with its add-ons (`Member + Key manager`) — and since Meridian's card shows three facts, Joined moved to the table only and the card keeps Last active.
23
+ - **The assistant never exceeds the person it acts for.** `src/lib/collection.ts` gains `pageOnlyFields` (fields an agent reads and never writes) and `writableFields(c)`; the members collection declares `pageOnlyFields: ['role', 'addOns']`, so `create_members` and `update_members` leave them out of their schemas and refuse them if sent anyway. `app/api/assistant/route.ts` registers a `view_<name>` tool only where the person satisfies that view's need, hands `members` in through the boundary, and forces an agent's member create to `role: 'Member'` with no add-ons (FR-12). `src/lib/assistant/scoped.ts` is new: `scopedQuery(collection, scope, name)` re-checks the same tenant and subject and the read grant at the moment a query runs, so a tool listed before a demotion refuses after it. Nothing to do for an adopter unless they declare their own `pageOnlyFields`.
24
+ - **The demo policy binds `members` through the boundary.** `src/data/access.ts`'s `bind(scope, 'members')` returns `membersFor(scope)`, and `app/(dashboard)/members/actions.ts` routes `inviteMember`, `setMemberStatus` and `removeMember` through it, exports `assignRole`, and no longer invalidates the tag itself. A person may no longer suspend or remove themselves, and a change that would leave the workspace with no active Owner is refused — including a status change through `update`, which is new.
25
+ - **The three paths that skipped the permission read go through the data layer.** `src/data/collections.ts` gains `customers` (the twelve sample rows, read-only) and `workspace` (one record: the name, address and time zone the Settings form shows), `/customers` and the customers view tool reach them through `read()`, so `customers:read` is asked on the same path as every other collection, and Settings saves through a new server action (`saveWorkspace`) that asks `ACTIONS['save-workspace'].needs`. The Analytics heatmap, hours and regions come from `analyticsFigures()` in `src/data/analytics.ts`, which refuses a scope without `requests:read`. `CUSTOMERS` is no longer re-exported by `src/data/sample.ts` — take it from `@/system/fixtures/sample-records`, or read the `customers` collection; `SettingsBody` gains optional `workspace` and `save` props.
26
+ - **A general action is shown only where the person may perform it.** `KeysView` takes `may: { create; revoke }` and `line`, and `RequestView` takes `mayReplay` and `replayLine`: each control is hidden — never drawn disabled, never left to fail on submit — when the person may not use it, and the page shows one line saying who can. `ACTIONS` in `src/data/features.ts` gains an optional `line`, and `src/views/no-access.tsx` exports `actionLine(id)`. Both view props are optional, so an existing direct caller of a view is unaffected.
27
+ - **Settings shows each section by what a person may do.** `SettingsBody` takes a required `may: { workspaceRead; workspaceUpdate; workspaceRemove }`, computed on the server page from the grant table. The Workspace section appears only to someone who may read it — read-only, with the line "Only an Owner can change the workspace." and no save bar, when they may not change it — and the Danger zone only to someone who may remove the workspace; a Member or Viewer no longer sees the Workspace section at all. Notifications, Appearance, Assistant and Agents and MCP stay for everyone.
28
+ - **A view tool carries its feature's need and asks it before it reads.** `ViewTool` gains a required `needs: Need`, so an adopter's own view tools must declare one (`needs: FEATURES.<id>.needs`) and call `await requireNeed(FEATURES.<id>.needs)` as the first statement of their `read`. Tool filtering by need is what keeps the assistant's `view_*` tools matching the person it acts for.
29
+ - **Every dashboard page asks its feature before it reads.** Each page opens with its gate, `/keys` drops its hand-written check, and `/requests/[id]`'s `generateMetadata` answers the feature title `Request` for a person who may not open it without reading the record. A page that awaits its gate is an async page, so `/`, `/analytics`, `/customers` and `/requests` stream their masthead with the gate rather than in the static shell, and `OverviewPage`/`AnalyticsPage` accept a defaulted props object.
30
+ - **`NoAccess` takes a feature, not a title, and is async.** `src/views/no-access.tsx` exports `NoAccess({ feature }: { feature: FeatureId })` — awaited — in place of `NoAccess({ title })`; a caller that passed `title` passes a `FeatureId` and awaits it.
31
+ - **A nav item carries its feature's need, so the rail asks one table.** `src/data/features.ts` is new: `FEATURES` (what each page needs), `ACTIONS` (what each control needs), and the `FeatureId` and `ActionId` types. Every item in `src/app.config.ts`'s `nav` now writes `needs: FEATURES.<id>.needs` by reference, and `NavItem` gains a required `needs: Need` — so every adopter's nav item must add one: import `FEATURES` and point each item at its feature, or write a `Need` (`'keys:read'`, `{ allOf: [...] }`, `'public'`) directly. The rail and the palette then show only the destinations the current person may reach: a Viewer sees eight of the ten (`/keys` and `/members` are gone) and every other role sees all ten. This is presentation only — a destination kept out of the rail is still reachable by its address, and every page keeps its own check.
32
+ - **The console's chrome takes the request as a promise.** `ConsoleRail` no longer takes `user`, and neither it nor `ConsolePalette` takes `only`; both take `access?: Promise<ChromeAccess | null>` — `chromeAccess()` from `src/data/access.ts` — which the layout passes without awaiting. Each piece of chrome waits for it in its own `<Suspense>` boundary, so the frame stays in the static shell and the person's own rail streams in the same response: nothing about the identity is drawn and then taken away. A product that passed `only` or `user` passes `access={chromeAccess()}` instead, or its own promise of the same shape — a console with no sign-in passes no `access` at all. `skills/zz-meridian/references/existing-project.md` shows the new call.
33
+
34
+ ### Fixed
35
+
36
+ - **The demo policy binds the customers and the workspace.** `DEMO_COLLECTIONS` in `src/data/access.ts` gains `customers` and `workspace`, so the `customers:read` and `workspace:*` grants the role table freezes are reachable through `read()` — without them `/customers` refused everybody, the Owner included.
37
+ - **`/keys` asks `keys:read` before it reads.** The keys page checks the caller's grant first: a Viewer gets a minimal NoAccess view (a normal 200 holding none of the keys' records) and a request with no session is redirected to `/sign-in`, both before any read. The new `src/views/no-access.tsx` holds that view.
38
+ - **A key records the person who made it.** Creating an API key stored the sample's own person as the owner, so a key Lucas Meyer created said Maya Chen made it. The owner is now the caller the policy names.
39
+ - **A closed browser check leaves no Chrome profile behind**: `scripts/lib/chrome.ts` removed a closed browser's profile from a timer that never ran when the script exited at once, so every `verify`, audit or shot left one in the temp folder. It now removes it as soon as the browser is closed — in the background, never on the caller's path. A suite closes its browser inside a hook, a hook has a ten-second ceiling, and a profile that has loaded many pages runs to tens of megabytes, so removing one synchronously timed a hook out on a runner (`Hook timed out in 10000ms`, `tests/deep-dom.test.ts`'s `afterAll`) after first failing with `ENOTEMPTY: directory not empty` when Chrome was still writing into it. That test now asserts the close does not do that work on the caller's path. A project brought in with `adopt` gets the fix with `update`.
40
+ - **A card is one class**: Card's look moved from a dozen utilities to `.card` in `base.css`, and the Data table's wrapper uses it too, so every page carries less HTML (`/health` from 102,225 to 98,154 bytes, `/requests` from 148,614 to 141,568). A utility on a card still overrides it.
41
+ - **Removing a member is undone, not confirmed.** Remove takes the person off the table at once with a toast offering Undo; the removal is sent when the toast closes (`UNDO_MS`, 8 s, now exported by the Toast) or at once if the page goes away first, and a refusal brings them back with its reason. Revoking an API key keeps its confirmation.
42
+ - **A page reached by a click no longer replays its arrival** (#19). On a client navigation, or when the router shows a page it kept, the shell marks its main area `data-still`: arrivals end at once in their final state, and the mark lifts after `dur-grow` + `dur-enter`, so a later change on the page (a new period) still animates. The first load of a visit arrives as before.
43
+ - **`navigate.ts` times real content** (#18): its `data` time no longer counts a skeleton's rows or the rows of a page the router keeps hidden, so a `loading.tsx` that holds a click for 300 ms or more now shows in `data`, where it read as the skeleton's 30 ms before.
44
+ - **A rail link answers a click before its page arrives** (#18): its icon becomes a Spinner while a page that is not prefetched is on the way (`useLinkStatus`). `cache.md`, `customize.md` and `standard.md` now say what a request-time read costs a navigation (React holds a shown fallback 300 ms or more) and to read through `read()` wherever the data allows.
45
+ - **The rail's marker measures after paint**, so opening the phone drawer costs one render and no forced layout; the current link draws the same pill until it has.
46
+ - **A trend chart's readout stays inside its chart**: it measures its own width, wraps a long label and keeps an extreme value whole; on a phone it pins to the plot's leading edge. Money axis ticks from $10,000 read at a glance (`$25K`, `$5.0B`). `scripts/shot.ts --point "<chart>" --at 0.6` captures a readout.
47
+ - **A sort or a page press is answered before the server is.** `useQueryState` returns a third value, `pending`, and shows the asked-for state at once (`useOptimistic` inside the navigation's transition); the DataTable takes `busy` (rows at 60% opacity, `aria-busy`) until the new rows land. A caller that destructures two values is unaffected.
48
+ - **Dates and figures reuse their Intl formatters**: `formatDate` and the money and count formatters build their `Intl` formatter once instead of on every call (200 dates: 209 ms to 2.5 ms), which shortens the first tap on Alerts or the drawer on a phone.
49
+
50
+ ### Breaking
51
+
52
+ Ten changes need a line from anyone updating a project. Each says what to do instead.
53
+
54
+ - **A nav item carries a required `needs`.** Every item in `src/app.config.ts`'s `nav` writes one: import `FEATURES` and point the item at its feature (`needs: FEATURES.<id>.needs`), or write the `Need` yourself (`'keys:read'`, `{ allOf: [...] }`, `'public'`). `node scripts/check.ts` now fails a malformed one, naming the href.
55
+ - **A view tool carries a required `needs`.** An adopter's own view tools declare `needs: FEATURES.<id>.needs` and call `await requireNeed(FEATURES.<id>.needs)` as the first statement of their `read`.
56
+ - **A dashboard page that awaits its gate is an async page.** `/`, `/analytics`, `/customers` and `/requests` stream their masthead with the gate rather than in the static shell, and `OverviewPage`/`AnalyticsPage` accept a defaulted props object. A test that asserted the static shell on one of those routes, or code calling a page component synchronously, updates the assertion or the call.
57
+ - **`NoAccess` takes a feature, not a title, and is awaited.** `NoAccess({ feature }: { feature: FeatureId })` replaces `NoAccess({ title })`: a caller that passed a title passes a `FeatureId` and awaits it.
58
+ - **`SettingsBody` takes a required `may`.** `{ workspaceRead, workspaceUpdate, workspaceRemove }`, computed on the server page from the grant table. A direct caller that passed nothing computes and passes it. A Member or Viewer no longer sees the Workspace section at all.
59
+ - **`ConsoleRail` and `ConsolePalette` take the request as a promise.** Both drop `user` and `only` for `access?: Promise<ChromeAccess | null>` — `chromeAccess()` from `src/data/access.ts` — which the layout passes without awaiting and each piece of chrome waits for in its own `<Suspense>` boundary. Pass `access={chromeAccess()}`, or your own promise of the same shape; a console with no sign-in passes none.
60
+ - **The demo policy's scope values change.** `subjectId` is a member id, not `'owner'`, and `authorizationKey` is `demo:<id>:<role>:<add-ons deduped and sorted>`, not `demo:1`. Code that hard-coded either reads the scope instead.
61
+ - **`ROLES` left `src/data/sample.ts`.** The role table is `src/data/roles.ts` (`MAIN_ROLES`, `ADD_ONS`, `GRANTS`, `whoCan`): an importer takes `MAIN_ROLES` from `@/data/roles`, and a member record now carries `role` and `addOns`.
62
+ - **`CUSTOMERS` is no longer re-exported by `src/data/sample.ts`.** Take the twelve rows from `@/system/fixtures/sample-records`, or read the `customers` collection through `read()`.
63
+ - **A person may no longer suspend or remove themselves, and the workspace always keeps one active Owner.** The member boundary refuses both — including a status change through `update`, which is new — with its frozen messages. A UI that offered either gets the refusal, and `memberAccess(id).reason` answers the row's own reason.
64
+
65
+ **An update to 0.13.0 reports one migration.** `nav-needs`: every item in a project's `nav` needs a `needs`, which the rail asks before it draws a destination. `update` names the change and the file it is in, and `--finalize` runs the project's gate, which fails, naming the href, until the change is made — `needs: 'public'` for a page everyone may open, or the page's own feature (`needs: FEATURES.<id>.needs`), as step 4 of the Start guide has it. A project whose `NavItem` type predates this release has to carry the property too. `adopt` writes `needs: 'public'` into the projects it sets up from now on, so only a project that predates this release makes the change by hand.
66
+
67
+ ## [0.12.3] · 2026-10-10
68
+
69
+ ### Fixed
70
+
71
+ - **The assistant and live-data walk-throughs, and the vitals' taps, reach controls inside web components.** `scripts/assistant.ts`, `scripts/live.ts` and `scripts/vitals.ts` looked for buttons, rows, menus and dialogs in the light DOM only, so on a product whose controls are web components the walk-throughs failed at their first press and vitals measured INP over no tap at all. They now search through open shadow roots with `scripts/lib/deep.ts`, which gains `deepQueryAll`; the live check also looks for a change every 50 ms, since a row a web component draws shows its text without a mutation the page's observer hears, so its timings are now the time to show rather than to the next mutation. Each vitals line ends with `taps: N`. `/deep-tap` pins the tap.
72
+ - **A browser suite reads a page once it has loaded.** `open()` in `scripts/lib/chrome.ts` waited a fixed time and for fonts (at most 4 s), so on a busy machine a suite could read a page still loading; it now also waits for the load event, up to 25 s. `/slow-load` pins it.
73
+ - **A preset whose values differ by theme holds in a nested scope.** Graphite (and jade in dark) set their lightness with ancestor selectors, which cannot say which theme scope is nearest, so a dark scope inside a light page drew graphite's light values: a dark tile on a dark ground. `tokens.css` now picks them with `@scope`, whose proximity rule makes the nearest theme win; the unscoped rules stay, at one attribute's specificity, for a browser without `@scope`. Regenerate with `pnpm tokens`; nothing to change in a product. `tests/deep-accent-scope.test.ts` renders the fourteen nestings in Chrome.
74
+ - **The command palette's field shows that it has focus.** The search band's hairline takes `accent` and its icon `accent-ink` while focus is inside; the audit failed the card without it.
75
+ - **A region or key name never breaks at its hyphen.** `keepHyphenated` moved from the Incident card into `@/components/base/text-roles`; the Banner and the Toast use it too, so "eu-central-1" no longer splits after "eu-central-". Tokens over 24 characters still wrap.
76
+ - **A narrow table's lead column takes the space it is given.** Under 512px of table the `grow` column takes the slack instead of a third, so a phone no longer cuts a route beside empty space.
77
+ - **A disabled Select option is disabled on both lines.** Its description takes `ink-disabled` with its label.
78
+ - **The request timeline's first tick reads `0ms`**, through `formatDuration`, like the ticks after it.
79
+ - **Every category in a column chart keeps its label**, cut to its column while a column is at least 36px wide; dates still thin out.
80
+ - **The assistant says it is working before its first words**: a spinner and "Working on an answer" until text arrives.
81
+ - **A narrow metric tile drops its comparison whole** instead of "vs previou…", and its finding wraps instead of being cut.
82
+ - **The search pill folds by the top bar's own width** (under 40rem), so a canvas narrowed by the assistant folds it too. The top bar is now a container.
83
+ - **A read-only form section keeps its values legible** in `ink-2` instead of `ink-disabled`.
84
+ - **A phone's record card reads its facts as one sentence**: the DataTable card is a wrapping row instead of a six-column grid, so a separator dot never lands alone on a line and a fact is never cut to fit a third.
85
+ - **Members shows the date its order follows**: Joined appears from 768px of table (Role now drops below 1024px and Team below 1152px), so the newest-first order is explained wherever the table has columns; on a phone the card reads "Joined 01 Oct 2026", in the same form as its "Active 07 Jul 2026" and as API keys' "Created 05 Mar 2026": members joined across two years, so a day without its year would be misread.
86
+ - **The Atlas home drops its numbered section labels** (01 to 05), a default Meridian's own register rules out.
87
+ - **A figure is never cut.** In a Metric tile or a Featured metric a number longer than nine characters is set at a glance with `fitFigure` ("9.9B", "$1.2M") instead of being clipped by its card; `splitFigure` keeps a compact amount's decimal in the number ("$1.2M", not "$1" and ".2M").
88
+ - **A day inside a sentence reads "1 Oct"**: `formatDay` in `src/lib/format-date.ts`; tables and axes keep `formatDate`'s "01 Oct".
89
+ - **The colour parser reads `lab()`.** A production build may ship an authored `oklch()` as `lab()`, which `src/lib/color.ts` could not parse; the contrast pairs the gate checks now live in `scripts/lib/contrast-pairs.ts`, so the Atlas measures the same ones.
90
+
5
91
  ## [0.12.2] · 2026-10-09
6
92
 
7
93
  ### Fixed
@@ -69,7 +69,7 @@ Write the status on the line under the title: `Status: beta`.
69
69
  ## Done means
70
70
 
71
71
  - Every state is specified and previewed: rest, hover, pressed, focus, selected, disabled, loading, empty, error.
72
- - It holds in both themes, all four accents and both densities, with contrast computed in each by `pnpm contrast`: text 4.5:1, focus rings, control outlines and chart marks 3:1. A new pair goes into `scripts/contrast.ts`.
72
+ - It holds in both themes, all four accents and both densities, with contrast computed in each by `pnpm contrast`: text 4.5:1, focus rings, control outlines and chart marks 3:1. A new pair goes into `scripts/lib/contrast-pairs.ts`, which the gate and the Atlas colour page both read.
73
73
  - It holds at 390, 768, 1024, 1280 and 1440px, and in an embed at 360 and 720px inline and in fullscreen.
74
74
  - Nothing scrolls sideways, nothing clips, and no label shrinks to fit (cut words instead).
75
75
  - Under reduced motion it shows its final state with nothing moving.
@@ -5,6 +5,7 @@ import { Freshness } from '@/components/patterns/freshness';
5
5
  import { PeriodSelect } from '@/components/patterns/period-select';
6
6
  import { Skeleton } from '@/components/ui/skeleton';
7
7
  import { OverviewBody } from '@/views/overview';
8
+ import { gate } from '@/data/access';
8
9
  import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
9
10
  import { overviewTool } from '@/views/tools';
10
11
  import { cache } from 'react';
@@ -20,8 +21,10 @@ type SearchParams = Promise<{ period?: string; day?: string }>;
20
21
  /** The page's one read, through the Overview tool (`src/views/tools.ts`), shared by the export and the body. */
21
22
  const readPeriod = cache((period: ReturnType<typeof parsePeriod>, day?: string) => overviewTool.read({ period, day: /^\d{4}-\d{2}-\d{2}$/.test(day ?? '') ? day : undefined }));
22
23
 
23
- /** The masthead renders at once; the period select, the export and the body read the address inside their own boundaries. */
24
- export default function OverviewPage({ searchParams }: { searchParams: SearchParams }) {
24
+ /** The page asks its feature before it renders — the rail hiding `/` is presentation, not this gate — then the masthead renders and the period select, the export and the body read the address inside their own boundaries. Next always passes the address; the default keeps the component callable with none, which a check that renders it bare relies on. */
25
+ export default async function OverviewPage({ searchParams }: { searchParams: SearchParams } = { searchParams: Promise.resolve({}) }) {
26
+ const denied = await gate('overview');
27
+ if (denied) return denied;
25
28
  return (
26
29
  <PageFrame
27
30
  kicker={<>{app.name} · {app.workspace}</>}
@@ -6,6 +6,7 @@ import { Freshness } from '@/components/patterns/freshness';
6
6
  import { PeriodSelect } from '@/components/patterns/period-select';
7
7
  import { Skeleton } from '@/components/ui/skeleton';
8
8
  import { AnalyticsBody } from '@/views/analytics';
9
+ import { gate } from '@/data/access';
9
10
  import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
10
11
  import { analyticsTool } from '@/views/tools';
11
12
  import { cache } from 'react';
@@ -17,8 +18,10 @@ export const metadata = { title: 'Analytics' };
17
18
 
18
19
  type SearchParams = Promise<{ period?: string }>;
19
20
 
20
- /** The masthead renders at once; the period select, the export and the body read the address inside their own boundaries. */
21
- export default function AnalyticsPage({ searchParams }: { searchParams: SearchParams }) {
21
+ /** The page asks its feature before it renders — the rail hiding `/analytics` is presentation, not this gate — then the masthead renders and the period select, the export and the body read the address inside their own boundaries. Next always passes the address; the default keeps the component callable with none, which a check that renders it bare relies on. */
22
+ export default async function AnalyticsPage({ searchParams }: { searchParams: SearchParams } = { searchParams: Promise.resolve({}) }) {
23
+ const denied = await gate('analytics');
24
+ if (denied) return denied;
22
25
  return (
23
26
  <PageFrame
24
27
  kicker={<>{app.name} · {app.workspace}</>}
@@ -1,14 +1,22 @@
1
1
  import { Suspense } from 'react';
2
2
  import { app } from '@/app.config';
3
3
  import { PageFrame } from '@/components/base/shell';
4
- import { CUSTOMERS } from '@/data/sample';
4
+ import { gate } from '@/data/access';
5
+ import { read } from '@/data/read';
6
+ import type { CustomerRecord } from '@/data/sample';
5
7
  import { CustomersView, InviteCustomer } from '@/views/customers';
6
8
  import { Busy, TableSkeleton, TilesSkeleton } from '../_loading';
7
9
 
8
10
  export const metadata = { title: 'Customers' };
9
11
 
10
- /** The masthead sits outside the boundary that reads the address (the table's filters), so a cold load has its title in the first HTML. */
11
- export default function CustomersPage() {
12
+ /**
13
+ * The page asks its feature before it renders; the rail hiding `/customers` is presentation, never this gate. The
14
+ * masthead then sits outside the boundary that reads the address (the table's filters), and the boundary is where the
15
+ * page reads its records.
16
+ */
17
+ export default async function CustomersPage() {
18
+ const denied = await gate('customers');
19
+ if (denied) return denied;
12
20
  return (
13
21
  <PageFrame
14
22
  kicker={<>{app.name} · {app.workspace}</>}
@@ -24,8 +32,18 @@ export default function CustomersPage() {
24
32
  </Busy>
25
33
  }
26
34
  >
27
- <CustomersView rows={CUSTOMERS} />
35
+ <Customers />
28
36
  </Suspense>
29
37
  </PageFrame>
30
38
  );
31
39
  }
40
+
41
+ /**
42
+ * The page's one read of the customers, through `read()`: `customers:read` is asked on the same path as every other
43
+ * collection, so a policy that does not bind the collection (or a person who does not hold the grant) refuses here before
44
+ * a row is drawn, exactly as it does for the members, the keys and the request log.
45
+ */
46
+ async function Customers() {
47
+ const { rows } = await read('customers');
48
+ return <CustomersView rows={rows as CustomerRecord[]} />;
49
+ }
@@ -1,4 +1,5 @@
1
1
  import { app } from '@/app.config';
2
+ import { gate } from '@/data/access';
2
3
  import { PageFrame } from '@/components/base/shell';
3
4
  import { Freshness } from '@/components/patterns/freshness';
4
5
  import { PackedHealthBody, SubscribeButton } from '@/views/health';
@@ -7,8 +8,10 @@ import { packServices } from '@/components/patterns/status-list/summarise';
7
8
 
8
9
  export const metadata = { title: 'Health' };
9
10
 
10
- /** Rendered from the Health tool's own read (`src/views/tools.ts`): the services and incidents the caller may read. */
11
+ /** Rendered from the Health tool's own read (`src/views/tools.ts`), once the page's feature is held: the rail's hiding is presentation, not this gate. */
11
12
  export default async function HealthPage() {
13
+ const denied = await gate('health');
14
+ if (denied) return denied;
12
15
  const { data } = await healthTool.read({});
13
16
  return (
14
17
  <PageFrame
@@ -3,9 +3,8 @@
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { updateTag } from 'next/cache';
5
5
  import { z } from 'zod';
6
- import { app } from '@/app.config';
7
6
  import { clock } from '@/data/collections';
8
- import { AccessDenied, can, collectionFor, resolveAccess } from '@/data/access';
7
+ import { AccessDenied, can, collectionFor, nameOf, resolveAccess } from '@/data/access';
9
8
  import { collectionTag } from '@/data/read';
10
9
  import { SCOPES } from '@/views/key-scopes';
11
10
  import type { ApiKey } from '@/data/sample';
@@ -19,6 +18,8 @@ const draft = z.object({
19
18
  /**
20
19
  * Creates a key and returns it with its full secret: the only moment the secret exists outside the caller's own copy.
21
20
  * What is stored is the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret.
21
+ * The key's owner is the caller — the person the policy names — so a key Lucas makes records Lucas, never the sample's
22
+ * own person; `subjectId` is the honest fallback when the policy names nobody.
22
23
  */
23
24
  export async function createKey(input: z.input<typeof draft>): Promise<ApiKey & { secret: string }> {
24
25
  const { name, env, scopes } = draft.parse(input);
@@ -27,7 +28,8 @@ export async function createKey(input: z.input<typeof draft>): Promise<ApiKey &
27
28
  const secret = `zzm_${env}_${randomBytes(16).toString('hex')}`;
28
29
  const hint = `zzm_${env}_…${secret.slice(-4)}`;
29
30
  const secretHash = createHash('sha256').update(secret).digest('hex');
30
- const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner: app.user.name, created: clock().toISOString(), lastUsed: null, hint, secretHash });
31
+ const owner = (await nameOf(scope)) ?? scope.subjectId;
32
+ const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner, created: clock().toISOString(), lastUsed: null, hint, secretHash });
31
33
  updateTag(collectionTag(scope.tenantId, 'keys'));
32
34
  return { ...(key as ApiKey), secret };
33
35
  }
@@ -1,11 +1,36 @@
1
+ import { gate, may } from '@/data/access';
2
+ import { ACTIONS } from '@/data/features';
1
3
  import { read } from '@/data/read';
2
4
  import { KeysView } from '@/views/keys';
5
+ import { actionLine } from '@/views/no-access';
3
6
  import type { ApiKey } from '@/data/sample';
4
7
  import { createKey, revokeKey } from './actions';
5
8
 
6
9
  export const metadata = { title: 'API keys' };
7
10
 
11
+ /**
12
+ * The keys page asks its feature first, before it reads anything: a person who may not see the keys gets the NoAccess
13
+ * view and no record of theirs, and a request with no session is sent to sign-in. The rail hides `/keys` from that
14
+ * person too, but hiding is presentation only — this check is the gate, and it runs before the read either way.
15
+ *
16
+ * After the gate it asks the action table what this person may do here, so the view draws Create key and Revoke only
17
+ * where they may be used. A Member opens this page (they may read keys) but may not create or revoke one, and the line
18
+ * says who can. The authority is a snapshot: a control shown here is still refused by the server action if it changed.
19
+ */
8
20
  export default async function KeysPage() {
21
+ const denied = await gate('keys');
22
+ if (denied) return denied;
9
23
  const { rows, observedAt } = await read('keys');
10
- return <KeysView rows={rows as ApiKey[]} now={observedAt} createKey={createKey} revokeKey={revokeKey} />;
24
+ const mayCreate = await may(ACTIONS['create-key'].needs);
25
+ const mayRevoke = await may(ACTIONS['revoke-key'].needs);
26
+ return (
27
+ <KeysView
28
+ rows={rows as ApiKey[]}
29
+ now={observedAt}
30
+ may={{ create: mayCreate, revoke: mayRevoke }}
31
+ line={mayCreate ? undefined : actionLine('create-key')}
32
+ createKey={createKey}
33
+ revokeKey={revokeKey}
34
+ />
35
+ );
11
36
  }
@@ -4,7 +4,7 @@ import { ConsolePalette, ConsoleRail } from '@/views/console-chrome';
4
4
  import { ShellTools } from '@/components/patterns/shell-tools';
5
5
  import { assistantConfig } from '@/lib/assistant/config';
6
6
  import { DEMO_NOW, ALERTS } from '@/data/sample';
7
- import { resolveAccess } from '@/data/access';
7
+ import { chromeAccess, resolveAccess } from '@/data/access';
8
8
  import { ConsoleLive } from '@/views/console-live';
9
9
 
10
10
  /** Every console page: the rail on the frame, the page on the canvas. Put your sign-in gate here. */
@@ -15,10 +15,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
15
15
  const assistant = connection().then(() => assistantConfig(process.env) !== null);
16
16
  // The scope key reaches the live provider the same way, as a promise it resolves behind its own boundary.
17
17
  const scope = resolveAccess().then((s) => `${s.tenantId}/${s.subjectId}`);
18
+ // The rail's destinations, the signed-in person and the View as group, one promise for both pieces of chrome: not
19
+ // awaited, so the frame is still the first thing streamed. An identity nobody can resolve settles on null, which the
20
+ // chrome draws as the placeholder person and no destination — never the sample person.
21
+ const access = chromeAccess().catch(() => null);
18
22
  return (
19
- <AppShell rail={<ConsoleRail />} tools={<ShellTools alerts={ALERTS} now={DEMO_NOW} />} assistant={assistant}>
23
+ <AppShell rail={<ConsoleRail access={access} />} tools={<ShellTools alerts={ALERTS} now={DEMO_NOW} />} assistant={assistant}>
20
24
  <ConsoleLive scope={scope}>{children}</ConsoleLive>
21
- <ConsolePalette />
25
+ <ConsolePalette access={access} />
22
26
  </AppShell>
23
27
  );
24
28
  }
@@ -1,6 +1,6 @@
1
1
  # Members page
2
2
 
3
- Who is in the workspace, what role and team they hold, and when they last used it: invite someone, suspend or reactivate them, or remove them.
3
+ Who is in the workspace, what role and team they hold, and when they last used it: invite someone, change their role and add-ons, suspend or reactivate them, or remove them.
4
4
 
5
5
  Status: beta
6
6
 
@@ -8,29 +8,34 @@ Status: beta
8
8
 
9
9
  | Row | Pattern | Console | Mobile |
10
10
  |---|---|---|---|
11
- | Masthead | PageFrame: kicker, "Members", one sentence, Invite member (primary) | One band | Stacked |
12
- | 1 | Data table: Name (avatar and name, grow), Email (from 1024px), Role (from 768px), Team (from 1280px), Status, Joined (from 1280px), Last active, and a row action menu | Columns | Cards: name and the menu, then status, team and last active |
11
+ | Masthead | PageFrame: kicker, "Members", one sentence, Invite member (primary), shown only to a person who may invite (`invitableRoles()` is not empty) | One band | Stacked |
12
+ | 1 | Data table: Name (avatar and name, grow), Email (from 896px), Role as a person reads it, add-ons and all (as a column from 512px, and on the card at every width below), Team (from 1440px), Status, Joined (from 768px), Last active, and a row action menu | Columns | Cards: name and the menu, then the role with its add-ons, status and last active |
13
+ | 2 | Roles & access matrix: a card whose table ticks each role, main roles first and add-ons after, against every non-public feature and then every action; a legend under it says that an add-on adds to a main role and that the member rules still apply. Generated from `GRANTS`, `FEATURES` and `ACTIONS`, never written by hand; at a phone's width the table scrolls inside its own frame | Columns, scrolling inside the card | The same table, scrolling inside the card |
13
14
  | Sheet | Invite a member: Name and Email (required), Role, Team; Cancel and Send invitation | From the right, 440px | From the bottom |
14
- | Dialog | Remove: "Remove {name}?", the consequence, and the suspend alternative; Cancel and Remove member (danger) | Centred | Bottom sheet |
15
+ | Sheet | Change {name}'s role: Role and Add-ons, offering only the choices the row's boundary allowed; a refusal reopens it with the reason in a critical Banner; Cancel and Save role | From the right, 440px | From the bottom |
16
+ | Toast | Remove: the person leaves the table at once and a toast says "{name} removed · They lose access when this closes" with Undo. The removal is sent when the toast closes (8 s, `UNDO_MS`), or at once if the page goes away first; Undo brings them back and sends nothing; a refusal brings them back with its reason | Bottom right | Top, full width |
15
17
 
16
- The row menu holds Suspend (Reactivate on a suspended member) and Remove.
18
+ A row the caller may not change draws no menu at all: its own reason where the restriction is the target's — the first rule in the boundary's frozen order that hides the action, such as "Only an Owner can change or remove an Owner." on an Owner's row read by an Admin — and nothing where the caller simply lacks the grant, which the page's one line above the table already says ("Owners and Admins manage members."). The row menu holds Change role, Suspend (Reactivate on a suspended member) and Remove, each shown only where the boundary allowed it; an invited row offers none until the server gives it a capability.
17
19
 
18
20
  ## States
19
21
 
20
22
  | State | What shows |
21
23
  |---|---|
22
24
  | Loading | `loading.tsx`: skeleton table rows; the members are read per request, so the prerendered shell holds none |
23
- | Default | Newest members first |
24
- | Invited | The status Badge in `accent`; Last active reads "Never" in `ink-3` |
25
+ | Default | Newest members first, as the Joined column beside them shows |
26
+ | Invited | The status Badge in `accent`; Last active reads "Never" in `ink-3`; no row actions |
25
27
  | Suspended | The status Badge in `warning` |
26
28
  | Name or email missing | The field's error under it; nothing is sent |
27
- | Changed | The page refreshes from the collection and a toast confirms: "Member suspended", "Member reactivated", "Member removed", "Invitation sent to {email}" |
28
- | Rejected | The table goes back to what it was. A refused invitation reopens the sheet with what was typed and a critical Banner, "Invitation not sent", with the reason; a refused status change or removal shows a critical toast with the reason |
29
+ | Changed | The page refreshes from the collection and a toast confirms: "Role changed", "Member suspended", "Member reactivated", "Invitation sent to {email}"; a removal's toast is its Undo, "{name} removed" |
30
+ | Rejected | The table goes back to what it was. A refused invitation reopens the sheet with what was typed and a critical Banner, "Invitation not sent", with the reason; a refused role change reopens the Change role sheet with the draft it was sent from and a critical Banner, "Role not changed", with the reason; a refused status change or removal shows a critical toast with the reason |
29
31
  | No members | The first-run empty state with Invite member |
32
+ | May not invite | No Invite member in the masthead and none in the empty state, because the boundary offers no invitable role |
30
33
 
31
34
  ## Data
32
35
 
33
- The `members` collection in `src/data/collections.ts`, read with `members.query({})` at request time. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status) and `members.remove`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
36
+ The `members` collection in `src/data/collections.ts`, read with `members.query({})` at request time. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status), `members.remove` and `assignRole`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
37
+
38
+ Every one of those writes passes `src/data/member-mutations.ts`, which re-resolves the caller, checks the operation's grant and refuses a change or removal of an Owner without `workspace:update`, one's own role or add-ons, suspending or removing oneself, and any change that would leave no active Owner. The page reads that boundary per row, once, and is handed only what it allows: `memberAccess(id)` gives the row's booleans, its role choices, its add-on choices and its reason, and `invitableRoles()` gives the roles an invitation may carry. The page decides nothing from a role of its own. A role or add-on change is one `assignRole` write, with its Activity line in the same step; the matrix in row 2 asks `whoCan` over the same `GRANTS` the boundary uses.
34
39
 
35
40
  ## Embed view
36
41
 
@@ -38,7 +43,7 @@ Not offered. Inviting and removing people are decisions for the console, not a c
38
43
 
39
44
  ## Surfaces
40
45
 
41
- - **Console**: as above. **Mobile**: cards, the sheet and the dialog rise from the bottom. **Embed**: not offered.
46
+ - **Console**: as above. **Mobile**: cards, the sheets rise from the bottom. **Embed**: not offered.
42
47
 
43
48
  ## Agents
44
49
 
@@ -46,7 +51,7 @@ The assistant reads and proposes changes here through Proposals: it adds, change
46
51
 
47
52
  ### What the agent reads
48
53
 
49
- Counts by status, role (with what each role may do) and team, everyone with their role, team, status and when they were last seen (25 at most; `query_members` finds the rest), and what code found: active members unseen for 60 days or more, unanswered invitations, and who can change the workspace. It says that "last active" is a console sign-in, not use of the API.
54
+ Counts by status, role (with what each role may do) and team, everyone with their role, add-ons, team, status and when they were last seen (25 at most; `query_members` finds the rest), and what code found: active members unseen for 60 days or more, unanswered invitations, and who can change the workspace. It says that "last active" is a console sign-in, not use of the API.
50
55
 
51
56
  ```text
52
57
  - 3 active members have not been seen for 60 days or more: Alice Moreno (Viewer, Support, 94 days), Felix Andersson (Viewer, Engineering, 75 days), Ravi Patel (Viewer, Support, 71 days).
@@ -57,5 +62,7 @@ Counts by status, role (with what each role may do) and team, everyone with thei
57
62
 
58
63
  ## Content
59
64
 
60
- - One verb through each flow: "Remove member", "Remove {name}?", "Member removed".
65
+ - One verb through each flow: "Remove", then "{name} removed" with Undo. A removal is undone, not confirmed: it is the change a person makes on purpose and regrets by accident. Revoking an API key stays a confirmation, because requests using the key fail the moment it is sent.
61
66
  - Statuses are words ("Invited", "Suspended"), never colour alone.
67
+ - A role reads the way a person holds it: "Member + Key manager", never "Member" with the add-on left to be inferred.
68
+ - The matrix names roles, not grants: the grant table stays in `src/data/roles.ts`, and a reader who wants it reads the page's own section instead of a list of permissions here.
@@ -1,29 +1,29 @@
1
1
  'use server';
2
2
 
3
- import { updateTag } from 'next/cache';
4
3
  import { clock } from '@/data/collections';
5
4
  import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
6
- import { collectionTag } from '@/data/read';
5
+ import { assignMemberRole, REFUSALS } from '@/data/member-mutations';
7
6
  import type { AnyCollection } from '@/lib/collection';
8
7
  import type { Result } from '@/views/members';
9
- import { ROLES, TEAMS, STATUSES, type Member } from '@/data/sample';
8
+ import { STATUSES, TEAMS, type Member } from '@/data/sample';
9
+ import { MAIN_ROLES, type AddOn, type MainRole } from '@/data/roles';
10
10
 
11
11
  const fail = (e: unknown): Result => ({
12
12
  ok: false,
13
- error: e instanceof Unauthenticated ? 'Sign in again to make this change.' : e instanceof Error ? e.message : 'The change did not go through.',
13
+ error: e instanceof Unauthenticated ? REFUSALS.signIn : e instanceof Error ? e.message : 'The change did not go through.',
14
14
  });
15
15
 
16
16
  /**
17
- * Run one change against the members collection: authorize it and every record it touches, write through the caller's
18
- * own collection, and only after the commit drop the tenant's cached reads. A rejection becomes a reason, never a thrown
19
- * error, and a refused change writes nothing, emits nothing and invalidates nothing.
17
+ * Run one change against the members collection: authorize it and every record it touches, then write through the
18
+ * caller's own collection — which is the member boundary, so the Owner, self and last-active-Owner rules apply and the
19
+ * tenant's cached reads drop only after the step commits. A rejection becomes a reason, never a thrown error, and a
20
+ * refused change writes nothing, emits nothing and invalidates nothing.
20
21
  */
21
22
  async function run(op: 'create' | 'update' | 'remove', ids: string[] | undefined, change: (c: AnyCollection) => Promise<unknown>): Promise<Result> {
22
23
  try {
23
24
  const scope = await resolveAccess();
24
- if (!(await can(scope, 'members', op, ids))) return { ok: false, error: 'You do not have permission to make this change.' };
25
+ if (!(await can(scope, 'members', op, ids))) return { ok: false, error: REFUSALS.noGrant };
25
26
  await change(collectionFor(scope, 'members'));
26
- updateTag(collectionTag(scope.tenantId, 'members'));
27
27
  return { ok: true };
28
28
  } catch (e) {
29
29
  return fail(e);
@@ -35,7 +35,9 @@ export async function inviteMember(input: { name: string; email: string; role: M
35
35
  const email = String(input.email ?? '').trim();
36
36
  if (!name) return { ok: false, error: 'Name the person you are inviting.' };
37
37
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false, error: 'Enter their work email, like ana@northwind.example.' };
38
- if (!ROLES.includes(input.role) || !TEAMS.includes(input.team)) return { ok: false, error: 'Choose a role and a team from the lists.' };
38
+ if (!MAIN_ROLES.includes(input.role) || !TEAMS.includes(input.team)) return { ok: false, error: 'Choose a role and a team from the lists.' };
39
+ // The boundary decides the role the caller may give — an Admin inviting an Owner is refused there with the frozen
40
+ // rule's message — so this path only shapes the invitation.
39
41
  return run('create', undefined, (c) => c.create!({ name, email, role: input.role, team: input.team, status: 'Invited', joined: clock().toISOString().slice(0, 10), lastActive: null }));
40
42
  }
41
43
 
@@ -47,3 +49,15 @@ export async function setMemberStatus(id: string, status: Member['status']): Pro
47
49
  export async function removeMember(id: string): Promise<Result> {
48
50
  return run('remove', [id], (c) => c.remove!([id]));
49
51
  }
52
+
53
+ /** A member's role and add-ons change only here: the boundary decides, and a row the caller may not change is refused. */
54
+ export async function assignRole(input: { id: string; role: MainRole; addOns: AddOn[] }): Promise<Result> {
55
+ try {
56
+ // The boundary re-reads the caller and the target and refuses with the frozen message, so this path does not
57
+ // decide it a second time.
58
+ await assignMemberRole(input);
59
+ return { ok: true };
60
+ } catch (e) {
61
+ return fail(e);
62
+ }
63
+ }
@@ -1,11 +1,28 @@
1
+ import { gate } from '@/data/access';
2
+ import { invitableRoles, memberAccess, type MemberRowAccess } from '@/data/member-mutations';
1
3
  import { read } from '@/data/read';
2
- import { inviteMember, removeMember, setMemberStatus } from './actions';
4
+ import { assignRole, inviteMember, removeMember, setMemberStatus } from './actions';
3
5
  import { MembersView } from '@/views/members';
4
6
  import type { Member } from '@/data/sample';
5
7
 
6
8
  export const metadata = { title: 'Members' };
7
9
 
10
+ /**
11
+ * The members page asks its feature before it reads: the rail hides `/members` too, but that is presentation, not the gate.
12
+ *
13
+ * After the read it asks the member boundary what this caller may do to each row, and which roles it may invite with —
14
+ * once per request, for the rows the page is about to show, never per row from the view. The boundary is the one every
15
+ * member write passes, so the actions this page draws and the ones the server accepts cannot disagree; authority can
16
+ * still change between the render and a submission, and then the submission is refused with its reason.
17
+ */
8
18
  export default async function MembersPage() {
19
+ const denied = await gate('members');
20
+ if (denied) return denied;
9
21
  const { rows, observedAt } = await read('members', { sort: { field: 'joined', dir: 'desc' } });
10
- return <MembersView rows={rows as Member[]} now={observedAt} actions={{ invite: inviteMember, setStatus: setMemberStatus, remove: removeMember }} />;
22
+ const members = rows as Member[];
23
+ const [access, roles] = await Promise.all([
24
+ Promise.all(members.map(async (m): Promise<readonly [string, MemberRowAccess]> => [m.id, await memberAccess(m.id)])).then((entries) => Object.fromEntries(entries)),
25
+ invitableRoles(),
26
+ ]);
27
+ return <MembersView rows={members} now={observedAt} actions={{ invite: inviteMember, setStatus: setMemberStatus, remove: removeMember, assignRole }} access={access} roles={roles} />;
11
28
  }