@steve31415/baselib 3.46.0 → 3.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
6
6
  `~/migration/research/base-services-design.md`.
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `connector`, `s2s`, `db`, `http`, `sync`,
9
- `app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
9
+ `app-update`, `llm`, `email`, `twitter`, and `hub` (the connection hub's server core and app notifier). Browser exports: `log-browser`, `rum`,
10
10
  `slow-fetch`, `sync-browser`, and `app-update-browser`.
11
11
 
12
12
  `sync` provides the Postgres event-log and server protocol primitives;
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './server.js';
3
+ export * from './notify.js';
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './server.js';
3
+ export * from './notify.js';
@@ -0,0 +1,56 @@
1
+ import type { Logger } from '../log.js';
2
+ import { serviceFetch } from '../s2s.js';
3
+ import type { Hint } from './types.js';
4
+ export declare const NOTIFY_HUB_TIMEOUT_MS = 1000;
5
+ /** Consecutive failures that open the circuit. */
6
+ export declare const HUB_BREAKER_FAILURES = 3;
7
+ /** How long an open circuit skips the hub before trying again. */
8
+ export declare const HUB_BREAKER_OPEN_MS = 30000;
9
+ export type AppHint = Omit<Hint, 'app'>;
10
+ export interface HubNotifierOptions {
11
+ /** The hub's canonical URL (also the OIDC audience). */
12
+ hubUrl: string;
13
+ /** This app's name; the hub checks it against the caller's service account. */
14
+ app: string;
15
+ logger: Logger;
16
+ timeoutMs?: number;
17
+ breakerFailures?: number;
18
+ breakerOpenMs?: number;
19
+ /** Injectable for tests (default baselib serviceFetch). */
20
+ fetch?: typeof serviceFetch;
21
+ now?: () => number;
22
+ }
23
+ export declare class HubNotifier {
24
+ private readonly url;
25
+ private readonly audience;
26
+ private readonly app;
27
+ private readonly logger;
28
+ private readonly timeoutMs;
29
+ private readonly breakerFailures;
30
+ private readonly breakerOpenMs;
31
+ private readonly fetch;
32
+ private readonly now;
33
+ private consecutiveFailures;
34
+ private openUntil;
35
+ private skipped;
36
+ constructor(opts: HubNotifierOptions);
37
+ /** Send one hint. Never throws; resolves within the bound. */
38
+ notify(hint: AppHint): Promise<void>;
39
+ /** Hints for one request, coalesced: add() as events commit, then await
40
+ * flush() once, after the commit, before answering. */
41
+ batch(): HubNotifyBatch;
42
+ private send;
43
+ }
44
+ export declare class HubNotifyBatch {
45
+ private readonly notifier;
46
+ private readonly hints;
47
+ constructor(notifier: HubNotifier);
48
+ /** Coalesced per (user, kind), as design §5.3 says, except `notification`,
49
+ * whose payloads differ in meaning: only exact duplicates of those fold.
50
+ * A folded `work` keeps the first hint's ref (refs are optional pickup
51
+ * shortcuts; the daemon re-reads its queue either way). */
52
+ add(hint: AppHint): void;
53
+ /** Send every distinct hint (concurrently, each within the notifier's
54
+ * bound) and empty the batch. Never throws. */
55
+ flush(): Promise<void>;
56
+ }
@@ -0,0 +1,118 @@
1
+ // The app side of the connection hub (design §5.3, review §14.3 S1/S7):
2
+ // after every commit, whoever made it, the app tells the hub which user's
3
+ // clients should go and ask. Rules: called after commit and awaited inside
4
+ // the request (a throttled instance still has CPU then); one attempt bounded
5
+ // at 1 s including the ID-token mint, no inline retry; a failure is a WARN and
6
+ // never fails the write; after repeated failures the notifier skips the hub
7
+ // for a while (a hub outage must not add 1 s to every write). Fire it on an
8
+ // apply outcome of `duplicate` as well as `applied`: a retried POST whose first
9
+ // attempt secretly succeeded must still hint. Lost hints are tolerated: every
10
+ // client re-checks on its own timer.
11
+ import { serializeError } from '../log.js';
12
+ import { serviceFetch } from '../s2s.js';
13
+ export const NOTIFY_HUB_TIMEOUT_MS = 1_000;
14
+ /** Consecutive failures that open the circuit. */
15
+ export const HUB_BREAKER_FAILURES = 3;
16
+ /** How long an open circuit skips the hub before trying again. */
17
+ export const HUB_BREAKER_OPEN_MS = 30_000;
18
+ export class HubNotifier {
19
+ url;
20
+ audience;
21
+ app;
22
+ logger;
23
+ timeoutMs;
24
+ breakerFailures;
25
+ breakerOpenMs;
26
+ fetch;
27
+ now;
28
+ consecutiveFailures = 0;
29
+ openUntil = -Infinity;
30
+ skipped = 0;
31
+ constructor(opts) {
32
+ this.audience = opts.hubUrl.replace(/\/$/, '');
33
+ this.url = `${this.audience}/api/notify`;
34
+ this.app = opts.app;
35
+ this.logger = opts.logger;
36
+ this.timeoutMs = opts.timeoutMs ?? NOTIFY_HUB_TIMEOUT_MS;
37
+ this.breakerFailures = opts.breakerFailures ?? HUB_BREAKER_FAILURES;
38
+ this.breakerOpenMs = opts.breakerOpenMs ?? HUB_BREAKER_OPEN_MS;
39
+ this.fetch = opts.fetch ?? serviceFetch;
40
+ this.now = opts.now ?? (() => performance.now());
41
+ }
42
+ /** Send one hint. Never throws; resolves within the bound. */
43
+ async notify(hint) {
44
+ if (this.now() < this.openUntil) {
45
+ this.skipped++;
46
+ return;
47
+ }
48
+ const failure = await this.send(hint);
49
+ if (failure === null) {
50
+ if (this.consecutiveFailures >= this.breakerFailures) {
51
+ this.logger.info('hub notify recovered', { skipped: this.skipped });
52
+ }
53
+ this.consecutiveFailures = 0;
54
+ this.skipped = 0;
55
+ return;
56
+ }
57
+ this.consecutiveFailures++;
58
+ const fields = { kind: hint.kind, consecutiveFailures: this.consecutiveFailures, ...failure.fields };
59
+ if (failure.bug)
60
+ this.logger.error('hub notify refused', fields);
61
+ else
62
+ this.logger.warn('hub notify failed', fields);
63
+ if (this.consecutiveFailures >= this.breakerFailures) {
64
+ this.openUntil = this.now() + this.breakerOpenMs;
65
+ if (this.consecutiveFailures === this.breakerFailures) {
66
+ this.logger.warn('hub notify circuit open', { openMs: this.breakerOpenMs });
67
+ }
68
+ }
69
+ }
70
+ /** Hints for one request, coalesced: add() as events commit, then await
71
+ * flush() once, after the commit, before answering. */
72
+ batch() {
73
+ return new HubNotifyBatch(this);
74
+ }
75
+ async send(hint) {
76
+ try {
77
+ const res = await this.fetch(this.url, {
78
+ audience: this.audience,
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ ...hint, app: this.app }),
82
+ signal: AbortSignal.timeout(this.timeoutMs),
83
+ });
84
+ // An unread body holds the connection until garbage collection.
85
+ await res.body?.cancel().catch(() => { });
86
+ if (res.status === 202)
87
+ return null;
88
+ // 400/401/403/413: the hint or the grant is wrong, not the network.
89
+ return { bug: [400, 401, 403, 413].includes(res.status), fields: { status: res.status } };
90
+ }
91
+ catch (error) {
92
+ return { bug: false, fields: { error: serializeError(error) } };
93
+ }
94
+ }
95
+ }
96
+ export class HubNotifyBatch {
97
+ notifier;
98
+ hints = new Map();
99
+ constructor(notifier) {
100
+ this.notifier = notifier;
101
+ }
102
+ /** Coalesced per (user, kind), as design §5.3 says, except `notification`,
103
+ * whose payloads differ in meaning: only exact duplicates of those fold.
104
+ * A folded `work` keeps the first hint's ref (refs are optional pickup
105
+ * shortcuts; the daemon re-reads its queue either way). */
106
+ add(hint) {
107
+ const key = hint.kind === 'notification' ? JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]) : JSON.stringify([hint.user, hint.kind]);
108
+ if (!this.hints.has(key))
109
+ this.hints.set(key, hint);
110
+ }
111
+ /** Send every distinct hint (concurrently, each within the notifier's
112
+ * bound) and empty the batch. Never throws. */
113
+ async flush() {
114
+ const hints = [...this.hints.values()];
115
+ this.hints.clear();
116
+ await Promise.all(hints.map((hint) => this.notifier.notify(hint)));
117
+ }
118
+ }
@@ -0,0 +1,87 @@
1
+ import type { Hono } from 'hono';
2
+ import type { UpgradeWebSocket } from 'hono/ws';
3
+ import type pg from 'pg';
4
+ import { type RequireIdentityOptions } from '../auth.js';
5
+ import { type Logger } from '../log.js';
6
+ import type { Grants } from '../s2s.js';
7
+ import { type DbBroadcastBridgeOptions } from '../sync/db-bridge.js';
8
+ /** The hub database's one table (the hub repo's first migration carries the
9
+ * same statements). */
10
+ export declare const HUB_SCHEMA_SQL = "\ncreate table if not exists daemon_tokens (\n app text not null,\n daemon_id text not null,\n token_hash text not null unique,\n expires_at timestamptz not null,\n registered_at timestamptz not null default now(),\n primary key (app, daemon_id)\n);\ncreate index if not exists daemon_tokens_expires_at on daemon_tokens (expires_at);\n";
11
+ export declare const HUB_CHANNEL = "hub_hints";
12
+ /** A registered token's lifetime, on the database clock. */
13
+ export declare const DAEMON_TOKEN_TTL_SECONDS = 3600;
14
+ /** Distinct daemon ids per app with live tokens: a backstop against a daemon
15
+ * minting in a loop (each process upserts one row by its daemon id). */
16
+ export declare const DAEMON_IDS_PER_APP_MAX = 20;
17
+ /** The cross-copy publish's own bound; it runs after the 202. */
18
+ export declare const HUB_PUBLISH_TIMEOUT_MS = 2000;
19
+ export declare const DAEMON_WAIT_MAX_SECONDS = 55;
20
+ export declare const DAEMON_LOOKBACK_MAX_MS = 60000;
21
+ /** 401s per client address per minute on the token path before further
22
+ * tokens from it are refused without a database lookup. */
23
+ export declare const TOKEN_FAILURES_PER_MIN = 20;
24
+ /** Cross-copy publishes in flight at once; past it a publish is dropped (a
25
+ * lost hint is tolerated) so a slow database cannot queue publishes ahead
26
+ * of the token lookups that share the pool. */
27
+ export declare const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
28
+ /** The largest frame a browser may send (its frames are `connect` and `ping`). */
29
+ export declare const HUB_SOCKET_MAX_PAYLOAD = 4096;
30
+ /** A socket that has sent nothing (clients ping every 30 s) for this long is
31
+ * closed: a half-dead socket would otherwise hold a slot for an hour. */
32
+ export declare const HUB_SOCKET_IDLE_MS = 180000;
33
+ export interface HubOptions {
34
+ pool: pg.Pool;
35
+ logger: Logger;
36
+ /** The hub's canonical URL, e.g. https://hub.apps.snewman.net: the OIDC
37
+ * audience, and its host is the load-balancer host for client addresses. */
38
+ audience: string;
39
+ /** The hub's grants: every app's service account on POST /api/notify and
40
+ * POST /api/register-daemon; the coding agent is always admitted. */
41
+ grants: Grants;
42
+ /** Service accounts are `<app>-svc@<serviceAccountDomain>`; the part
43
+ * before `-svc@` names the caller's app (test mode: e.g. 'test'). */
44
+ serviceAccountDomain: string;
45
+ /** Pages are `https://<app>.<appDomain>`; a socket for app A is admitted
46
+ * only from that origin. */
47
+ appDomain: string;
48
+ /** Also admit `http://localhost:<port>` and `http://127.0.0.1:<port>`
49
+ * origins (local development and Playwright). */
50
+ allowLocalOrigins?: boolean;
51
+ upgradeWebSocket: UpgradeWebSocket;
52
+ /** The upgrader's `ws` server (`createNodeWebSocket(...).wss`): the hub
53
+ * caps its incoming frame size (ws's default is 100 MiB). */
54
+ webSocketServer?: {
55
+ options: {
56
+ maxPayload?: number;
57
+ };
58
+ };
59
+ /** Passed through to requireIdentity (authUrl, whoamiFetch, verifyClient,
60
+ * rateLimitPerMin…). The hub admits invited users for sockets. */
61
+ identity?: Partial<Omit<RequireIdentityOptions, 'audience' | 'grants' | 'logger' | 'userAccess'>>;
62
+ /** Injectable for tests. */
63
+ bridge?: Partial<Pick<DbBroadcastBridgeOptions, 'heartbeatMs' | 'heartbeatTimeoutMs' | 'reconnectDelaysMs'>>;
64
+ /** Monotonic milliseconds (tests). */
65
+ now?: () => number;
66
+ /** Tests: the idle-socket limit and sweep interval. */
67
+ socketIdle?: {
68
+ idleMs: number;
69
+ sweepMs: number;
70
+ };
71
+ }
72
+ export interface Hub {
73
+ /** Start the cross-copy bridge. Call before the server accepts
74
+ * connections; never rejects (a failed first connect enters the bridge's
75
+ * reconnect ladder). */
76
+ start(): Promise<void>;
77
+ /** SIGTERM: stop admitting, close browser sockets with 1012, answer every
78
+ * parked wait 204, wait (bounded) for in-flight publishes, stop the
79
+ * bridge. The caller then ends the pool and flushes the logger. */
80
+ stop(): Promise<void>;
81
+ /** Local counts, for logs and tests. */
82
+ stats(): {
83
+ sockets: number;
84
+ waits: number;
85
+ };
86
+ }
87
+ export declare function createHub(app: Hono, opts: HubOptions): Hub;
@@ -0,0 +1,564 @@
1
+ // The connection hub's server core (design: ~/migration/research/
2
+ // connection-hub-design-2026-10-30.md §5, as refined by §14 and its review
3
+ // dispositions §14.3, which bind). The `hub` repo is a thin wrapper: it builds
4
+ // the pool, logger and WebSocket upgrader, mounts this, and starts the server.
5
+ //
6
+ // One always-on service holds the fleet's long-lived connections so the apps
7
+ // need not: browser pages park a socket here and daemons park their idle
8
+ // waits here; apps POST a hint after every commit and the hub fans it out.
9
+ // Hints are information only ("go ask the app"): the hub stores none, a hint
10
+ // with no listener is gone, and every client re-checks on its own timer.
11
+ //
12
+ // Copies. Any number of hub copies may run (two is normal). Each delivers a
13
+ // hint to its own sockets and waits, then publishes it on the hub database's
14
+ // `hub_hints` channel tagged with its bridge's origin; the other copies relay
15
+ // it to theirs and skip their own. State per copy is memory only and
16
+ // rebuildable from nothing, apart from the daemon-token table (§14.1), which
17
+ // both copies read.
18
+ //
19
+ // Routes:
20
+ // POST /api/notify app → hub, OIDC; the hint's app must be the
21
+ // caller's own (from its service account)
22
+ // POST /api/register-daemon app → hub, OIDC; registers a daemon hub token
23
+ // GET /api/daemon/wait daemon, X-Hub-Token (or a granted service
24
+ // caller, for the dark-run curl); answers on a
25
+ // `work` hint, else 204 after `wait` seconds
26
+ // GET /api/ws?app=<app> browser WebSocket, fleet cookie, Origin
27
+ // must be that app's page
28
+ import { createHash } from 'node:crypto';
29
+ import { createMiddleware } from 'hono/factory';
30
+ import { requireIdentity } from '../auth.js';
31
+ import { forwardedClientIp } from '../http.js';
32
+ import { serializeError } from '../log.js';
33
+ import { DbBroadcastBridge } from '../sync/db-bridge.js';
34
+ import { checkHint, isAppName } from './types.js';
35
+ /** The hub database's one table (the hub repo's first migration carries the
36
+ * same statements). */
37
+ export const HUB_SCHEMA_SQL = `
38
+ create table if not exists daemon_tokens (
39
+ app text not null,
40
+ daemon_id text not null,
41
+ token_hash text not null unique,
42
+ expires_at timestamptz not null,
43
+ registered_at timestamptz not null default now(),
44
+ primary key (app, daemon_id)
45
+ );
46
+ create index if not exists daemon_tokens_expires_at on daemon_tokens (expires_at);
47
+ `;
48
+ export const HUB_CHANNEL = 'hub_hints';
49
+ /** A registered token's lifetime, on the database clock. */
50
+ export const DAEMON_TOKEN_TTL_SECONDS = 3600;
51
+ /** Distinct daemon ids per app with live tokens: a backstop against a daemon
52
+ * minting in a loop (each process upserts one row by its daemon id). */
53
+ export const DAEMON_IDS_PER_APP_MAX = 20;
54
+ /** The cross-copy publish's own bound; it runs after the 202. */
55
+ export const HUB_PUBLISH_TIMEOUT_MS = 2_000;
56
+ export const DAEMON_WAIT_MAX_SECONDS = 55;
57
+ export const DAEMON_LOOKBACK_MAX_MS = 60_000;
58
+ /** 401s per client address per minute on the token path before further
59
+ * tokens from it are refused without a database lookup. */
60
+ export const TOKEN_FAILURES_PER_MIN = 20;
61
+ /** Cross-copy publishes in flight at once; past it a publish is dropped (a
62
+ * lost hint is tolerated) so a slow database cannot queue publishes ahead
63
+ * of the token lookups that share the pool. */
64
+ export const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
65
+ /** The largest frame a browser may send (its frames are `connect` and `ping`). */
66
+ export const HUB_SOCKET_MAX_PAYLOAD = 4096;
67
+ /** A socket that has sent nothing (clients ping every 30 s) for this long is
68
+ * closed: a half-dead socket would otherwise hold a slot for an hour. */
69
+ export const HUB_SOCKET_IDLE_MS = 180_000;
70
+ const HUB_SOCKET_SWEEP_MS = 30_000;
71
+ const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
72
+ const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/;
73
+ const TOKEN_HASH_FORMAT = /^[0-9a-f]{64}$/;
74
+ const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
75
+ export function createHub(app, opts) {
76
+ const { pool, logger } = opts;
77
+ const now = opts.now ?? (() => performance.now());
78
+ const canonicalHost = new URL(opts.audience).host;
79
+ const identity = requireIdentity({
80
+ rateLimitPerMin: 6_000,
81
+ ...opts.identity,
82
+ audience: opts.audience,
83
+ grants: opts.grants,
84
+ logger,
85
+ userAccess: 'invited',
86
+ });
87
+ const serviceAccount = new RegExp(`^([a-z][a-z0-9-]*)-svc@${escapeRegExp(opts.serviceAccountDomain)}$`);
88
+ if (opts.webSocketServer)
89
+ opts.webSocketServer.options.maxPayload = HUB_SOCKET_MAX_PAYLOAD;
90
+ // app → user → sockets
91
+ const sockets = new Map();
92
+ const waits = new Map();
93
+ // app → when this copy last saw a `work` hint for it (local or relayed)
94
+ const lastWork = new Map();
95
+ // when the bridge last recovered: hints sent while it was deaf are unknowable
96
+ let lastBlanketAt = -Infinity;
97
+ const publishes = new Set();
98
+ let droppedPublishes = 0;
99
+ let lastDropWarnAt = -Infinity;
100
+ const tokenFailures = new FailureCounter(TOKEN_FAILURES_PER_MIN);
101
+ let stopping = false;
102
+ const bridge = new DbBroadcastBridge({
103
+ pool,
104
+ channel: HUB_CHANNEL,
105
+ logger,
106
+ ignoreOwn: true,
107
+ ...opts.bridge,
108
+ onMessage: (message) => {
109
+ const check = checkHint(message?.hint);
110
+ if (!check.ok) {
111
+ logger.warn('hub relayed hint invalid', { error: check.error });
112
+ return;
113
+ }
114
+ deliverLocal(check.hint);
115
+ },
116
+ onRecovered: () => blanket(),
117
+ });
118
+ function deliverLocal(hint) {
119
+ if (hint.kind === 'work') {
120
+ // Work hints go to daemon waits only, never to a browser socket
121
+ // whatever its app (review B1).
122
+ lastWork.set(hint.app, { at: now(), ...(hint.ref !== undefined ? { ref: hint.ref } : {}) });
123
+ for (const waiter of [...(waits.get(hint.app) ?? [])])
124
+ waiter.finish(workResponse(hint.ref));
125
+ return;
126
+ }
127
+ const users = sockets.get(hint.app);
128
+ if (!users)
129
+ return;
130
+ const targets = hint.user === '*' ? [...users.values()].flatMap((set) => [...set]) : [...(users.get(hint.user) ?? [])];
131
+ if (targets.length === 0)
132
+ return;
133
+ const frame = JSON.stringify({ type: 'hint', ...hint });
134
+ for (const socket of targets)
135
+ send(socket, frame);
136
+ }
137
+ function send(socket, frame) {
138
+ try {
139
+ socket.ws.send(frame);
140
+ }
141
+ catch (error) {
142
+ logger.warn('hub socket send failed', { app: socket.app, error: serializeError(error) });
143
+ }
144
+ }
145
+ function blanket() {
146
+ lastBlanketAt = now();
147
+ for (const [appName, users] of sockets) {
148
+ for (const [user, set] of users) {
149
+ const frame = JSON.stringify({ type: 'hint', app: appName, user, kind: 'sync_available' });
150
+ for (const socket of set)
151
+ send(socket, frame);
152
+ }
153
+ }
154
+ for (const set of waits.values())
155
+ for (const waiter of [...set])
156
+ waiter.finish(workResponse());
157
+ }
158
+ function publish(hint) {
159
+ if (publishes.size >= HUB_PUBLISHES_IN_FLIGHT_MAX) {
160
+ droppedPublishes++;
161
+ if (now() - lastDropWarnAt >= 10_000) {
162
+ lastDropWarnAt = now();
163
+ logger.warn('hub publishes dropped', { dropped: droppedPublishes, inFlight: publishes.size });
164
+ droppedPublishes = 0;
165
+ }
166
+ return;
167
+ }
168
+ const p = withinMs(bridge.notify({ hint }), HUB_PUBLISH_TIMEOUT_MS, 'hub publish')
169
+ .catch((error) => {
170
+ logger.warn('hub publish failed', { app: hint.app, kind: hint.kind, error: serializeError(error) });
171
+ })
172
+ .finally(() => publishes.delete(p));
173
+ publishes.add(p);
174
+ }
175
+ /** requireIdentity without letting it end the chain: whether it admitted
176
+ * the request (identity set), else the response it chose. */
177
+ async function runIdentity(c) {
178
+ let admitted = false;
179
+ const response = await identity(c, async () => {
180
+ admitted = true;
181
+ });
182
+ return { admitted, response: response ?? undefined };
183
+ }
184
+ /** The caller's app, from its service account; null for any other caller
185
+ * (the coding agent, a browser). */
186
+ function callerApp(c) {
187
+ const id = c.var.identity;
188
+ if (id?.type !== 's2s')
189
+ return null;
190
+ return serviceAccount.exec(id.email)?.[1] ?? null;
191
+ }
192
+ // ---- app → hub ----
193
+ app.post('/api/notify', identity, async (c) => {
194
+ const caller = callerApp(c);
195
+ if (!caller)
196
+ return c.json({ error: 'app service accounts only' }, 403);
197
+ const check = checkHint(await c.req.json().catch(() => null));
198
+ if (!check.ok)
199
+ return c.json({ error: check.error }, check.status);
200
+ if (check.hint.app !== caller) {
201
+ logger.warn('hub notify for another app refused', { caller, app: check.hint.app });
202
+ return c.json({ error: 'an app may hint only its own clients' }, 403);
203
+ }
204
+ deliverLocal(check.hint);
205
+ // After local delivery and off the response path, with its own bound, so
206
+ // a slow hub pool never trips the apps' 1 s notify bound (review S1).
207
+ publish(check.hint);
208
+ return c.body(null, 202);
209
+ });
210
+ app.post('/api/register-daemon', identity, async (c) => {
211
+ if (stopping)
212
+ return c.json({ error: 'hub restarting' }, 503);
213
+ const caller = callerApp(c);
214
+ if (!caller)
215
+ return c.json({ error: 'app service accounts only' }, 403);
216
+ const body = (await c.req.json().catch(() => null));
217
+ const tokenHash = body?.tokenHash;
218
+ const daemonId = body?.daemonId;
219
+ if (typeof tokenHash !== 'string' || !TOKEN_HASH_FORMAT.test(tokenHash))
220
+ return c.json({ error: 'invalid tokenHash' }, 400);
221
+ if (typeof daemonId !== 'string' || !DAEMON_ID_FORMAT.test(daemonId))
222
+ return c.json({ error: 'invalid daemonId' }, 400);
223
+ const client = await pool.connect();
224
+ let ok = false;
225
+ try {
226
+ await client.query('begin');
227
+ // Serializes this app's registrations, so the cap is exact.
228
+ await client.query(`select pg_advisory_xact_lock(hashtext('hub_register:' || $1))`, [caller]);
229
+ const { rows } = await client.query(`select count(*)::int as n from daemon_tokens where app = $1 and daemon_id <> $2 and expires_at > now()`, [caller, daemonId]);
230
+ if (rows[0].n >= DAEMON_IDS_PER_APP_MAX) {
231
+ await client.query('rollback');
232
+ ok = true;
233
+ logger.error('hub daemon registration cap reached', { app: caller, daemons: rows[0].n });
234
+ return c.json({ error: 'too many daemons registered for this app' }, 429);
235
+ }
236
+ // Upsert by daemon id: a retry or an orphaned register overwrites the
237
+ // process's one row, and its previous token dies at once (review S3).
238
+ await client.query(`insert into daemon_tokens (app, daemon_id, token_hash, expires_at)
239
+ values ($1, $2, $3, now() + make_interval(secs => $4))
240
+ on conflict (app, daemon_id) do update
241
+ set token_hash = excluded.token_hash, expires_at = excluded.expires_at, registered_at = now()`, [caller, daemonId, tokenHash, DAEMON_TOKEN_TTL_SECONDS]);
242
+ await client.query(`delete from daemon_tokens where expires_at < now() - interval '1 day'`);
243
+ await client.query('commit');
244
+ ok = true;
245
+ }
246
+ finally {
247
+ if (!ok)
248
+ await client.query('rollback').catch(() => { });
249
+ client.release(ok ? undefined : true);
250
+ }
251
+ return c.json({ ttlSeconds: DAEMON_TOKEN_TTL_SECONDS });
252
+ });
253
+ // ---- daemon waits ----
254
+ /** Resolves the waiting app: from a daemon hub token, or (no token) from
255
+ * `?app=` for a granted service caller. Cookies are refused (review B1). */
256
+ const daemonAuth = createMiddleware(async (c, next) => {
257
+ // The lookback and the wait both count from arrival, before the token
258
+ // lookup or identity check, whose time would otherwise open a gap.
259
+ c.set('hubArrivedAt', now());
260
+ const token = c.req.header('x-hub-token');
261
+ if (token === undefined) {
262
+ const { admitted, response } = await runIdentity(c);
263
+ if (!admitted)
264
+ return response;
265
+ if (c.var.identity?.type !== 's2s')
266
+ return c.json({ error: 'service callers only' }, 403);
267
+ const named = c.req.query('app');
268
+ if (!isAppName(named))
269
+ return c.json({ error: 'app required' }, 400);
270
+ c.set('hubApp', named);
271
+ await next();
272
+ return;
273
+ }
274
+ if (c.req.header('authorization') !== undefined) {
275
+ // A daemon's hub client never sends Authorization; both together means
276
+ // an app credential is travelling to the hub (review S4).
277
+ logger.error('hub daemon wait with both X-Hub-Token and Authorization', {});
278
+ return c.json({ error: 'send X-Hub-Token or Authorization, not both' }, 400);
279
+ }
280
+ const ip = forwardedClientIp(c, canonicalHost);
281
+ if (tokenFailures.blocked(ip, now()))
282
+ return c.json({ error: 'too many invalid tokens' }, 429);
283
+ if (!TOKEN_FORMAT.test(token)) {
284
+ tokenFailures.fail(ip, now());
285
+ return c.json({ error: 'invalid token' }, 401);
286
+ }
287
+ let found;
288
+ try {
289
+ const { rows } = await pool.query(`select app from daemon_tokens where token_hash = $1 and expires_at > now()`, [createHash('sha256').update(token).digest('hex')]);
290
+ found = rows[0]?.app;
291
+ }
292
+ catch (error) {
293
+ // 503, not 401: a hub database outage must not make every daemon
294
+ // re-mint; the daemon treats 503 as a hub failure.
295
+ logger.warn('hub token lookup failed', { error: serializeError(error) });
296
+ return c.json({ error: 'token lookup unavailable' }, 503);
297
+ }
298
+ if (!found) {
299
+ tokenFailures.fail(ip, now());
300
+ return c.json({ error: 'invalid or expired token' }, 401);
301
+ }
302
+ c.set('hubApp', found);
303
+ await next();
304
+ });
305
+ app.get('/api/daemon/wait', daemonAuth, (c) => {
306
+ const appName = c.var.hubApp;
307
+ const waitSeconds = intParam(c.req.query('wait'), 0, DAEMON_WAIT_MAX_SECONDS, 50);
308
+ const lookbackMs = intParam(c.req.query('lookbackMs'), 0, DAEMON_LOOKBACK_MAX_MS, 0);
309
+ if (stopping)
310
+ return c.body(null, 204);
311
+ // A work hint between the daemon's direct request and this wait being
312
+ // parked would otherwise be lost to the 30 s timer (review S2). The
313
+ // daemon's lookbackMs covers from its direct request's start to its
314
+ // arrival here; this copy has seen every hint since arrival.
315
+ const since = c.var.hubArrivedAt - lookbackMs;
316
+ const last = lastWork.get(appName);
317
+ if (last && last.at >= since)
318
+ return workResponse(last.ref);
319
+ if (lastBlanketAt >= since)
320
+ return workResponse();
321
+ const remainingMs = Math.max(0, waitSeconds * 1000 - (now() - c.var.hubArrivedAt));
322
+ return new Promise((resolve) => {
323
+ let set = waits.get(appName);
324
+ if (!set)
325
+ waits.set(appName, (set = new Set()));
326
+ const waiters = set;
327
+ const signal = c.req.raw.signal;
328
+ const waiter = {
329
+ app: appName,
330
+ finish: (response) => {
331
+ if (!waiters.delete(waiter))
332
+ return;
333
+ if (waiters.size === 0 && waits.get(appName) === waiters)
334
+ waits.delete(appName);
335
+ clearTimeout(timer);
336
+ signal.removeEventListener('abort', onAbort);
337
+ resolve(response);
338
+ },
339
+ };
340
+ const onAbort = () => waiter.finish(new Response(null, { status: 204 }));
341
+ const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })), remainingMs);
342
+ waiters.add(waiter);
343
+ if (signal.aborted)
344
+ onAbort();
345
+ else
346
+ signal.addEventListener('abort', onAbort, { once: true });
347
+ });
348
+ });
349
+ // ---- browser sockets ----
350
+ // requireIdentity's refusal (401/403/503), kept so the socket can open and
351
+ // close with a code the client understands rather than fail its handshake.
352
+ const refusals = new WeakMap();
353
+ const originCheck = createMiddleware(async (c, next) => {
354
+ const appName = c.req.query('app');
355
+ if (!isAppName(appName))
356
+ return c.json({ error: 'app required' }, 400);
357
+ // Only a well-formed handshake may reach the upgrader: @hono/node-ws
358
+ // keeps the request of an upgrade that ws aborts (a bad key or version)
359
+ // for the life of the process.
360
+ const version = c.req.header('sec-websocket-version');
361
+ if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket' ||
362
+ !WEBSOCKET_KEY.test(c.req.header('sec-websocket-key') ?? '') ||
363
+ (version !== '13' && version !== '8')) {
364
+ return c.json({ error: 'websocket handshake required' }, 400);
365
+ }
366
+ if (!originAllowed(c.req.header('origin'), appName, opts)) {
367
+ logger.warn('hub socket origin refused', { app: appName, origin: c.req.header('origin') ?? null });
368
+ return c.json({ error: 'origin not allowed' }, 403);
369
+ }
370
+ await next();
371
+ });
372
+ const socketIdentity = createMiddleware(async (c, next) => {
373
+ const { admitted, response } = await runIdentity(c);
374
+ if (!admitted)
375
+ refusals.set(c, response?.status ?? 401);
376
+ // The client may have gone while its identity was checked; the upgrader
377
+ // would then keep its request forever (as above). Nothing awaits between
378
+ // this check and the upgrade.
379
+ const incoming = c.env?.incoming;
380
+ if (incoming?.socket && !(incoming.socket.readable && incoming.socket.writable))
381
+ return c.body(null, 400); // no one reads it
382
+ await next();
383
+ });
384
+ app.get('/api/ws', originCheck, socketIdentity, opts.upgradeWebSocket((c) => {
385
+ const appName = c.req.query('app');
386
+ const refusal = refusals.get(c);
387
+ const id = c.var.identity;
388
+ const user = refusal === undefined && id?.type === 'user' ? id.email : null;
389
+ let socket = null;
390
+ const remove = () => {
391
+ if (!socket)
392
+ return;
393
+ const users = sockets.get(socket.app);
394
+ const set = users?.get(socket.user);
395
+ set?.delete(socket);
396
+ if (set?.size === 0)
397
+ users.delete(socket.user);
398
+ if (users?.size === 0)
399
+ sockets.delete(socket.app);
400
+ socket = null;
401
+ };
402
+ return {
403
+ onOpen: (_evt, ws) => {
404
+ if (stopping) {
405
+ ws.close(1012, 'hub restarting');
406
+ return;
407
+ }
408
+ if (!user) {
409
+ // 4403: identity missing or expired (the client re-checks its
410
+ // session); 1013: the auth service is unavailable, try again.
411
+ if (refusal === 503)
412
+ ws.close(1013, 'auth unavailable');
413
+ else
414
+ ws.close(4403, 'user identity required');
415
+ return;
416
+ }
417
+ socket = { ws, app: appName, user, lastHeardAt: now() };
418
+ let users = sockets.get(appName);
419
+ if (!users)
420
+ sockets.set(appName, (users = new Map()));
421
+ let set = users.get(user);
422
+ if (!set)
423
+ users.set(user, (set = new Set()));
424
+ set.add(socket);
425
+ },
426
+ onMessage: (evt, ws) => {
427
+ if (!socket)
428
+ return;
429
+ socket.lastHeardAt = now();
430
+ let message;
431
+ try {
432
+ message = JSON.parse(String(evt.data));
433
+ }
434
+ catch {
435
+ return;
436
+ }
437
+ // Unknown frames and fields are ignored on both sides: hub
438
+ // versions overlap during a hub deploy.
439
+ if (message?.type === 'ping')
440
+ ws.send(JSON.stringify({ type: 'pong' }));
441
+ else if (message?.type === 'connect')
442
+ ws.send(JSON.stringify({ type: 'ack' }));
443
+ },
444
+ onClose: remove,
445
+ onError: remove,
446
+ };
447
+ }));
448
+ const idle = opts.socketIdle ?? { idleMs: HUB_SOCKET_IDLE_MS, sweepMs: HUB_SOCKET_SWEEP_MS };
449
+ let sweeper;
450
+ function sweepIdleSockets() {
451
+ const t = now();
452
+ for (const users of sockets.values()) {
453
+ for (const set of users.values()) {
454
+ for (const socket of set) {
455
+ if (t - socket.lastHeardAt < idle.idleMs)
456
+ continue;
457
+ try {
458
+ socket.ws.close(4408, 'idle');
459
+ }
460
+ catch {
461
+ // already closing
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ return {
468
+ async start() {
469
+ sweeper = setInterval(sweepIdleSockets, idle.sweepMs);
470
+ sweeper.unref?.();
471
+ await bridge.start();
472
+ },
473
+ async stop() {
474
+ stopping = true;
475
+ clearInterval(sweeper);
476
+ for (const users of sockets.values()) {
477
+ for (const set of users.values()) {
478
+ for (const socket of set) {
479
+ try {
480
+ socket.ws.close(1012, 'hub restarting');
481
+ }
482
+ catch {
483
+ // already closing
484
+ }
485
+ }
486
+ }
487
+ }
488
+ sockets.clear();
489
+ for (const set of waits.values())
490
+ for (const waiter of [...set])
491
+ waiter.finish(new Response(null, { status: 204 }));
492
+ // A notify racing shutdown may add a publish while these settle.
493
+ while (publishes.size > 0)
494
+ await Promise.allSettled([...publishes]);
495
+ await bridge.stop();
496
+ },
497
+ stats() {
498
+ let socketCount = 0;
499
+ for (const users of sockets.values())
500
+ for (const set of users.values())
501
+ socketCount += set.size;
502
+ let waitCount = 0;
503
+ for (const set of waits.values())
504
+ waitCount += set.size;
505
+ return { sockets: socketCount, waits: waitCount };
506
+ },
507
+ };
508
+ }
509
+ function workResponse(ref) {
510
+ return Response.json({ kind: 'work', ...(ref !== undefined ? { ref } : {}) });
511
+ }
512
+ function originAllowed(origin, appName, opts) {
513
+ if (!origin)
514
+ return false;
515
+ if (origin === `https://${appName}.${opts.appDomain}`)
516
+ return true;
517
+ return opts.allowLocalOrigins === true && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
518
+ }
519
+ function intParam(value, min, max, fallback) {
520
+ if (value === undefined || !/^\d+$/.test(value))
521
+ return fallback;
522
+ return Math.min(max, Math.max(min, Number(value)));
523
+ }
524
+ function escapeRegExp(s) {
525
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
526
+ }
527
+ /** `promise`, or a rejection once `ms` pass; the work itself continues. */
528
+ function withinMs(promise, ms, what) {
529
+ let timer;
530
+ const timeout = new Promise((_, reject) => {
531
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
532
+ });
533
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
534
+ }
535
+ /** Failed token attempts per client address in a fixed one-minute window.
536
+ * Keys are chosen by unauthenticated callers, so the map is bounded. */
537
+ class FailureCounter {
538
+ perMinute;
539
+ windows = new Map();
540
+ constructor(perMinute) {
541
+ this.perMinute = perMinute;
542
+ }
543
+ blocked(key, t) {
544
+ const w = this.windows.get(key);
545
+ return w !== undefined && t - w.start < 60_000 && w.count >= this.perMinute;
546
+ }
547
+ fail(key, t) {
548
+ const w = this.windows.get(key);
549
+ if (w && t - w.start < 60_000) {
550
+ w.count++;
551
+ return;
552
+ }
553
+ this.windows.delete(key);
554
+ if (this.windows.size >= 10_000) {
555
+ for (const [k, old] of this.windows) {
556
+ if (t - old.start >= 60_000 || this.windows.size >= 10_000)
557
+ this.windows.delete(k);
558
+ else
559
+ break;
560
+ }
561
+ }
562
+ this.windows.set(key, { start: t, count: 1 });
563
+ }
564
+ }
@@ -0,0 +1,25 @@
1
+ export type HintKind = 'sync_available' | 'build_changed' | 'notification' | 'work';
2
+ export interface Hint {
3
+ app: string;
4
+ /** A user's email, or '*' (every user of the app: build_changed, work). */
5
+ user: string;
6
+ kind: HintKind;
7
+ ref?: string;
8
+ payload?: Record<string, unknown>;
9
+ }
10
+ export declare const HINT_KINDS: readonly HintKind[];
11
+ /** The largest `payload`, serialized. NOTIFY allows 8000 bytes. */
12
+ export declare const HINT_PAYLOAD_MAX_BYTES = 1024;
13
+ export declare const HINT_REF_MAX_LENGTH = 200;
14
+ export declare function isAppName(value: unknown): value is string;
15
+ export type HintCheck = {
16
+ ok: true;
17
+ hint: Hint;
18
+ } | {
19
+ ok: false;
20
+ status: 400 | 413;
21
+ error: string;
22
+ };
23
+ /** Validate an untrusted hint. `build_changed` and `work` go to every user
24
+ * ('*'); `sync_available` and `notification` name one. */
25
+ export declare function checkHint(value: unknown): HintCheck;
@@ -0,0 +1,47 @@
1
+ // The connection hub's one message shape (design: ~/migration/research/
2
+ // connection-hub-design-2026-10-30.md §5.2). A hint means only "go ask the
3
+ // app": it carries ids and kinds, never content, and duplicates, reordering
4
+ // and drops are all harmless because every client re-checks on a timer.
5
+ export const HINT_KINDS = ['sync_available', 'build_changed', 'notification', 'work'];
6
+ /** The largest `payload`, serialized. NOTIFY allows 8000 bytes. */
7
+ export const HINT_PAYLOAD_MAX_BYTES = 1024;
8
+ export const HINT_REF_MAX_LENGTH = 200;
9
+ const APP_NAME = /^[a-z][a-z0-9-]{0,39}$/;
10
+ export function isAppName(value) {
11
+ return typeof value === 'string' && APP_NAME.test(value);
12
+ }
13
+ /** Validate an untrusted hint. `build_changed` and `work` go to every user
14
+ * ('*'); `sync_available` and `notification` name one. */
15
+ export function checkHint(value) {
16
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
17
+ return bad('hint must be an object');
18
+ const v = value;
19
+ if (!isAppName(v.app))
20
+ return bad('invalid app');
21
+ if (typeof v.kind !== 'string' || !HINT_KINDS.includes(v.kind))
22
+ return bad('invalid kind');
23
+ const kind = v.kind;
24
+ if (typeof v.user !== 'string' || v.user.length === 0 || v.user.length > 320)
25
+ return bad('invalid user');
26
+ const broadcast = kind === 'build_changed' || kind === 'work';
27
+ if (broadcast !== (v.user === '*'))
28
+ return bad(`${kind} takes ${broadcast ? "user '*'" : 'one user'}`);
29
+ if (v.ref !== undefined && (typeof v.ref !== 'string' || v.ref.length > HINT_REF_MAX_LENGTH))
30
+ return bad('invalid ref');
31
+ if (v.payload !== undefined) {
32
+ if (v.payload === null || typeof v.payload !== 'object' || Array.isArray(v.payload))
33
+ return bad('invalid payload');
34
+ if (Buffer.byteLength(JSON.stringify(v.payload)) > HINT_PAYLOAD_MAX_BYTES) {
35
+ return { ok: false, status: 413, error: `payload over ${HINT_PAYLOAD_MAX_BYTES} bytes` };
36
+ }
37
+ }
38
+ const hint = { app: v.app, user: v.user, kind };
39
+ if (v.ref !== undefined)
40
+ hint.ref = v.ref;
41
+ if (v.payload !== undefined)
42
+ hint.payload = v.payload;
43
+ return { ok: true, hint };
44
+ }
45
+ function bad(error) {
46
+ return { ok: false, status: 400, error };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.46.0",
3
+ "version": "3.47.1",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,6 +65,10 @@
65
65
  "types": "./dist/sync-browser/index.d.ts",
66
66
  "default": "./dist/sync-browser/index.js"
67
67
  },
68
+ "./hub": {
69
+ "types": "./dist/hub/index.d.ts",
70
+ "default": "./dist/hub/index.js"
71
+ },
68
72
  "./app-update": {
69
73
  "types": "./dist/app-update/index.d.ts",
70
74
  "default": "./dist/app-update/index.js"
@@ -116,6 +120,7 @@
116
120
  "zod": "^4.6.5"
117
121
  },
118
122
  "devDependencies": {
123
+ "@hono/node-ws": "^1.3.1",
119
124
  "@types/node": "^22.0.0",
120
125
  "@types/pg": "^8.11.0",
121
126
  "fake-indexeddb": "^6.2.5",