@openparachute/vault 0.6.5-rc.9 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RAM/arch/OS tier selection for `transcription install` (scribe-fold
|
|
3
|
+
* Phase 2b — the ratified tier table, 2026-07-03).
|
|
4
|
+
*
|
|
5
|
+
* This is the PURE decision layer for the install verb's AUTO-DEFAULT: given
|
|
6
|
+
* host facts (OS, arch, total RAM), which local provider should this box run?
|
|
7
|
+
* No side effects — the CLI probes the host, prints the pick + footprint, and
|
|
8
|
+
* the operator confirms or overrides with `--provider`/`--model`.
|
|
9
|
+
*
|
|
10
|
+
* ## The ratified table
|
|
11
|
+
*
|
|
12
|
+
* | Host | Default |
|
|
13
|
+
* |-----------------------------|--------------------------------------------|
|
|
14
|
+
* | macOS Apple Silicon, 8GB+ | parakeet-mlx (parakeet-tdt-0.6b-v3; |
|
|
15
|
+
* | | ~2.5GB disk, ~2.5–3GB peak) |
|
|
16
|
+
* | Linux 4GB+ | onnx-asr (parakeet-0.6b int8; ~670MB disk, |
|
|
17
|
+
* | | ~1.2–2GB+ peak) |
|
|
18
|
+
* | Linux ~2GB | transcribe-cpp + whisper-small GGUF — NOT |
|
|
19
|
+
* | | Parakeet (scribe#82: Parakeet peak RAM |
|
|
20
|
+
* | | exceeds 2GB on meeting-length audio) |
|
|
21
|
+
* | Linux ~1GB | REMOTE (scribe-http) by default; local |
|
|
22
|
+
* | | whisper-tiny only as explicit opt-in |
|
|
23
|
+
* | Below floor / unknown host | remote guidance — never force local |
|
|
24
|
+
*
|
|
25
|
+
* Hosts the table doesn't name fall back to transcribe-cpp with its own
|
|
26
|
+
* Phase 2a RAM tiers (e.g. Intel macs; a hypothetical <8GB Apple-Silicon
|
|
27
|
+
* box) — that matrix already carries the scribe#82 Parakeet-at-4GB floor.
|
|
28
|
+
*
|
|
29
|
+
* ## Nominal-RAM slack
|
|
30
|
+
*
|
|
31
|
+
* Tier boundaries are NOMINAL sizes ("a 4GB droplet"), but the kernel/firmware
|
|
32
|
+
* reserve memory before userspace sees it — a nominal-4GB Linux box reports
|
|
33
|
+
* ~3.6–3.9GB via MemTotal. `NOMINAL_SLACK_GB` absorbs that so the box the
|
|
34
|
+
* table targets actually lands in its tier. The 1GB remote floor uses a
|
|
35
|
+
* smaller slack: below-floor boxes must steer remote, never get a local
|
|
36
|
+
* install forced on them.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
MODELS,
|
|
41
|
+
selectAsset,
|
|
42
|
+
selectModelForRam,
|
|
43
|
+
type ModelChoice,
|
|
44
|
+
} from "./install.ts";
|
|
45
|
+
import { DEFAULT_ONNX_ASR_MODEL, DEFAULT_PARAKEET_MLX_MODEL } from "./select.ts";
|
|
46
|
+
|
|
47
|
+
const GB = 2 ** 30;
|
|
48
|
+
|
|
49
|
+
/** Kernel/firmware reservation tolerance on nominal tier boundaries. */
|
|
50
|
+
export const NOMINAL_SLACK_GB = 0.4;
|
|
51
|
+
|
|
52
|
+
/** Slack at the 1GB remote floor (a nominal-1GB box reports ~0.94–0.97GB). */
|
|
53
|
+
const FLOOR_SLACK_GB = 0.1;
|
|
54
|
+
|
|
55
|
+
/** A provider name the tier table can select. */
|
|
56
|
+
export type TierProviderName = "parakeet-mlx" | "onnx-asr" | "transcribe-cpp" | "scribe-http";
|
|
57
|
+
|
|
58
|
+
/** The resolved tier pick for a host. */
|
|
59
|
+
export interface TierPlan {
|
|
60
|
+
/** The tier-default provider for this host. */
|
|
61
|
+
provider: TierProviderName;
|
|
62
|
+
platform: string;
|
|
63
|
+
arch: string;
|
|
64
|
+
/** Detected total RAM, GB (rounded to 1dp) for the human print. */
|
|
65
|
+
totalRamGb: number;
|
|
66
|
+
/**
|
|
67
|
+
* Model id for the pick: an HF repo (parakeet-mlx), an onnx-asr registry
|
|
68
|
+
* name, or a transcribe-cpp GGUF id from `MODELS`. Absent for scribe-http.
|
|
69
|
+
*/
|
|
70
|
+
model?: string;
|
|
71
|
+
/** Approx download footprint, MB (model + runtime), for the confirm prompt. */
|
|
72
|
+
approxDiskMb?: number;
|
|
73
|
+
/** Human peak-RAM note for the confirm prompt. */
|
|
74
|
+
peakRamNote?: string;
|
|
75
|
+
/** One-line rationale for the pick (printed to the operator). */
|
|
76
|
+
reason: string;
|
|
77
|
+
/**
|
|
78
|
+
* Set on the remote-default tier when a local install is still POSSIBLE as
|
|
79
|
+
* an explicit opt-in (Linux ~1GB → transcribe-cpp + whisper-tiny). The CLI
|
|
80
|
+
* prints it as guidance; it is never auto-installed.
|
|
81
|
+
*/
|
|
82
|
+
localOptIn?: { provider: "transcribe-cpp"; model: string; note: string };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Host facts the tier decision needs. */
|
|
86
|
+
export interface TierInput {
|
|
87
|
+
/** Node `os.platform()` value ("darwin" / "linux" / …). */
|
|
88
|
+
platform: string;
|
|
89
|
+
/** Node `os.arch()` value ("arm64" / "x64" / …). */
|
|
90
|
+
arch: string;
|
|
91
|
+
/** Detected total physical RAM in bytes. */
|
|
92
|
+
totalRamBytes: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pick the tier-default provider for a host. Pure — see the module header for
|
|
97
|
+
* the ratified table. The scribe#82 invariant this encodes: **no Parakeet
|
|
98
|
+
* variant is ever the default below 4GB** (and on Linux, below-4GB boxes get
|
|
99
|
+
* transcribe-cpp whisper models or the remote steer — never onnx-asr).
|
|
100
|
+
*/
|
|
101
|
+
export function selectDefaultProvider(input: TierInput): TierPlan {
|
|
102
|
+
const { platform, arch, totalRamBytes } = input;
|
|
103
|
+
const gb = totalRamBytes / GB;
|
|
104
|
+
const base = { platform, arch, totalRamGb: Math.round(gb * 10) / 10 };
|
|
105
|
+
|
|
106
|
+
// macOS Apple Silicon, 8GB+ → parakeet-mlx (MLX uses the GPU/unified memory;
|
|
107
|
+
// the best quality/speed on this hardware).
|
|
108
|
+
if (platform === "darwin" && arch === "arm64" && gb >= 8 - NOMINAL_SLACK_GB) {
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
provider: "parakeet-mlx",
|
|
112
|
+
model: DEFAULT_PARAKEET_MLX_MODEL,
|
|
113
|
+
approxDiskMb: 2500,
|
|
114
|
+
peakRamNote: "~2.5–3GB peak while transcribing",
|
|
115
|
+
reason: "Apple Silicon with 8GB+ RAM — parakeet-mlx (MLX) is the best local quality/speed here.",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Linux 4GB+ → onnx-asr (Parakeet int8 ONNX — the proven scribe Linux
|
|
120
|
+
// path). Arch-gated: onnxruntime publishes wheels for x64/arm64 only.
|
|
121
|
+
if (platform === "linux" && (arch === "x64" || arch === "arm64") && gb >= 4 - NOMINAL_SLACK_GB) {
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
provider: "onnx-asr",
|
|
125
|
+
model: DEFAULT_ONNX_ASR_MODEL,
|
|
126
|
+
approxDiskMb: 670,
|
|
127
|
+
peakRamNote: "~1.2–2GB+ peak while transcribing (meeting-length audio uses the high end)",
|
|
128
|
+
reason: "Linux with 4GB+ RAM — onnx-asr runs Parakeet (int8 ONNX) comfortably at this size.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// transcribe-cpp fallback tiers — only where a release asset exists for the
|
|
133
|
+
// host. Covers Linux ~2GB (whisper-small — NOT Parakeet, scribe#82) and the
|
|
134
|
+
// hosts the ratified table doesn't name (Intel macs etc.), reusing the
|
|
135
|
+
// Phase 2a RAM matrix (which already floors Parakeet-GGUF at 4GB).
|
|
136
|
+
const asset = selectAsset(platform, arch);
|
|
137
|
+
if (asset && gb >= 2 - NOMINAL_SLACK_GB) {
|
|
138
|
+
// Model pick: slack belongs to TIER-ENTRY boundaries, never to model-floor
|
|
139
|
+
// checks. The 4GB Parakeet-GGUF floor is checked against the UN-INFLATED
|
|
140
|
+
// RAM (adding slack here once picked a ~3.5GB-peak model for a real-3.6GB
|
|
141
|
+
// Intel Mac — ~0.1GB headroom, the scribe#82 OOM class resurrected).
|
|
142
|
+
// Below a true 4GB, the ratified "~2GB → small whisper" row covers the
|
|
143
|
+
// whole band (entry already vetted ≥ ~2GB nominal above).
|
|
144
|
+
const model: ModelChoice =
|
|
145
|
+
gb >= 4
|
|
146
|
+
? (selectModelForRam(totalRamBytes) ?? MODELS["whisper-small.en"]!)
|
|
147
|
+
: MODELS["whisper-small.en"]!;
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
provider: "transcribe-cpp",
|
|
151
|
+
model: model.name,
|
|
152
|
+
approxDiskMb: model.approxSizeMb,
|
|
153
|
+
peakRamNote: `~${model.approxRuntimeGb}GB peak while transcribing`,
|
|
154
|
+
reason:
|
|
155
|
+
gb < 4
|
|
156
|
+
? `~${base.totalRamGb}GB RAM — a small whisper GGUF via transcribe-cpp fits; Parakeet's peak RAM exceeds 2GB on meeting-length audio (scribe#82).`
|
|
157
|
+
: `${platform}/${arch} isn't a parakeet-mlx/onnx-asr tier host — transcribe-cpp with a RAM-tier GGUF is the local default.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Linux ~1GB (and any asset-supported host just above the floor) → remote
|
|
162
|
+
// by default; whisper-tiny via transcribe-cpp only as an explicit opt-in.
|
|
163
|
+
if (asset && gb >= 1 - FLOOR_SLACK_GB) {
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
provider: "scribe-http",
|
|
167
|
+
reason: `only ~${base.totalRamGb}GB RAM — a remote provider is the reliable default at this size.`,
|
|
168
|
+
localOptIn: {
|
|
169
|
+
provider: "transcribe-cpp",
|
|
170
|
+
model: "whisper-tiny.en",
|
|
171
|
+
note: "a tiny local model is possible but slow/low-quality: `transcription install --provider transcribe-cpp --model whisper-tiny.en`",
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Below the floor, or a host with no local runtime at all → remote guidance.
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
provider: "scribe-http",
|
|
180
|
+
reason: asset
|
|
181
|
+
? `only ~${base.totalRamGb}GB RAM — below the 1GB local floor; use a remote provider.`
|
|
182
|
+
: `no local transcription runtime for ${platform}/${arch} — use a remote provider.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/vault-create.test.ts
CHANGED
|
@@ -358,11 +358,11 @@ describe("vault create — services.json registration (#208)", () => {
|
|
|
358
358
|
* Default-pack seeding on create (originally demo-prep Workstream A — A1/A3;
|
|
359
359
|
* reshaped for named seed packs).
|
|
360
360
|
*
|
|
361
|
-
* A freshly-created vault must contain the `welcome` pack (
|
|
362
|
-
*
|
|
363
|
-
* NOT the `surface-starter` pack, which is opt-in via `add-pack`
|
|
364
|
-
* 2026-07-02). Idempotent + best-effort: the seed never fails a
|
|
365
|
-
* never clobbers an edited note.
|
|
361
|
+
* A freshly-created vault must contain the `welcome` pack (the five-guide
|
|
362
|
+
* welcome ring + the capture/guide tags) and the `getting-started`
|
|
363
|
+
* guide — and NOT the `surface-starter` pack, which is opt-in via `add-pack`
|
|
364
|
+
* (ratified 2026-07-02). Idempotent + best-effort: the seed never fails a
|
|
365
|
+
* create and never clobbers an edited note.
|
|
366
366
|
*/
|
|
367
367
|
describe("vault create — default pack seeding (welcome + getting-started)", () => {
|
|
368
368
|
/** Read all (path, content) rows from a created vault's SQLite DB. */
|
|
@@ -400,23 +400,28 @@ describe("vault create — default pack seeding (welcome + getting-started)", ()
|
|
|
400
400
|
const notes = readNotes("guided");
|
|
401
401
|
const gs = notes.find((n) => n.path === "Getting Started");
|
|
402
402
|
const welcome = notes.find((n) => n.path === "Welcome to your vault 🪂");
|
|
403
|
-
const
|
|
403
|
+
const captureAnything = notes.find((n) => n.path === "Capture anything");
|
|
404
|
+
const tagsGraph = notes.find((n) => n.path === "Tags and the graph");
|
|
404
405
|
const connectAi = notes.find((n) => n.path === "Connect your AI");
|
|
406
|
+
const yoursToKeep = notes.find((n) => n.path === "Yours to keep");
|
|
405
407
|
expect(gs).toBeDefined();
|
|
406
408
|
expect(welcome).toBeDefined();
|
|
407
|
-
expect(
|
|
409
|
+
expect(captureAnything).toBeDefined();
|
|
410
|
+
expect(tagsGraph).toBeDefined();
|
|
408
411
|
expect(connectAi).toBeDefined();
|
|
412
|
+
expect(yoursToKeep).toBeDefined();
|
|
409
413
|
expect(gs!.content).toContain("# Getting Started");
|
|
410
414
|
// Surface Starter is out of the default seed — no note, no dangling link.
|
|
411
415
|
expect(notes.find((n) => n.path === "Surface Starter")).toBeUndefined();
|
|
412
416
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
413
|
-
expect(gs!.content).toContain("add-pack
|
|
414
|
-
|
|
415
|
-
// The
|
|
416
|
-
// and nothing else (fresh-vault seed =
|
|
417
|
-
// capture/text + capture/voice
|
|
418
|
-
|
|
419
|
-
expect(
|
|
417
|
+
expect(gs!.content).toContain("add-pack");
|
|
418
|
+
|
|
419
|
+
// The capture tag Notes requires arrives with the welcome pack, alongside
|
|
420
|
+
// the guide (skill-file) tag — and nothing else (fresh-vault seed = 6 notes:
|
|
421
|
+
// the 5-guide ring + Getting Started. The retired capture/text + capture/voice
|
|
422
|
+
// subtypes and the vestigial #pinned seed are no longer seeded).
|
|
423
|
+
expect(readTagNames("guided").sort()).toEqual(["capture", "guide"]);
|
|
424
|
+
expect(notes).toHaveLength(6);
|
|
420
425
|
});
|
|
421
426
|
|
|
422
427
|
test("seeding doesn't break --json stdout (notes seeded silently)", () => {
|
package/src/vault.test.ts
CHANGED
|
@@ -1093,7 +1093,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1093
1093
|
close();
|
|
1094
1094
|
});
|
|
1095
1095
|
|
|
1096
|
-
// -- tag-scoped MCP wrappers (
|
|
1096
|
+
// -- tag-scoped MCP wrappers (docs/contracts/tag-scoped-tokens.md) ------------
|
|
1097
1097
|
//
|
|
1098
1098
|
// These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
|
|
1099
1099
|
// wrapped tool's execute() honors the auth's scoped_tags allowlist. The
|
package/src/ws-server.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query WebSocket binding — the SELF-HOST (Bun.serve) integration
|
|
3
|
+
* (WS-hibernation migration, 2026-07-04; contract
|
|
4
|
+
* `parachute-cloud/workers/vault/docs/live-query-ws.md`).
|
|
5
|
+
*
|
|
6
|
+
* The cloud door runs this logic inside a per-vault Durable Object with
|
|
7
|
+
* Hibernatable WebSockets; the self-host door runs it in the single long-lived
|
|
8
|
+
* `Bun.serve` process (`server.ts`). Same wire contract, minus hibernation — a
|
|
9
|
+
* self-run Bun box has no per-connection duration bill, so a socket just lives
|
|
10
|
+
* in memory (free): no attachment / rehydration / eviction / sweep, no
|
|
11
|
+
* lifetime cap. Per-socket state lives on `ws.data` and is mutated in place.
|
|
12
|
+
*
|
|
13
|
+
* ## Where the pieces live
|
|
14
|
+
* - `ws-subscribe.ts` — the PURE half (close codes, query validation, chunked
|
|
15
|
+
* snapshot, message parsing, verb-rank) shared byte-shaped with cloud.
|
|
16
|
+
* - `subscriptions.ts` — the transport-agnostic manager + `WsSink`
|
|
17
|
+
* (`{ type, ...data }`), the sole live-transport sink since Phase 5.
|
|
18
|
+
* - this module — the Bun.serve upgrade decision + `open`/`message`/`close`
|
|
19
|
+
* handlers + the per-vault live-socket registry (the WS cap).
|
|
20
|
+
*
|
|
21
|
+
* ## First-message auth (fork 2, ratified)
|
|
22
|
+
* The socket is accepted pre-auth; the FIRST message must be
|
|
23
|
+
* `{"type":"auth","token"}` (browsers can't header-auth a WebSocket and the hub
|
|
24
|
+
* ws-bridge drops subprotocols). Auth reuses the FULL self-host auth seam by
|
|
25
|
+
* synthesizing a `Bearer` request — the WS token validates exactly as an HTTP
|
|
26
|
+
* `Authorization` header would (operator bearer / hub JWT / legacy YAML keys /
|
|
27
|
+
* dropped pvt_* → 401). A pending socket that never auths within ~10s is closed
|
|
28
|
+
* 4408. Re-auth on the open socket (client re-sends `auth` on token refresh) may
|
|
29
|
+
* only narrow-or-equal the granted verb (a widen → 4403); no re-snapshot.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
33
|
+
import type { Store } from "../core/src/types.ts";
|
|
34
|
+
import type { VaultConfig } from "./config.ts";
|
|
35
|
+
import { readVaultConfig } from "./config.ts";
|
|
36
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
37
|
+
import { authenticateVaultRequest, type AuthResult } from "./auth.ts";
|
|
38
|
+
import { hasScopeForVault } from "./scopes.ts";
|
|
39
|
+
import { expandTokenTagScope, filterNotesByTagScope } from "./tag-scope.ts";
|
|
40
|
+
import { buildLiveMatcher } from "./live-match.ts";
|
|
41
|
+
import {
|
|
42
|
+
subscriptionManager,
|
|
43
|
+
WsSink,
|
|
44
|
+
type SubscriptionHandle,
|
|
45
|
+
type SubscriptionManager,
|
|
46
|
+
} from "./subscriptions.ts";
|
|
47
|
+
import {
|
|
48
|
+
MAX_WS_SUBSCRIPTIONS,
|
|
49
|
+
WS_AUTH_DEADLINE_MS,
|
|
50
|
+
WS_CLOSE,
|
|
51
|
+
buildSnapshotFrames,
|
|
52
|
+
parseClientMessage,
|
|
53
|
+
sameTagScope,
|
|
54
|
+
subscriptionCapResponse,
|
|
55
|
+
urlFromQuery,
|
|
56
|
+
validateWsSubscribeQuery,
|
|
57
|
+
vaultVerbRank,
|
|
58
|
+
} from "./ws-subscribe.ts";
|
|
59
|
+
|
|
60
|
+
/** Per-connection state on `ws.data`. Mutated in place (no hibernation). */
|
|
61
|
+
export interface SubscribeWsData {
|
|
62
|
+
vaultName: string;
|
|
63
|
+
/** Raw query string incl. leading `?` (e.g. `?tag=watch`). */
|
|
64
|
+
rawQuery: string;
|
|
65
|
+
state: "pending" | "ready";
|
|
66
|
+
/** Granted scopes recorded at auth (the re-auth narrow-or-equal check). */
|
|
67
|
+
grantedScopes: string[];
|
|
68
|
+
/** Granted tag-scope (`scoped_tags`) recorded at auth. The live matcher's
|
|
69
|
+
* tag-scope is frozen at initial auth, so a re-auth that changes this is
|
|
70
|
+
* refused (4403) rather than silently keeping the old scope. */
|
|
71
|
+
grantedTagScope: string[] | null;
|
|
72
|
+
/** Manager handle once the sub is live (null while pending). */
|
|
73
|
+
handle: SubscriptionHandle | null;
|
|
74
|
+
/** Pending-auth deadline timer (cleared on auth success / close). */
|
|
75
|
+
authTimer: ReturnType<typeof setTimeout> | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Upgrade decision returned to `server.ts`'s fetch. */
|
|
79
|
+
export type WsUpgradeVerdict =
|
|
80
|
+
| { kind: "upgraded" }
|
|
81
|
+
| { kind: "response"; response: Response }
|
|
82
|
+
| { kind: "pass" };
|
|
83
|
+
|
|
84
|
+
export interface SubscribeWsDeps {
|
|
85
|
+
/** Resolve a vault's store. Defaults to the process-wide `getVaultStore`. */
|
|
86
|
+
getStore?: (vaultName: string) => Store;
|
|
87
|
+
/** Read a vault's config. Defaults to `readVaultConfig`. */
|
|
88
|
+
getVaultConfig?: (vaultName: string) => VaultConfig | null;
|
|
89
|
+
/** The subscription manager. Defaults to the process-wide singleton. */
|
|
90
|
+
manager?: SubscriptionManager;
|
|
91
|
+
/**
|
|
92
|
+
* Authenticate a synthesized Bearer request. Defaults to
|
|
93
|
+
* `authenticateVaultRequest` (the full self-host auth seam). Injectable so
|
|
94
|
+
* tests can drive every close-code path (401 → 4401, 403 → 4403) without a
|
|
95
|
+
* live hub JWKS.
|
|
96
|
+
*/
|
|
97
|
+
authenticate?: (
|
|
98
|
+
req: Request,
|
|
99
|
+
vaultConfig: VaultConfig,
|
|
100
|
+
) => Promise<{ error: Response } | AuthResult>;
|
|
101
|
+
/** Per-vault concurrent WS-subscription cap. Defaults to
|
|
102
|
+
* {@link MAX_WS_SUBSCRIPTIONS}; tests set it low to exercise the 503. */
|
|
103
|
+
maxSubscriptions?: number;
|
|
104
|
+
/** Pending-auth deadline (ms). Defaults to {@link WS_AUTH_DEADLINE_MS};
|
|
105
|
+
* tests set it low to exercise the 4408 close without a 10s wait. */
|
|
106
|
+
authDeadlineMs?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function json(data: unknown, status = 200): Response {
|
|
110
|
+
return Response.json(data, { status });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True iff the request is a WebSocket upgrade. */
|
|
114
|
+
export function isWebSocketUpgrade(req: Request): boolean {
|
|
115
|
+
return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const SUBSCRIBE_PATH = /^\/vault\/([^/]+)\/api\/subscribe$/;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The self-host live-query WebSocket binding: an upgrade-decision function
|
|
122
|
+
* (called from `server.ts`'s fetch BEFORE the normal route pipeline) plus the
|
|
123
|
+
* `Bun.serve` `websocket` handlers. Both share the per-vault live-socket
|
|
124
|
+
* registry (closure state) that enforces the WS cap.
|
|
125
|
+
*/
|
|
126
|
+
export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
127
|
+
tryUpgrade: (req: Request, server: Server<SubscribeWsData>, path: string) => WsUpgradeVerdict;
|
|
128
|
+
handlers: WebSocketHandler<SubscribeWsData>;
|
|
129
|
+
} {
|
|
130
|
+
const getStore = deps.getStore ?? getVaultStore;
|
|
131
|
+
const getVaultConfig = deps.getVaultConfig ?? readVaultConfig;
|
|
132
|
+
const manager = deps.manager ?? subscriptionManager;
|
|
133
|
+
const authenticate = deps.authenticate ?? authenticateVaultRequest;
|
|
134
|
+
const maxSubscriptions = deps.maxSubscriptions ?? MAX_WS_SUBSCRIPTIONS;
|
|
135
|
+
const authDeadlineMs = deps.authDeadlineMs ?? WS_AUTH_DEADLINE_MS;
|
|
136
|
+
|
|
137
|
+
// Per-vault live sockets (pending + ready) — the WS cap counts over these,
|
|
138
|
+
// mirroring the cloud door's `getWebSockets()` live count. A pending socket
|
|
139
|
+
// that never auths self-heals off this set via the ~10s deadline close.
|
|
140
|
+
const liveSockets = new Map<string, Set<ServerWebSocket<SubscribeWsData>>>();
|
|
141
|
+
const liveCount = (vaultName: string): number => liveSockets.get(vaultName)?.size ?? 0;
|
|
142
|
+
const addLiveSocket = (vaultName: string, ws: ServerWebSocket<SubscribeWsData>): void => {
|
|
143
|
+
let set = liveSockets.get(vaultName);
|
|
144
|
+
if (!set) {
|
|
145
|
+
set = new Set();
|
|
146
|
+
liveSockets.set(vaultName, set);
|
|
147
|
+
}
|
|
148
|
+
set.add(ws);
|
|
149
|
+
};
|
|
150
|
+
const removeLiveSocket = (vaultName: string, ws: ServerWebSocket<SubscribeWsData>): void => {
|
|
151
|
+
const set = liveSockets.get(vaultName);
|
|
152
|
+
if (!set) return;
|
|
153
|
+
set.delete(ws);
|
|
154
|
+
if (set.size === 0) liveSockets.delete(vaultName);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const clearAuthTimer = (ws: ServerWebSocket<SubscribeWsData>): void => {
|
|
158
|
+
if (ws.data.authTimer) {
|
|
159
|
+
clearTimeout(ws.data.authTimer);
|
|
160
|
+
ws.data.authTimer = null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Drop a socket's manager subscription + registry slot (idempotent). */
|
|
165
|
+
const cleanupSocket = (ws: ServerWebSocket<SubscribeWsData>): void => {
|
|
166
|
+
clearAuthTimer(ws);
|
|
167
|
+
removeLiveSocket(ws.data.vaultName, ws);
|
|
168
|
+
if (ws.data.handle) {
|
|
169
|
+
ws.data.handle.close();
|
|
170
|
+
ws.data.handle = null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Close a socket with an explicit application code, then tear down its sub
|
|
175
|
+
* IMMEDIATELY (so no further event fans out to the closing socket). The
|
|
176
|
+
* `close` handler re-runs cleanup idempotently. */
|
|
177
|
+
const closeWs = (ws: ServerWebSocket<SubscribeWsData>, code: number, reason: string): void => {
|
|
178
|
+
try {
|
|
179
|
+
ws.close(code, reason);
|
|
180
|
+
} catch {
|
|
181
|
+
/* already closing / closed */
|
|
182
|
+
}
|
|
183
|
+
cleanupSocket(ws);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const closeCodeForAuthError = (res: Response): number =>
|
|
187
|
+
res.status === 403 ? WS_CLOSE.FORBIDDEN : WS_CLOSE.UNAUTHORIZED;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* First auth on a pending socket. On success: build the matcher + tag-scope +
|
|
191
|
+
* snapshot (all awaited), then SYNCHRONOUSLY flip → ready, register, and flush
|
|
192
|
+
* the chunked snapshot (no await between register and flush, so a deferred
|
|
193
|
+
* hook dispatch can't slip a live event ahead of the snapshot — the SSE
|
|
194
|
+
* ordering). Failure → 4401 (auth) / 4403 (scope) / 4400 (bad query).
|
|
195
|
+
*/
|
|
196
|
+
async function authenticatePendingSocket(ws: ServerWebSocket<SubscribeWsData>, token: string): Promise<void> {
|
|
197
|
+
const { vaultName, rawQuery } = ws.data;
|
|
198
|
+
const vaultConfig = getVaultConfig(vaultName);
|
|
199
|
+
if (!vaultConfig) {
|
|
200
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "vault not found");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let store: Store;
|
|
204
|
+
try {
|
|
205
|
+
store = getStore(vaultName);
|
|
206
|
+
} catch {
|
|
207
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "vault not available");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Reuse the FULL self-host auth seam via a synthesized Bearer request.
|
|
212
|
+
const authReq = new Request(`http://ws.local/vault/${vaultName}/api/subscribe${rawQuery}`, {
|
|
213
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
214
|
+
});
|
|
215
|
+
const auth = await authenticate(authReq, vaultConfig);
|
|
216
|
+
if ("error" in auth) {
|
|
217
|
+
closeWs(ws, closeCodeForAuthError(auth.error), "auth failed");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Subscribe is a READ operation (admin ⊇ write ⊇ read).
|
|
221
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
|
|
222
|
+
closeWs(ws, WS_CLOSE.FORBIDDEN, "insufficient scope");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Query was validated at upgrade; re-validate from the stored raw string.
|
|
227
|
+
const validated = validateWsSubscribeQuery(urlFromQuery(rawQuery));
|
|
228
|
+
if ("error" in validated) {
|
|
229
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "invalid subscription query");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let tagScopeAllowed: Set<string> | null;
|
|
234
|
+
let matcher;
|
|
235
|
+
let snapshotNotes;
|
|
236
|
+
try {
|
|
237
|
+
tagScopeAllowed = await expandTokenTagScope(store, auth.scoped_tags);
|
|
238
|
+
matcher = await buildLiveMatcher(store, validated.queryOpts);
|
|
239
|
+
// Snapshot = the COMPLETE scoped matching set — strip paging so snapshot ⊇ live.
|
|
240
|
+
const raw = await store.queryNotes({
|
|
241
|
+
...validated.queryOpts,
|
|
242
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
243
|
+
offset: undefined,
|
|
244
|
+
});
|
|
245
|
+
snapshotNotes = filterNotesByTagScope(raw, tagScopeAllowed, auth.scoped_tags);
|
|
246
|
+
} catch {
|
|
247
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "snapshot query failed");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const frames = buildSnapshotFrames(snapshotNotes);
|
|
251
|
+
|
|
252
|
+
// --- SYNCHRONOUS from here (no await) so no write interleaves between
|
|
253
|
+
// register and the snapshot flush.
|
|
254
|
+
clearAuthTimer(ws);
|
|
255
|
+
ws.data.state = "ready";
|
|
256
|
+
ws.data.grantedScopes = auth.scopes;
|
|
257
|
+
ws.data.grantedTagScope = auth.scoped_tags;
|
|
258
|
+
const handle = manager.register({
|
|
259
|
+
vaultName,
|
|
260
|
+
matcher,
|
|
261
|
+
tagScopeAllowed,
|
|
262
|
+
tagScopeRaw: auth.scoped_tags,
|
|
263
|
+
sink: new WsSink(ws),
|
|
264
|
+
tracksFlush: false,
|
|
265
|
+
countsTowardCap: false,
|
|
266
|
+
});
|
|
267
|
+
if (!handle) {
|
|
268
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "could not register subscription");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
ws.data.handle = handle;
|
|
272
|
+
|
|
273
|
+
const sink = new WsSink(ws);
|
|
274
|
+
for (const frame of frames) if (!sink.sendRaw(frame)) break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Re-auth on an open socket (client re-sends `auth` on token refresh):
|
|
279
|
+
* re-validate + enforce narrow-or-equal scope (a WIDEN → 4403), then update
|
|
280
|
+
* the recorded scopes. NO re-snapshot, NO re-register — the matcher + socket
|
|
281
|
+
* are unchanged (the self-host socket lives in memory regardless of exp; the
|
|
282
|
+
* re-auth check exists for wire-contract congruence with the cloud door).
|
|
283
|
+
*
|
|
284
|
+
* The live matcher's TAG-scope is frozen at initial auth (not re-derived per
|
|
285
|
+
* message), so a re-auth whose tag-scope differs in ANY way is refused (4403).
|
|
286
|
+
* The client reconnects fresh → the initial-connect path re-derives scope
|
|
287
|
+
* correctly. This closes a WS-only per-agent-isolation gap the SSE route can't
|
|
288
|
+
* have (SSE reconnects re-derive scope every time). See `sameTagScope`.
|
|
289
|
+
*/
|
|
290
|
+
async function reauthReadySocket(ws: ServerWebSocket<SubscribeWsData>, token: string): Promise<void> {
|
|
291
|
+
const { vaultName, grantedScopes, grantedTagScope, rawQuery } = ws.data;
|
|
292
|
+
const vaultConfig = getVaultConfig(vaultName);
|
|
293
|
+
if (!vaultConfig) {
|
|
294
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "vault not found");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const authReq = new Request(`http://ws.local/vault/${vaultName}/api/subscribe${rawQuery}`, {
|
|
298
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
299
|
+
});
|
|
300
|
+
const auth = await authenticate(authReq, vaultConfig);
|
|
301
|
+
if ("error" in auth) {
|
|
302
|
+
closeWs(ws, closeCodeForAuthError(auth.error), "re-auth failed");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (vaultVerbRank(auth.scopes, vaultName) > vaultVerbRank(grantedScopes, vaultName)) {
|
|
306
|
+
closeWs(ws, WS_CLOSE.FORBIDDEN, "re-auth widens scope");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Tag-scope delta → force a clean reconnect (which re-derives scope). A
|
|
310
|
+
// narrowed/revoked/differently-scoped token must NOT keep the frozen matcher.
|
|
311
|
+
if (!sameTagScope(auth.scoped_tags, grantedTagScope)) {
|
|
312
|
+
closeWs(ws, WS_CLOSE.FORBIDDEN, "re-auth changes tag scope");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
ws.data.grantedScopes = auth.scopes;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function tryUpgrade(req: Request, server: Server<SubscribeWsData>, path: string): WsUpgradeVerdict {
|
|
319
|
+
const match = path.match(SUBSCRIBE_PATH);
|
|
320
|
+
if (!match) return { kind: "pass" };
|
|
321
|
+
const vaultName = match[1]!;
|
|
322
|
+
|
|
323
|
+
// WS upgrades are GET.
|
|
324
|
+
if (req.method !== "GET") {
|
|
325
|
+
return { kind: "response", response: json({ error: "Method not allowed", message: "subscribe is GET-only" }, 405) };
|
|
326
|
+
}
|
|
327
|
+
if (!getVaultConfig(vaultName)) {
|
|
328
|
+
return { kind: "response", response: json({ error: "Vault not found", vault: vaultName }, 404) };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const url = new URL(req.url);
|
|
332
|
+
// Same query rejects as the SSE route (byte-identical 400 body).
|
|
333
|
+
const validated = validateWsSubscribeQuery(url);
|
|
334
|
+
if ("error" in validated) return { kind: "response", response: validated.error };
|
|
335
|
+
|
|
336
|
+
// Per-vault WS cap (over the live sockets) — pending sockets count, and
|
|
337
|
+
// self-heal off the set via the ~10s auth-deadline close.
|
|
338
|
+
if (liveCount(vaultName) >= maxSubscriptions) {
|
|
339
|
+
return { kind: "response", response: subscriptionCapResponse() };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const data: SubscribeWsData = {
|
|
343
|
+
vaultName,
|
|
344
|
+
rawQuery: url.search,
|
|
345
|
+
state: "pending",
|
|
346
|
+
grantedScopes: [],
|
|
347
|
+
grantedTagScope: null,
|
|
348
|
+
handle: null,
|
|
349
|
+
authTimer: null,
|
|
350
|
+
};
|
|
351
|
+
const ok = server.upgrade(req, { data });
|
|
352
|
+
if (ok) return { kind: "upgraded" };
|
|
353
|
+
// Not a valid WS handshake (missing Sec-WebSocket-* headers).
|
|
354
|
+
return {
|
|
355
|
+
kind: "response",
|
|
356
|
+
response: json({ error: "Expected a WebSocket upgrade request" }, 426),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const handlers: WebSocketHandler<SubscribeWsData> = {
|
|
361
|
+
open(ws) {
|
|
362
|
+
addLiveSocket(ws.data.vaultName, ws);
|
|
363
|
+
// Pending-auth deadline: an accepted socket that never auths is closed
|
|
364
|
+
// 4408. `unref` so the timer never holds the process (or a test) open.
|
|
365
|
+
const timer = setTimeout(() => {
|
|
366
|
+
if (ws.data.state === "pending") closeWs(ws, WS_CLOSE.AUTH_TIMEOUT, "auth timeout");
|
|
367
|
+
}, authDeadlineMs);
|
|
368
|
+
(timer as { unref?: () => void }).unref?.();
|
|
369
|
+
ws.data.authTimer = timer;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async message(ws, message) {
|
|
373
|
+
// Client-driven liveness: the literal `ping` string → `pong`. Never JSON,
|
|
374
|
+
// so it short-circuits before `parseClientMessage`.
|
|
375
|
+
if (typeof message === "string" && message === "ping") {
|
|
376
|
+
try {
|
|
377
|
+
ws.send("pong");
|
|
378
|
+
} catch {
|
|
379
|
+
/* socket gone */
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const parsed = parseClientMessage(message as string | Uint8Array);
|
|
385
|
+
if (parsed.kind === "malformed") {
|
|
386
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "malformed message");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (ws.data.state === "pending") {
|
|
391
|
+
if (parsed.kind !== "auth") {
|
|
392
|
+
closeWs(ws, WS_CLOSE.PROTOCOL, "expected auth message");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
await authenticatePendingSocket(ws, parsed.token);
|
|
396
|
+
} else if (parsed.kind === "auth") {
|
|
397
|
+
await reauthReadySocket(ws, parsed.token);
|
|
398
|
+
}
|
|
399
|
+
// Any other message on a ready socket: ignored (forward-compat).
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
close(ws) {
|
|
403
|
+
cleanupSocket(ws);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
return { tryUpgrade, handlers };
|
|
408
|
+
}
|