zz-meridian 0.6.1 → 0.8.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.
package/README.md CHANGED
@@ -6,10 +6,15 @@ dashboard depends on it afterwards.
6
6
 
7
7
  ## The one sentence
8
8
 
9
- Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, from your frontend's folder:
9
+ Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, with what you want in your own
10
+ words at the end:
10
11
 
11
- > Run `npx zz-meridian@latest adopt` here, then follow the zz-meridian skill it installs: keep our data layer and
12
- > routes, restyle every page with Meridian's components and tokens, and run pnpm verify until it passes.
12
+ > Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
13
+
14
+ The skill picks the command from what you said and what is in the folder, and names it before running anything:
15
+ `adopt` to change this Next.js App Router project in place ("make this admin panel look professional"), `create` for a
16
+ new dashboard in a new folder ("build an ops dashboard", "a new one based on this folder, in another folder"), and
17
+ `update` or `brand` for a project that already has Meridian.
13
18
 
14
19
  ## Commands
15
20
 
@@ -72,6 +77,14 @@ sends nothing anywhere; the only network access is your package manager's instal
72
77
  `--no-install`. Every release is built and published by GitHub Actions with npm provenance, so the registry shows the
73
78
  commit and workflow each version came from.
74
79
 
80
+ ## Privacy and feedback
81
+
82
+ The package collects nothing: no telemetry, no analytics, no account, and the template turns off Next.js's anonymous
83
+ telemetry. It talks to npm only, when `npx` fetches it and when `update` reads releases. Feedback reaches us only as a
84
+ [GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose), a Bug or a Feature request; issues are
85
+ public, so leave out anything that identifies you, your organisation or your product, and describe the problem with
86
+ Meridian's own template or sample data.
87
+
75
88
  ## License
76
89
 
77
90
  MIT
package/dist/adopt.js CHANGED
@@ -258,8 +258,12 @@ Rename or move them, then run adopt again.`);
258
258
  writes.set(`${dir}/icon.ts`, exactImports(`${dir}/icon.ts`, readPayload('app/icon.ts'), known));
259
259
  const ignore = path.join(root, '.gitignore');
260
260
  const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
261
- if (!/^\/?out\/?$/m.test(ignored))
262
- writes.set('.gitignore', `${ignored.trimEnd()}\n\n# zz-meridian: verify's report and the screenshots\n/out/\n`);
261
+ // verify's report and screenshots, and an update in progress: Meridian's own, never the team's to commit. The manifest,
262
+ // keep.json and history/ are committed, since the next update reads them.
263
+ const bare = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
264
+ const unignored = ['/out/', '/.meridian/update/', '/.meridian/update.lock'].filter((l) => !ignored.split('\n').some((x) => bare(x) === bare(l)));
265
+ if (unignored.length)
266
+ writes.set('.gitignore', `${ignored.trimEnd()}\n\n# zz-meridian: verify's report and an update in progress\n${unignored.join('\n')}\n`);
263
267
  // ── Write: every target checked first, so a bad one stops it before anything changes ─────────────────
264
268
  try {
265
269
  for (const rel of writes.keys())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.6.1",
3
+ "version": "0.8.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,29 @@
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.8.0] · 2026-10-06
6
+
7
+ ### Added
8
+
9
+ - **A demo password in front of the whole product.** With `DEMO_PASSWORD` set at run time, `proxy.ts` sends every route to the sign-in page (an API answers 401), whose panel becomes "Open the demo": one password field, a 30-day signed session (`src/lib/demo-gate.ts`, keyed by `DEMO_SECRET` when set), and opening the page again signs out. Without it nothing changes: the panel is the product's sign-in and nothing is gated. The sign-in panel now streams in behind a Suspense boundary, so the rest of the page still prerenders. Meridian's own demo deploys to CapRover from this repository (`Dockerfile`, `captain-definition`, neither in the package).
10
+
11
+ ### Changed
12
+
13
+ - **`update` is tested from the last three releases.** The release updates a project of the release before, adopted and created, through finalize; the weekly run the last three. Every published origin used to run at each release, which took 11 of its 18 minutes and grew with every release. From an older project, update in steps (`references/update.md`).
14
+
15
+ ### Fixed
16
+
17
+ - **A project leaves an update in progress and each person's agent settings out of git.** Its `.gitignore` (the template's, for a created project; three lines `adopt` adds, for an adopted one) ignores `.meridian/update/` and `.meridian/update.lock`, the staged copies, backups and lock of an unfinished update, which a commit mid-session used to take in; a created project also ignores `.claude/settings.local.json` and `.claude/*.lock`. The manifest, `keep.json`, `.meridian/history/` and both copies of the skill stay committed: the next update and the team's agents read them. In a project made before 0.8.0, add those lines to `.gitignore`.
18
+ - **The one sentence shows its blank.** It ended in `<what you want>`, which GitHub and npm read as an HTML tag and dropped, so it read "installs to: .". It ends in `[what you want, in your own words]`.
19
+
20
+ ## [0.7.0] · 2026-10-06
21
+
22
+ ### Changed
23
+
24
+ - **One sentence for every route, and the skill chooses the command.** The README's sentence is `Run npx zz-meridian@latest skill --global, then follow the zz-meridian skill it installs to: <what you want>`. The skill opens with "Choose the route": a table from what the person said and what is in the folder to `adopt` (this Next.js App Router project, in place), `create` (a new folder, reading any folder named as the source and never writing it) or `update` and `brand`, and it says the route before the first command. The old sentence named `adopt`, which is wrong for a new dashboard.
25
+ - **Next.js telemetry is off.** The template's `next.config.ts` sets `NEXT_TELEMETRY_DISABLED` for `next dev` and `next build`, and Meridian's scripts set it for every `next` they run, an adopted project's included. Delete the line in `next.config.ts` to send it.
26
+ - **Feedback is an offer, and it identifies no one.** The skill's last step drafts a Bug or a Feature request issue only when the run found something about Meridian, removes every name, address, URL, record, schema and path of the person's own, shows the draft, and files nothing without a yes. The repository has Bug and Feature request issue forms that say the same, and no blank issues. The README and the npm page say what reaches the network (npm, the font download at build, what the team configures) and that an issue is the only way anything reaches Meridian.
27
+
5
28
  ## [0.6.1] · 2026-10-06
6
29
 
7
30
  ### Fixed
package/payload/README.md CHANGED
@@ -74,31 +74,50 @@ Dark is the default, written on `:root`; the light theme follows the operating s
74
74
  | `scripts/` | Generators, gates, `brand.ts` and `verify.ts` (see `CONTRIBUTING.md`) |
75
75
  | `skills/zz-meridian/` | The agent skill (Claude Code, Codex) that builds dashboards on this template or brings it into yours |
76
76
  | `cli/` | The `zz-meridian` npm package: `create`, `adopt`, `update`, `brand` and `skill`, its build, its smoke test and the fixture app (`docs/distribution.md`) |
77
- | `.github/workflows/release.yml` | The release, each check once: a timed default verify and the consumer path from the tarball (every published origin updated), then npm with provenance, the registry's bytes and provenance checked, then the tag (`.claude/commands/release.md`) |
77
+ | `.github/workflows/release.yml` | The release, each check once: a timed default verify and the consumer path from the tarball (the release before updated; the last three weekly), then npm with provenance, the registry's bytes and provenance checked, then the tag (`.claude/commands/release.md`) |
78
78
  | `.github/workflows/weekly.yml` | Weekly, never at release: `verify --full --perf` (perf a report) and the consumer smoke's recovery and failure cases; nothing waits on it |
79
+ | `Dockerfile`, `captain-definition` | The demo deployment: the template and the Atlas behind `DEMO_PASSWORD` (`proxy.ts`, `app/sign-in/README.md`), deployed to CapRover from this repository; never in the package |
79
80
 
80
- ## Bring Meridian into your dashboard, in one sentence
81
+ ## Get a dashboard on Meridian, in one sentence
81
82
 
82
- Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, from your frontend's folder:
83
+ Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, with what you want in your own
84
+ words at the end:
83
85
 
84
- > Run `npx zz-meridian@latest adopt` here, then follow the zz-meridian skill it installs: keep our data layer and routes, restyle every page with Meridian's components and tokens, and run pnpm verify until it passes.
86
+ > Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
85
87
 
86
- `adopt` does the settled part, the same way every time, and proves it type checks: it copies the components, tokens
87
- and gates in, merges the dependencies, brands it, adds Meridian's managed block to your `AGENTS.md` (your own text there is kept byte
88
- for byte) and an empty `docs/brief.md` for your product's own context, and installs the skill. The agent does the judgement: rebuilding each page on Meridian, and running
89
- `pnpm verify` until the project meets the standard (and `pnpm verify --full` after a change to shared components, the shell or
90
- data). It needs Node 22.18+, and Google Chrome for the browser checks. If your pages call a live API, say so in the sentence:
91
- `verify --full` presses every control, Delete included, so the agent builds a fake API first. For a new dashboard: `npx zz-meridian@latest create <dir>`. To move a project to a newer
92
- Meridian, run `npx zz-meridian@latest update --dry-run`, then follow the skill's `references/update.md`. The package
93
- (`cli/`, decision 0009) copies files and nothing depends on it afterwards.
88
+ You do not choose between the package's commands; the skill does, from what you said and what is in the folder, and
89
+ names the route before it runs anything:
94
90
 
95
- ## Install the skill once, for every project
96
-
97
- ```sh
98
- npx zz-meridian@latest skill --global
99
- ```
100
-
101
- That puts the skill in `~/.agents/skills/zz-meridian` (Codex) and `~/.claude/skills/zz-meridian` (Claude Code). Then tell your agent what you need, in your own words: "build an ops dashboard for our shipments", "make this admin panel look professional", "turn this schema into a dashboard". The skill reads what you already have, asks only what it cannot find out, creates the project (or brings Meridian into yours) with the package, builds the pages, and runs `pnpm verify` until the project meets the standard.
91
+ | You say | The skill |
92
+ |---|---|
93
+ | "Make this admin panel look professional", "change this product into our new dashboard" | `adopt` here, keeping your data layer and routes, when it is Next.js with the App Router; otherwise `create` next to it and port your pages |
94
+ | "Build an ops dashboard for our shipments", "turn this schema into a dashboard" | `create` in a new folder |
95
+ | "Based on this folder, make a new dashboard in another folder" | `create` in that folder, reading this one and leaving it untouched |
96
+ | "Update Meridian", "change our brand colour" | `update` or `brand`, in a project that already has Meridian |
97
+
98
+ `adopt` copies the components, tokens and gates in, merges the dependencies, brands it, adds Meridian's managed block to
99
+ your `AGENTS.md` (your own text there is kept byte for byte) and an empty `docs/brief.md` for your product's own
100
+ context, and installs the skill into the project. `create` writes a new, branded project with the same skill. The
101
+ agent then does the judgement: building each page on Meridian, and running `pnpm verify` until the project meets the
102
+ standard (`pnpm verify --full` after a change to shared components, the shell or data). It needs Node 22.18+, and
103
+ Google Chrome for the browser checks. If your pages call a live API, say so in the sentence: `verify --full` presses
104
+ every control, Delete included, so the agent builds a fake API first. The package (`cli/`, decision 0009) copies files
105
+ and nothing depends on it afterwards; its commands are in `cli/README.md`.
106
+
107
+ ## Privacy and feedback
108
+
109
+ Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
110
+ account, and the template turns off Next.js's own anonymous telemetry (`next.config.ts`; Meridian's scripts turn it off
111
+ for every `next` they run). What reaches the network is what you would expect: npm, when you run `npx` and when `update`
112
+ reads releases from it; Google Fonts, which `next/font` downloads the typefaces from at build time; and whatever you
113
+ configure yourself, such as the assistant's model provider.
114
+
115
+ The one way to tell us anything is a [GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose): a
116
+ **Bug** (something in Meridian did the wrong thing) or a **Feature request** (something it should do, or do more
117
+ easily). Issues are public, so leave out anything that identifies you, your organisation or your product: names,
118
+ email addresses, URLs, your data and schemas, screenshots of your pages. Describe the problem with Meridian's own
119
+ template or sample data instead. At the end of a build the skill may offer to draft one with all of that removed; it
120
+ files nothing unless you say yes.
102
121
 
103
122
  ## Commands
104
123
 
@@ -25,6 +25,16 @@ Two columns from 1024px; one below, the panel under the sentence.
25
25
  | Invalid email | The field's error: "Enter your work email, like maya@zz-meridian.example." |
26
26
  | Sending | The primary button is busy |
27
27
  | Sent | The panel says "Check your inbox", names the address, and offers "Use another email" |
28
+ | Demo | With `DEMO_PASSWORD` set at run time, the panel is "Open the demo": one password field (Field, Input `lg`) and "Open the demo" (primary, block, `lg`); a wrong password reads "That is not the demo password." in the field after 600 ms. A match sets a 30-day session and opens the console |
29
+ | Loading | While the panel streams in, its surface holds its place, empty |
30
+
31
+ ## The demo gate
32
+
33
+ `proxy.ts` and `src/lib/demo-gate.ts` put a password in front of the whole product when `DEMO_PASSWORD` is set: every
34
+ route but this one redirects here (303), and an API answers 401. Opening this page signs the demo out, which is the
35
+ rail's Sign out. `DEMO_SECRET`, when set, keys the session instead of the password. Without `DEMO_PASSWORD` nothing is
36
+ gated and the panel is the product's sign-in. The panel is the only part of the page that reads the request; the rest
37
+ prerenders (`gated-panel.tsx`, behind a Suspense boundary).
28
38
 
29
39
  ## Data
30
40
 
@@ -0,0 +1,19 @@
1
+ 'use server';
2
+
3
+ import { cookies, headers } from 'next/headers';
4
+ import { redirect } from 'next/navigation';
5
+ import { COOKIE, MAX_AGE, samePassword, token } from '@/lib/demo-gate';
6
+
7
+ export type DemoSignInState = { error: boolean };
8
+
9
+ /** Checks the demo password: a match sets the session and opens the console, a miss comes back after 600 ms. */
10
+ export async function demoSignIn(_: DemoSignInState, form: FormData): Promise<DemoSignInState> {
11
+ const given = String(form.get('password') ?? '').slice(0, 512);
12
+ if (!samePassword(given)) {
13
+ await new Promise((r) => setTimeout(r, 600));
14
+ return { error: true };
15
+ }
16
+ const secure = (await headers()).get('x-forwarded-proto') === 'https';
17
+ (await cookies()).set(COOKIE, token(), { httpOnly: true, path: '/', sameSite: 'lax', maxAge: MAX_AGE, secure });
18
+ redirect('/');
19
+ }
@@ -0,0 +1,9 @@
1
+ import { connection } from 'next/server';
2
+ import { PasswordPanel, SignInPanel } from './panel';
3
+ import { gated } from '@/lib/demo-gate';
4
+
5
+ /** The demo's password when `DEMO_PASSWORD` is set at run time, the product's sign-in otherwise; the page around it prerenders. */
6
+ export async function GatedPanel() {
7
+ await connection();
8
+ return gated() ? <PasswordPanel /> : <SignInPanel />;
9
+ }
@@ -1,4 +1,6 @@
1
- import { SignInPanel } from './panel';
1
+ import { Suspense } from 'react';
2
+ import { GatedPanel } from './gated-panel';
3
+ import { PanelFrame } from './panel';
2
4
  import { SampleFooter } from '@/views/sample-footer';
3
5
  import { Standalone } from '@/views/standalone';
4
6
  import { Meridian } from '@/components/charts/meridian';
@@ -18,7 +20,7 @@ export default function SignInPage() {
18
20
  kicker={`${app.name} · Console`}
19
21
  sentence="Know your API before your customers do."
20
22
  lead="Traffic, latency, spend and health for every endpoint, on your desk, on your phone, and inside the assistant you already use."
21
- aside={<SignInPanel />}
23
+ aside={<Suspense fallback={<PanelFrame />}><GatedPanel /></Suspense>}
22
24
  >
23
25
  {/* The proof is the signature itself: point at a day and the line reads it, as every chart in the console does. */}
24
26
  <figure className="mt-12 max-w-xl rounded-xl border border-line bg-surface/60 p-5 backdrop-blur-md max-lg:hidden">
@@ -1,11 +1,43 @@
1
1
  'use client';
2
2
 
3
- import { useState, type FormEvent } from 'react';
4
- import { ArrowRight, KeyRound, Mail } from 'lucide-react';
3
+ import { useActionState, useState, type FormEvent, type ReactNode } from 'react';
4
+ import { ArrowRight, KeyRound, LockKeyhole, Mail } from 'lucide-react';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Field } from '@/components/ui/field';
7
7
  import { Input } from '@/components/ui/input';
8
8
  import { app, domain } from '@/app.config';
9
+ import { demoSignIn } from './actions';
10
+
11
+ /** The panel's surface, shared by both panels and the placeholder that holds their place while one streams in. */
12
+ export function PanelFrame({ children }: { children?: ReactNode }) {
13
+ return (
14
+ <section aria-labelledby={children ? 'sign-in' : undefined} aria-hidden={children ? undefined : true} className="relative overflow-hidden rounded-xl border border-line bg-surface/80 p-7 shadow-overlay backdrop-blur-xl sm:p-8">
15
+ <span aria-hidden className="absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent via-accent-ink/60 to-transparent" />
16
+ {children ?? <div className="min-h-[22rem]" />}
17
+ </section>
18
+ );
19
+ }
20
+
21
+ /** The demo's panel, when `DEMO_PASSWORD` is set: one password field and one primary action. */
22
+ export function PasswordPanel() {
23
+ const [state, action, pending] = useActionState(demoSignIn, { error: false });
24
+ return (
25
+ <PanelFrame>
26
+ <form action={action}>
27
+ <span className="grid size-11 place-items-center rounded-full bg-accent-tint text-accent-ink"><LockKeyhole className="size-5" /></span>
28
+ <h2 id="sign-in" className="t-section mt-5">Open the demo</h2>
29
+ <p className="t-small mt-2 text-ink-2">{app.name} on sample data. Enter the password you were given.</p>
30
+ <input type="text" name="username" value="demo" autoComplete="username" readOnly hidden />
31
+ <div className="mt-7 flex flex-col gap-4">
32
+ <Field label="Demo password" error={state.error ? 'That is not the demo password.' : undefined}>
33
+ {(p) => <Input {...p} name="password" type="password" size="lg" autoComplete="current-password" required autoFocus leading={<KeyRound className="size-4" />} />}
34
+ </Field>
35
+ <Button type="submit" variant="primary" size="lg" block busy={pending} trailing={<ArrowRight />}>Open the demo</Button>
36
+ </div>
37
+ </form>
38
+ </PanelFrame>
39
+ );
40
+ }
9
41
 
10
42
  /** The sign-in panel: one field, one primary action, SSO beside it. A sent link replaces the form, never a toast. */
11
43
  export function SignInPanel() {
@@ -27,8 +59,7 @@ export function SignInPanel() {
27
59
  setState('sso');
28
60
  };
29
61
  return (
30
- <section aria-labelledby="sign-in" className="relative overflow-hidden rounded-xl border border-line bg-surface/80 p-7 shadow-overlay backdrop-blur-xl sm:p-8">
31
- <span aria-hidden className="absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent via-accent-ink/60 to-transparent" />
62
+ <PanelFrame>
32
63
  {state === 'sent' ? (
33
64
  <div role="status">
34
65
  <span className="grid size-11 place-items-center rounded-full bg-accent-tint text-accent-ink"><Mail className="size-5" /></span>
@@ -60,6 +91,6 @@ export function SignInPanel() {
60
91
  <p className="t-caption mt-6 text-pretty">By continuing you agree to the Terms and the Privacy notice. We never post anything for you.</p>
61
92
  </form>
62
93
  )}
63
- </section>
94
+ </PanelFrame>
64
95
  );
65
96
  }
@@ -54,8 +54,10 @@ this is the reason they agree.
54
54
  refuses, keeping every backup, when a path it wrote was edited since.
55
55
  - `--resume`, `--finalize` and `--abort` belong to the package version that began the session. They read the session's
56
56
  own copies and never ask the registry.
57
- - Updates start from 0.3.0. The four published origins, a project adopted and a project created with 0.3.0 and with
58
- 0.4.0, are the supported starting points, and the release smoke runs every one of them through update and finalize.
57
+ - Updates start from 0.3.0. An update is tested from the last three releases, adopted and created: the release smoke
58
+ runs the release before through update and finalize, and the weekly smoke the last three. The window moves with each
59
+ release and never grows; from an older project, update in steps (`references/update.md`). Every published origin was
60
+ tested at release until 0.7.0, when the smoke took 11 of the release's 18 minutes and grew with each release.
59
61
 
60
62
  ### The access seam, scoped caching and live data
61
63
 
@@ -96,9 +98,9 @@ this is the reason they agree.
96
98
  - Weekly is weekly, release is release, and nothing runs twice. The release gates, each once, on the default verify
97
99
  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
100
  and its unit tests run inside it) and on the consumer smoke from the tarball: adopt, create with its default verify,
99
- and all four published origins updated through finalize. After publishing it checks the registry serves the tested
101
+ and the release before updated through finalize (the last three weekly). After publishing it checks the registry serves the tested
100
102
  tarball (equal sha256) with provenance, and tags last. A weekly workflow runs `verify --full --perf` (the perf part a
101
- report) and the consumer smoke's adopted default-verify cases and recovery and failure cases; a failure notifies and
103
+ report) and the consumer smoke's adopted default-verify cases, the update from the last three releases, and its recovery and failure cases; a failure notifies and
102
104
  nothing waits on it.
103
105
 
104
106
  ### Every breaking interface, with its migration
@@ -4,12 +4,16 @@ Status: v1 (`create`, `adopt`, `skill`) shipped in 0.2.0 (decision 0009). v2 (`u
4
4
 
5
5
  ## The one sentence
6
6
 
7
- A team gives its coding agent this, from its frontend's folder:
7
+ A team gives its coding agent this, with what it wants in its own words at the end:
8
8
 
9
- > Run `npx zz-meridian@latest adopt` here, then follow the zz-meridian skill it installs: keep our data layer and
10
- > routes, restyle every page with Meridian's components and tokens, and run pnpm verify until it passes.
9
+ > Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
11
10
 
12
- For a new dashboard: `npx zz-meridian@latest create <dir>`, then the same skill.
11
+ The sentence names no command, because people do not: "change this product into our dashboard", "a new one based on
12
+ this folder, in another folder" and "build an orders console" all ask for a dashboard on Meridian and differ only in
13
+ which folder holds it. The skill's "Choose the route" table maps what was said, and what is in the folder, to `adopt`
14
+ (this Next.js App Router project, in place), `create` (a new folder; a folder named as the source is read, never
15
+ written) or `update` and `brand` (a project that already has Meridian), and says the route before the first command.
16
+ An earlier sentence named `adopt`, the wrong command for every request for a new dashboard.
13
17
 
14
18
  The split follows what each part is good at. The package does the settled work, the same way every time, and proves it
15
19
  built: what to copy, which dependencies to merge, the alias, the stylesheet, the brand. The agent does the judgement:
@@ -214,13 +218,17 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
214
218
  - `adopt` into `cli/test/fixture-next-app` (a minimal App Router app with one page and its own stylesheet), then
215
219
  install, `tsc --noEmit` and `next build`, all green. This is the test that keeps the Route A list complete.
216
220
  - `create` into a clean folder, then its default `pnpm verify`. The smoke quotes the coverage line.
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.
221
+ - `update` from the release before (adopted and created, read from the registry): a dry-run that writes nothing, the
222
+ update, what it reports resolved, finalize with the gate and the build, and the team's bytes unchanged. One origin
223
+ at release and the last three weekly, so the time stays the same however many releases there are; the updater's
224
+ own stages and each migration have their tests (`--update-all`, `tests/cli-*`).
219
225
  5. **Publish** the tarball with `npm` 11.5.1 or newer through trusted publishing (OIDC), with `--provenance`. `pnpm
220
226
  publish` does not perform the OIDC exchange.
221
227
  6. **The registry's package**: `npx zz-meridian@<version> --version` answers the version, the registry's tarball has the
222
- tested tarball's sha256, and it carries a provenance attestation. The bytes passed step 4 already, so nothing runs
223
- them twice. A failure here is an unsuccessful release, not a rollback: the version stays published and untagged
228
+ tested tarball's sha256, and it carries a provenance attestation. The bytes passed step 4 already; two paths still
229
+ change once the version is published, and run here, in about 10 seconds: `create` from the registry (npm renames a
230
+ packed `.gitignore` on install, so the project must have one), and `update` to the version in a project created
231
+ with the release before (it compares the running package with the registry's only once that one exists). A failure here is an unsuccessful release, not a rollback: the version stays published and untagged
224
232
  until a fix is released.
225
233
  7. **Tag `v<version>` last**, then the GitHub Release with the version's `CHANGELOG.md` section as its body.
226
234
 
@@ -228,8 +236,10 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
228
236
 
229
237
  **Weekly** (`.github/workflows/weekly.yml`, Mondays and on demand, master's current commit): the template's
230
238
  `verify --full --perf`, one gate and one build for both (the perf part a report: a p95 over budget is a warning, a
231
- broken sample a failure), and the consumer smoke's `--adopt --update-all --verify` from a tarball packed from that
232
- commit: the adopted project's default verify cases and every recovery and failure case of the update. Weekly is weekly
239
+ broken sample a failure), and the consumer smoke's `--adopt --update --origins 3 --update-all --verify` from a tarball packed from that
240
+ commit, labelled as the next patch so `update` treats it as unpublished: the adopted project's default verify cases,
241
+ the update from each of the last three releases, the updater's stages on the 0.3.0 fixture, and every recovery and
242
+ failure case of the update. Weekly is weekly
233
243
  and release is release: neither runs what the other does. Each job keeps its log as an artifact. A failure notifies;
234
244
  nothing waits on it.
235
245
 
package/payload/gitignore CHANGED
@@ -28,6 +28,16 @@ test-results
28
28
  # deployment
29
29
  .vercel
30
30
 
31
+ # zz-meridian: an update in progress (its staged copies, backups and lock). .meridian/manifest.json, keep.json and
32
+ # history/ are committed: the next update reads them.
33
+ /.meridian/update/
34
+ /.meridian/update.lock
35
+
36
+ # agents: each person's own settings and runtime files. The skills under .claude/skills and .agents/skills are
37
+ # committed: they are the version of the skill that matches this project's Meridian, and update keeps them so.
38
+ /.claude/settings.local.json
39
+ /.claude/*.lock
40
+
31
41
  # logs
32
42
  *-debug.log*
33
43
  *-error.log*
@@ -1,5 +1,8 @@
1
1
  import type { NextConfig } from 'next';
2
2
 
3
+ // Next.js's anonymous usage telemetry is off for `next dev` and `next build`; delete this line to send it.
4
+ process.env.NEXT_TELEMETRY_DISABLED ??= '1';
5
+
3
6
  const config: NextConfig = {
4
7
  reactStrictMode: true,
5
8
  cacheComponents: true,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
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.",
@@ -0,0 +1,37 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { COOKIE, MAX_AGE, expiresOf, gated, token } from '@/lib/demo-gate';
3
+
4
+ /**
5
+ * The demo's password gate (`src/lib/demo-gate.ts`), open unless `DEMO_PASSWORD` is set. Every route needs a live
6
+ * session except the sign-in page and the files a browser fetches for it; an API answers 401 instead of redirecting.
7
+ * Opening the sign-in page signs the demo out, which is what the rail's Sign out does. A visit past half the session's
8
+ * life renews it.
9
+ */
10
+ export function proxy(req: NextRequest) {
11
+ if (!gated()) return NextResponse.next();
12
+ const { pathname } = req.nextUrl;
13
+ const value = req.cookies.get(COOKIE)?.value;
14
+ if (pathname === '/sign-in') {
15
+ const res = NextResponse.next();
16
+ // A POST here is the sign-in itself, which sets the session; only a visit clears it.
17
+ if (req.method === 'GET' && value) res.cookies.set(COOKIE, '', { path: '/', maxAge: 0 });
18
+ return res;
19
+ }
20
+ const expires = expiresOf(value);
21
+ if (!expires) {
22
+ if (pathname.startsWith('/api/')) return NextResponse.json({ error: 'Sign in to the demo first.' }, { status: 401 });
23
+ const to = req.nextUrl.clone();
24
+ to.pathname = '/sign-in';
25
+ to.search = '';
26
+ return NextResponse.redirect(to, 303);
27
+ }
28
+ const res = NextResponse.next();
29
+ if (expires - Date.now() / 1000 < MAX_AGE / 2) {
30
+ res.cookies.set(COOKIE, token(), { httpOnly: true, path: '/', sameSite: 'lax', maxAge: MAX_AGE, secure: req.headers.get('x-forwarded-proto') === 'https' });
31
+ }
32
+ return res;
33
+ }
34
+
35
+ export const config = {
36
+ matcher: ['/((?!_next/static|_next/image|favicon|icon|apple-icon).*)'],
37
+ };
@@ -281,11 +281,12 @@ const retirements: Retirement[] = [];
281
281
  }
282
282
 
283
283
  // ── The template's request boundaries ────────────────────────────────────────────────────────────────
284
- // Two places read the request: the console layout, which hands the shell a promise, and the not-found page, which streams
285
- // the address in. A `connection()` anywhere else makes the page behind it wait for the request, and a template whose
284
+ // Three places read the request: the console layout, which hands the shell a promise, the not-found page, which streams
285
+ // the address in, and the sign-in page's panel, which streams in as the demo's password or the product's sign-in, set by
286
+ // DEMO_PASSWORD at run time. A `connection()` anywhere else makes the page behind it wait for the request, and a template whose
286
287
  // pages all prerender has none. In a product those pages are the team's, so the rule is the template's alone.
287
288
  if (!fs.existsSync(manifestFile)) {
288
- const BOUNDARIES = [`${APP_DIR}/(dashboard)/layout.tsx`, `${APP_DIR}/not-found.tsx`];
289
+ const BOUNDARIES = [`${APP_DIR}/(dashboard)/layout.tsx`, `${APP_DIR}/not-found.tsx`, `${APP_DIR}/sign-in/gated-panel.tsx`];
289
290
  /** Comments blanked, not removed, so a line number is the file's. */
290
291
  const blanked = (f: string) => read(f).replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' ')).replace(/(^|[^:])\/\/[^\n]*/g, '$1');
291
292
  const lineOf = (src: string, at: number) => src.slice(0, at).split('\n').length;
@@ -293,7 +294,7 @@ if (!fs.existsSync(manifestFile)) {
293
294
  for (const f of sources) {
294
295
  if (BOUNDARIES.includes(f)) continue;
295
296
  blanked(f).split('\n').forEach((line, i) => {
296
- if (/\bconnection\s*\(/.test(line)) problems.push(`${f}:${i + 1}: connection() outside the template's two request boundaries`);
297
+ if (/\bconnection\s*\(/.test(line)) problems.push(`${f}:${i + 1}: connection() outside the template's three request boundaries`);
297
298
  });
298
299
  }
299
300
  // A page and a view read the sample through src/data, so the day it becomes an API is one file's change. Every
@@ -339,7 +340,8 @@ const TOOLKIT = /^src\/lib\/(format|color|collection|live)\.ts$/;
339
340
  // release may add an export the team's code does not use yet. Only the project's own src/lib and src/data are swept.
340
341
  // The template, with no manifest, is swept whole.
341
342
  const meridians = new Set(Object.keys(effective?.files ?? {}));
342
- const kept = ['src', 'app', 'scripts'].flatMap((d) => walk(d, /\.tsx?$/)).filter((f) => !/(^|\/)preview\.tsx$/.test(f) && !f.startsWith('app/system/') && !atlasOnly.includes(f));
343
+ // A root proxy.ts is the product's own code too: Next runs it before every request.
344
+ const kept = [...['src', 'app', 'scripts'].flatMap((d) => walk(d, /\.tsx?$/)), ...(fs.existsSync(path.join(ROOT, 'proxy.ts')) ? ['proxy.ts'] : [])].filter((f) => !/(^|\/)preview\.tsx$/.test(f) && !f.startsWith('app/system/') && !atlasOnly.includes(f));
343
345
  const resolveSpec = (from: string, spec: string) => {
344
346
  const base = spec.startsWith('@/') ? path.join('src', spec.slice(2)) : spec.startsWith('.') ? path.join(path.dirname(from), spec) : null;
345
347
  if (!base) return null;
@@ -5,4 +5,8 @@ import path from 'node:path';
5
5
 
6
6
  const ROOT = path.resolve(import.meta.dirname, '../..');
7
7
 
8
+ // The gate, verify and the live check run `next` in the project: Meridian's own runs send no Next.js telemetry, whatever
9
+ // the project's next.config says.
10
+ process.env.NEXT_TELEMETRY_DISABLED ??= '1';
11
+
8
12
  export const bin = (name: string) => path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name);
@@ -38,6 +38,35 @@ These hold in every project, however it started, and nothing below overrides the
38
38
  API or read a database names a `fakeApi` (or says `noLiveApi`) in `scripts/verify.config.ts` first
39
39
  (`references/existing-project.md`, step 5).
40
40
 
41
+ ## Choose the route before anything else
42
+
43
+ Whatever the words, the person wants one thing: a dashboard on Meridian. What you decide is **where its code lives**
44
+ and **which folder you may write to**, and that picks one of four commands. People rarely name the command, so read
45
+ the intent, not the verb: "change this product into our new dashboard", "redo this with Meridian", "make a new one
46
+ based on this folder in another folder" and "build me an orders console" are all requests for a dashboard; they
47
+ differ only in which folder ends up holding it.
48
+
49
+ | What is true | The route | The command |
50
+ |---|---|---|
51
+ | `optional:.meridian/manifest.json` exists in the folder they point at | Already on Meridian: update, rebrand or keep building | `update` or `brand` (the next section), or straight to step 5 |
52
+ | They want **this** project changed in place ("restyle this", "change this product into our dashboard", "make our admin look professional"), and it is Next.js with the App Router | Adopt, here | `npx zz-meridian@latest adopt` (`references/existing-project.md`, Route A) |
53
+ | They want this project changed, and it is another stack (Vite, CRA, Remix, Vue, a static page) | A new project next to it, ported from it | `create <sibling folder>` (`references/existing-project.md`, Routes A2, B, C) |
54
+ | They want a **new** dashboard: in another folder, "based on" or "from" this folder, a schema, a CSV, a spec or nothing | Create, elsewhere; what they pointed at is input you read, never a folder you write | `npx zz-meridian@latest create <new folder>` |
55
+
56
+ Rules that settle the hard cases:
57
+
58
+ - **The folder they name to read from is not the folder you write to unless they said "this", "here" or "in place".**
59
+ "Based on this folder", "use this as the source", "copy what this does" mean: read it, create next to it, leave it
60
+ untouched.
61
+ - **`create` writes only into a folder that does not exist or is empty**, and **`adopt` changes the project it runs in**.
62
+ Never run `adopt` in a folder they asked you to leave alone, and never `create` inside an existing project.
63
+ - **Two routes fit and nothing decides between them** (an existing Next.js app here and "build me a dashboard for
64
+ this"): ask once, with in place (`adopt`) as the recommended option when they said "this" or "our", and a new folder
65
+ (`create`) when they said "new" or "another". With no way to ask, take that recommendation and say so in the
66
+ hand-over.
67
+ - **Say the route in one sentence before the first command**: "This is a Next.js App Router project you want changed
68
+ in place, so I am running `adopt` here." A wrong route is cheap to stop before the command and costly after it.
69
+
41
70
  ## Updating or rebranding a project that already has Meridian
42
71
 
43
72
  When `optional:.meridian/manifest.json` exists and the person asks to update Meridian, or to change the brand, do not
@@ -70,8 +99,8 @@ Before asking anything, look at what you already have:
70
99
  Ask in a single round (AskUserQuestion when available), only the questions your homework could not answer, and offer
71
100
  your draft as the recommended option so the person can accept it in one click. Usually that is:
72
101
 
73
- 1. **New project or this one?** Only when a frontend already exists here: create a new Meridian project next to it, or
74
- bring Meridian into the existing one (`references/existing-project.md`).
102
+ 1. **New project or this one?** Only when "Choose the route" above left two routes open: create a new Meridian project
103
+ in another folder, or bring Meridian into the existing one (`references/existing-project.md`).
75
104
  2. **What it is and for whom.** The product name, who uses it, and the one question the home page must answer.
76
105
  3. **Pages.** Your drafted list, mapped to Meridian presets (Overview, list, detail, analytics, health, settings, sign-in).
77
106
  4. **Brand and surfaces.** A brand colour (a hex, or "no preference" for indigo), light or dark first (dark is the
@@ -230,49 +259,57 @@ Pages: <list, one line each, with what each answers>.
230
259
  Brand: <accent and how it was derived>. Surfaces: console, mobile<, MCP views: …>.
231
260
  Validation: pnpm verify passed; coverage line: <the line verify printed>.
232
261
  Next steps: replace the sample data in src/data/ with <their API>; run pnpm verify after every change.
233
- Feedback: <the issue URL from step 8, or "nothing to report">.
262
+ Feedback: <the issue URL from step 8, "nothing to report", or "declined">.
234
263
  ```
235
264
 
236
265
  Attach or show the screenshots of the main pages in both themes. Say plainly what is sample data and what is not. If
237
266
  the Atlas stays, say that `/system` is the live specification and that `node scripts/brand.ts --no-atlas` removes it
238
267
  before the product goes public.
239
268
 
240
- ## 8. Report back to Meridian
269
+ ## 8. Offer feedback to Meridian
241
270
 
242
- Every build teaches Meridian something. Before you finish, write down everything this run found about Meridian itself
243
- and file it as **one** GitHub issue on `zhixuan312/zz-meridian`, so the next person does not hit the same thing.
271
+ Meridian collects nothing from the people who use it: no telemetry, no analytics, no account. A GitHub issue the person
272
+ chooses to open is the only way anything reaches Meridian, so this step is an offer, never a requirement, and it
273
+ happens only when the run found something about Meridian itself.
244
274
 
245
- Collect, from the whole session (not only the last step):
275
+ Collect, from the whole session (not only the last step), and sort each finding into one of two kinds:
246
276
 
247
- - **Bugs**: a component, pattern, script, check or doc that did the wrong thing: a gate that failed on correct code, a
277
+ - **Bug**: a component, pattern, script, check or doc that did the wrong thing: a gate that failed on correct code, a
248
278
  check that passed broken code, a component that clipped, overflowed or did nothing, a doc that sent you the wrong way.
249
- - **Improvements**: anything that worked but cost you a workaround, a second try, or a guess, and how it could be easier.
250
- - **Not covered**: what the product needed that Meridian has no answer for: a missing component, pattern, state, page
251
- kind, surface rule, or a question this skill and the docs never answered.
279
+ - **Feature request**: anything that worked but cost a workaround, a second try or a guess, and the change that would
280
+ have saved it; and what the product needed that Meridian has no answer for (a component, pattern, state, page kind,
281
+ surface rule, or a question this skill and the docs never answered).
252
282
 
253
- Each item gets what someone needs to act on it without asking you: what happened, where (`file:line`, the route, the
254
- command), how to see it again, and what you did instead. Leave out what is the person's own (their product name, data,
255
- URLs, credentials, screenshots of their pages); describe the shape of the problem with Meridian's sample instead.
283
+ Each finding gets what someone needs to act on it without asking: what happened, where in Meridian (its `file:line`,
284
+ the Meridian command, the Meridian route), how to see it again in Meridian's own template or sample data, and what you
285
+ did instead.
256
286
 
257
- Draft it in this shape:
287
+ **Nothing in it may identify the person, their organisation or their product.** Before showing a draft, remove:
258
288
 
259
- ```
260
- Title: Field report: <one line on the most important finding>
289
+ - the product's, company's, team's and people's names, email addresses and accounts;
290
+ - their URLs, hostnames, IP addresses, API routes and environment variable values;
291
+ - their data, records, schemas, field names and screenshots of their pages;
292
+ - file paths outside Meridian's own files, and anything from `optional:docs/brief.md`.
293
+
294
+ Describe the shape of the problem with Meridian's sample instead ("a Data table with 40 columns", not their table). If
295
+ a finding cannot be told without one of these, leave it out.
261
296
 
262
- Meridian <commit or version> · Next <version> · <what was built, in generic terms: "an orders console, 6 pages">
297
+ Draft one issue per kind, in the shape of Meridian's two issue forms, Bug and Feature request:
298
+
299
+ ```
300
+ Title: <one line on the most important finding>
263
301
 
264
- ## Bugs
265
- - <what, where, how to reproduce, what you did instead>
302
+ Meridian <version from .meridian/manifest.json> · Next <version> · Route <adopt | create>
266
303
 
267
- ## Improvements
268
- - <what cost time, and the change that would have saved it>
304
+ ## What happened
305
+ - <finding: what, where in Meridian, how to see it again, what you did instead>
269
306
 
270
- ## Not covered
271
- - <what was needed, and how you filled the gap>
307
+ ## What would fix it
308
+ - <the change you would make to Meridian>
272
309
  ```
273
310
 
274
- Show the draft to the person and file it only when they agree: it is published under their account. File it with
275
- `gh issue create --repo zhixuan312/zz-meridian --title "<title>" --body-file <draft>`. Without `gh`, give them a link
276
- that opens the form filled in: `https://github.com/zhixuan312/zz-meridian/issues/new?title=<encoded title>&body=<encoded
277
- body>`. Put the issue's URL in the hand-over. If the run truly found nothing, say "nothing to report" rather than filing
278
- an empty issue.
311
+ Show the draft to the person and ask whether to open it: it is published under their account, in public. Only on a
312
+ yes, file it with `gh issue create --repo zhixuan312/zz-meridian --label bug|enhancement --title "<title>" --body-file
313
+ <draft>`, or, without `gh`, give them `https://github.com/zhixuan312/zz-meridian/issues/new/choose` and the draft to
314
+ paste. Put the issue's URL in the hand-over. When the run found nothing about Meridian, or they decline, say so in one
315
+ line and file nothing.
@@ -5,6 +5,10 @@ Meridian would change, and which of those changes need the team's decision. It w
5
5
  `npx zz-meridian@latest update` then applies the safe changes and stages the rest. Do not copy files by hand to make up
6
6
  for either.
7
7
 
8
+ An update is tested from the last three releases. When the manifest's version is older than that (`npm view
9
+ zz-meridian versions` lists them), update in steps: first with the release three after it,
10
+ `npx zz-meridian@<that version> update`, through finalize, then again with `latest`.
11
+
8
12
  ## What it does
9
13
 
10
14
  It works from `optional:.meridian/manifest.json`, which records the release the project was copied from and a hash of
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A password in front of the whole product, for a demo deployment: an HttpOnly cookie holding an expiry and its HMAC.
3
+ * It is closed only when `DEMO_PASSWORD` is set; without it (local development, and every product that never sets it)
4
+ * nothing is gated. The key is derived from `DEMO_SECRET` (or, without it, from `DEMO_PASSWORD`), so a session survives
5
+ * restarts and redeploys, and changing either signs everyone out. A session lasts 30 days and is renewed when a visit
6
+ * finds it past half its life. Server only.
7
+ */
8
+ import crypto from 'node:crypto';
9
+
10
+ export const COOKIE = 'zz_meridian_demo';
11
+ export const MAX_AGE = 30 * 24 * 3600;
12
+
13
+ const password = () => process.env.DEMO_PASSWORD ?? '';
14
+ /** Whether the gate is closed: only when a password is configured. */
15
+ export const gated = () => password() !== '';
16
+ const key = () => crypto.createHmac('sha256', process.env.DEMO_SECRET || password()).update('zz-meridian-demo-session-v1').digest();
17
+ const sign = (expires: number | string) => crypto.createHmac('sha256', key()).update(String(expires)).digest('hex');
18
+
19
+ /** A fresh session value: expiry.signature. */
20
+ export function token(): string {
21
+ const expires = Math.floor(Date.now() / 1000) + MAX_AGE;
22
+ return `${expires}.${sign(expires)}`;
23
+ }
24
+
25
+ /** The session's expiry in seconds when the cookie value is genuine and unexpired, else 0. */
26
+ export function expiresOf(value: string | undefined): number {
27
+ const m = value?.match(/^([0-9]+)\.([0-9a-f]{64})$/);
28
+ if (!m || Number(m[1]) < Date.now() / 1000) return 0;
29
+ const a = Buffer.from(m[2]!, 'hex');
30
+ const b = Buffer.from(sign(m[1]!), 'hex');
31
+ return a.length === b.length && crypto.timingSafeEqual(a, b) ? Number(m[1]) : 0;
32
+ }
33
+
34
+ /** Compares with the configured password in constant time, whatever the lengths. */
35
+ export function samePassword(given: string): boolean {
36
+ const a = crypto.createHash('sha256').update(given).digest();
37
+ const b = crypto.createHash('sha256').update(password()).digest();
38
+ return gated() && crypto.timingSafeEqual(a, b);
39
+ }
@@ -0,0 +1,64 @@
1
+ // @vitest-environment node
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { NextRequest } from 'next/server';
4
+ import { COOKIE, MAX_AGE, expiresOf, gated, samePassword, token } from '@/lib/demo-gate';
5
+ import { proxy } from '../proxy';
6
+
7
+ const request = (path: string, { cookie, method = 'GET' }: { cookie?: string; method?: string } = {}) =>
8
+ new NextRequest(`https://demo.example${path}`, { method, headers: cookie ? { cookie: `${COOKIE}=${cookie}` } : {} });
9
+
10
+ afterEach(() => { delete process.env.DEMO_PASSWORD; delete process.env.DEMO_SECRET; });
11
+
12
+ describe('the demo gate, without DEMO_PASSWORD', () => {
13
+ it('is open: every route passes and no password matches', () => {
14
+ expect(gated()).toBe(false);
15
+ expect(samePassword('')).toBe(false);
16
+ expect(proxy(request('/')).headers.get('location')).toBeNull();
17
+ expect(proxy(request('/api/export/requests')).status).toBe(200);
18
+ });
19
+ });
20
+
21
+ describe('the demo gate, with DEMO_PASSWORD', () => {
22
+ beforeEach(() => { process.env.DEMO_PASSWORD = 'open sesame'; });
23
+
24
+ it('accepts the password and nothing else', () => {
25
+ expect(samePassword('open sesame')).toBe(true);
26
+ expect(samePassword('open sesam')).toBe(false);
27
+ expect(samePassword('')).toBe(false);
28
+ });
29
+ it('signs a session it can read back, and refuses a forged, an expired or a re-keyed one', () => {
30
+ const t = token();
31
+ expect(expiresOf(t)).toBeGreaterThan(Date.now() / 1000 + MAX_AGE - 5);
32
+ expect(expiresOf(`${Number(t.split('.')[0]) + 1}.${t.split('.')[1]}`)).toBe(0);
33
+ expect(expiresOf(`1.${t.split('.')[1]}`)).toBe(0);
34
+ expect(expiresOf(undefined)).toBe(0);
35
+ process.env.DEMO_SECRET = 'rotated';
36
+ expect(expiresOf(t)).toBe(0);
37
+ });
38
+ it('sends a visitor without a session to the sign-in page, and answers an API with 401', () => {
39
+ const page = proxy(request('/system'));
40
+ expect(page.status).toBe(303);
41
+ expect(new URL(page.headers.get('location')!).pathname).toBe('/sign-in');
42
+ expect(proxy(request('/api/assistant', { method: 'POST' })).status).toBe(401);
43
+ });
44
+ it('lets a session through, and renews one past half its life', () => {
45
+ const fresh = proxy(request('/', { cookie: token() }));
46
+ expect(fresh.headers.get('location')).toBeNull();
47
+ expect(fresh.cookies.get(COOKIE)).toBeUndefined();
48
+ vi.useFakeTimers();
49
+ try {
50
+ const t = token();
51
+ vi.setSystemTime(Date.now() + 20 * 24 * 3600 * 1000);
52
+ const renewed = proxy(request('/', { cookie: t })).cookies.get(COOKIE)?.value;
53
+ expect(renewed).not.toBe(t);
54
+ expect(expiresOf(renewed)).toBeGreaterThan(expiresOf(t));
55
+ } finally {
56
+ vi.useRealTimers();
57
+ }
58
+ });
59
+ it('signs the demo out when the sign-in page is opened, and leaves the sign-in itself alone', () => {
60
+ expect(proxy(request('/sign-in')).status).toBe(200);
61
+ expect(proxy(request('/sign-in', { cookie: token() })).cookies.get(COOKIE)?.value).toBe('');
62
+ expect(proxy(request('/sign-in', { cookie: token(), method: 'POST' })).cookies.get(COOKIE)).toBeUndefined();
63
+ });
64
+ });
@@ -0,0 +1,15 @@
1
+ // @vitest-environment node
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ beforeEach(() => { vi.resetModules(); delete process.env.NEXT_TELEMETRY_DISABLED; });
5
+
6
+ describe('Next.js telemetry', () => {
7
+ it("is off in the template's next.config, which next dev and next build load before they send any", async () => {
8
+ await import('../next.config.ts');
9
+ expect(process.env.NEXT_TELEMETRY_DISABLED).toBe('1');
10
+ });
11
+ it("is off for every next the gate, verify and the live check run, whatever the project's own config says", async () => {
12
+ await import('../scripts/lib/bin.ts');
13
+ expect(process.env.NEXT_TELEMETRY_DISABLED).toBe('1');
14
+ });
15
+ });