@openparachute/vault 0.6.5-rc.11 → 0.6.5-rc.13
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/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/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +226 -0
- package/src/module-manifest.ts +8 -0
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +1 -1
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +24 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscribe.ts +23 -22
- package/src/subscriptions.ts +125 -37
- 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/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/.parachute/module.json
CHANGED
package/core/src/core.test.ts
CHANGED
|
@@ -4046,7 +4046,7 @@ describe("query-notes link expansion", async () => {
|
|
|
4046
4046
|
});
|
|
4047
4047
|
|
|
4048
4048
|
// ---------------------------------------------------------------------------
|
|
4049
|
-
// Tag hierarchy via tags.parent_names (post-v14,
|
|
4049
|
+
// Tag hierarchy via tags.parent_names (post-v14, docs/contracts/tag-data-model.md)
|
|
4050
4050
|
// ---------------------------------------------------------------------------
|
|
4051
4051
|
|
|
4052
4052
|
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
@@ -5107,7 +5107,7 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
|
5107
5107
|
});
|
|
5108
5108
|
});
|
|
5109
5109
|
|
|
5110
|
-
describe("expandTagsWithDescendants (tag-scoped tokens —
|
|
5110
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — docs/contracts/tag-scoped-tokens.md)", async () => {
|
|
5111
5111
|
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
5112
5112
|
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
5113
5113
|
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
@@ -5143,10 +5143,10 @@ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-t
|
|
|
5143
5143
|
});
|
|
5144
5144
|
|
|
5145
5145
|
// ---------------------------------------------------------------------------
|
|
5146
|
-
// Tag record API —
|
|
5146
|
+
// Tag record API — docs/contracts/tag-data-model.md
|
|
5147
5147
|
// ---------------------------------------------------------------------------
|
|
5148
5148
|
|
|
5149
|
-
describe("tag record API (
|
|
5149
|
+
describe("tag record API (docs/contracts/tag-data-model.md)", async () => {
|
|
5150
5150
|
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
5151
5151
|
await store.upsertTagRecord("project", {
|
|
5152
5152
|
description: "long-running deliverable",
|
|
@@ -5430,7 +5430,7 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5430
5430
|
});
|
|
5431
5431
|
|
|
5432
5432
|
// ---------------------------------------------------------------------------
|
|
5433
|
-
// Schema migration v13 → v14 —
|
|
5433
|
+
// Schema migration v13 → v14 — docs/contracts/tag-data-model.md
|
|
5434
5434
|
// ---------------------------------------------------------------------------
|
|
5435
5435
|
|
|
5436
5436
|
describe("schema migration v13 → v14", async () => {
|
|
@@ -5809,7 +5809,7 @@ describe("schema migration v15 → v16", async () => {
|
|
|
5809
5809
|
});
|
|
5810
5810
|
|
|
5811
5811
|
// ---------------------------------------------------------------------------
|
|
5812
|
-
// Tag-scope auth post-v14 —
|
|
5812
|
+
// Tag-scope auth post-v14 — docs/contracts/tag-scoped-tokens.md
|
|
5813
5813
|
// ---------------------------------------------------------------------------
|
|
5814
5814
|
|
|
5815
5815
|
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
@@ -5824,7 +5824,7 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
|
5824
5824
|
});
|
|
5825
5825
|
|
|
5826
5826
|
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
5827
|
-
// Per
|
|
5827
|
+
// Per docs/contracts/tag-scoped-tokens.md §Storage details, the auth check
|
|
5828
5828
|
// also splits on '/' and matches the root verbatim against the raw
|
|
5829
5829
|
// allowlist. This survives the v14 source-of-truth swap because the
|
|
5830
5830
|
// fallback lives in src/tag-scope.ts, not in the resolver.
|
package/core/src/mcp.ts
CHANGED
|
@@ -1408,7 +1408,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1408
1408
|
{
|
|
1409
1409
|
name: "update-tag",
|
|
1410
1410
|
requiredVerb: "write",
|
|
1411
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-
|
|
1411
|
+
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-vault/docs/contracts/tag-data-model.md.",
|
|
1412
1412
|
inputSchema: {
|
|
1413
1413
|
type: "object",
|
|
1414
1414
|
properties: {
|
package/core/src/schema.ts
CHANGED
|
@@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
41
41
|
|
|
42
42
|
-- Tags: first-class identity carrying schema, hierarchy, and typed-link
|
|
43
43
|
-- declarations. One row per tag; no notes-as-config sidecars for these
|
|
44
|
-
-- concerns. See
|
|
44
|
+
-- concerns. See docs/contracts/tag-data-model.md.
|
|
45
45
|
--
|
|
46
46
|
-- description — human-readable blurb (markdown).
|
|
47
47
|
-- fields — JSON: indexed metadata field declarations per
|
|
@@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
136
136
|
-- scoped_tags is a JSON-encoded array of root tag names that constrain the
|
|
137
137
|
-- token's effective access (intersection with the scopes column). NULL
|
|
138
138
|
-- means unscoped — full vault access per scopes. Introduced in v13 per
|
|
139
|
-
--
|
|
139
|
+
-- docs/contracts/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
|
|
140
140
|
-- time via getTagDescendants; the column stores root names only.
|
|
141
141
|
--
|
|
142
142
|
-- vault_name (v16) binds the token to a single vault. NULL means the
|
|
@@ -443,7 +443,7 @@ export function initSchema(db: Database): void {
|
|
|
443
443
|
// Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
|
|
444
444
|
// description/fields/relationships/parent_names/timestamps; copy data
|
|
445
445
|
// from the v6-era tag_schemas sidecar and from `_tags/<name>` config
|
|
446
|
-
// notes; drop tag_schemas after copy. See
|
|
446
|
+
// notes; drop tag_schemas after copy. See docs/contracts/tag-data-model.md.
|
|
447
447
|
migrateToV14(db);
|
|
448
448
|
|
|
449
449
|
// Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
|
|
@@ -650,7 +650,7 @@ function migrateToV12(db: Database): void {
|
|
|
650
650
|
* (current full-vault behavior); a JSON array of root tag names narrows the
|
|
651
651
|
* token's access to notes carrying one of those tags or a sub-tag thereof
|
|
652
652
|
* (hierarchy expansion via getTagDescendants at auth time). See
|
|
653
|
-
*
|
|
653
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
654
654
|
*/
|
|
655
655
|
function migrateToV13(db: Database): void {
|
|
656
656
|
if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
|
|
@@ -659,7 +659,7 @@ function migrateToV13(db: Database): void {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
|
-
* Migrate v13 → v14: tag-data-model reshape (
|
|
662
|
+
* Migrate v13 → v14: tag-data-model reshape (docs/contracts/tag-data-model.md).
|
|
663
663
|
*
|
|
664
664
|
* Augments the `tags` table with five new columns and one timestamp pair,
|
|
665
665
|
* then copies pre-existing data from two notes-as-config sidecars:
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* History: pre-v14 vaults stored hierarchy in notes-as-config at
|
|
10
10
|
* `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
|
|
11
11
|
* lifts those parent declarations onto the tags row and the resolver here
|
|
12
|
-
* was swapped accordingly. See
|
|
12
|
+
* was swapped accordingly. See docs/contracts/tag-data-model.md.
|
|
13
13
|
*
|
|
14
14
|
* Resolution model:
|
|
15
15
|
* - Lazy: built on first access, cached on the store.
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each tag row carries: human-readable description, indexed metadata field
|
|
5
5
|
* declarations (`fields`), typed-link relationship declarations
|
|
6
6
|
* (`relationships`), and the hierarchy parent list (`parent_names`).
|
|
7
|
-
* See
|
|
7
|
+
* See docs/contracts/tag-data-model.md.
|
|
8
8
|
*
|
|
9
9
|
* This module retains the historical `tag-schemas` filename and exports
|
|
10
10
|
* (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
|
|
@@ -51,7 +51,7 @@ export interface TagFieldSchema {
|
|
|
51
51
|
* `{ target_tag, cardinality }` declaration — but `relationships` is now an
|
|
52
52
|
* opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
|
|
53
53
|
* so this is one valid value shape among many, not a required one.
|
|
54
|
-
* See
|
|
54
|
+
* See docs/contracts/tag-data-model.md §Relationships.
|
|
55
55
|
*/
|
|
56
56
|
export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
|
|
57
57
|
|
package/core/src/types.ts
CHANGED
|
@@ -406,7 +406,7 @@ export interface Store {
|
|
|
406
406
|
|
|
407
407
|
// Tag records — full v14 identity row (description + fields + typed
|
|
408
408
|
// relationships + parent_names + timestamps). See
|
|
409
|
-
//
|
|
409
|
+
// docs/contracts/tag-data-model.md.
|
|
410
410
|
listTagRecords(): Promise<TagRecord[]>;
|
|
411
411
|
getTagRecord(tag: string): Promise<TagRecord | null>;
|
|
412
412
|
/**
|
package/package.json
CHANGED
package/src/admin-spa.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Per-vault mount (vault#252): the SPA lives under `/vault/<name>/admin/*`
|
|
10
10
|
* rather than the origin-rooted `/admin/*` it shipped with. The hub only
|
|
11
|
-
* proxies `/vault/<name>/*` paths (per
|
|
11
|
+
* proxies `/vault/<name>/*` paths (per the module protocol —
|
|
12
|
+
* https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-protocol.md),
|
|
12
13
|
* so an origin-rooted SPA is unreachable through the hub. Asset URLs are
|
|
13
14
|
* relative (Vite `base: "./"`), so the same bundle works at any mount
|
|
14
15
|
* point — no rebuild per vault.
|
package/src/auth.ts
CHANGED
|
@@ -123,7 +123,7 @@ export interface AuthResult {
|
|
|
123
123
|
* Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
|
|
124
124
|
* (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
|
|
125
125
|
* always have null — tag scoping is a per-token vault-DB attribute, not
|
|
126
|
-
* an OAuth-claim concern. See
|
|
126
|
+
* an OAuth-claim concern. See docs/contracts/tag-scoped-tokens.md.
|
|
127
127
|
*/
|
|
128
128
|
scoped_tags: string[] | null;
|
|
129
129
|
/**
|
package/src/export-watch.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* cursor captured at the *start* of the previous cycle. Vault writes are
|
|
11
11
|
* HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
|
|
12
12
|
* is opaque), so polling on `updated_at >= cursor` is the simplest robust
|
|
13
|
-
* detection. See `
|
|
13
|
+
* detection. See `docs/contracts/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration
|
|
3
|
+
* Phase 3, self-host door).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS payload and the
|
|
7
|
+
* self-host SSE `data:` body carry a byte-IDENTICAL serialization of the inner
|
|
8
|
+
* payload (`notes` / `note` / `id`) — the ONLY difference is the `type`
|
|
9
|
+
* discriminator the WS message folds in (plus the snapshot `done` chunk flag).
|
|
10
|
+
* Because the corpus AND the `sseFrame`/`wsFrame` formatters are byte-shaped
|
|
11
|
+
* identical to cloud's, this transitively proves: self-host WS bytes === corpus
|
|
12
|
+
* === self-host SSE bytes === cloud WS/SSE bytes.
|
|
13
|
+
*
|
|
14
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
15
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "bun:test";
|
|
18
|
+
import { sseFrame, wsFrame, SseSink, WsSink, snapshotFrame } from "./subscriptions.ts";
|
|
19
|
+
import {
|
|
20
|
+
buildSnapshotFrames,
|
|
21
|
+
parseClientMessage,
|
|
22
|
+
vaultVerbRank,
|
|
23
|
+
sameTagScope,
|
|
24
|
+
validateWsSubscribeQuery,
|
|
25
|
+
subscriptionCapResponse,
|
|
26
|
+
urlFromQuery,
|
|
27
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
28
|
+
WS_CLOSE,
|
|
29
|
+
} from "./ws-subscribe.ts";
|
|
30
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
31
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
32
|
+
|
|
33
|
+
/** Strip the SSE `data:` body out of a single-event SSE frame. */
|
|
34
|
+
function sseDataBody(frame: string): string {
|
|
35
|
+
const line = frame.split("\n").find((l) => l.startsWith("data:"))!;
|
|
36
|
+
return line.slice("data:".length).replace(/^ /, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("live-frame parity — self-host WS bytes === corpus === SSE data body", () => {
|
|
40
|
+
for (const c of FRAME_CORPUS) {
|
|
41
|
+
it(`${c.name}: identical inner payload across transports`, () => {
|
|
42
|
+
const sse = sseFrame(c.event, c.data);
|
|
43
|
+
const ws = wsFrame(c.event, c.data);
|
|
44
|
+
|
|
45
|
+
// SSE frame shape (byte-identical to the pre-WS-migration contract).
|
|
46
|
+
expect(sse).toBe(`event: ${c.event}\ndata: ${JSON.stringify(c.data)}\n\n`);
|
|
47
|
+
// SSE data body parses back to exactly the payload.
|
|
48
|
+
expect(JSON.parse(sseDataBody(sse))).toEqual(c.data);
|
|
49
|
+
|
|
50
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
51
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
52
|
+
expect(parsedWs.type).toBe(c.event);
|
|
53
|
+
const { type, ...rest } = parsedWs;
|
|
54
|
+
void type;
|
|
55
|
+
expect(rest).toEqual(c.data);
|
|
56
|
+
|
|
57
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
58
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY in both frames AND
|
|
59
|
+
// equals the corpus serialization (the cross-door invariant).
|
|
60
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
61
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
62
|
+
expect(sse.includes(innerBytes)).toBe(true);
|
|
63
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("snapshotFrame() is the SSE snapshot frame for a note set", () => {
|
|
68
|
+
expect(snapshotFrame([NOTE_A as any])).toBe(sseFrame("snapshot", { notes: [NOTE_A] }));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("sink classes render through the ONE formatter each", () => {
|
|
73
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
74
|
+
const sent: string[] = [];
|
|
75
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
76
|
+
for (const c of FRAME_CORPUS) {
|
|
77
|
+
sink.send(c.event, c.data);
|
|
78
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("SseSink.send emits exactly sseFrame(event, data); comment() emits `:\\n\\n`", () => {
|
|
83
|
+
const pushed: string[] = [];
|
|
84
|
+
const sink = new SseSink((f) => (pushed.push(f), true), () => {});
|
|
85
|
+
sink.send("upsert", { note: NOTE_A });
|
|
86
|
+
expect(pushed.at(-1)).toBe(sseFrame("upsert", { note: NOTE_A }));
|
|
87
|
+
sink.comment();
|
|
88
|
+
expect(pushed.at(-1)).toBe(":\n\n");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
93
|
+
it("empty set → exactly one done:true frame", () => {
|
|
94
|
+
const frames = buildSnapshotFrames([]);
|
|
95
|
+
expect(frames.length).toBe(1);
|
|
96
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
100
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
101
|
+
expect(frames.length).toBe(1);
|
|
102
|
+
const f = JSON.parse(frames[0]!);
|
|
103
|
+
expect(f.done).toBe(true);
|
|
104
|
+
expect(f.notes.length).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
108
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
109
|
+
const frames = buildSnapshotFrames(many as any);
|
|
110
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
111
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
112
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
113
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
114
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
115
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
116
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
120
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
121
|
+
const frames = buildSnapshotFrames(big as any);
|
|
122
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
123
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
124
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("parseClientMessage", () => {
|
|
129
|
+
it("valid auth", () => {
|
|
130
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
131
|
+
});
|
|
132
|
+
it("auth without a token → malformed", () => {
|
|
133
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
134
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
135
|
+
});
|
|
136
|
+
it("non-json / non-object → malformed", () => {
|
|
137
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
138
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
139
|
+
});
|
|
140
|
+
it("other typed message → other", () => {
|
|
141
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
142
|
+
});
|
|
143
|
+
it("decodes a Uint8Array frame", () => {
|
|
144
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
145
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
150
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
151
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
152
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
153
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
154
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
155
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
156
|
+
});
|
|
157
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
158
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
159
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
164
|
+
it("both unscoped (null) → same", () => {
|
|
165
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
168
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
169
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
172
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
173
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it("different membership → differ", () => {
|
|
176
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
177
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
182
|
+
const reject = async (q: string) => {
|
|
183
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
184
|
+
expect("error" in v).toBe(true);
|
|
185
|
+
const body = await (v as { error: Response }).error.json();
|
|
186
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
187
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
188
|
+
};
|
|
189
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
190
|
+
await reject("search=hi");
|
|
191
|
+
await reject("near[note_id]=abc");
|
|
192
|
+
await reject("cursor=xyz");
|
|
193
|
+
await reject("has_links=true");
|
|
194
|
+
});
|
|
195
|
+
it("accepts a supported query", () => {
|
|
196
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
197
|
+
expect("queryOpts" in v).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
200
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
201
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
202
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
203
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
204
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
205
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
206
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
207
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("misc pure helpers", () => {
|
|
212
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
213
|
+
const res = subscriptionCapResponse();
|
|
214
|
+
expect(res.status).toBe(503);
|
|
215
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
216
|
+
});
|
|
217
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
218
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
219
|
+
expect(urlFromQuery("").search).toBe("");
|
|
220
|
+
});
|
|
221
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
222
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
223
|
+
4400, 4401, 4403, 4408,
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/module-manifest.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
|
|
|
48
48
|
readonly paths: readonly string[];
|
|
49
49
|
readonly health: string;
|
|
50
50
|
readonly stripPrefix?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
|
|
53
|
+
* binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
54
|
+
* vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
|
|
55
|
+
* self-registered services.json row too (`ServiceEntry.websocket`); the hub
|
|
56
|
+
* honors either source. See `parachute-hub/src/ws-bridge.ts`.
|
|
57
|
+
*/
|
|
58
|
+
readonly websocket?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
package/src/routes.ts
CHANGED
|
@@ -2089,7 +2089,7 @@ export async function handleTags(
|
|
|
2089
2089
|
|
|
2090
2090
|
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
2091
2091
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2092
|
-
// are preserved, explicit null clears. See
|
|
2092
|
+
// are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
|
|
2093
2093
|
if (req.method === "PUT") {
|
|
2094
2094
|
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2095
2095
|
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
@@ -2114,7 +2114,7 @@ export async function handleTags(
|
|
|
2114
2114
|
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2115
2115
|
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2116
2116
|
* so removing a field row actually deletes the field. See
|
|
2117
|
-
*
|
|
2117
|
+
* docs/contracts/tag-data-model.md.
|
|
2118
2118
|
*/
|
|
2119
2119
|
replace_fields?: unknown;
|
|
2120
2120
|
};
|
|
@@ -2211,7 +2211,7 @@ export async function handleTags(
|
|
|
2211
2211
|
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
2212
2212
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
2213
2213
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
2214
|
-
// before retrying.
|
|
2214
|
+
// before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
|
|
2215
2215
|
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
2216
2216
|
if (referenced_by.length > 0) {
|
|
2217
2217
|
return json(
|
package/src/routing.test.ts
CHANGED
|
@@ -1631,7 +1631,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1631
1631
|
expect(res.status).toBe(401);
|
|
1632
1632
|
});
|
|
1633
1633
|
|
|
1634
|
-
// ----- tag-scoped tokens (
|
|
1634
|
+
// ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
|
|
1635
1635
|
|
|
1636
1636
|
/**
|
|
1637
1637
|
* Mint a tag-scoped token. Mirrors `mintToken` above but threads
|
|
@@ -1745,7 +1745,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1745
1745
|
});
|
|
1746
1746
|
|
|
1747
1747
|
// ----- Q6: orphan-sub-tag fail-open ------------------------------------
|
|
1748
|
-
//
|
|
1748
|
+
// docs/contracts/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
|
|
1749
1749
|
// schema, the string-form root authorizes. Token allowlisted for `health`
|
|
1750
1750
|
// must see `#health/food` even when no `_tags/health/food` schema exists.
|
|
1751
1751
|
|
package/src/routing.ts
CHANGED
|
@@ -856,7 +856,7 @@ export async function route(
|
|
|
856
856
|
|
|
857
857
|
const apiPath = apiMatch[1] ?? "";
|
|
858
858
|
|
|
859
|
-
// Tag-scoped tokens (
|
|
859
|
+
// Tag-scoped tokens (docs/contracts/tag-scoped-tokens.md): expand the token's
|
|
860
860
|
// root-tag allowlist into `{root} ∪ descendants(root)` once per request,
|
|
861
861
|
// so handlers can intersect against the note's tag set without re-walking
|
|
862
862
|
// the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
|
|
@@ -382,6 +382,25 @@ describe("self-register", () => {
|
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
|
|
386
|
+
withParachuteHome((home) => {
|
|
387
|
+
const { log, warn } = captureLogs();
|
|
388
|
+
selfRegister({
|
|
389
|
+
version: "0.4.8-rc.3",
|
|
390
|
+
log,
|
|
391
|
+
warn,
|
|
392
|
+
readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
|
|
393
|
+
resolvePackageRoot: () => "/fake/install/dir",
|
|
394
|
+
listVaults: () => [],
|
|
395
|
+
readGlobalConfig: () => ({ port: 1940 }),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const raw = readFileSync(join(home, "services.json"), "utf8");
|
|
399
|
+
const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
|
|
400
|
+
expect(parsed.services[0]!.websocket).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
385
404
|
test("changed=true when installDir differs from prior row", () => {
|
|
386
405
|
withParachuteHome(() => {
|
|
387
406
|
const { log, warn } = captureLogs();
|
package/src/self-register.ts
CHANGED
|
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
191
191
|
displayName?: string;
|
|
192
192
|
tagline?: string;
|
|
193
193
|
stripPrefix?: boolean;
|
|
194
|
+
websocket?: boolean;
|
|
194
195
|
} = {
|
|
195
196
|
name: manifest.manifestName,
|
|
196
197
|
port,
|
|
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
202
203
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
203
204
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
204
205
|
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
206
|
+
// Carry the live-query WS capability onto the row so the hub ws-bridge admits
|
|
207
|
+
// upgrades without having to read module.json (the row is its first source).
|
|
208
|
+
if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
|
|
205
209
|
|
|
206
210
|
// Detect whether the existing row already matches (no-op idempotency
|
|
207
211
|
// signal). We don't gate the write on this — `upsertService` itself is
|
|
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
232
236
|
priorRow.health !== health ||
|
|
233
237
|
(priorRow as { displayName?: string }).displayName !== manifest.displayName ||
|
|
234
238
|
(priorRow as { tagline?: string }).tagline !== manifest.tagline ||
|
|
235
|
-
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix
|
|
239
|
+
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
|
|
240
|
+
(priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
|
|
236
241
|
|
|
237
242
|
try {
|
|
238
243
|
upsertImpl(entry);
|
package/src/server.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "./mirror-config.ts";
|
|
62
62
|
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
63
63
|
import { selfRegister } from "./self-register.ts";
|
|
64
|
+
import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
|
|
64
65
|
import { warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
65
66
|
import pkg from "../package.json" with { type: "json" };
|
|
66
67
|
|
|
@@ -490,10 +491,18 @@ const hostname = resolveBindHostname();
|
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
|
|
494
|
+
// Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
|
|
495
|
+
// subscription contract as the SSE route, WebSocket transport. Handlers +
|
|
496
|
+
// upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
|
|
497
|
+
// there. Advertised via `"websocket": true` in `.parachute/module.json` so the
|
|
498
|
+
// hub ws-bridge admits the upgrade.
|
|
499
|
+
const subscribeWs = createSubscribeWsBinding();
|
|
500
|
+
|
|
493
501
|
const server = Bun.serve({
|
|
494
502
|
port,
|
|
495
503
|
hostname,
|
|
496
504
|
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
505
|
+
websocket: subscribeWs.handlers,
|
|
497
506
|
async fetch(req, server) {
|
|
498
507
|
const url = new URL(req.url);
|
|
499
508
|
const path = url.pathname;
|
|
@@ -517,6 +526,21 @@ const server = Bun.serve({
|
|
|
517
526
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
518
527
|
}
|
|
519
528
|
|
|
529
|
+
// Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
|
|
530
|
+
// WS upgrades don't traverse `route()`. Non-upgrade requests (incl. the SSE
|
|
531
|
+
// fallback, which has no Upgrade header) fall straight through unchanged.
|
|
532
|
+
if (isWebSocketUpgrade(req)) {
|
|
533
|
+
const verdict = subscribeWs.tryUpgrade(req, server, path);
|
|
534
|
+
if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
|
|
535
|
+
if (verdict.kind === "response") {
|
|
536
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
537
|
+
verdict.response.headers.set(k, v);
|
|
538
|
+
}
|
|
539
|
+
return verdict.response;
|
|
540
|
+
}
|
|
541
|
+
// "pass" → not a subscribe WS upgrade; fall through to the pipeline.
|
|
542
|
+
}
|
|
543
|
+
|
|
520
544
|
try {
|
|
521
545
|
const start = Date.now();
|
|
522
546
|
const response = await route(req, path);
|
package/src/services-manifest.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface ServiceEntry {
|
|
|
15
15
|
paths: string[];
|
|
16
16
|
health: string;
|
|
17
17
|
version: string;
|
|
18
|
+
/**
|
|
19
|
+
* When `true`, the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
20
|
+
* this module's mounts to it (deny-by-default). Carried from the module's
|
|
21
|
+
* `.parachute/module.json` `websocket` field by self-registration; the hub
|
|
22
|
+
* honors either source. Optional + free-riding — extra fields survive the
|
|
23
|
+
* read/merge via the `...e` spread in `validateEntry` / `upsertService`.
|
|
24
|
+
*/
|
|
25
|
+
websocket?: boolean;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface ServicesManifest {
|