@steve31415/baselib 3.46.0 → 3.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/hub/index.d.ts +3 -0
- package/dist/hub/index.js +3 -0
- package/dist/hub/notify.d.ts +52 -0
- package/dist/hub/notify.js +112 -0
- package/dist/hub/server.d.ts +66 -0
- package/dist/hub/server.js +490 -0
- package/dist/hub/types.d.ts +25 -0
- package/dist/hub/types.js +47 -0
- package/package.json +6 -1
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 `
|
|
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,52 @@
|
|
|
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
|
+
add(hint: AppHint): void;
|
|
49
|
+
/** Send every distinct hint (concurrently, each within the notifier's
|
|
50
|
+
* bound) and empty the batch. Never throws. */
|
|
51
|
+
flush(): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
if (res.status === 202)
|
|
85
|
+
return null;
|
|
86
|
+
// 400/401/403/413: the hint or the grant is wrong, not the network.
|
|
87
|
+
return { bug: [400, 401, 403, 413].includes(res.status), fields: { status: res.status } };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return { bug: false, fields: { error: serializeError(error) } };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export class HubNotifyBatch {
|
|
95
|
+
notifier;
|
|
96
|
+
hints = new Map();
|
|
97
|
+
constructor(notifier) {
|
|
98
|
+
this.notifier = notifier;
|
|
99
|
+
}
|
|
100
|
+
add(hint) {
|
|
101
|
+
const key = JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]);
|
|
102
|
+
if (!this.hints.has(key))
|
|
103
|
+
this.hints.set(key, hint);
|
|
104
|
+
}
|
|
105
|
+
/** Send every distinct hint (concurrently, each within the notifier's
|
|
106
|
+
* bound) and empty the batch. Never throws. */
|
|
107
|
+
async flush() {
|
|
108
|
+
const hints = [...this.hints.values()];
|
|
109
|
+
this.hints.clear();
|
|
110
|
+
await Promise.all(hints.map((hint) => this.notifier.notify(hint)));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
export interface HubOptions {
|
|
25
|
+
pool: pg.Pool;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
/** The hub's canonical URL, e.g. https://hub.apps.snewman.net: the OIDC
|
|
28
|
+
* audience, and its host is the load-balancer host for client addresses. */
|
|
29
|
+
audience: string;
|
|
30
|
+
/** The hub's grants: every app's service account on POST /api/notify and
|
|
31
|
+
* POST /api/register-daemon; the coding agent is always admitted. */
|
|
32
|
+
grants: Grants;
|
|
33
|
+
/** Service accounts are `<app>-svc@<serviceAccountDomain>`; the part
|
|
34
|
+
* before `-svc@` names the caller's app (test mode: e.g. 'test'). */
|
|
35
|
+
serviceAccountDomain: string;
|
|
36
|
+
/** Pages are `https://<app>.<appDomain>`; a socket for app A is admitted
|
|
37
|
+
* only from that origin. */
|
|
38
|
+
appDomain: string;
|
|
39
|
+
/** Also admit `http://localhost:<port>` and `http://127.0.0.1:<port>`
|
|
40
|
+
* origins (local development and Playwright). */
|
|
41
|
+
allowLocalOrigins?: boolean;
|
|
42
|
+
upgradeWebSocket: UpgradeWebSocket;
|
|
43
|
+
/** Passed through to requireIdentity (authUrl, whoamiFetch, verifyClient,
|
|
44
|
+
* rateLimitPerMin…). The hub admits invited users for sockets. */
|
|
45
|
+
identity?: Partial<Omit<RequireIdentityOptions, 'audience' | 'grants' | 'logger' | 'userAccess'>>;
|
|
46
|
+
/** Injectable for tests. */
|
|
47
|
+
bridge?: Partial<Pick<DbBroadcastBridgeOptions, 'heartbeatMs' | 'heartbeatTimeoutMs' | 'reconnectDelaysMs'>>;
|
|
48
|
+
/** Monotonic milliseconds (tests). */
|
|
49
|
+
now?: () => number;
|
|
50
|
+
}
|
|
51
|
+
export interface Hub {
|
|
52
|
+
/** Start the cross-copy bridge. Call before the server accepts
|
|
53
|
+
* connections; never rejects (a failed first connect enters the bridge's
|
|
54
|
+
* reconnect ladder). */
|
|
55
|
+
start(): Promise<void>;
|
|
56
|
+
/** SIGTERM: stop admitting, close browser sockets with 1012, answer every
|
|
57
|
+
* parked wait 204, wait (bounded) for in-flight publishes, stop the
|
|
58
|
+
* bridge. The caller then ends the pool and flushes the logger. */
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
/** Local counts, for logs and tests. */
|
|
61
|
+
stats(): {
|
|
62
|
+
sockets: number;
|
|
63
|
+
waits: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export declare function createHub(app: Hono, opts: HubOptions): Hub;
|
|
@@ -0,0 +1,490 @@
|
|
|
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
|
+
const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
|
|
62
|
+
const TOKEN_HASH_FORMAT = /^[0-9a-f]{64}$/;
|
|
63
|
+
const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
|
|
64
|
+
export function createHub(app, opts) {
|
|
65
|
+
const { pool, logger } = opts;
|
|
66
|
+
const now = opts.now ?? (() => performance.now());
|
|
67
|
+
const canonicalHost = new URL(opts.audience).host;
|
|
68
|
+
const identity = requireIdentity({
|
|
69
|
+
rateLimitPerMin: 6_000,
|
|
70
|
+
...opts.identity,
|
|
71
|
+
audience: opts.audience,
|
|
72
|
+
grants: opts.grants,
|
|
73
|
+
logger,
|
|
74
|
+
userAccess: 'invited',
|
|
75
|
+
});
|
|
76
|
+
const serviceAccount = new RegExp(`^([a-z][a-z0-9-]*)-svc@${escapeRegExp(opts.serviceAccountDomain)}$`);
|
|
77
|
+
// app → user → sockets
|
|
78
|
+
const sockets = new Map();
|
|
79
|
+
const waits = new Map();
|
|
80
|
+
// app → when this copy last saw a `work` hint for it (local or relayed)
|
|
81
|
+
const lastWork = new Map();
|
|
82
|
+
// when the bridge last recovered: hints sent while it was deaf are unknowable
|
|
83
|
+
let lastBlanketAt = -Infinity;
|
|
84
|
+
const publishes = new Set();
|
|
85
|
+
const tokenFailures = new FailureCounter(TOKEN_FAILURES_PER_MIN);
|
|
86
|
+
let stopping = false;
|
|
87
|
+
const bridge = new DbBroadcastBridge({
|
|
88
|
+
pool,
|
|
89
|
+
channel: HUB_CHANNEL,
|
|
90
|
+
logger,
|
|
91
|
+
ignoreOwn: true,
|
|
92
|
+
...opts.bridge,
|
|
93
|
+
onMessage: (message) => {
|
|
94
|
+
const check = checkHint(message?.hint);
|
|
95
|
+
if (!check.ok) {
|
|
96
|
+
logger.warn('hub relayed hint invalid', { error: check.error });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
deliverLocal(check.hint);
|
|
100
|
+
},
|
|
101
|
+
onRecovered: () => blanket(),
|
|
102
|
+
});
|
|
103
|
+
function deliverLocal(hint) {
|
|
104
|
+
if (hint.kind === 'work') {
|
|
105
|
+
// Work hints go to daemon waits only, never to a browser socket
|
|
106
|
+
// whatever its app (review B1).
|
|
107
|
+
lastWork.set(hint.app, { at: now(), ...(hint.ref !== undefined ? { ref: hint.ref } : {}) });
|
|
108
|
+
for (const waiter of [...(waits.get(hint.app) ?? [])])
|
|
109
|
+
waiter.finish(workResponse(hint.ref));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const users = sockets.get(hint.app);
|
|
113
|
+
if (!users)
|
|
114
|
+
return;
|
|
115
|
+
const targets = hint.user === '*' ? [...users.values()].flatMap((set) => [...set]) : [...(users.get(hint.user) ?? [])];
|
|
116
|
+
if (targets.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
const frame = JSON.stringify({ type: 'hint', ...hint });
|
|
119
|
+
for (const socket of targets)
|
|
120
|
+
send(socket, frame);
|
|
121
|
+
}
|
|
122
|
+
function send(socket, frame) {
|
|
123
|
+
try {
|
|
124
|
+
socket.ws.send(frame);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
logger.warn('hub socket send failed', { app: socket.app, error: serializeError(error) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function blanket() {
|
|
131
|
+
lastBlanketAt = now();
|
|
132
|
+
for (const [appName, users] of sockets) {
|
|
133
|
+
for (const [user, set] of users) {
|
|
134
|
+
const frame = JSON.stringify({ type: 'hint', app: appName, user, kind: 'sync_available' });
|
|
135
|
+
for (const socket of set)
|
|
136
|
+
send(socket, frame);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const set of waits.values())
|
|
140
|
+
for (const waiter of [...set])
|
|
141
|
+
waiter.finish(workResponse());
|
|
142
|
+
}
|
|
143
|
+
function publish(hint) {
|
|
144
|
+
const p = withinMs(bridge.notify({ hint }), HUB_PUBLISH_TIMEOUT_MS, 'hub publish')
|
|
145
|
+
.catch((error) => {
|
|
146
|
+
logger.warn('hub publish failed', { app: hint.app, kind: hint.kind, error: serializeError(error) });
|
|
147
|
+
})
|
|
148
|
+
.finally(() => publishes.delete(p));
|
|
149
|
+
publishes.add(p);
|
|
150
|
+
}
|
|
151
|
+
/** requireIdentity without letting it end the chain: whether it admitted
|
|
152
|
+
* the request (identity set), else the response it chose. */
|
|
153
|
+
async function runIdentity(c) {
|
|
154
|
+
let admitted = false;
|
|
155
|
+
const response = await identity(c, async () => {
|
|
156
|
+
admitted = true;
|
|
157
|
+
});
|
|
158
|
+
return { admitted, response: response ?? undefined };
|
|
159
|
+
}
|
|
160
|
+
/** The caller's app, from its service account; null for any other caller
|
|
161
|
+
* (the coding agent, a browser). */
|
|
162
|
+
function callerApp(c) {
|
|
163
|
+
const id = c.var.identity;
|
|
164
|
+
if (id?.type !== 's2s')
|
|
165
|
+
return null;
|
|
166
|
+
return serviceAccount.exec(id.email)?.[1] ?? null;
|
|
167
|
+
}
|
|
168
|
+
// ---- app → hub ----
|
|
169
|
+
app.post('/api/notify', identity, async (c) => {
|
|
170
|
+
const caller = callerApp(c);
|
|
171
|
+
if (!caller)
|
|
172
|
+
return c.json({ error: 'app service accounts only' }, 403);
|
|
173
|
+
const check = checkHint(await c.req.json().catch(() => null));
|
|
174
|
+
if (!check.ok)
|
|
175
|
+
return c.json({ error: check.error }, check.status);
|
|
176
|
+
if (check.hint.app !== caller) {
|
|
177
|
+
logger.warn('hub notify for another app refused', { caller, app: check.hint.app });
|
|
178
|
+
return c.json({ error: 'an app may hint only its own clients' }, 403);
|
|
179
|
+
}
|
|
180
|
+
deliverLocal(check.hint);
|
|
181
|
+
// After local delivery and off the response path, with its own bound, so
|
|
182
|
+
// a slow hub pool never trips the apps' 1 s notify bound (review S1).
|
|
183
|
+
publish(check.hint);
|
|
184
|
+
return c.body(null, 202);
|
|
185
|
+
});
|
|
186
|
+
app.post('/api/register-daemon', identity, async (c) => {
|
|
187
|
+
const caller = callerApp(c);
|
|
188
|
+
if (!caller)
|
|
189
|
+
return c.json({ error: 'app service accounts only' }, 403);
|
|
190
|
+
const body = (await c.req.json().catch(() => null));
|
|
191
|
+
const tokenHash = body?.tokenHash;
|
|
192
|
+
const daemonId = body?.daemonId;
|
|
193
|
+
if (typeof tokenHash !== 'string' || !TOKEN_HASH_FORMAT.test(tokenHash))
|
|
194
|
+
return c.json({ error: 'invalid tokenHash' }, 400);
|
|
195
|
+
if (typeof daemonId !== 'string' || !DAEMON_ID_FORMAT.test(daemonId))
|
|
196
|
+
return c.json({ error: 'invalid daemonId' }, 400);
|
|
197
|
+
const client = await pool.connect();
|
|
198
|
+
let ok = false;
|
|
199
|
+
try {
|
|
200
|
+
await client.query('begin');
|
|
201
|
+
// Serializes this app's registrations, so the cap is exact.
|
|
202
|
+
await client.query(`select pg_advisory_xact_lock(hashtext('hub_register:' || $1))`, [caller]);
|
|
203
|
+
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]);
|
|
204
|
+
if (rows[0].n >= DAEMON_IDS_PER_APP_MAX) {
|
|
205
|
+
await client.query('rollback');
|
|
206
|
+
ok = true;
|
|
207
|
+
logger.error('hub daemon registration cap reached', { app: caller, daemons: rows[0].n });
|
|
208
|
+
return c.json({ error: 'too many daemons registered for this app' }, 429);
|
|
209
|
+
}
|
|
210
|
+
// Upsert by daemon id: a retry or an orphaned register overwrites the
|
|
211
|
+
// process's one row, and its previous token dies at once (review S3).
|
|
212
|
+
await client.query(`insert into daemon_tokens (app, daemon_id, token_hash, expires_at)
|
|
213
|
+
values ($1, $2, $3, now() + make_interval(secs => $4))
|
|
214
|
+
on conflict (app, daemon_id) do update
|
|
215
|
+
set token_hash = excluded.token_hash, expires_at = excluded.expires_at, registered_at = now()`, [caller, daemonId, tokenHash, DAEMON_TOKEN_TTL_SECONDS]);
|
|
216
|
+
await client.query(`delete from daemon_tokens where expires_at < now() - interval '1 day'`);
|
|
217
|
+
await client.query('commit');
|
|
218
|
+
ok = true;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (!ok)
|
|
222
|
+
await client.query('rollback').catch(() => { });
|
|
223
|
+
client.release(ok ? undefined : true);
|
|
224
|
+
}
|
|
225
|
+
return c.json({ ttlSeconds: DAEMON_TOKEN_TTL_SECONDS });
|
|
226
|
+
});
|
|
227
|
+
// ---- daemon waits ----
|
|
228
|
+
/** Resolves the waiting app: from a daemon hub token, or (no token) from
|
|
229
|
+
* `?app=` for a granted service caller. Cookies are refused (review B1). */
|
|
230
|
+
const daemonAuth = createMiddleware(async (c, next) => {
|
|
231
|
+
const token = c.req.header('x-hub-token');
|
|
232
|
+
if (token === undefined) {
|
|
233
|
+
const { admitted, response } = await runIdentity(c);
|
|
234
|
+
if (!admitted)
|
|
235
|
+
return response;
|
|
236
|
+
if (c.var.identity?.type !== 's2s')
|
|
237
|
+
return c.json({ error: 'service callers only' }, 403);
|
|
238
|
+
const named = c.req.query('app');
|
|
239
|
+
if (!isAppName(named))
|
|
240
|
+
return c.json({ error: 'app required' }, 400);
|
|
241
|
+
c.set('hubApp', named);
|
|
242
|
+
await next();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (c.req.header('authorization') !== undefined) {
|
|
246
|
+
// A daemon's hub client never sends Authorization; both together means
|
|
247
|
+
// an app credential is travelling to the hub (review S4).
|
|
248
|
+
logger.error('hub daemon wait with both X-Hub-Token and Authorization', {});
|
|
249
|
+
return c.json({ error: 'send X-Hub-Token or Authorization, not both' }, 400);
|
|
250
|
+
}
|
|
251
|
+
const ip = forwardedClientIp(c, canonicalHost);
|
|
252
|
+
if (tokenFailures.blocked(ip, now()))
|
|
253
|
+
return c.json({ error: 'too many invalid tokens' }, 429);
|
|
254
|
+
if (!TOKEN_FORMAT.test(token)) {
|
|
255
|
+
tokenFailures.fail(ip, now());
|
|
256
|
+
return c.json({ error: 'invalid token' }, 401);
|
|
257
|
+
}
|
|
258
|
+
let found;
|
|
259
|
+
try {
|
|
260
|
+
const { rows } = await pool.query(`select app from daemon_tokens where token_hash = $1 and expires_at > now()`, [createHash('sha256').update(token).digest('hex')]);
|
|
261
|
+
found = rows[0]?.app;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// 503, not 401: a hub database outage must not make every daemon
|
|
265
|
+
// re-mint; the daemon treats 503 as a hub failure.
|
|
266
|
+
logger.warn('hub token lookup failed', { error: serializeError(error) });
|
|
267
|
+
return c.json({ error: 'token lookup unavailable' }, 503);
|
|
268
|
+
}
|
|
269
|
+
if (!found) {
|
|
270
|
+
tokenFailures.fail(ip, now());
|
|
271
|
+
return c.json({ error: 'invalid or expired token' }, 401);
|
|
272
|
+
}
|
|
273
|
+
c.set('hubApp', found);
|
|
274
|
+
await next();
|
|
275
|
+
});
|
|
276
|
+
app.get('/api/daemon/wait', daemonAuth, (c) => {
|
|
277
|
+
const appName = c.var.hubApp;
|
|
278
|
+
const waitSeconds = intParam(c.req.query('wait'), 0, DAEMON_WAIT_MAX_SECONDS, 50);
|
|
279
|
+
const lookbackMs = intParam(c.req.query('lookbackMs'), 0, DAEMON_LOOKBACK_MAX_MS, 0);
|
|
280
|
+
if (stopping)
|
|
281
|
+
return c.body(null, 204);
|
|
282
|
+
// A work hint between the daemon's direct request and this wait being
|
|
283
|
+
// parked would otherwise be lost to the 30 s timer (review S2).
|
|
284
|
+
const t = now();
|
|
285
|
+
const last = lastWork.get(appName);
|
|
286
|
+
if (lookbackMs > 0 && last && t - last.at <= lookbackMs)
|
|
287
|
+
return workResponse(last.ref);
|
|
288
|
+
if (lookbackMs > 0 && t - lastBlanketAt <= lookbackMs)
|
|
289
|
+
return workResponse();
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
let set = waits.get(appName);
|
|
292
|
+
if (!set)
|
|
293
|
+
waits.set(appName, (set = new Set()));
|
|
294
|
+
const waiters = set;
|
|
295
|
+
const signal = c.req.raw.signal;
|
|
296
|
+
const waiter = {
|
|
297
|
+
app: appName,
|
|
298
|
+
finish: (response) => {
|
|
299
|
+
if (!waiters.delete(waiter))
|
|
300
|
+
return;
|
|
301
|
+
if (waiters.size === 0 && waits.get(appName) === waiters)
|
|
302
|
+
waits.delete(appName);
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
signal.removeEventListener('abort', onAbort);
|
|
305
|
+
resolve(response);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const onAbort = () => waiter.finish(new Response(null, { status: 204 }));
|
|
309
|
+
const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })), waitSeconds * 1000);
|
|
310
|
+
waiters.add(waiter);
|
|
311
|
+
if (signal.aborted)
|
|
312
|
+
onAbort();
|
|
313
|
+
else
|
|
314
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
// ---- browser sockets ----
|
|
318
|
+
// requireIdentity's refusal (401/403/503), kept so the socket can open and
|
|
319
|
+
// close with a code the client understands rather than fail its handshake.
|
|
320
|
+
const refusals = new WeakMap();
|
|
321
|
+
const originCheck = createMiddleware(async (c, next) => {
|
|
322
|
+
const appName = c.req.query('app');
|
|
323
|
+
if (!isAppName(appName))
|
|
324
|
+
return c.json({ error: 'app required' }, 400);
|
|
325
|
+
if (!originAllowed(c.req.header('origin'), appName, opts)) {
|
|
326
|
+
logger.warn('hub socket origin refused', { app: appName, origin: c.req.header('origin') ?? null });
|
|
327
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
328
|
+
}
|
|
329
|
+
await next();
|
|
330
|
+
});
|
|
331
|
+
const socketIdentity = createMiddleware(async (c, next) => {
|
|
332
|
+
const { admitted, response } = await runIdentity(c);
|
|
333
|
+
if (!admitted)
|
|
334
|
+
refusals.set(c, response?.status ?? 401);
|
|
335
|
+
await next();
|
|
336
|
+
});
|
|
337
|
+
app.get('/api/ws', originCheck, socketIdentity, opts.upgradeWebSocket((c) => {
|
|
338
|
+
const appName = c.req.query('app');
|
|
339
|
+
const refusal = refusals.get(c);
|
|
340
|
+
const id = c.var.identity;
|
|
341
|
+
const user = refusal === undefined && id?.type === 'user' ? id.email : null;
|
|
342
|
+
let socket = null;
|
|
343
|
+
const remove = () => {
|
|
344
|
+
if (!socket)
|
|
345
|
+
return;
|
|
346
|
+
const users = sockets.get(socket.app);
|
|
347
|
+
const set = users?.get(socket.user);
|
|
348
|
+
set?.delete(socket);
|
|
349
|
+
if (set?.size === 0)
|
|
350
|
+
users.delete(socket.user);
|
|
351
|
+
if (users?.size === 0)
|
|
352
|
+
sockets.delete(socket.app);
|
|
353
|
+
socket = null;
|
|
354
|
+
};
|
|
355
|
+
return {
|
|
356
|
+
onOpen: (_evt, ws) => {
|
|
357
|
+
if (stopping) {
|
|
358
|
+
ws.close(1012, 'hub restarting');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!user) {
|
|
362
|
+
// 4403: identity missing or expired (the client re-checks its
|
|
363
|
+
// session); 1013: the auth service is unavailable, try again.
|
|
364
|
+
if (refusal === 503)
|
|
365
|
+
ws.close(1013, 'auth unavailable');
|
|
366
|
+
else
|
|
367
|
+
ws.close(4403, 'user identity required');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
socket = { ws, app: appName, user };
|
|
371
|
+
let users = sockets.get(appName);
|
|
372
|
+
if (!users)
|
|
373
|
+
sockets.set(appName, (users = new Map()));
|
|
374
|
+
let set = users.get(user);
|
|
375
|
+
if (!set)
|
|
376
|
+
users.set(user, (set = new Set()));
|
|
377
|
+
set.add(socket);
|
|
378
|
+
},
|
|
379
|
+
onMessage: (evt, ws) => {
|
|
380
|
+
if (!socket)
|
|
381
|
+
return;
|
|
382
|
+
let message;
|
|
383
|
+
try {
|
|
384
|
+
message = JSON.parse(String(evt.data));
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Unknown frames and fields are ignored on both sides: hub
|
|
390
|
+
// versions overlap during a hub deploy.
|
|
391
|
+
if (message?.type === 'ping')
|
|
392
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
393
|
+
else if (message?.type === 'connect')
|
|
394
|
+
ws.send(JSON.stringify({ type: 'ack' }));
|
|
395
|
+
},
|
|
396
|
+
onClose: remove,
|
|
397
|
+
onError: remove,
|
|
398
|
+
};
|
|
399
|
+
}));
|
|
400
|
+
return {
|
|
401
|
+
start: () => bridge.start(),
|
|
402
|
+
async stop() {
|
|
403
|
+
stopping = true;
|
|
404
|
+
for (const users of sockets.values()) {
|
|
405
|
+
for (const set of users.values()) {
|
|
406
|
+
for (const socket of set) {
|
|
407
|
+
try {
|
|
408
|
+
socket.ws.close(1012, 'hub restarting');
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// already closing
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
sockets.clear();
|
|
417
|
+
for (const set of waits.values())
|
|
418
|
+
for (const waiter of [...set])
|
|
419
|
+
waiter.finish(new Response(null, { status: 204 }));
|
|
420
|
+
await Promise.allSettled([...publishes]);
|
|
421
|
+
await bridge.stop();
|
|
422
|
+
},
|
|
423
|
+
stats() {
|
|
424
|
+
let socketCount = 0;
|
|
425
|
+
for (const users of sockets.values())
|
|
426
|
+
for (const set of users.values())
|
|
427
|
+
socketCount += set.size;
|
|
428
|
+
let waitCount = 0;
|
|
429
|
+
for (const set of waits.values())
|
|
430
|
+
waitCount += set.size;
|
|
431
|
+
return { sockets: socketCount, waits: waitCount };
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function workResponse(ref) {
|
|
436
|
+
return Response.json({ kind: 'work', ...(ref !== undefined ? { ref } : {}) });
|
|
437
|
+
}
|
|
438
|
+
function originAllowed(origin, appName, opts) {
|
|
439
|
+
if (!origin)
|
|
440
|
+
return false;
|
|
441
|
+
if (origin === `https://${appName}.${opts.appDomain}`)
|
|
442
|
+
return true;
|
|
443
|
+
return opts.allowLocalOrigins === true && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
|
444
|
+
}
|
|
445
|
+
function intParam(value, min, max, fallback) {
|
|
446
|
+
if (value === undefined || !/^\d+$/.test(value))
|
|
447
|
+
return fallback;
|
|
448
|
+
return Math.min(max, Math.max(min, Number(value)));
|
|
449
|
+
}
|
|
450
|
+
function escapeRegExp(s) {
|
|
451
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
452
|
+
}
|
|
453
|
+
/** `promise`, or a rejection once `ms` pass; the work itself continues. */
|
|
454
|
+
function withinMs(promise, ms, what) {
|
|
455
|
+
let timer;
|
|
456
|
+
const timeout = new Promise((_, reject) => {
|
|
457
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
|
|
458
|
+
});
|
|
459
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
460
|
+
}
|
|
461
|
+
/** Failed token attempts per client address in a fixed one-minute window.
|
|
462
|
+
* Keys are chosen by unauthenticated callers, so the map is bounded. */
|
|
463
|
+
class FailureCounter {
|
|
464
|
+
perMinute;
|
|
465
|
+
windows = new Map();
|
|
466
|
+
constructor(perMinute) {
|
|
467
|
+
this.perMinute = perMinute;
|
|
468
|
+
}
|
|
469
|
+
blocked(key, t) {
|
|
470
|
+
const w = this.windows.get(key);
|
|
471
|
+
return w !== undefined && t - w.start < 60_000 && w.count >= this.perMinute;
|
|
472
|
+
}
|
|
473
|
+
fail(key, t) {
|
|
474
|
+
const w = this.windows.get(key);
|
|
475
|
+
if (w && t - w.start < 60_000) {
|
|
476
|
+
w.count++;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
this.windows.delete(key);
|
|
480
|
+
if (this.windows.size >= 10_000) {
|
|
481
|
+
for (const [k, old] of this.windows) {
|
|
482
|
+
if (t - old.start >= 60_000 || this.windows.size >= 10_000)
|
|
483
|
+
this.windows.delete(k);
|
|
484
|
+
else
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
this.windows.set(key, { start: t, count: 1 });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "3.47.0",
|
|
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",
|