@hadoku/service-kernel 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +125 -0
  3. package/dist/auth/index.js +189 -0
  4. package/dist/capacity/capacity.d.ts +33 -0
  5. package/dist/capacity/capacity.js +119 -0
  6. package/dist/capacity/index.d.ts +1 -0
  7. package/dist/capacity/index.js +1 -0
  8. package/dist/erase/anonymise.d.ts +18 -0
  9. package/dist/erase/anonymise.js +77 -0
  10. package/dist/erase/erase.d.ts +59 -0
  11. package/dist/erase/erase.js +72 -0
  12. package/dist/erase/index.d.ts +2 -0
  13. package/dist/erase/index.js +2 -0
  14. package/dist/health/health.d.ts +42 -0
  15. package/dist/health/health.js +52 -0
  16. package/dist/health/index.d.ts +1 -0
  17. package/dist/health/index.js +1 -0
  18. package/dist/http/index.d.ts +123 -0
  19. package/dist/http/index.js +192 -0
  20. package/dist/lifecycle/announce.d.ts +74 -0
  21. package/dist/lifecycle/announce.js +232 -0
  22. package/dist/lifecycle/bootClock.d.ts +7 -0
  23. package/dist/lifecycle/bootClock.js +14 -0
  24. package/dist/lifecycle/busy.d.ts +57 -0
  25. package/dist/lifecycle/busy.js +47 -0
  26. package/dist/lifecycle/flush-sockets.d.ts +41 -0
  27. package/dist/lifecycle/flush-sockets.js +61 -0
  28. package/dist/lifecycle/index.d.ts +4 -0
  29. package/dist/lifecycle/index.js +4 -0
  30. package/dist/limits/index.d.ts +1 -0
  31. package/dist/limits/index.js +1 -0
  32. package/dist/limits/limits.d.ts +76 -0
  33. package/dist/limits/limits.js +103 -0
  34. package/dist/log/index.d.ts +1 -0
  35. package/dist/log/index.js +1 -0
  36. package/dist/log/log.d.ts +7 -0
  37. package/dist/log/log.js +8 -0
  38. package/dist/relays/client-events.d.ts +51 -0
  39. package/dist/relays/client-events.js +53 -0
  40. package/dist/relays/index.d.ts +2 -0
  41. package/dist/relays/index.js +2 -0
  42. package/dist/relays/usage.d.ts +53 -0
  43. package/dist/relays/usage.js +75 -0
  44. package/dist/server/bus.d.ts +27 -0
  45. package/dist/server/bus.js +21 -0
  46. package/dist/server/index.d.ts +2 -0
  47. package/dist/server/index.js +2 -0
  48. package/dist/server/server.d.ts +157 -0
  49. package/dist/server/server.js +244 -0
  50. package/dist/state/index.d.ts +1 -0
  51. package/dist/state/index.js +1 -0
  52. package/dist/state/state-dir.d.ts +39 -0
  53. package/dist/state/state-dir.js +63 -0
  54. package/dist/telemetry/index.d.ts +1 -0
  55. package/dist/telemetry/index.js +1 -0
  56. package/dist/telemetry/telemetry.d.ts +105 -0
  57. package/dist/telemetry/telemetry.js +221 -0
  58. package/package.json +71 -4
  59. package/README.md +0 -3
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Account deletion's erase hook (hadoku_site IDENTITY_MODEL R12, "Erase hooks"; the caller is
3
+ * edge-router's `eraseMemberData`, which runs it on account delete and on the merged-away side of
4
+ * an account merge).
5
+ *
6
+ * The edge calls it with its own seal and `X-User-Id`, and wants 200 `{ erased: { <what>: <count> } }`
7
+ * back, zeros on a second call. Anything else fails the whole delete, which the edge retries, so a
8
+ * step that throws answers 500 rather than a partial count.
9
+ *
10
+ * Only the seal admits it — `edgeSealed` is an injected check, never a tier: this module knows no
11
+ * app's edge-auth binding. `deriveParticipantId` is likewise injected: the id a person is also
12
+ * known by (the `fk:`/`dev:`/`eph:` scheme, whichever app provides it) is platform but app-bound,
13
+ * not this kernel's to import.
14
+ *
15
+ * What is erased is a list of steps each store registers where it is built (`register`), so a
16
+ * store and its erase live side by side in the app. Steps run one at a time, in registration
17
+ * order, so two never rewrite one file at once.
18
+ */
19
+ import { placeholderId } from "./anonymise.js";
20
+ import { reportSealRefused } from "../telemetry/telemetry.js";
21
+ /** `/api/_account/erase` — the one path every app answers this hook on. */
22
+ export const ERASE_PATH = '/api/_account/erase';
23
+ export function personOf(userId, deriveParticipantId) {
24
+ const participantId = deriveParticipantId(userId);
25
+ return { userId, participantId, ids: new Set([userId, participantId]), placeholder: placeholderId() };
26
+ }
27
+ export function createAccountErase(opts) {
28
+ const steps = [];
29
+ const erase = async (userId) => {
30
+ const person = personOf(userId, opts.deriveParticipantId);
31
+ const erased = {};
32
+ for (const step of steps) {
33
+ const counts = await step.erase(person);
34
+ if (typeof counts === 'number')
35
+ erased[step.what] = counts;
36
+ else
37
+ Object.assign(erased, counts);
38
+ }
39
+ return erased;
40
+ };
41
+ return {
42
+ register(step) {
43
+ if (steps.some((s) => s.what === step.what))
44
+ throw new Error(`erase step '${step.what}' is registered twice`);
45
+ steps.push(step);
46
+ },
47
+ erase,
48
+ async handle(req, res) {
49
+ if (!opts.edgeSealed(req)) {
50
+ reportSealRefused('http', req.headers, req.path);
51
+ res.status(401).json({ error: "erase needs the edge's seal" });
52
+ return;
53
+ }
54
+ const raw = req.headers['x-user-id'];
55
+ const userId = typeof raw === 'string' ? raw.trim() : '';
56
+ if (userId === '') {
57
+ res.status(400).json({ error: 'X-User-Id: whose data to erase' });
58
+ return;
59
+ }
60
+ try {
61
+ const erased = await erase(userId);
62
+ opts.log({ evt: 'account-erased', userId, erased });
63
+ res.json({ erased });
64
+ }
65
+ catch (err) {
66
+ const reason = err instanceof Error ? err.message : String(err);
67
+ opts.log({ evt: 'account-erase-failed', userId, reason });
68
+ res.status(500).json({ error: reason });
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,2 @@
1
+ export { placeholderId, replaceIds } from './anonymise.js';
2
+ export { createAccountErase, ERASE_PATH, personOf, type AccountErase, type AccountEraseOptions, type EraseCounts, type ErasedPerson, type EraseStep, } from './erase.js';
@@ -0,0 +1,2 @@
1
+ export { placeholderId, replaceIds } from "./anonymise.js";
2
+ export { createAccountErase, ERASE_PATH, personOf, } from "./erase.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The `/health` body, hadoku health v1: `status` ("ok" | "degraded"), `service`
3
+ * (this app's id in hadoku_site's scripts/child-apps.json) and `commit` (the git
4
+ * sha of the running code). mgmt-api's deploy probe compares `commit` with the
5
+ * sha it just deployed, so it must be the checkout's HEAD, never a guess.
6
+ */
7
+ /** The commit, or null plus why it could not be read. Never a placeholder sha. */
8
+ export type Commit = {
9
+ commit: string;
10
+ commit_error?: undefined;
11
+ } | {
12
+ commit: null;
13
+ commit_error: string;
14
+ };
15
+ /**
16
+ * `git rev-parse HEAD` in `repoRoot`, read once at boot by the caller: a deploy
17
+ * restarts the process, so the checkout cannot move under a running server.
18
+ * Each instance reads its OWN checkout (an app's prod and its PPE are two).
19
+ */
20
+ export declare function readCommit(repoRoot: string): Commit;
21
+ /**
22
+ * The instance's child-apps id, from the prefix the edge serves it under:
23
+ * `/cardtable` is `cardtable`, `/cardtable-ppe` is `cardtable-ppe`. Both
24
+ * instances run this same code, and the prefix is the one setting that
25
+ * child-apps.json gives each of them differently. `fallback` is the app's own
26
+ * id, used only when the prefix is empty (dev, or a misconfigured deploy).
27
+ */
28
+ export declare function serviceIdOf(basePrefix: string, fallback: string): string;
29
+ export interface HealthInput {
30
+ service: string;
31
+ commit: Commit;
32
+ /** The base-path verdict (the app's own bundle/base-path check); `ok: false` is the one not-ready state. */
33
+ bundle: {
34
+ ok: boolean;
35
+ reason: string;
36
+ };
37
+ }
38
+ /** The HTTP status and body `/health` answers. */
39
+ export declare function healthResponse({ service, commit, bundle }: HealthInput, now?: Date): {
40
+ code: number;
41
+ body: Record<string, unknown>;
42
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `/health` body, hadoku health v1: `status` ("ok" | "degraded"), `service`
3
+ * (this app's id in hadoku_site's scripts/child-apps.json) and `commit` (the git
4
+ * sha of the running code). mgmt-api's deploy probe compares `commit` with the
5
+ * sha it just deployed, so it must be the checkout's HEAD, never a guess.
6
+ */
7
+ import { execFileSync } from 'node:child_process';
8
+ /**
9
+ * `git rev-parse HEAD` in `repoRoot`, read once at boot by the caller: a deploy
10
+ * restarts the process, so the checkout cannot move under a running server.
11
+ * Each instance reads its OWN checkout (an app's prod and its PPE are two).
12
+ */
13
+ export function readCommit(repoRoot) {
14
+ try {
15
+ const sha = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], {
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ timeout: 5_000,
19
+ }).trim();
20
+ if (/^[0-9a-f]{40,64}$/.test(sha))
21
+ return { commit: sha };
22
+ return { commit: null, commit_error: `git rev-parse HEAD in ${repoRoot} printed ${JSON.stringify(sha.slice(0, 80))}, not a sha` };
23
+ }
24
+ catch (err) {
25
+ const stderr = err.stderr;
26
+ const why = typeof stderr === 'string' && stderr.trim() ? stderr.trim() : err instanceof Error ? err.message : String(err);
27
+ return { commit: null, commit_error: `git rev-parse HEAD in ${repoRoot} failed: ${why.slice(0, 200)}` };
28
+ }
29
+ }
30
+ /**
31
+ * The instance's child-apps id, from the prefix the edge serves it under:
32
+ * `/cardtable` is `cardtable`, `/cardtable-ppe` is `cardtable-ppe`. Both
33
+ * instances run this same code, and the prefix is the one setting that
34
+ * child-apps.json gives each of them differently. `fallback` is the app's own
35
+ * id, used only when the prefix is empty (dev, or a misconfigured deploy).
36
+ */
37
+ export function serviceIdOf(basePrefix, fallback) {
38
+ return basePrefix.replace(/^\/+|\/+$/g, '') || fallback;
39
+ }
40
+ /** The HTTP status and body `/health` answers. */
41
+ export function healthResponse({ service, commit, bundle }, now = new Date()) {
42
+ const body = {
43
+ status: bundle.ok ? 'ok' : 'degraded',
44
+ service,
45
+ commit: commit.commit,
46
+ ...(commit.commit === null ? { commit_error: commit.commit_error } : {}),
47
+ ...(bundle.ok ? {} : { reason: `the UI bundle is not servable (bundle: ${bundle.reason})` }),
48
+ bundle: bundle.reason,
49
+ timestamp: now.toISOString(),
50
+ };
51
+ return { code: bundle.ok ? 200 : 503, body };
52
+ }
@@ -0,0 +1 @@
1
+ export { healthResponse, readCommit, serviceIdOf, type Commit, type HealthInput } from './health.js';
@@ -0,0 +1 @@
1
+ export { healthResponse, readCommit, serviceIdOf } from "./health.js";
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The one handshake gate, the base-path contract, and the origin stamp — the
3
+ * HTTP-and-sockets half of the platform's edge auth. Knows no game: the
4
+ * surface label a refusal is reported under, the app's display name and
5
+ * embed-bundle filenames, and the functions that read its tier ladder and its
6
+ * secret are all supplied by the app that binds this module.
7
+ */
8
+ import type { IncomingMessage, Server } from 'node:http';
9
+ import type { NextFunction, Request, Response } from 'express';
10
+ import type { Server as SocketIOServer } from 'socket.io';
11
+ /** The app's own label for a `seal-refused` report, e.g. `'http' | 'draft-socket'`. */
12
+ export type GateSurface = string;
13
+ /** `minTier`: the app's configured floor. `surface`: the `reportSealRefused` label. */
14
+ export interface GateOptions {
15
+ minTier: string;
16
+ surface: GateSurface;
17
+ }
18
+ /** `reportSealRefused`'s own shape — the app supplies the implementation. */
19
+ export type SealRefusedReporter = (surface: GateSurface, headers: Record<string, unknown>, path: string | null) => void;
20
+ export interface GateDeps {
21
+ /** Sanitize `headers` in place against edge provenance (the app's `createEdgeAuth(...).sanitizeHeaders`). */
22
+ sanitizeHeaders(headers: Record<string, unknown>): void;
23
+ /** Express/engine.io middleware that does the same, for a socket's handshake (the app's `createEdgeAuth(...).edgeProvenance`). */
24
+ edgeProvenance(req: Request, res: Response, next: NextFunction): void;
25
+ /** The app's own `tierOf` (its auth binding's, so a verified-member promotion if any applies). */
26
+ tierOf(req: Request): string;
27
+ rankOf(tier: string): number;
28
+ reportSealRefused: SealRefusedReporter;
29
+ /** The app's configured floor for `requireMinTier`, read live (HTTP has one fixed floor; sockets and upgrades take their own per call). */
30
+ minTier(): string;
31
+ }
32
+ export interface Gate {
33
+ /** Sanitize `headers` in place against edge provenance, then rank the result against `minTier`. */
34
+ floor(headers: Record<string, unknown>, minTier: string): {
35
+ ok: boolean;
36
+ tier: string;
37
+ };
38
+ /** Provenance + the floor on a socket.io handshake. `next(new Error('not found'))` is the client's `connect_error`. */
39
+ gateSocketIo(io: SocketIOServer, opts: GateOptions): void;
40
+ /** The floor for a raw upgrade: no middleware chain runs, so the caller answers the refusal itself. */
41
+ gateUpgrade(req: IncomingMessage, opts: GateOptions): {
42
+ ok: boolean;
43
+ tier: string;
44
+ };
45
+ /**
46
+ * Express middleware: refuse anything below the configured floor. 404, not
47
+ * 403 — a private app should not confirm its own existence to a caller who
48
+ * was never meant to reach it.
49
+ */
50
+ requireMinTier(req: Request, res: Response, next: NextFunction): void;
51
+ }
52
+ /** One gate, bound to an app's auth functions and its `reportSealRefused`. */
53
+ export declare function createGate(deps: GateDeps): Gate;
54
+ /**
55
+ * This is the one thing about a loopback-UI app that breaks SILENTLY, so it is
56
+ * the one thing a server built this way refuses to guess about.
57
+ *
58
+ * The edge gives us stripPrefix: hadoku.me/<app>/assets/x.js arrives here as
59
+ * /assets/x.js, which we serve happily. But the BROWSER is at
60
+ * hadoku.me/<app>/, so a bundle built with the default base emits
61
+ * `src="/assets/x.js"` — an absolute URL with no prefix. The browser asks the
62
+ * edge for hadoku.me/assets/x.js, which is not this app's route, and the page
63
+ * renders blank with a 404 in the console and a 200 on every server-side probe.
64
+ *
65
+ * So: compare them here, once, at boot, and say precisely what to change.
66
+ *
67
+ * ## Reading the file is not serving it
68
+ *
69
+ * `checkBasePath` proves index.html exists and is built right. It does not
70
+ * prove the server can HAND IT OUT, and those came apart in practice: express's
71
+ * `res.sendFile` goes through `send`, whose default `dotfiles: 'ignore'` refuses
72
+ * any absolute path with a segment starting with `.` — so a server started from
73
+ * a dot-segment path (every agent worktree) answered every page with a 404
74
+ * while `readFileSync` read the same file happily and `/health` said `healthy`.
75
+ *
76
+ * So `checkBundle` finishes by asking the serving mechanism itself: it mounts
77
+ * `sendIndex` — the exact function the SPA fallback uses — on a throwaway
78
+ * loopback server and fetches it. If that is not a 200, the verdict is
79
+ * `unservable`. It deliberately does not special-case dot segments: any future
80
+ * reason `sendFile` refuses the file fails the same way.
81
+ */
82
+ export interface BasePathVerdict {
83
+ ok: boolean;
84
+ /** 'ok' | 'missing' | 'unprefixed' | 'unservable' — machine-readable for /health. */
85
+ reason: 'ok' | 'missing' | 'unprefixed' | 'unservable';
86
+ /** Root-relative URLs in index.html that do not carry the prefix. */
87
+ offenders: string[];
88
+ detail: string;
89
+ }
90
+ /**
91
+ * @param indexHtmlPath absolute path to the built index.html
92
+ * @param basePrefix e.g. `/cardtable` (no trailing slash)
93
+ */
94
+ export declare function checkBasePath(indexHtmlPath: string, basePrefix: string): BasePathVerdict;
95
+ export declare function isEmbedEntry(filePath: string, embedEntries: readonly string[]): boolean;
96
+ /**
97
+ * Serve index.html. The ONE place a page leaves this server, shared by the SPA
98
+ * fallback and by `probeIndex`, so the probe cannot drift from what it vouches
99
+ * for: change an option here and the boot check changes with it.
100
+ */
101
+ export declare function sendIndex(res: Response, indexHtmlPath: string, done: (err?: Error) => void): void;
102
+ /**
103
+ * The HTTP status `sendIndex` actually produces for this file, measured over a
104
+ * real loopback request rather than predicted — `send`'s refusals are decided
105
+ * inside `send`, and re-deriving its rules here would be a second copy of them.
106
+ */
107
+ export declare function probeIndex(indexHtmlPath: string): Promise<number>;
108
+ /**
109
+ * The whole boot verdict: built right (`checkBasePath`) AND servable
110
+ * (`probeIndex`). This, not `checkBasePath` alone, is what `/health` should report.
111
+ */
112
+ export declare function checkBundle(indexHtmlPath: string, basePrefix: string): Promise<BasePathVerdict>;
113
+ /** The page served in place of a bundle that would render blank. */
114
+ export declare function diagnosticPage(verdict: BasePathVerdict, basePrefix: string, appName: string): string;
115
+ /**
116
+ * Every response a server built this way sends carries this header, so the
117
+ * edge can tell "nothing is listening yet" (a 502 worth holding through a
118
+ * restart) from an answer the app itself produced (a 502 of its own, which
119
+ * must reach the browser at once). Set at the socket level so it covers
120
+ * express, socket.io's polling and every error path alike.
121
+ */
122
+ export declare const ORIGIN_HEADER = "X-Hadoku-Origin";
123
+ export declare function stampOrigin(server: Server, originValue: string): void;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The one handshake gate, the base-path contract, and the origin stamp — the
3
+ * HTTP-and-sockets half of the platform's edge auth. Knows no game: the
4
+ * surface label a refusal is reported under, the app's display name and
5
+ * embed-bundle filenames, and the functions that read its tier ladder and its
6
+ * secret are all supplied by the app that binds this module.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { createServer } from 'node:http';
10
+ import { sep } from 'node:path';
11
+ import express from 'express';
12
+ /** One gate, bound to an app's auth functions and its `reportSealRefused`. */
13
+ export function createGate(deps) {
14
+ function floor(headers, minTier) {
15
+ deps.sanitizeHeaders(headers);
16
+ const tier = deps.tierOf({ headers });
17
+ return { ok: deps.rankOf(tier) >= deps.rankOf(minTier), tier };
18
+ }
19
+ function gateSocketIo(io, opts) {
20
+ io.engine.use(deps.edgeProvenance);
21
+ io.use((socket, next) => {
22
+ const result = floor(socket.request.headers, opts.minTier);
23
+ if (!result.ok) {
24
+ deps.reportSealRefused(opts.surface, socket.request.headers, null);
25
+ next(new Error('not found'));
26
+ return;
27
+ }
28
+ next();
29
+ });
30
+ }
31
+ function gateUpgrade(req, opts) {
32
+ const result = floor(req.headers, opts.minTier);
33
+ if (!result.ok)
34
+ deps.reportSealRefused(opts.surface, req.headers, req.url ?? null);
35
+ return result;
36
+ }
37
+ function requireMinTier(req, res, next) {
38
+ if (floor(req.headers, deps.minTier()).ok)
39
+ return next();
40
+ deps.reportSealRefused('http', req.headers, req.path);
41
+ res.status(404).type('text/plain').send('not found');
42
+ }
43
+ return { floor, gateSocketIo, gateUpgrade, requireMinTier };
44
+ }
45
+ /** `src="/…"` / `href="/…"` — root-relative only. Protocol-relative `//` is not ours. */
46
+ const ROOT_RELATIVE = /(?:src|href)\s*=\s*"(\/(?!\/)[^"]*)"/g;
47
+ const BASE_HREF = /<base\b[^>]*\bhref\s*=\s*"([^"]*)"/i;
48
+ /**
49
+ * Root-relative URLs that are MEANT for the site root: hadoku.me itself serves
50
+ * them, for every app, so resolving them against the root is the intent rather
51
+ * than the drift this check exists to catch. Exact paths, never a prefix — a
52
+ * pattern here would wave through the very assets this guards.
53
+ */
54
+ const SITE_ROOT_URLS = new Set(['/favicon.svg']);
55
+ /**
56
+ * @param indexHtmlPath absolute path to the built index.html
57
+ * @param basePrefix e.g. `/cardtable` (no trailing slash)
58
+ */
59
+ export function checkBasePath(indexHtmlPath, basePrefix) {
60
+ let html;
61
+ try {
62
+ html = readFileSync(indexHtmlPath, 'utf8');
63
+ }
64
+ catch {
65
+ return {
66
+ ok: false,
67
+ reason: 'missing',
68
+ offenders: [],
69
+ detail: `No built bundle at ${indexHtmlPath}. Run the child repo's build (it must emit index.html plus its assets into this directory).`,
70
+ };
71
+ }
72
+ const wanted = basePrefix.endsWith('/') ? basePrefix : `${basePrefix}/`;
73
+ // A <base href> that already points at the prefix rebases every relative URL
74
+ // in the document, so root-relative stragglers are the only remaining risk —
75
+ // and <base> does NOT rebase those. Check them either way.
76
+ const declaredBase = BASE_HREF.exec(html)?.[1];
77
+ const offenders = [];
78
+ for (const m of html.matchAll(ROOT_RELATIVE)) {
79
+ const url = m[1];
80
+ if (url === wanted || url.startsWith(wanted) || SITE_ROOT_URLS.has(url))
81
+ continue;
82
+ offenders.push(url);
83
+ }
84
+ if (offenders.length > 0) {
85
+ return {
86
+ ok: false,
87
+ reason: 'unprefixed',
88
+ offenders,
89
+ detail: `index.html emits ${offenders.length} root-relative URL(s) that do not start with "${wanted}". ` +
90
+ `The browser is at hadoku.me${wanted} and will resolve them against the site root, not this app. ` +
91
+ `Fix it where the bundle is BUILT — for Vite that is \`base: '${wanted}'\` in vite.config.ts — ` +
92
+ `not by adding a rewrite here.` +
93
+ (declaredBase && declaredBase !== wanted
94
+ ? ` (index.html also declares <base href="${declaredBase}">, which disagrees with the edge's stripPrefix "${basePrefix}".)`
95
+ : ''),
96
+ };
97
+ }
98
+ return { ok: true, reason: 'ok', offenders: [], detail: `base path "${wanted}" verified` };
99
+ }
100
+ export function isEmbedEntry(filePath, embedEntries) {
101
+ return embedEntries.some((name) => filePath.endsWith(`${sep}embed${sep}${name}`));
102
+ }
103
+ /**
104
+ * Serve index.html. The ONE place a page leaves this server, shared by the SPA
105
+ * fallback and by `probeIndex`, so the probe cannot drift from what it vouches
106
+ * for: change an option here and the boot check changes with it.
107
+ */
108
+ export function sendIndex(res, indexHtmlPath, done) {
109
+ res.set('Cache-Control', 'no-store').type('html').sendFile(indexHtmlPath, (err) => done(err));
110
+ }
111
+ /**
112
+ * The HTTP status `sendIndex` actually produces for this file, measured over a
113
+ * real loopback request rather than predicted — `send`'s refusals are decided
114
+ * inside `send`, and re-deriving its rules here would be a second copy of them.
115
+ */
116
+ export async function probeIndex(indexHtmlPath) {
117
+ const probe = express();
118
+ probe.get('/', (_req, res, next) => sendIndex(res, indexHtmlPath, (err) => err && next(err)));
119
+ // A refusal is the expected outcome being measured, not a fault: answer with
120
+ // send's status and keep express's default handler from printing a stack.
121
+ probe.use((err, _req, res, _next) => {
122
+ res.status(err.status ?? 500).end();
123
+ });
124
+ const server = createServer(probe);
125
+ await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
126
+ try {
127
+ const { port } = server.address();
128
+ const answer = await fetch(`http://127.0.0.1:${port}/`);
129
+ await answer.arrayBuffer();
130
+ return answer.status;
131
+ }
132
+ finally {
133
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
134
+ }
135
+ }
136
+ /**
137
+ * The whole boot verdict: built right (`checkBasePath`) AND servable
138
+ * (`probeIndex`). This, not `checkBasePath` alone, is what `/health` should report.
139
+ */
140
+ export async function checkBundle(indexHtmlPath, basePrefix) {
141
+ const built = checkBasePath(indexHtmlPath, basePrefix);
142
+ if (!built.ok)
143
+ return built;
144
+ const status = await probeIndex(indexHtmlPath);
145
+ if (status === 200)
146
+ return built;
147
+ const dotSegments = indexHtmlPath.split(sep).filter((part) => part.startsWith('.') && part.length > 1);
148
+ return {
149
+ ok: false,
150
+ reason: 'unservable',
151
+ offenders: [],
152
+ detail: `index.html is at ${indexHtmlPath} and reads fine, but serving it answered ${status}. ` +
153
+ (dotSegments.length > 0
154
+ ? `The path has a dot-segment (${dotSegments.join(', ')}), and express's sendFile refuses those ` +
155
+ `(send's dotfiles: 'ignore'). Run the server from a copy outside it — an e2e harness usually does.`
156
+ : `express's sendFile refused a file that exists; check its permissions and sendIndex's options.`),
157
+ };
158
+ }
159
+ /** The page served in place of a bundle that would render blank. */
160
+ export function diagnosticPage(verdict, basePrefix, appName) {
161
+ const list = verdict.offenders.length > 0
162
+ ? `<ul>${verdict.offenders
163
+ .slice(0, 12)
164
+ .map((u) => `<li><code>${escapeHtml(u)}</code></li>`)
165
+ .join('')}</ul>`
166
+ : '';
167
+ return `<!doctype html><meta charset=utf-8><title>${escapeHtml(appName)} — not serving</title>
168
+ <style>body{font:15px/1.6 system-ui,Segoe UI,Roboto,sans-serif;background:#15171c;color:#e7e9ee;margin:0;padding:40px;display:flex;justify-content:center}
169
+ main{max-width:720px}h1{font-size:19px;font-weight:600}code{background:#22252c;padding:2px 6px;border-radius:4px}
170
+ p.why{color:#9aa3b2}</style>
171
+ <main><h1>${escapeHtml(appName)} is running, but its bundle would not load</h1>
172
+ <p>${escapeHtml(verdict.detail)}</p>${list}
173
+ <p class=why>Served by the ${escapeHtml(appName.toLowerCase())} loopback server on behalf of <code>${escapeHtml(basePrefix)}/</code>.
174
+ This page exists so the failure is visible: the alternative is a blank page and a green health check.</p></main>`;
175
+ }
176
+ function escapeHtml(s) {
177
+ return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
178
+ }
179
+ /* ── The origin stamp ──────────────────────────────────────────────────── */
180
+ /**
181
+ * Every response a server built this way sends carries this header, so the
182
+ * edge can tell "nothing is listening yet" (a 502 worth holding through a
183
+ * restart) from an answer the app itself produced (a 502 of its own, which
184
+ * must reach the browser at once). Set at the socket level so it covers
185
+ * express, socket.io's polling and every error path alike.
186
+ */
187
+ export const ORIGIN_HEADER = 'X-Hadoku-Origin';
188
+ export function stampOrigin(server, originValue) {
189
+ server.prependListener('request', (_req, res) => {
190
+ res.setHeader(ORIGIN_HEADER, originValue);
191
+ });
192
+ }
@@ -0,0 +1,74 @@
1
+ import type { JsonLog } from '../log/log.js';
2
+ export interface ReleaseNotes {
3
+ version: string;
4
+ title: string;
5
+ body: string;
6
+ }
7
+ export interface Embed {
8
+ title?: string;
9
+ description: string;
10
+ url: string;
11
+ color: number;
12
+ }
13
+ /**
14
+ * How release-notes file names are ordered: the kernel knows no directory layout of its own, so
15
+ * the app supplies this (cardtable's adapter binds `scripts/release/build-version.ts`).
16
+ */
17
+ export interface ReleaseOrdering {
18
+ /** The newest release file name among `names`, or null when there is none. */
19
+ newest(names: readonly string[]): string | null;
20
+ /** Every release file name among `names`, newest first. */
21
+ newestFirst(names: readonly string[]): string[];
22
+ }
23
+ /** The newest release notes in `dir`, by `ordering`, or null when there are none. */
24
+ export declare function latestRelease(dir: string, ordering: ReleaseOrdering): Promise<ReleaseNotes | null>;
25
+ /**
26
+ * How many releases, newest first, a boot re-sends as edits. ArchiveBot keeps a post's record for 7
27
+ * days and refuses an edit it has no record of, so older notes cannot be changed this way anyway.
28
+ */
29
+ export declare const EDITABLE_RELEASES = 3;
30
+ /** The newest `count` release notes in `dir`, newest first, by `ordering`. */
31
+ export declare function recentReleases(dir: string, ordering: ReleaseOrdering, count?: number): Promise<ReleaseNotes[]>;
32
+ /** The `# ` heading is the title; everything after it is the body. */
33
+ export declare function parseNotes(version: string, text: string): ReleaseNotes;
34
+ /**
35
+ * The body cut into descriptions of at most 4096 characters: whole `## ` sections packed in order,
36
+ * and a section that alone is too long cut at line breaks. Never truncated.
37
+ */
38
+ export declare function splitBody(body: string, max?: number): string[];
39
+ /**
40
+ * The body as Discord should get it. Discord breaks the line at every newline it is sent, and a
41
+ * notes file is wrapped at about a hundred columns, so a line that continues the one above is
42
+ * joined to it. Discord also spaces a heading by itself, so a blank line beside one would double
43
+ * the gap: those go. Other blank lines, block starts and anything inside a code fence stay.
44
+ */
45
+ export declare function forDiscord(body: string): string;
46
+ /**
47
+ * Who a release thanks: every `@name` in its notes, the hadoku name of whoever reported a bug the
48
+ * release fixes (docs/RELEASE.md § "Attribution"), each once, in the order the notes first name them.
49
+ * A sentence's closing full stop is not part of a name.
50
+ */
51
+ export declare function mentionsIn(body: string): string[];
52
+ export declare function releaseEmbeds(notes: ReleaseNotes, appUrl: string): Embed[];
53
+ export interface AnnounceOptions {
54
+ /** This app's id: the idempotency key and `source` field are `<service>-release-<version>` / `service`. */
55
+ service: string;
56
+ ordering: ReleaseOrdering;
57
+ releasesDir: string;
58
+ /** The app's webhook secret; empty = skip. */
59
+ secret: string;
60
+ /** The secret's env var name, used only in the "no secret" skip log line. */
61
+ secretEnvName: string;
62
+ archivebotUrl: string;
63
+ appUrl: string;
64
+ log: JsonLog;
65
+ fetch?: typeof fetch;
66
+ now?: () => Date;
67
+ }
68
+ /**
69
+ * Post the current release's notes, then re-send the newest few as edits, so a corrected notes file
70
+ * (a credit added to a bullet, a typo) changes its post at the next boot. ArchiveBot edits only a post
71
+ * whose shown text differs and never posts from an edit. Resolves on every outcome; the outcome is
72
+ * logged, never thrown.
73
+ */
74
+ export declare function announceRelease(options: AnnounceOptions): Promise<void>;