zz-meridian 0.5.0 → 0.6.1

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 (53) hide show
  1. package/dist/adopt.js +2 -2
  2. package/dist/create.js +2 -2
  3. package/dist/files.js +13 -4
  4. package/dist/reconcile.js +6 -3
  5. package/dist/replay.js +3 -3
  6. package/package.json +1 -1
  7. package/payload/CHANGELOG.md +25 -0
  8. package/payload/app/(dashboard)/keys/README.md +3 -2
  9. package/payload/app/(dashboard)/keys/actions.ts +11 -5
  10. package/payload/app/(dashboard)/members/README.md +1 -1
  11. package/payload/app/(dashboard)/requests/[id]/README.md +5 -3
  12. package/payload/app/(dashboard)/requests/[id]/actions.ts +34 -0
  13. package/payload/app/(dashboard)/requests/[id]/page.tsx +2 -1
  14. package/payload/app/system/(atlas)/page.tsx +1 -1
  15. package/payload/decisions/0010-the-adopter-contract.md +1 -1
  16. package/payload/docs/assistant.md +1 -1
  17. package/payload/docs/benchmark.md +1 -1
  18. package/payload/docs/distribution.md +5 -4
  19. package/payload/package.json +1 -1
  20. package/payload/scripts/gate.ts +2 -1
  21. package/payload/skills/zz-meridian/references/cache.md +1 -1
  22. package/payload/src/components/charts/timeline/README.md +1 -1
  23. package/payload/src/components/charts/timeline/index.tsx +1 -1
  24. package/payload/src/components/patterns/data-table/README.md +2 -2
  25. package/payload/src/components/patterns/data-table/index.tsx +1 -1
  26. package/payload/src/components/patterns/filter-bar/index.tsx +1 -1
  27. package/payload/src/components/patterns/prose/README.md +1 -1
  28. package/payload/src/components/patterns/shell-tools/index.tsx +1 -1
  29. package/payload/src/components/ui/breadcrumb/README.md +2 -2
  30. package/payload/src/components/ui/card/preview.tsx +1 -1
  31. package/payload/src/components/ui/copy-field/README.md +1 -0
  32. package/payload/src/components/ui/toast/README.md +1 -1
  33. package/payload/src/components/ui/toast/index.tsx +1 -1
  34. package/payload/src/data/access.ts +1 -1
  35. package/payload/src/data/collections.ts +8 -3
  36. package/payload/src/lib/collection.ts +4 -1
  37. package/payload/src/styles/base.css +10 -14
  38. package/payload/src/system/fixtures/sample-records.ts +11 -6
  39. package/payload/src/system/fixtures/sample.ts +2 -0
  40. package/payload/src/views/keys.tsx +22 -12
  41. package/payload/src/views/members.tsx +13 -7
  42. package/payload/src/views/not-found-address.tsx +1 -1
  43. package/payload/src/views/overview.tsx +2 -2
  44. package/payload/src/views/request.tsx +13 -4
  45. package/payload/tests/collection.test.ts +12 -0
  46. package/payload/tests/data-table.test.tsx +9 -1
  47. package/payload/tests/keys-actions.test.ts +15 -1
  48. package/payload/tests/keys-scopes-view.test.tsx +1 -1
  49. package/payload/tests/keys-secret-view.test.tsx +34 -0
  50. package/payload/tests/members-optimistic.test.tsx +9 -4
  51. package/payload/tests/request-replay.test.ts +35 -0
  52. package/payload/tests/request-view-replay.test.tsx +36 -0
  53. /package/payload/{.gitignore → gitignore} +0 -0
package/dist/adopt.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { PAYLOAD, VERSION, brandArgs, effectiveBrand, inside, installSkill, packageManager, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
11
+ import { VERSION, brandArgs, effectiveBrand, inside, installSkill, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
12
12
  import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
13
13
  import { FIXED, adoptSetOf, managedPaths } from './ownership.js';
14
14
  /** The template's dependencies a dashboard built from these files needs. */
@@ -161,7 +161,7 @@ https://github.com/zhixuan312/zz-meridian/blob/master/skills/zz-meridian/referen
161
161
  owned.push(rel);
162
162
  };
163
163
  for (const rel of set) {
164
- const raw = fs.readFileSync(path.join(PAYLOAD, rel));
164
+ const raw = payloadBytes(rel);
165
165
  own(rel, TS.test(rel) ? exactImports(rel, raw.toString('utf8'), known) : raw);
166
166
  }
167
167
  own('src/app.config.ts', exactImports('src/app.config.ts', appConfig(routes(root, dir)), known));
package/dist/create.js CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { managedPaths } from './ownership.js';
9
9
  import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
10
- import { PAYLOAD, VERSION, brandArgs, effectiveBrand, hasCommand, inside, installSkill, payloadFiles, run, sha256, writeIn, writeManifest } from './files.js';
10
+ import { VERSION, brandArgs, effectiveBrand, hasCommand, inside, installSkill, payloadBytes, payloadFiles, run, sha256, writeIn, writeManifest } from './files.js';
11
11
  export function create(o) {
12
12
  const root = path.resolve(o.dir);
13
13
  if (fs.existsSync(root) && fs.readdirSync(root).length)
@@ -22,7 +22,7 @@ export function create(o) {
22
22
  for (const rel of payloadFiles()) {
23
23
  if (rel.startsWith('skills/'))
24
24
  continue;
25
- writeIn(root, rel, fs.readFileSync(path.join(PAYLOAD, rel)));
25
+ writeIn(root, rel, payloadBytes(rel));
26
26
  }
27
27
  const name = brand.name ?? path.basename(root).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
28
28
  brand = effectiveBrand({ ...brand, name });
package/dist/files.js CHANGED
@@ -10,7 +10,16 @@ import { fileURLToPath } from 'node:url';
10
10
  /** The template snapshot this package carries, built from `git ls-files` at the release commit. */
11
11
  export const PAYLOAD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'payload');
12
12
  export const VERSION = JSON.parse(fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version;
13
- /** Every file under a payload folder, as posix paths relative to the payload. Symbolic links are never followed. */
13
+ /**
14
+ * npm renames a `.gitignore` inside an installed package to `.npmignore`, so the payload carries the template's as
15
+ * `gitignore`. `shippedPath` is the name a project path ships under; `projectPath` reads one back, and also reads a
16
+ * `.gitignore` as itself, which is how releases before 0.6.1 carry it.
17
+ */
18
+ export const shippedPath = (rel) => (rel === '.gitignore' ? 'gitignore' : rel);
19
+ export const projectPath = (rel) => (rel === 'gitignore' ? '.gitignore' : rel);
20
+ /** A payload file's bytes, by its project path. */
21
+ export const payloadBytes = (rel) => fs.readFileSync(path.join(PAYLOAD, shippedPath(rel)));
22
+ /** Every file under a payload folder, as project paths. Symbolic links are never followed. */
14
23
  export function payloadFiles(dir = '') {
15
24
  const out = [];
16
25
  const walk = (rel) => {
@@ -21,14 +30,14 @@ export function payloadFiles(dir = '') {
21
30
  if (e.isDirectory())
22
31
  walk(p);
23
32
  else if (e.isFile())
24
- out.push(p);
33
+ out.push(projectPath(p));
25
34
  }
26
35
  };
27
36
  if (fs.existsSync(path.join(PAYLOAD, dir)))
28
37
  walk(dir);
29
38
  return out.sort();
30
39
  }
31
- export const readPayload = (rel) => fs.readFileSync(path.join(PAYLOAD, rel), 'utf8');
40
+ export const readPayload = (rel) => payloadBytes(rel).toString('utf8');
32
41
  /**
33
42
  * A path inside `root`; anything that would resolve outside it is refused: `..`, an absolute path, or a symbolic link
34
43
  * on the way (a linked folder or file could carry a write somewhere else).
@@ -132,7 +141,7 @@ export function installSkill(root, record) {
132
141
  for (const base of ['.agents/skills/zz-meridian', '.claude/skills/zz-meridian']) {
133
142
  for (const f of files) {
134
143
  const rel = `${base}/${f.slice('skills/zz-meridian/'.length)}`;
135
- const content = fs.readFileSync(path.join(PAYLOAD, f));
144
+ const content = payloadBytes(f);
136
145
  writeIn(root, rel, content);
137
146
  if (record)
138
147
  record[rel] = sha256(content);
package/dist/reconcile.js CHANGED
@@ -6,10 +6,13 @@ import { upsertManagedBlock } from './context.js';
6
6
  export const TOOLCHAIN = ['typescript', '@types/node', '@types/react', '@types/react-dom', 'tailwindcss', '@tailwindcss/postcss', 'eslint', 'eslint-config-next', 'vitest', '@vitejs/plugin-react', 'jsdom', '@testing-library/react', '@testing-library/jest-dom'];
7
7
  export const MERIDIAN_SCRIPTS = ['typecheck', 'test', 'tokens', 'check', 'contrast', 'gate', 'audit', 'verify', 'brand', 'shot', 'interactions', 'keyboard', 'vitals'];
8
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
9
- /** The minimum version of `x.y.z`, `^x.y.z`, `~x.y.z` or `>=x.y.z`; null for anything else. */
9
+ /**
10
+ * The minimum version of `x.y.z`, `^x.y.z`, `~x.y.z` or `>=x.y.z`, where a missing minor or patch is 0 (`^24` is
11
+ * 24.0.0, `~5.9` is 5.9.0); null for anything else.
12
+ */
10
13
  function minimum(spec) {
11
- const m = typeof spec === 'string' ? /^(?:\^|~|>=)?(\d+)\.(\d+)\.(\d+)$/.exec(spec.trim()) : null;
12
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
14
+ const m = typeof spec === 'string' ? /^(?:\^|~|>=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/.exec(spec.trim()) : null;
15
+ return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null;
13
16
  }
14
17
  const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
15
18
  const file = (id, summary, instructions, path) => ({ id, summary, paths: [path], instructions, checks: [] });
package/dist/replay.js CHANGED
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { brandArgs, inside, run, sha256 } from './files.js';
10
+ import { brandArgs, inside, projectPath, run, sha256 } from './files.js';
11
11
  /** The package folder of the running CLI: the parent of its `dist/`. */
12
12
  export const runningRelease = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
13
  /** A command's failure as one message: the cause, then everything it printed. */
@@ -165,7 +165,7 @@ function replayTree(pkgRoot, route, brand, scratch, moduleType) {
165
165
  export function replay(pkgRoot, route, brand, scratch, moduleType = false) {
166
166
  return replayTree(pkgRoot, route, brand, scratch, moduleType).hashes;
167
167
  }
168
- /** Every regular file under `<release>/payload` as posix paths, symbolic links never followed. */
168
+ /** Every regular file under `<release>/payload` as posix project paths, symbolic links never followed. */
169
169
  export function payloadList(release) {
170
170
  const base = path.join(release, 'payload');
171
171
  const out = [];
@@ -175,7 +175,7 @@ export function payloadList(release) {
175
175
  if (e.isDirectory())
176
176
  walk(p);
177
177
  else if (e.isFile())
178
- out.push(p);
178
+ out.push(projectPath(p));
179
179
  }
180
180
  };
181
181
  if (fs.existsSync(base))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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,31 @@
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.6.1] · 2026-10-06
6
+
7
+ ### Fixed
8
+
9
+ - **A created project has its `.gitignore`.** npm renames a `.gitignore` inside an installed package to `.npmignore`, so every project made with `create` since 0.3.0 had a `.npmignore` and no `.gitignore`, and `git add` took in `node_modules`, `.next` and `.env.local`. The package now carries the template's as `payload/gitignore`, and `create` writes it as `.gitignore`. In a project created before 0.6.1, run `git mv .npmignore .gitignore`, then `git rm -r --cached node_modules .next` for whatever was committed; `update` leaves the file alone, since it is the team's.
10
+ - **`update` runs from the registry.** It compares the running package with the published one, and npm's rename made the two differ, so `npx zz-meridian@<version> update` refused itself with "the running package differs from zz-meridian@<version> on the registry" since 0.5.0. 0.6.0 is on npm with this defect and has no tag or GitHub Release: update with 0.6.1.
11
+ - **A gate step whose tool is not installed says so.** `scripts/gate.ts` printed a `TypeError` in place of the missing command.
12
+
13
+ ## [0.6.0] · 2026-10-06
14
+
15
+ ### Changed
16
+
17
+ - **An API key is shown in full once, when it is created.** The keys collection stores each key's `hint` (its prefix and last four characters, `zzm_live_…f601`) and `secretHash` (the secret's SHA-256), never the secret. `createKey` returns the new key with its `secret` for the creation banner, and the table lists every key by its hint, with no Reveal or Copy. Breaking: `ApiKey` has `hint` and `secretHash` in place of `secret`, and `KeysView`'s `createKey` returns `ApiKey & { secret: string }`. A created project's own keys page and fixtures keep the old shape until the team takes the release's versions.
18
+ - **Links are never underlined.** Hover is a colour change and focus is the focus ring every control has. `.link` (accent ink in a sentence) turns `ink` on hover; `.row-link` (a destination in a list) takes `accent-ink`; a Data table's linked row takes the `fill-hover` tint and its title keeps its colour. Before, both classes drew a 1px line across the link's box, which under a link holding a second line read as a rule across its whole width.
19
+ - **Replay sends a failed request again.** On a 5xx or 429 request's page, Replay calls `replayRequest` (`app/(dashboard)/requests/[id]/actions.ts`): it authorizes the write, creates a new request with the same method, route, customer, region and model and a `replayOf` naming the original, and refreshes the request reads. A toast names the new request with Open, and its page links back. It used to toast "Replay queued" and send nothing. The requests collection allows `create` as a page-only operation, so the assistant cannot replay. `arrayCollection` takes `derive`, which works out a row's `derived` fields on every create and change. The request page no longer shows a fixed "API key" it did not know. Breaking: `RequestView` takes `replay`.
20
+ - **A form in an open sheet says why it failed inside the sheet.** A refused invitation reopens the Members sheet with what was typed and a critical Banner, "Invitation not sent", with the reason. A refused key reads "Key not created" at the top of the Create a key sheet. Neither sends a toast over the sheet's own buttons. Refused changes made from a table row still toast.
21
+
22
+ ### Fixed
23
+
24
+ - **`update` reads a dependency written as `^24` (or `~5.9`) as a version.** It used to call any range without a minor and patch "a specification Meridian cannot compare". Every created project carries `"@types/node": "^24"`, so each update asked the team to replace `^24` with "at least ^24" before it could complete. A missing minor or patch now counts as 0.
25
+
26
+ ### Release
27
+
28
+ - **The release's timed default verify allows 180 s on the shared GitHub 4-CPU runner.** The same commit measured 111 s on one runner CPU and 152 s on another, so 120 s stopped a release on the draw of the machine. The default verify must still pass in full; 120 s remains the target on an adopter's own machine. The stakeholder's decision.
29
+
5
30
  ## [0.5.0] · 2026-10-06
6
31
 
7
32
  ### Added
@@ -10,7 +10,7 @@ Status: beta
10
10
  |---|---|---|---|
11
11
  | Masthead | PageFrame: kicker, "API keys", one sentence, Create key (primary) | One band | Stacked |
12
12
  | 1 | Banner (accent), only right after a key is created: "Copy {name} now", the full key in a Copy field, dismissable | Full width | Full width |
13
- | 2 | Data table: Name with its environment, Key (masked Copy field, from 768px), Scopes (two and "+N", from 1280px), Created by (from 1024px), Created, Last used, and Revoke | Columns | Cards: name and environment, "Created …" and "Used …", Revoke |
13
+ | 2 | Data table: Name with its environment, Key (its hint, `zzm_live_…f601`, in mono, from 768px), Scopes (two and "+N", from 1280px), Created by (from 1024px), Created, Last used, and Revoke | Columns | Cards: name and environment, "Created …" and "Used …", Revoke |
14
14
  | Sheet | Create a key: Name (required), Environment (Live or Test), Scopes (checkboxes); Cancel and Create key | From the right, 440px | From the bottom |
15
15
  | Dialog | Revoke: "Revoke {name}?", what fails and that it cannot be undone, when it was last used; Cancel and Revoke key (danger) | Centred | Bottom sheet |
16
16
 
@@ -22,13 +22,14 @@ Status: beta
22
22
  | Default | The collection's keys, in its order |
23
23
  | Just created | The accent Banner with the only full view of the secret; a toast "Key created" |
24
24
  | Name missing | The Name field's error: "Name the key after what uses it" |
25
+ | Not created | A critical Banner at the top of the open sheet, "Key not created", with the server's reason; the sheet keeps what was typed |
25
26
  | Revoked | The row leaves; a toast "Key revoked" |
26
27
  | No keys | The first-run empty state with Create key |
27
28
  | Never used | "Never" in `ink-3` |
28
29
 
29
30
  ## Data
30
31
 
31
- The keys come from `collections.keys` (`src/data/collections.ts`), read on every request. Create and Revoke are server actions (`actions.ts`) that call `keys.create` and `keys.remove`, then the view refreshes the route; a rejected action shows a critical toast and changes nothing. The secret is generated on the server and shown once, in the banner. A secret is always masked in the table; Copy copies the real value without revealing it.
32
+ The keys come from `collections.keys` (`src/data/collections.ts`), read on every request. Create and Revoke are server actions (`actions.ts`) that call `keys.create` and `keys.remove`, then the view refreshes the route. A rejected create is said inside the sheet; a rejected revoke shows a critical toast and puts the row back. The secret is generated on the server and returned once, to the banner, which is the only place it is ever shown. The collection stores the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret, so the table shows the hint and nothing in the browser or the cache can reveal or copy a listed key. To replace a key, create a new one and revoke the old.
32
33
 
33
34
  ## Embed view
34
35
 
@@ -1,6 +1,6 @@
1
1
  'use server';
2
2
 
3
- import { randomBytes } from 'node:crypto';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
4
  import { updateTag } from 'next/cache';
5
5
  import { z } from 'zod';
6
6
  import { app } from '@/app.config';
@@ -16,14 +16,20 @@ const draft = z.object({
16
16
  scopes: z.array(z.enum(SCOPES), { error: 'Choose only the scopes a key can carry.' }).min(1, 'Give the key at least one scope.'),
17
17
  });
18
18
 
19
- /** Creates a key and returns it: the only moment its full secret leaves the server for a banner. */
20
- export async function createKey(input: z.input<typeof draft>): Promise<ApiKey> {
19
+ /**
20
+ * Creates a key and returns it with its full secret: the only moment the secret exists outside the caller's own copy.
21
+ * What is stored is the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret.
22
+ */
23
+ export async function createKey(input: z.input<typeof draft>): Promise<ApiKey & { secret: string }> {
21
24
  const { name, env, scopes } = draft.parse(input);
22
25
  const scope = await resolveAccess();
23
26
  if (!(await can(scope, 'keys', 'create'))) throw new AccessDenied();
24
- const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner: app.user.name, created: clock().toISOString(), lastUsed: null, secret: `zzm_${env}_${randomBytes(16).toString('hex')}` });
27
+ const secret = `zzm_${env}_${randomBytes(16).toString('hex')}`;
28
+ const hint = `zzm_${env}_…${secret.slice(-4)}`;
29
+ 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 });
25
31
  updateTag(collectionTag(scope.tenantId, 'keys'));
26
- return key;
32
+ return { ...(key as ApiKey), secret };
27
33
  }
28
34
 
29
35
  export async function revokeKey(id: string): Promise<void> {
@@ -25,7 +25,7 @@ The row menu holds Suspend (Reactivate on a suspended member) and Remove.
25
25
  | Suspended | The status Badge in `warning` |
26
26
  | Name or email missing | The field's error under it; nothing is sent |
27
27
  | Changed | The page refreshes from the collection and a toast confirms: "Member suspended", "Member reactivated", "Member removed", "Invitation sent to {email}" |
28
- | Rejected | A critical toast with the reason; the table does not change |
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
29
  | No members | The first-run empty state with Invite member |
30
30
 
31
31
  ## Data
@@ -9,7 +9,7 @@ Status: beta
9
9
  | Row | Pattern | Console | Mobile |
10
10
  |---|---|---|---|
11
11
  | Masthead | Detail head: "← Requests", the ID in mono with its status Badge, facts (method and route, latency, customer, when), More actions, Copy as cURL, and Replay as the primary when the request failed (5xx or 429) | One band | Facts wrap; actions on their own row |
12
- | 1 | Row `2/3`: Request facts (Key value, two columns: received, endpoint, latency, response size, customer linking to their requests, region, API key, cost, and model and tokens only on the routes that call a model; the ID and status are in the head, not repeated) · Trace (one bar per phase on a shared scale: gateway, auth, queue, the route's own work, the response) | Side by side | Stacked |
12
+ | 1 | Row `2/3`: Request facts (Key value, two columns: received, endpoint, latency, response size, customer linking to their requests, region, the request it replays when it is a replay, cost, and model and tokens only on the routes that call a model; the ID and status are in the head, not repeated) · Trace (one bar per phase on a shared scale: gateway, auth, queue, the route's own work, the response) | Side by side | Stacked |
13
13
  | 2 | Request body and Response body side by side, each a card with a code block (wrapping, never scrolling sideways) and Copy | 1/2 + 1/2 | Stacked |
14
14
 
15
15
  ## States
@@ -19,13 +19,15 @@ Status: beta
19
19
  | Loading | `loading.tsx`: the facts beside the trace, then two body cards, as skeletons. The id is read inside that boundary |
20
20
  | Success | Status `neutral`; the route's own phase (Model, Search, Storage or Parse) in the accent; no primary action |
21
21
  | Rate limited, rejected, not found | Status `warning`; the trace ends at the limit, the validation or the lookup phase, in `critical`; tokens and cost read "None, refused"; the response is a short error body |
22
- | No request body (GET, DELETE) | The Request body card says "No body: a GET carries everything it needs in its address." in a dashed frame; Copy as cURL sends no `-d`; Replay says it runs again without "with the same body" |
22
+ | No request body (GET, DELETE) | The Request body card says "No body: a GET carries everything it needs in its address." in a dashed frame; Copy as cURL sends no `-d` |
23
23
  | Failed | Status `critical`; the route's own phase and the error phase in `critical`; Replay is the primary |
24
+ | Replayed | A toast "Replayed: 200 OK" (or 201) naming the new request, with Open; the new request leads the list and its page shows "Replay of" linking back |
25
+ | Replay refused | A critical toast "Not replayed" with the reason; nothing is written |
24
26
  | Unknown ID | The page renders the console's Not found screen itself (`MissingPage`, see `app/not-found/README.md`), with `noindex`, rather than throwing `notFound()`, so the screen is in the first HTML |
25
27
 
26
28
  ## Data
27
29
 
28
- The request from the `requests` collection (`src/data/collections.ts`), queried by id; its trace and payloads from `traceOf()` and `payloadsOf()` in `src/system/fixtures/sample-records.ts`. Phases add up to the request's latency; the axis shows 0, half and the total.
30
+ The request from the `requests` collection (`src/data/collections.ts`), queried by id. Replay is the page's Server Action (`actions.ts`, `replayRequest`): it authorizes, reads the request, and creates a new one with the same method, route, customer, region and model, naming the one it replays, then refreshes the tenant's request reads. A product calls its gateway there with the stored request; the sample's gateway answers a replay of a 5xx or a 429, the only requests that offer Replay, with the route's success. Its its trace and payloads from `traceOf()` and `payloadsOf()` in `src/system/fixtures/sample-records.ts`. Phases add up to the request's latency; the axis shows 0, half and the total.
29
31
 
30
32
  ## Embed view
31
33
 
@@ -0,0 +1,34 @@
1
+ 'use server';
2
+
3
+ import { updateTag } from 'next/cache';
4
+ import { clock } from '@/data/collections';
5
+ import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
6
+ import { collectionTag } from '@/data/read';
7
+ import type { RequestRow } from '@/data/sample';
8
+
9
+ export type ReplayResult = { ok: true; id: string; status: number } | { ok: false; error: string };
10
+
11
+ /**
12
+ * Sends a logged request again: the same method, route, customer, region and model, as a new request that names the one
13
+ * it replays. A product calls its gateway here with the stored request; the sample's gateway answers a replay of a
14
+ * temporary failure (a 5xx or a 429, the only requests that offer Replay) with the route's success. Authorized like any
15
+ * write, then the tenant's request reads are refreshed.
16
+ */
17
+ export async function replayRequest(id: string): Promise<ReplayResult> {
18
+ try {
19
+ const scope = await resolveAccess();
20
+ if (!(await can(scope, 'requests', 'create'))) return { ok: false, error: 'You do not have permission to replay requests.' };
21
+ const requests = collectionFor(scope, 'requests');
22
+ const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined;
23
+ if (!original) return { ok: false, error: `No request with id ${id}.` };
24
+ const status = original.method === 'POST' ? 201 : 200;
25
+ const replay = await requests.create!({
26
+ at: clock().toISOString(), method: original.method, route: original.route, status, latency: original.latency,
27
+ customer: original.customer, region: original.region, bytes: original.bytes, model: original.model, replayOf: original.id,
28
+ });
29
+ updateTag(collectionTag(scope.tenantId, 'requests'));
30
+ return { ok: true, id: String(replay.id), status };
31
+ } catch (e) {
32
+ return { ok: false, error: e instanceof Unauthenticated ? 'Sign in again to replay this request.' : e instanceof Error ? e.message : 'The request was not replayed.' };
33
+ }
34
+ }
@@ -2,6 +2,7 @@ import { read } from '@/data/read';
2
2
  import { FEATURED_REQUEST_IDS, traceOf, payloadsOf, type RequestRow } from '@/data/sample';
3
3
  import { MissingPage } from '@/views/missing-page';
4
4
  import { RequestView } from '@/views/request';
5
+ import { replayRequest } from './actions';
5
6
 
6
7
  export function generateStaticParams() {
7
8
  return FEATURED_REQUEST_IDS.map((id) => ({ id }));
@@ -22,5 +23,5 @@ export default async function RequestPage({ params }: { params: Promise<{ id: st
22
23
  const { request: r, observedAt } = await byId(id);
23
24
  // Rendered here rather than thrown, so the screen is in the first HTML (see MissingPage).
24
25
  if (!r) return <MissingPage />;
25
- return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} now={observedAt} />;
26
+ return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} now={observedAt} replay={replayRequest} />;
26
27
  }
@@ -129,7 +129,7 @@ export default function AtlasHome() {
129
129
  <p className="t-kicker">{release.version} · {release.date}</p>
130
130
  <p className="t-small mt-4 max-w-[48ch] text-ink-2">{release.summary}</p>
131
131
  {changelog ? (
132
- <Link href={changelog} className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-accent-ink hover:underline">
132
+ <Link href={changelog} className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-accent-ink hover:text-ink">
133
133
  Full changelog <ArrowRight className="size-3.5" />
134
134
  </Link>
135
135
  ) : null}
@@ -94,7 +94,7 @@ this is the reason they agree.
94
94
  - HTML caps are `/requests` 150 KiB and `/health` 100 KiB. The stakeholder set them after measurement showed no variant
95
95
  kept the page title instant within 130 KiB.
96
96
  - Weekly is weekly, release is release, and nothing runs twice. The release gates, each once, on the default verify
97
- from a clean `.next` on the GitHub-hosted ubuntu-24.04 4-CPU runner (at most 120 s, with its browser smoke; the gate
97
+ from a clean `.next` on the GitHub-hosted ubuntu-24.04 4-CPU runner (at most 120 s, raised to 180 s in 0.6.0 because the runner's CPU varies, with its browser smoke; the gate
98
98
  and its unit tests run inside it) and on the consumer smoke from the tarball: adopt, create with its default verify,
99
99
  and all four published origins updated through finalize. After publishing it checks the registry serves the tested
100
100
  tarball (equal sha256) with provenance, and tags last. A weekly workflow runs `verify --full --perf` (the perf part a
@@ -93,7 +93,7 @@ Add it to `collections`. For a real store, write the same `Collection` yourself:
93
93
 
94
94
  Two fields on a collection keep the assistant in bounds:
95
95
 
96
- - `pageOnly`: operations only a page may perform. The assistant never gets a tool for them. In the sample, creating an API key is `pageOnly`, because the secret is shown once, on the page.
96
+ - `pageOnly`: operations only a page may perform. The assistant never gets a tool for them. In the sample, creating an API key is `pageOnly`, because the secret is shown once, on the page, and so is replaying a request, a person's action on its page.
97
97
  - `hidden`: fields only a page may see. They are left out of every tool's input and of every query result. The sample hides a key's `secret`.
98
98
 
99
99
  Per collection the assistant gets `query_<name>`, and `create_<name>`, `update_<name>` and `remove_<name>` for what is allowed and not `pageOnly`. A query takes `where` conditions (`eq`, `ne`, `gt`, `lt`, `contains`, `in`), a `sort` and a `limit`. A field the collection does not have, or hides, is rejected, and a field with no value matches only `ne`. `arrayCollection` parses what is created or changed with the collection's `fields`, so the schema is where a record's rules live (the sample's members need a name and a valid email).
@@ -11,7 +11,7 @@ Meridian held to the bar of award-winning sites (Awwwards, Webby, FWA) and of th
11
11
  | Visual hierarchy | Strong | One protagonist per page. Hierarchy ratio (largest text over median) on analytical pages: Overview 6×, Health 6.5×, Requests 4.6×, Analytics 4.6×; standalone screens 7.2–7.8× |
12
12
  | Colour | Strong | A night-sky neutral leaning indigo; one accent that is two numbers (hue, chroma), four presets; status trio reserved; six categorical chart slots validated for colour-vision deficiency (worst adjacent ΔE 10.2 light, 8.7 dark). 328 contrast pairs pass in every theme and accent |
13
13
  | Motion | Good | Three jobs (arrive, answer, float), four durations, three curves; data arrives once (a line draws, an area reveals, bars grow); nothing loops but a live dot and a skeleton; reduced motion shows the final state at once |
14
- | Micro-interaction | Good | The rail's pill springs between items; the Segmented thumb slides; a lit edge fades in under the pointer; an underline draws in on row links; the Meridian cursor answers pointer, touch and arrow keys with a readout on every chart and tile. Every control on every page is pressed by `scripts/interactions.ts`, with taps at 390px; none does nothing |
14
+ | Micro-interaction | Good | The rail's pill springs between items; the Segmented thumb slides; a lit edge fades in under the pointer; a linked row takes a hover fill and a link changes colour, never underlined; the Meridian cursor answers pointer, touch and arrow keys with a readout on every chart and tile. Every control on every page is pressed by `scripts/interactions.ts`, with taps at 390px; none does nothing |
15
15
  | Responsive | Strong | Zero sideways scroll, zero clipped text and no table wider than its card at 390–1440px on every page (the last was missed until 2026-10-03: see below); tables become card lists under 768px; phone tiles put the sparkline beside the figure; dialogs become bottom sheets |
16
16
  | Originality | Strong | The Meridian (one time cursor shared by every chart, tile and the model); three surfaces including MCP Apps with a working host simulator; two operators with consent, provenance and handoff built into components |
17
17
 
@@ -150,8 +150,7 @@ For 0.5.0 they are:
150
150
  | `live-provider` | `src/data/collections.ts` without `src/data/live-actions.ts`, or a dashboard layout with neither `LiveProvider` nor `ConsoleLive` | `live.md`, "The live starters" and "Refreshing": add the five files and the provider |
151
151
  | `authorized-endpoints` | `app/api/assistant/route.ts`, or an `actions.ts` Server Action file, without a `resolveAccess` call | `cache.md`, "The assistant route": call `resolveAccess()` first and bring `tools.ts` and `respond.ts` over |
152
152
 
153
- The consumer smoke (`--update`) resolves the nine that touch source on all four published origins, a project adopted and a project created
154
- with 0.3.0 and with 0.4.0, as `references/update.md` tells an agent: a created origin, whose files are the template's,
153
+ The consumer smoke (`--update`) updates a project adopted and a project created with each published release from 0.3.0 on. It resolves the nine migrations that touch source on the 0.3.0 and 0.4.0 origins, as `references/update.md` tells an agent: a created origin, whose files are the template's,
155
154
  takes the release's versions of the affected team-owned files and the files that arrived with them; an adopted origin,
156
155
  whose pages are the team's own, makes the minimal edit the instruction names. It then records the resolutions and
157
156
  finalizes, so the gate and the build run on the result, and checks the team's own files kept their bytes. A 0.4.0
@@ -204,7 +203,8 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
204
203
  `cli/package.json`'s and the tag must be unused.
205
204
  2. **Gates** (ubuntu-24.04, 4 CPUs, the reference profile), each run once: the package's own tests (`tests/cli-*`, which
206
205
  no adopted or created project has); the template's default `verify` from a clean `.next` through
207
- `cli/scripts/release-check.ts`, within 120 s and with its browser smoke (the gate and the template's unit tests run
206
+ `cli/scripts/release-check.ts`, within 180 s on the shared runner (whose CPU varies: one commit measured 111 s on one
207
+ and 152 s on another) and with its browser smoke (the gate and the template's unit tests run
208
208
  inside it); and the consumer smoke from the built tarball (step 4). The full, perf and recovery suites are never
209
209
  run here: they run weekly (below).
210
210
  3. **Pack and assert, before anything is irreversible**: `npm pack` in `cli/`, then on the tarball: the bin has its
@@ -214,7 +214,8 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
214
214
  - `adopt` into `cli/test/fixture-next-app` (a minimal App Router app with one page and its own stylesheet), then
215
215
  install, `tsc --noEmit` and `next build`, all green. This is the test that keeps the Route A list complete.
216
216
  - `create` into a clean folder, then its default `pnpm verify`. The smoke quotes the coverage line.
217
- - `update` from all four published origins (0.3.0 and 0.4.0, adopted and created) through finalize, as above.
217
+ - `update` from every published origin since 0.3.0 (adopted and created) through finalize, as above. A 0.5.0 origin
218
+ reports no migration; a created one with nothing to resolve completes in the update itself.
218
219
  5. **Publish** the tarball with `npm` 11.5.1 or newer through trusted publishing (OIDC), with `--provenance`. `pnpm
219
220
  publish` does not perform the OIDC exchange.
220
221
  6. **The registry's package**: `npx zz-meridian@<version> --version` answers the version, the registry's tarball has the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
@@ -36,7 +36,8 @@ for (const [name, [cmd, ...args]] of STEPS) {
36
36
  const ok = r.status === 0;
37
37
  console.log(`${ok ? 'ok ' : 'FAIL'} ${name} (${((Date.now() - t) / 1000).toFixed(1)}s)`);
38
38
  if (!ok) {
39
- console.log((r.stdout + r.stderr).trim().split('\n').slice(-40).join('\n'));
39
+ // A tool that could not start (not installed) has no output, only the error.
40
+ console.log(`${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? `${cmd}: ${r.error.message}` : ''}`.trim().split('\n').slice(-40).join('\n'));
40
41
  process.exit(1);
41
42
  }
42
43
  }
@@ -141,7 +141,7 @@ const DEMO: AccessScope = { tenantId: 'demo', subjectId: 'owner', authorizationK
141
141
  const DEMO_COLLECTIONS: Record<string, { collection: AnyCollection; ops: readonly Operation[] }> = {
142
142
  members: { collection: members, ops: ['read', 'create', 'update', 'remove'] },
143
143
  keys: { collection: keys, ops: ['read', 'create', 'remove'] },
144
- requests: { collection: requests, ops: ['read'] },
144
+ requests: { collection: requests, ops: ['read', 'create'] },
145
145
  };
146
146
 
147
147
  export const { resolveAccess, collectionFor, can } = accessFrom({
@@ -33,7 +33,7 @@ Leave `from` and `to` out and the span is the items' own extent. A roadmap usual
33
33
  | State | Spec |
34
34
  |---|---|
35
35
  | Rest | as above |
36
- | Linked | an item with `href` makes its label a link (`row-link`: an underline draws in on hover) |
36
+ | Linked | an item with `href` makes its label a link (`row-link`: it takes the accent on hover, with no underline) |
37
37
  | Outside the span | a bar is clipped to the span and the side that continues is squared off (`rounded-l-none` when it started before `from`, `rounded-r-none` when it ends after `to`), so work that runs past the window does not read as work that began at its edge; Today is drawn only when it falls inside |
38
38
 
39
39
  ## Behaviour
@@ -115,7 +115,7 @@ export function Timeline({
115
115
  <Fragment key={it.id}>
116
116
  {/* The label is the item's one link: visible, focusable, and read with the table's dates. */}
117
117
  {it.href ? (
118
- <Link href={it.href} title={it.label} className="row-link self-center truncate py-2 pr-3 text-ink-2">{it.label}</Link>
118
+ <Link href={it.href} title={it.label} className="row-link self-center truncate py-2 pr-3 text-ink-2 hover:text-accent-ink">{it.label}</Link>
119
119
  ) : (
120
120
  <span aria-hidden className="self-center truncate py-2 pr-3 text-ink-2" title={it.label}>{it.label}</span>
121
121
  )}
@@ -10,7 +10,7 @@ Status: beta
10
10
  2. **Title** (optional): `title`, `description` and `actions` as a Card header, when the table is one card among others on a page; a hairline under it when no toolbar follows.
11
11
  3. **Toolbar** (optional): a band above the table, `card-pad` horizontal and 14px vertical padding, a `line` divider under it from 768px. It holds a Filter bar.
12
12
  4. **Header row**: Table header cells, `surface-sunk`, 36px, `text-xs` 500 `ink-3`. A sortable head is a button with the sort arrow in `accent-ink`.
13
- 5. **Rows**: `row-height` 52px (38px compact), a `line` divider between rows. The title column (`mobile: 'title'`, or the first) is a `.row-link` when the row opens a record.
13
+ 5. **Rows**: `row-height` 52px (38px compact), a `line` divider between rows. The title column (`mobile: 'title'`, or the first) is the row's link when the row opens a record. The whole row takes the `fill-hover` tint on hover; the title keeps its colour and is never underlined.
14
14
  6. **Selection column** (optional): 40px with a Checkbox; the head's checkbox selects the page and shows indeterminate when part of it is chosen.
15
15
  7. **Footer**: Pagination, under a `line` divider, shown only when the rows do not fit on one page.
16
16
  8. **Cards, not a second list** (under 768px): the same table's rows lay out as cards — the title and the status on one line, two or three facts under it in `text-xs` `ink-3`, separated by dots. It is the SAME tree: the row becomes a six-column grid and each cell says which part of the card it is (`data-mobile`), so nothing is built twice and no device downloads a tree it cannot show.
@@ -34,7 +34,7 @@ Row height and card padding come from the density: comfortable 52px and 24px, co
34
34
  | State | What shows |
35
35
  |---|---|
36
36
  | Rest | Rows; numeric columns right-aligned in tabular figures |
37
- | Hover (a linked row) | Row fill `fill-hover` over `dur-hover`; the title's underline draws in |
37
+ | Hover (a linked row) | Row fill `fill-hover` over `dur-hover`; nothing else changes |
38
38
  | Selected | Row fill `accent-tint`; the checkbox in `accent` |
39
39
  | Focus | The link or the checkbox shows the 2px `accent` ring |
40
40
  | Loading | `aria-busy`; up to eight skeleton rows shaped like real ones (a wide bar in the grow column, short bars elsewhere, right-aligned bars in numeric columns) — the same eight rows on a phone, laid out as cards |
@@ -294,7 +294,7 @@ export function DataTable<R>({
294
294
  const role = roleOf(c);
295
295
  // The title is the row's link wherever it is shown, phone included: the whole card takes the
296
296
  // pointer too (the row's own click), but the link is what a keyboard and a screen reader follow.
297
- const content = c === titleColumn && href ? <Link href={href} className="row-link">{c.cell(r)}</Link> : c.cell(r);
297
+ const content = c === titleColumn && href ? <Link href={href}>{c.cell(r)}</Link> : c.cell(r);
298
298
  return (
299
299
  <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} className={cellClass(c) || undefined}>
300
300
  {/* A phone may want other words for the same cell — "Used 1 min ago", not "1 min ago" — so
@@ -124,7 +124,7 @@ export function FilterBar({
124
124
  <span>
125
125
  <span className="font-medium text-ink">Set by {setBy}</span> · these filters came from the assistant
126
126
  </span>
127
- {onClear ? <button type="button" onClick={onClear} className="hit font-medium text-accent-ink hover:underline">Clear</button> : null}
127
+ {onClear ? <button type="button" onClick={onClear} className="hit font-medium text-accent-ink hover:text-ink">Clear</button> : null}
128
128
  </p>
129
129
  ) : null}
130
130
  </div>
@@ -12,7 +12,7 @@ Status: beta
12
12
  4. **Code**: inline on `surface-sunk` with a `line` border; a fenced block on `surface-sunk`, `radius-lg`, wrapping instead of scrolling.
13
13
  5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells.
14
14
  6. **Quote**: a 2px `accent` rule on the left, `ink` text.
15
- 7. **Links**: `.link`, in `accent-ink`, underlined on hover; a link out of the product sends no referrer.
15
+ 7. **Links**: `.link`, in `accent-ink`, turning `ink` on hover, never underlined; a link out of the product sends no referrer.
16
16
 
17
17
  ## Composition
18
18
 
@@ -58,7 +58,7 @@ export function AlertsPanel({ alerts, now, read = new Set(), onOpen, onMarkAll }
58
58
  <p className="text-sm font-semibold">Alerts</p>
59
59
  {unread ? <span className="t-caption">{unread} new</span> : null}
60
60
  {unread && onMarkAll ? (
61
- <button type="button" onClick={onMarkAll} className="hit ml-auto rounded-xs text-xs font-medium text-accent-ink hover:underline">
61
+ <button type="button" onClick={onMarkAll} className="hit ml-auto rounded-xs text-xs font-medium text-accent-ink hover:text-ink">
62
62
  Mark all read
63
63
  </button>
64
64
  ) : null}
@@ -6,7 +6,7 @@ Status: beta
6
6
 
7
7
  ## Anatomy
8
8
 
9
- 1. **Ancestor**: a link, `ink-2`; its underline draws in on hover and it turns `ink`.
9
+ 1. **Ancestor**: a link, `ink-2`; it turns `ink` on hover, with no underline.
10
10
  2. **Separator**: a 12px chevron in `ink-3`, decorative.
11
11
  3. **Current page**: `ink`, medium, `aria-current="page"`.
12
12
  4. **Fold** (more than four levels): a 24px ellipsis button that opens a Menu of the hidden levels.
@@ -27,7 +27,7 @@ Status: beta
27
27
  | State | Ancestor |
28
28
  |---|---|
29
29
  | Rest | `ink-2` |
30
- | Hover | `ink`, underline draws over `dur-hover` |
30
+ | Hover | `ink` over `dur-hover` |
31
31
  | Focus | 2px `accent` outline |
32
32
 
33
33
  ## Behaviour
@@ -25,7 +25,7 @@ export default function CardPreview() {
25
25
  <p className="t-caption mt-2">Resets on 1 November</p>
26
26
  </CardBody>
27
27
  <CardFooter>
28
- <a className="row-link inline-flex items-center gap-1 font-medium text-ink" href="#">Billing <ArrowUpRight className="size-3.5" /></a>
28
+ <a className="row-link inline-flex items-center gap-1 font-medium text-ink hover:text-accent-ink" href="#">Billing <ArrowUpRight className="size-3.5" /></a>
29
29
  </CardFooter>
30
30
  </Card>
31
31
  </Plane>
@@ -58,6 +58,7 @@ Label the field with what it is: "API key", "Webhook signing secret".
58
58
  ## Do and do not
59
59
 
60
60
  - Do mask every secret by default.
61
+ - Do not list a stored secret at all. An API key is shown in full once, when it is created; afterwards the page shows its hint (the prefix and the last four characters) as plain text, and the server never sends the secret again.
61
62
  - Do not offer Copy for something that is not meant to be pasted elsewhere.
62
63
 
63
64
  ## Implementation
@@ -63,7 +63,7 @@ When an agent's approved proposal applies, the toast names the agent: "Rate limi
63
63
  ## Do and do not
64
64
 
65
65
  - Do offer Undo instead of a confirmation dialog when an action can be reversed.
66
- - Do not use a toast for an error the person must act on; use a Banner or the field's error.
66
+ - Do not use a toast for an error the person must act on; use a Banner or the field's error. A form in an open sheet or dialog that fails shows its error inside the form, at the top, and keeps what was typed: a toast would land on the form's own actions.
67
67
 
68
68
  ## Implementation
69
69
 
@@ -54,7 +54,7 @@ export function ToastView({
54
54
  {description ? <p className="mt-0.5 text-xs text-ink-2">{description}</p> : null}
55
55
  </div>
56
56
  {action ? (
57
- <button type="button" className="press hit shrink-0 rounded-xs text-sm font-medium text-accent-ink hover:underline" onClick={action.onClick}>
57
+ <button type="button" className="press hit shrink-0 rounded-xs text-sm font-medium text-accent-ink hover:text-ink" onClick={action.onClick}>
58
58
  {action.label}
59
59
  </button>
60
60
  ) : null}
@@ -63,7 +63,7 @@ const DEMO: AccessScope = { tenantId: 'demo', subjectId: 'owner', authorizationK
63
63
  const DEMO_COLLECTIONS: Record<string, { collection: AnyCollection; ops: readonly Operation[] }> = {
64
64
  members: { collection: members, ops: ['read', 'create', 'update', 'remove'] },
65
65
  keys: { collection: keys, ops: ['read', 'create', 'remove'] },
66
- requests: { collection: requests, ops: ['read'] },
66
+ requests: { collection: requests, ops: ['read', 'create'] },
67
67
  };
68
68
 
69
69
  export const { resolveAccess, collectionFor, can } = accessFrom({