@zerotal/core 1.13.2 → 1.13.3

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The site gate: maintenance, and private preview.
3
+ *
4
+ * The primitive is here and the button is yours. An app that wants a toggle in
5
+ * its own console builds it against this; `zt down` / `zt preview` / `zt up` are
6
+ * the same three calls with a terminal in front of them.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { Gate } from "@zerotal/core/gate";
11
+ *
12
+ * await Gate.preview({ token, until: "2026-09-30", by: user.name });
13
+ * await Gate.maintenance({ retryAfter: 120, by: user.name });
14
+ * await Gate.open();
15
+ * Gate.status(); // { mode: "preview", since, until, by } — never the token
16
+ * ```
17
+ *
18
+ * @module
19
+ */
20
+ import { clearGate, gateExpired, hashToken, readGate, writeGate, type GateState } from "./state.ts";
21
+
22
+ /** Options for {@link Gate.maintenance}. */
23
+ export interface MaintenanceOptions {
24
+ /**
25
+ * Seconds to put in `Retry-After`. Defaults to 60.
26
+ *
27
+ * Worth setting honestly: a crawler that is told 60 and finds the site still
28
+ * down an hour later treats the next number with less patience.
29
+ */
30
+ retryAfter?: number | undefined;
31
+ /** Who raised it. Shown by `Gate.status()` and `zt doctor`. */
32
+ by?: string | undefined;
33
+ }
34
+
35
+ /** Options for {@link Gate.preview}. */
36
+ export interface PreviewOptions {
37
+ /**
38
+ * The secret that admits a visitor, via `?preview=<token>`.
39
+ *
40
+ * Stored as a hash, so this value cannot be read back out — keep the copy you
41
+ * hand to testers. Passing a new one rotates: every cookie issued under the
42
+ * old token stops working, which is the point, because the way a preview
43
+ * leaks is a tester forwarding the link to somebody who has left.
44
+ */
45
+ token: string;
46
+ /** ISO date the preview lifts itself on, e.g. `"2026-09-30"`. */
47
+ until?: string | undefined;
48
+ /** Who raised it. */
49
+ by?: string | undefined;
50
+ }
51
+
52
+ /** What {@link Gate.status} reports. Deliberately never includes the token. */
53
+ export interface GateStatus {
54
+ mode: GateState["mode"] | "open";
55
+ since?: string | undefined;
56
+ until?: string | undefined;
57
+ by?: string | undefined;
58
+ retryAfter?: number | undefined;
59
+ /** Whether `until` has passed — the gate is still up and should not be. */
60
+ expired?: boolean | undefined;
61
+ }
62
+
63
+ /**
64
+ * Read and write the site gate.
65
+ *
66
+ * Synchronous underneath — the state is one small file — but the methods that
67
+ * change it are `async` so an app's own console handler can `await` them without
68
+ * caring, and so a future driver (shared storage, for a multi-instance deploy)
69
+ * does not change the signature.
70
+ */
71
+ export const Gate = {
72
+ /**
73
+ * Take the site down. Everyone is refused, staff included.
74
+ *
75
+ * Answered `503` with `Retry-After`, never `200`. A maintenance page served at
76
+ * `200` tells a search engine that "we will be back shortly" is the content of
77
+ * your homepage, and it will index it as such.
78
+ */
79
+ async maintenance(options: MaintenanceOptions = {}): Promise<void> {
80
+ writeGate({
81
+ mode: "maintenance",
82
+ since: new Date().toISOString(),
83
+ retryAfter: options.retryAfter ?? 60,
84
+ ...(options.by ? { by: options.by } : {}),
85
+ });
86
+ },
87
+
88
+ /**
89
+ * Put the site behind a private preview. Invited visitors get the real site.
90
+ *
91
+ * @throws When the token is shorter than 16 characters. A preview token is a
92
+ * password on the whole site with no rate limit in front of it, and a short
93
+ * one chosen by hand is the failure this refuses rather than documents.
94
+ */
95
+ async preview(options: PreviewOptions): Promise<void> {
96
+ if (options.token.length < 16) {
97
+ throw new Error(
98
+ "[Zerotal] A preview token must be at least 16 characters. It is the only thing " +
99
+ "between the public and an unlaunched site, and nothing rate-limits guesses at it. " +
100
+ "Generate one with `zt preview` and no --token.",
101
+ );
102
+ }
103
+ writeGate({
104
+ mode: "preview",
105
+ since: new Date().toISOString(),
106
+ tokenHash: hashToken(options.token),
107
+ ...(options.until ? { until: options.until } : {}),
108
+ ...(options.by ? { by: options.by } : {}),
109
+ });
110
+ },
111
+
112
+ /** Open the site. Opening an already-open site is not an error. */
113
+ async open(): Promise<void> {
114
+ clearGate();
115
+ },
116
+
117
+ /** What the gate is doing, for a console or a health page. Never the token. */
118
+ status(): GateStatus {
119
+ const state = readGate();
120
+ if (!state) return { mode: "open" };
121
+ return {
122
+ mode: state.mode,
123
+ since: state.since,
124
+ until: state.until,
125
+ by: state.by,
126
+ retryAfter: state.retryAfter,
127
+ expired: gateExpired(state),
128
+ };
129
+ },
130
+ };
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The gate itself: what happens to a request while the site is closed.
3
+ *
4
+ * Runs in the kernel layer, before routing, before the session is resolved and
5
+ * before anything touches the database — because in maintenance mode the
6
+ * database may be the thing that is unavailable, and a gate that needs it cannot
7
+ * report that it is down.
8
+ *
9
+ * @module
10
+ */
11
+ import type { NextFn } from "../pipeline/types.ts";
12
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
13
+ import { BaseMiddleware } from "../middleware/BaseMiddleware.ts";
14
+ import { config } from "../helpers/config.ts";
15
+ import { hmacHex, safeEqual } from "../support/crypto.ts";
16
+ import { deployEnv, isProdLike } from "../support/env.ts";
17
+ import { gateExpired, hashToken, readGate, type GateState } from "./state.ts";
18
+
19
+ /** The cookie the gate issues once a token has been accepted. */
20
+ export const GATE_COOKIE = "zerotal_preview";
21
+
22
+ /** The query parameter that carries a preview token. */
23
+ export const GATE_QUERY = "preview";
24
+
25
+ /**
26
+ * Paths that stay reachable while the gate is up, whatever mode it is in.
27
+ *
28
+ * Every entry is something a naive gate breaks:
29
+ *
30
+ * - **The health endpoint**, or the uptime monitor pages the on-call about a
31
+ * planned window, and a deploy gate that polls health fails its own release.
32
+ * - **Static assets**, or the maintenance page 503s its own stylesheet and the
33
+ * apology arrives unstyled.
34
+ *
35
+ * Webhooks are deliberately *not* here — see `gate.allow` in config, and the
36
+ * warning next to it.
37
+ */
38
+ const ALWAYS_ALLOWED = ["/__zerotal/", "/css/", "/js/", "/assets/", "/favicon.ico"];
39
+
40
+ /** Shape read from `config/gate.ts`, all optional. */
41
+ interface GateConfig {
42
+ /**
43
+ * Extra path prefixes that stay reachable while the gate is up.
44
+ *
45
+ * **Put your webhook paths here.** This is the item on the list that costs
46
+ * money: a payment provider posting a settlement into a maintenance window
47
+ * gets a 503, and depending on the provider that is a retry, a dropped
48
+ * callback, or a payment your books never learn about. Nothing can infer which
49
+ * of your routes a third party calls, so it has to be declared.
50
+ */
51
+ allow?: string[];
52
+ /**
53
+ * What the public gets during a **preview** — not maintenance, which is always
54
+ * 503.
55
+ *
56
+ * `"holding"` (default) serves the holding view at `200`, which is right for a
57
+ * pre-launch site collecting an email address. `"notFound"` serves `404`, which
58
+ * is right when the site's existence is itself not public. Both are legitimate
59
+ * and the framework does not pick.
60
+ */
61
+ publicResponse?: "holding" | "notFound";
62
+ }
63
+
64
+ /**
65
+ * Middleware that answers for a closed site.
66
+ *
67
+ * @category Middleware
68
+ */
69
+ /**
70
+ * The gate's decision for one request, or `null` to let it through.
71
+ *
72
+ * A plain function over `Request` rather than a method on the middleware,
73
+ * because the middleware is not the only place this has to run.
74
+ * {@link Router.raw} handlers bypass the pipeline by design, and a gate that
75
+ * covers the pipeline alone is the worst kind: it gates the homepage, so it
76
+ * looks like it works, while every raw route stays public. This framework's own
77
+ * documentation site serves all of `/docs/*` from raw routes, which is how that
78
+ * was found — the front page said "coming soon" and every page of content was
79
+ * open.
80
+ *
81
+ * @param request - The incoming request.
82
+ * @param staff - Whether the caller has already established this is a staff
83
+ * user. Raw routes have no session resolved, so they pass `false`.
84
+ * @returns The response to send, or `null` when the request may proceed.
85
+ * @internal
86
+ */
87
+ export function _gateResponse(request: Request, staff: boolean): Response | null {
88
+ const state = readGate();
89
+ if (!state) return null;
90
+
91
+ // A preview whose `until` has passed lifts itself. The alternative is a gate
92
+ // that outlives its purpose because the person who set it moved on, which is
93
+ // the ordinary way pre-launch gates end.
94
+ if (state.mode === "preview" && gateExpired(state)) return null;
95
+
96
+ const url = new URL(request.url);
97
+ if (_allowed(url.pathname)) return null;
98
+
99
+ if (state.mode === "maintenance") return _maintenance(state);
100
+
101
+ const offered = url.searchParams.get(GATE_QUERY);
102
+ if (offered && state.tokenHash && safeEqual(hashToken(offered), state.tokenHash)) {
103
+ // Redirect with the parameter removed, and not for tidiness. A token left in
104
+ // the address bar travels into `Referer` on every outbound link, into
105
+ // analytics, into screenshots, and into the message where somebody shares
106
+ // "the page I was looking at". Strip it on first use and the secret lives
107
+ // only in a cookie.
108
+ url.searchParams.delete(GATE_QUERY);
109
+ return new Response(null, {
110
+ status: 302,
111
+ headers: {
112
+ Location: `${url.pathname}${url.search}${url.hash}`,
113
+ "Set-Cookie": _cookie(state),
114
+ "Cache-Control": "no-store",
115
+ },
116
+ });
117
+ }
118
+
119
+ if (_hasValidCookie(request, state)) return null;
120
+ if (staff) return null;
121
+
122
+ return config.safe<GateConfig>("gate", {}).publicResponse === "notFound"
123
+ ? new Response(_notFoundHtml(), {
124
+ status: 404,
125
+ headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
126
+ })
127
+ : new Response(_holdingHtml(), {
128
+ status: 200,
129
+ headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Whether this path stays reachable while the gate is up.
135
+ *
136
+ * Read through `config.safe`, not `config`: this has to answer on a request
137
+ * where the application may not be resolvable at all. A gate that throws because
138
+ * the container is unavailable is a gate that cannot report the app is down,
139
+ * which is the one job it has.
140
+ */
141
+ function _allowed(pathname: string): boolean {
142
+ const extra = config.safe<GateConfig>("gate", {}).allow ?? [];
143
+ return [...ALWAYS_ALLOWED, ...extra].some((prefix) => pathname.startsWith(prefix));
144
+ }
145
+
146
+ /** Whether the request carries a cookie this gate issued. */
147
+ function _hasValidCookie(request: Request, state: GateState): boolean {
148
+ const raw = _readCookie(request.headers.get("cookie"), GATE_COOKIE);
149
+ if (!raw || !state.tokenHash) return false;
150
+ // Signed over the token hash, so rotating the token invalidates every cookie
151
+ // issued under the previous one without tracking who holds what.
152
+ return safeEqual(raw, _signature(state.tokenHash));
153
+ }
154
+
155
+ /**
156
+ * 503 with `Retry-After`, and never anything else.
157
+ *
158
+ * The status is not cosmetic. Sites have lost their search rankings to a
159
+ * two-hour window served at 200, because a crawler that gets 200 believes the
160
+ * apology *is* the page. 503 tells it to come back and keep what it had.
161
+ */
162
+ function _maintenance(state: GateState): Response {
163
+ return new Response(_maintenanceHtml(), {
164
+ status: 503,
165
+ headers: {
166
+ "Content-Type": "text/html; charset=utf-8",
167
+ "Retry-After": String(state.retryAfter ?? 60),
168
+ // Nothing about a maintenance response should be cached: the next request
169
+ // after the window is a different answer at the same URL.
170
+ "Cache-Control": "no-store",
171
+ },
172
+ });
173
+ }
174
+
175
+ export class GateMiddleware extends BaseMiddleware<GateConfig> {
176
+ /**
177
+ * Read per-request rather than captured here, because the gate is changed
178
+ * while the process is running — that is the entire point of it — and options
179
+ * frozen at construction would need a restart to take effect.
180
+ */
181
+ protected options: GateConfig = {};
182
+
183
+ async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
184
+ const answer = _gateResponse(ctx.request, _isStaff(ctx));
185
+ return answer ?? next();
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Whether the request is already an authenticated non-customer.
191
+ *
192
+ * An app that has staff accounts should not need a second secret for the people
193
+ * who already sign in. Read defensively: this runs before the session middleware
194
+ * on a request that may have no session at all, so anything unexpected means
195
+ * "not staff" rather than an exception from the gate.
196
+ */
197
+ function _isStaff(ctx: HttpContext): boolean {
198
+ // `in` rather than a cast: `user` is attached by the auth package at runtime
199
+ // and is not on `HttpContext`, so narrowing is the honest way to read it.
200
+ if (!("user" in ctx)) return false;
201
+ const user = ctx.user;
202
+ if (typeof user !== "object" || user === null || !("role" in user)) return false;
203
+ const role = user.role;
204
+ return typeof role === "string" && role !== "customer";
205
+ }
206
+
207
+ /** HMAC of the token hash under the app key, so the cookie cannot be forged. */
208
+ function _signature(tokenHash: string): string {
209
+ const key = String(Bun.env["APP_KEY"] ?? "");
210
+ return hmacHex(`gate:${tokenHash}`, key);
211
+ }
212
+
213
+ /** The `Set-Cookie` value admitting this visitor. */
214
+ function _cookie(state: GateState): string {
215
+ const parts = [
216
+ `${GATE_COOKIE}=${_signature(state.tokenHash ?? "")}`,
217
+ "Path=/",
218
+ "HttpOnly",
219
+ "SameSite=Lax",
220
+ // Days, not months. A preview cookie with no lifetime means an ex-tester
221
+ // keeps access to a site that has since gone live with real customer data.
222
+ `Max-Age=${7 * 24 * 60 * 60}`,
223
+ ];
224
+ // `deployEnv()`, not the raw variable: after `setAppEnv()` the latter holds the
225
+ // runtime mode rather than the deployment name.
226
+ if (isProdLike(deployEnv())) parts.push("Secure");
227
+ return parts.join("; ");
228
+ }
229
+
230
+ /** Read one cookie out of a `Cookie` header. */
231
+ function _readCookie(header: string | null, name: string): string | undefined {
232
+ if (!header) return undefined;
233
+ for (const pair of header.split(";")) {
234
+ const index = pair.indexOf("=");
235
+ if (index === -1) continue;
236
+ if (pair.slice(0, index).trim() === name) return pair.slice(index + 1).trim();
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ /** Deliberately plain. Nobody wants a framework's opinion about their apology. */
242
+ function _maintenanceHtml(): string {
243
+ return _page("Back shortly", "We are carrying out planned maintenance. Please try again soon.");
244
+ }
245
+
246
+ function _holdingHtml(): string {
247
+ return _page("Coming soon", "This site is not open to the public yet.");
248
+ }
249
+
250
+ function _notFoundHtml(): string {
251
+ return _page("Not found", "");
252
+ }
253
+
254
+ function _page(heading: string, body: string): string {
255
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
256
+ <meta name="viewport" content="width=device-width,initial-scale=1">
257
+ <title>${heading}</title>
258
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;color:#111}h1{font-size:1.5rem;margin:0 0 .5rem}p{margin:0;color:#555}</style>
259
+ </head><body><h1>${heading}</h1>${body ? `<p>${body}</p>` : ""}</body></html>`;
260
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The doctor check for a gate that is still up.
3
+ *
4
+ * A site gate is the most reversible thing in the framework and the easiest to
5
+ * forget, because when it is working nothing complains: the people who would
6
+ * notice are the ones being kept out, and they have no way to tell you. Trekly's
7
+ * report describes the same shape from the other side — a `basic_auth` block
8
+ * held out of version control precisely so it could not be forgotten into
9
+ * production, which is a workaround for the absence of this check.
10
+ *
11
+ * @module
12
+ */
13
+ import type { Application } from "../application/Application.ts";
14
+ import { deployEnv, isProdLike } from "../support/env.ts";
15
+ import type { DoctorCheck, DoctorCheckResult } from "../doctor/AppDoctor.ts";
16
+ import { gateExpired, readGate } from "./state.ts";
17
+
18
+ /**
19
+ * Report a site that is not open, and say how long it has been that way.
20
+ *
21
+ * Severity is by mode and environment, because the same state means different
22
+ * things. Maintenance in production is an outage and is reported as a failure.
23
+ * A preview in production is usually deliberate — a pre-launch site — so it is a
24
+ * warning that names the date rather than an error. A gate in development is
25
+ * neither, and saying nothing there is what keeps the check worth reading.
26
+ */
27
+ export const siteGateCheck: DoctorCheck = {
28
+ id: "site-gate",
29
+ label: "Site gate",
30
+ run(_app: Application): DoctorCheckResult {
31
+ const state = readGate();
32
+ if (!state) return { status: "ok", message: "site is open" };
33
+
34
+ // The deployment environment, not the boot mode — `app._env` is web/worker/
35
+ // console and would never equal "production".
36
+ const production = isProdLike(deployEnv());
37
+ const since = state.since.slice(0, 10);
38
+ const who = state.by ? `, set by ${state.by}` : "";
39
+
40
+ if (state.mode === "preview" && gateExpired(state)) {
41
+ // Worth its own finding: the file says the site is gated and the gate is
42
+ // no longer gating, so what a reader believes and what visitors get have
43
+ // come apart.
44
+ return {
45
+ status: "warn",
46
+ message:
47
+ `A private preview's window closed on ${state.until} — the site has been public ` +
48
+ `since then, while the gate file still says otherwise.`,
49
+ fix: "Run `bun zt up` to clear it, or `bun zt preview --until=<date>` to extend it.",
50
+ };
51
+ }
52
+
53
+ if (state.mode === "maintenance") {
54
+ return {
55
+ status: production ? "fail" : "warn",
56
+ message:
57
+ `The site is in maintenance mode${who}, since ${since}. Every request is answered ` +
58
+ `503${production ? " — in production, this is an outage" : ""}.`,
59
+ fix: "Run `bun zt up` when the work is finished.",
60
+ };
61
+ }
62
+
63
+ return {
64
+ status: production ? "warn" : "ok",
65
+ message:
66
+ `The site is in private preview${who}, since ${since}` +
67
+ `${state.until ? `, until ${state.until}` : " with no end date"}. The public cannot see it.` +
68
+ `${state.until ? "" : " A preview with no end date is the kind that outlives its purpose."}`,
69
+ fix: "Run `bun zt up` to make it public, or `bun zt gate:status` to see the details.",
70
+ };
71
+ },
72
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The site gate — maintenance, and private preview.
3
+ *
4
+ * Published as `@zerotal/core/gate`. See {@link Gate} for the API and
5
+ * {@link GateMiddleware} for what happens to a request while the gate is up.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ export { Gate } from "./Gate.ts";
10
+ export type { GateStatus, MaintenanceOptions, PreviewOptions } from "./Gate.ts";
11
+ export { GateMiddleware, GATE_COOKIE, GATE_QUERY } from "./GateMiddleware.ts";
12
+ export { readGate, gateExpired, GATE_FILE } from "./state.ts";
13
+ export type { GateState, GateMode } from "./state.ts";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The site gate's state, and where it is kept.
3
+ *
4
+ * Two modes that look alike and behave differently, which is why building one
5
+ * and calling it both goes wrong:
6
+ *
7
+ * - **`maintenance`** — the site is *down*. Nobody may use it, staff included,
8
+ * because the usual reason it is down is that the database is being migrated
9
+ * underneath it. Minutes, not weeks. A request is answered `503` with
10
+ * `Retry-After`.
11
+ * - **`preview`** — the site is *up and working perfectly*, for the people
12
+ * invited to it. Not down, not broken, not public yet. Weeks. An invited
13
+ * visitor gets the real site at `200` and can transact on it.
14
+ *
15
+ * ## Why a file, and not the database
16
+ *
17
+ * A flag in the database is unreadable exactly when the database is the thing
18
+ * you are working on, so a maintenance mode kept there is one that works only on
19
+ * the days you did not need it. A file is also why the gate survives a restart —
20
+ * an in-memory flag would be lifted by the very deploy that was supposed to run
21
+ * behind it.
22
+ *
23
+ * ## Why the token is stored as a hash
24
+ *
25
+ * The state file sits on disk, readable by anything on the box and copied by
26
+ * every backup. Storing the preview token there would put a live credential in
27
+ * a file whose whole job is to be easy to read — the same mistake as a
28
+ * `.env.example` carrying a working key. Only `sha256Hex(token)` is written, so
29
+ * the file can verify a token and cannot leak one, and rotating means writing a
30
+ * new hash.
31
+ *
32
+ * @module
33
+ */
34
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
35
+ import { dirname, join } from "node:path";
36
+ import { sha256Hex } from "../support/crypto.ts";
37
+
38
+ /** Which of the two states the site is in. */
39
+ export type GateMode = "maintenance" | "preview";
40
+
41
+ /** What the gate records about itself. */
42
+ export interface GateState {
43
+ mode: GateMode;
44
+ /** ISO timestamp the gate was raised. */
45
+ since: string;
46
+ /**
47
+ * ISO date the gate lifts itself on, when one was set.
48
+ *
49
+ * A pre-launch gate outlives its purpose more often than it is taken down on
50
+ * time, so the deadline is worth recording even when nothing enforces it.
51
+ */
52
+ until?: string | undefined;
53
+ /** Who raised it, for a console that can say more than "on". */
54
+ by?: string | undefined;
55
+ /** `sha256Hex` of the preview token. Never the token. `preview` only. */
56
+ tokenHash?: string | undefined;
57
+ /** Seconds for the `Retry-After` header. `maintenance` only. */
58
+ retryAfter?: number | undefined;
59
+ }
60
+
61
+ /** Where the state file lives, relative to the project root. */
62
+ export const GATE_FILE = join("storage", "framework", "gate.json");
63
+
64
+ function _path(root: string): string {
65
+ return join(root, GATE_FILE);
66
+ }
67
+
68
+ /**
69
+ * Read the gate's state, or `null` when the site is open.
70
+ *
71
+ * Never throws. A corrupt or unreadable file reads as **open**, deliberately: a
72
+ * gate that fails closed would take a site down because a JSON file lost a
73
+ * brace, and "the site is up" is the safer error for a mechanism whose whole
74
+ * purpose is to be turned off again.
75
+ *
76
+ * @param root - Project root. Defaults to the working directory.
77
+ */
78
+ export function readGate(root: string = process.cwd()): GateState | null {
79
+ try {
80
+ const raw = readFileSync(_path(root), "utf8");
81
+ const state = JSON.parse(raw) as GateState;
82
+ if (state.mode !== "maintenance" && state.mode !== "preview") return null;
83
+ return state;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Write the gate's state, creating `storage/framework/` if it is not there.
91
+ *
92
+ * @param state - The state to record.
93
+ * @param root - Project root. Defaults to the working directory.
94
+ */
95
+ export function writeGate(state: GateState, root: string = process.cwd()): void {
96
+ const file = _path(root);
97
+ mkdirSync(dirname(file), { recursive: true });
98
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
99
+ }
100
+
101
+ /** Remove the state file. Opening an already-open site is not an error. */
102
+ export function clearGate(root: string = process.cwd()): void {
103
+ rmSync(_path(root), { force: true });
104
+ }
105
+
106
+ /** Whether a state file exists at all, without parsing it. */
107
+ export function gateFileExists(root: string = process.cwd()): boolean {
108
+ return existsSync(_path(root));
109
+ }
110
+
111
+ /**
112
+ * Whether a `until` date has passed.
113
+ *
114
+ * Compared as a date rather than an instant: `until: "2026-09-30"` means "through
115
+ * the 30th", which is what someone typing a date means.
116
+ */
117
+ export function gateExpired(state: GateState, now: Date = new Date()): boolean {
118
+ if (!state.until) return false;
119
+ const end = new Date(`${state.until.slice(0, 10)}T23:59:59.999Z`);
120
+ if (Number.isNaN(end.getTime())) return false;
121
+ return now.getTime() > end.getTime();
122
+ }
123
+
124
+ /** Hash a preview token for storage. Exported so the CLI and the API agree. */
125
+ export function hashToken(token: string): string {
126
+ return sha256Hex(token);
127
+ }