zz-meridian 0.7.0 → 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 +1 -1
- package/dist/adopt.js +6 -2
- package/package.json +1 -1
- package/payload/CHANGELOG.md +15 -0
- package/payload/README.md +3 -2
- package/payload/app/sign-in/README.md +10 -0
- package/payload/app/sign-in/actions.ts +19 -0
- package/payload/app/sign-in/gated-panel.tsx +9 -0
- package/payload/app/sign-in/page.tsx +4 -2
- package/payload/app/sign-in/panel.tsx +36 -5
- package/payload/decisions/0010-the-adopter-contract.md +6 -4
- package/payload/docs/distribution.md +9 -5
- package/payload/gitignore +10 -0
- package/payload/package.json +1 -1
- package/payload/proxy.ts +37 -0
- package/payload/scripts/check.ts +7 -5
- package/payload/skills/zz-meridian/references/update.md +4 -0
- package/payload/src/lib/demo-gate.ts +39 -0
- package/payload/tests/demo-gate.test.ts +64 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ dashboard depends on it afterwards.
|
|
|
9
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
10
|
words at the end:
|
|
11
11
|
|
|
12
|
-
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to:
|
|
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
13
|
|
|
14
14
|
The skill picks the command from what you said and what is in the folder, and names it before running anything:
|
|
15
15
|
`adopt` to change this Next.js App Router project in place ("make this admin panel look professional"), `create` for a
|
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
|
-
|
|
262
|
-
|
|
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.
|
|
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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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
|
+
|
|
5
20
|
## [0.7.0] · 2026-10-06
|
|
6
21
|
|
|
7
22
|
### Changed
|
package/payload/README.md
CHANGED
|
@@ -74,15 +74,16 @@ 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 (
|
|
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
81
|
## Get a dashboard on Meridian, in one sentence
|
|
81
82
|
|
|
82
83
|
Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, with what you want in your own
|
|
83
84
|
words at the end:
|
|
84
85
|
|
|
85
|
-
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to:
|
|
86
|
+
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
|
|
86
87
|
|
|
87
88
|
You do not choose between the package's commands; the skill does, from what you said and what is in the folder, and
|
|
88
89
|
names the route before it runs anything:
|
|
@@ -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 {
|
|
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={<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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.
|
|
58
|
-
|
|
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
|
|
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
|
|
@@ -6,7 +6,7 @@ Status: v1 (`create`, `adopt`, `skill`) shipped in 0.2.0 (decision 0009). v2 (`u
|
|
|
6
6
|
|
|
7
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 skill --global`, then follow the zz-meridian skill it installs to:
|
|
9
|
+
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
|
|
10
10
|
|
|
11
11
|
The sentence names no command, because people do not: "change this product into our dashboard", "a new one based on
|
|
12
12
|
this folder, in another folder" and "build an orders console" all ask for a dashboard on Meridian and differ only in
|
|
@@ -218,8 +218,10 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
|
|
|
218
218
|
- `adopt` into `cli/test/fixture-next-app` (a minimal App Router app with one page and its own stylesheet), then
|
|
219
219
|
install, `tsc --noEmit` and `next build`, all green. This is the test that keeps the Route A list complete.
|
|
220
220
|
- `create` into a clean folder, then its default `pnpm verify`. The smoke quotes the coverage line.
|
|
221
|
-
- `update` from
|
|
222
|
-
|
|
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-*`).
|
|
223
225
|
5. **Publish** the tarball with `npm` 11.5.1 or newer through trusted publishing (OIDC), with `--provenance`. `pnpm
|
|
224
226
|
publish` does not perform the OIDC exchange.
|
|
225
227
|
6. **The registry's package**: `npx zz-meridian@<version> --version` answers the version, the registry's tarball has the
|
|
@@ -234,8 +236,10 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
|
|
|
234
236
|
|
|
235
237
|
**Weekly** (`.github/workflows/weekly.yml`, Mondays and on demand, master's current commit): the template's
|
|
236
238
|
`verify --full --perf`, one gate and one build for both (the perf part a report: a p95 over budget is a warning, a
|
|
237
|
-
broken sample a failure), and the consumer smoke's `--adopt --update-all --verify` from a tarball packed from that
|
|
238
|
-
commit
|
|
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
|
|
239
243
|
and release is release: neither runs what the other does. Each job keeps its log as an artifact. A failure notifies;
|
|
240
244
|
nothing waits on it.
|
|
241
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*
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
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.",
|
package/payload/proxy.ts
ADDED
|
@@ -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
|
+
};
|
package/payload/scripts/check.ts
CHANGED
|
@@ -281,11 +281,12 @@ const retirements: Retirement[] = [];
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// ── The template's request boundaries ────────────────────────────────────────────────────────────────
|
|
284
|
-
//
|
|
285
|
-
// the address in
|
|
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
|
|
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
|
-
|
|
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,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
|
+
});
|