@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/src/subscribe.ts
CHANGED
|
@@ -24,8 +24,8 @@ import { buildLiveMatcher, unsupportedSubscriptionReason } from "./live-match.ts
|
|
|
24
24
|
import {
|
|
25
25
|
snapshotFrame,
|
|
26
26
|
subscriptionManager,
|
|
27
|
+
SseSink,
|
|
27
28
|
type SubscriptionManager,
|
|
28
|
-
type SubscriptionSink,
|
|
29
29
|
} from "./subscriptions.ts";
|
|
30
30
|
|
|
31
31
|
/** Keepalive interval — `:` comment every ~25s to defeat idle-proxy timeouts. */
|
|
@@ -160,27 +160,28 @@ export async function handleSubscribe(
|
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (cancelled) return;
|
|
172
|
-
cancelled = true;
|
|
173
|
-
if (keepalive) {
|
|
174
|
-
clearInterval(keepalive);
|
|
175
|
-
keepalive = null;
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
controllerRef?.close();
|
|
179
|
-
} catch {
|
|
180
|
-
/* already closed */
|
|
181
|
-
}
|
|
182
|
-
},
|
|
163
|
+
// SSE sink over the stream queue. `send(event, data)` and the `:` keepalive
|
|
164
|
+
// comment both route through this same push → the emitted bytes are identical
|
|
165
|
+
// to the pre-WS-migration SSE contract (surface-client parses them unchanged).
|
|
166
|
+
const push = (frame: string): boolean => {
|
|
167
|
+
if (cancelled) return false;
|
|
168
|
+
queue.push(frame);
|
|
169
|
+
flushQueue();
|
|
170
|
+
return true;
|
|
183
171
|
};
|
|
172
|
+
const sink = new SseSink(push, () => {
|
|
173
|
+
if (cancelled) return;
|
|
174
|
+
cancelled = true;
|
|
175
|
+
if (keepalive) {
|
|
176
|
+
clearInterval(keepalive);
|
|
177
|
+
keepalive = null;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
controllerRef?.close();
|
|
181
|
+
} catch {
|
|
182
|
+
/* already closed */
|
|
183
|
+
}
|
|
184
|
+
});
|
|
184
185
|
|
|
185
186
|
const stream = new ReadableStream<Uint8Array>({
|
|
186
187
|
start(controller) {
|
|
@@ -219,7 +220,7 @@ export async function handleSubscribe(
|
|
|
219
220
|
// 3. Keepalive comments.
|
|
220
221
|
keepalive = setInterval(() => {
|
|
221
222
|
if (cancelled) return;
|
|
222
|
-
sink.
|
|
223
|
+
sink.comment(); // `:\n\n` — byte-identical keepalive comment.
|
|
223
224
|
}, KEEPALIVE_MS);
|
|
224
225
|
},
|
|
225
226
|
pull() {
|
package/src/subscriptions.ts
CHANGED
|
@@ -8,6 +8,25 @@
|
|
|
8
8
|
* connection-scoped, driving a live UI. Same event source, same predicate,
|
|
9
9
|
* different durability.
|
|
10
10
|
*
|
|
11
|
+
* ## Two transports, one contract (WS-hibernation migration, 2026-07-04)
|
|
12
|
+
*
|
|
13
|
+
* The manager is transport-agnostic: it emits abstract `(event, data)` tuples
|
|
14
|
+
* and a {@link SubscriptionSink} renders them for its wire.
|
|
15
|
+
* - {@link SseSink} renders `event:`/`data:` frames — bytes UNCHANGED from the
|
|
16
|
+
* original SSE contract (surface-client parses them identically).
|
|
17
|
+
* - {@link WsSink} renders a WebSocket message `{ type: event, ...data }` — the
|
|
18
|
+
* SSE `event:` name folds into a `type` discriminator; the inner payload
|
|
19
|
+
* (`note` / `id` / `notes`) serializes byte-for-byte identically to the SSE
|
|
20
|
+
* `data:` body. That parity is pinned by the shared frame-corpus fixture and
|
|
21
|
+
* is congruent with the cloud door (`parachute-cloud/workers/vault/src/
|
|
22
|
+
* live/subscriptions.ts`). See `parachute-cloud/workers/vault/docs/
|
|
23
|
+
* live-query-ws.md`.
|
|
24
|
+
*
|
|
25
|
+
* Both transports share ONE process-wide {@link SubscriptionManager}. Self-host
|
|
26
|
+
* has no hibernation (a self-run Bun box has no per-connection duration bill) —
|
|
27
|
+
* the WS binding adds bidirectionality + contract-congruence with cloud, nothing
|
|
28
|
+
* more. The Bun.serve WS integration lives in `ws-server.ts` + `ws-subscribe.ts`.
|
|
29
|
+
*
|
|
11
30
|
* ## How fan-out works
|
|
12
31
|
*
|
|
13
32
|
* All vault stores share the process-wide `defaultHookRegistry`
|
|
@@ -54,16 +73,19 @@ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
|
|
|
54
73
|
* Default bound on a single subscription's pending (unflushed) event buffer.
|
|
55
74
|
* If a slow client lets the buffer grow past this, the stream is closed — it
|
|
56
75
|
* reconnects and re-snapshots rather than the server growing memory unbounded.
|
|
76
|
+
* Only enforced for flush-tracking sinks (SSE); the WS transport delegates
|
|
77
|
+
* outgoing-buffer bounds to the Bun runtime.
|
|
57
78
|
*/
|
|
58
79
|
export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
|
|
59
80
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The abstract sink an event is rendered to. The manager calls `send(event,
|
|
83
|
+
* data)`; each transport formats for its wire. Returns `false` when the sink is
|
|
84
|
+
* gone (the manager then drops the subscription).
|
|
85
|
+
*/
|
|
63
86
|
export interface SubscriptionSink {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** Close the underlying stream (teardown). Idempotent. */
|
|
87
|
+
send(event: string, data: unknown): boolean;
|
|
88
|
+
/** Close the underlying stream/socket (teardown). Idempotent. */
|
|
67
89
|
close(): void;
|
|
68
90
|
}
|
|
69
91
|
|
|
@@ -75,16 +97,84 @@ interface Subscription {
|
|
|
75
97
|
/** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
|
|
76
98
|
readonly tagScopeRaw: string[] | null;
|
|
77
99
|
readonly sink: SubscriptionSink;
|
|
78
|
-
/**
|
|
100
|
+
/** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
|
|
101
|
+
readonly tracksFlush: boolean;
|
|
102
|
+
/** Whether this sub counts against the per-vault manager cap (SSE) — the WS
|
|
103
|
+
* transport enforces its own cap via the live-socket count at upgrade. */
|
|
104
|
+
readonly countsTowardCap: boolean;
|
|
105
|
+
/** Pending unflushed frame count (backpressure bound; SSE only). */
|
|
79
106
|
buffered: number;
|
|
80
107
|
readonly maxBuffered: number;
|
|
81
108
|
closed: boolean;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
|
-
|
|
111
|
+
/** SSE wire frame: `event: <name>\ndata: <json>\n\n`. The ONE place SSE bytes
|
|
112
|
+
* are formatted (route + tests + SseSink all route through it). */
|
|
113
|
+
export function sseFrame(event: string, data: unknown): string {
|
|
85
114
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
86
115
|
}
|
|
87
116
|
|
|
117
|
+
/** WebSocket message: `{ type: <event>, ...data }`. The SSE `event:` name folds
|
|
118
|
+
* into `type`; the inner payload serializes identically to the SSE `data:`
|
|
119
|
+
* body. The ONE place WS bytes are formatted. */
|
|
120
|
+
export function wsFrame(event: string, data: unknown): string {
|
|
121
|
+
const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
122
|
+
return JSON.stringify({ type: event, ...body });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** SSE sink: renders `(event, data)` to an SSE frame and hands it to `push`
|
|
126
|
+
* (the route's stream-queue writer). `push` returns false when the stream is
|
|
127
|
+
* gone. `comment()` emits a raw `:` keepalive (SSE-only; not part of the
|
|
128
|
+
* abstract contract). Bytes are byte-identical to the pre-migration SSE path. */
|
|
129
|
+
export class SseSink implements SubscriptionSink {
|
|
130
|
+
constructor(
|
|
131
|
+
private readonly push: (frame: string) => boolean,
|
|
132
|
+
private readonly onClose: () => void,
|
|
133
|
+
) {}
|
|
134
|
+
send(event: string, data: unknown): boolean {
|
|
135
|
+
return this.push(sseFrame(event, data));
|
|
136
|
+
}
|
|
137
|
+
comment(text = ""): boolean {
|
|
138
|
+
return this.push(`:${text}\n\n`);
|
|
139
|
+
}
|
|
140
|
+
close(): void {
|
|
141
|
+
this.onClose();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
|
|
146
|
+
* {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
|
|
147
|
+
export interface WsLike {
|
|
148
|
+
send(data: string): unknown;
|
|
149
|
+
close(code?: number, reason?: string): unknown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** WebSocket sink: renders `(event, data)` to a `{ type, ...data }` message and
|
|
153
|
+
* sends it on the socket. `sendRaw` is for pre-formatted frames (the chunked
|
|
154
|
+
* snapshot the WS binding builds directly). A send on a dead socket returns
|
|
155
|
+
* false → the manager drops the sub. */
|
|
156
|
+
export class WsSink implements SubscriptionSink {
|
|
157
|
+
constructor(private readonly ws: WsLike) {}
|
|
158
|
+
send(event: string, data: unknown): boolean {
|
|
159
|
+
return this.sendRaw(wsFrame(event, data));
|
|
160
|
+
}
|
|
161
|
+
sendRaw(frame: string): boolean {
|
|
162
|
+
try {
|
|
163
|
+
this.ws.send(frame);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
close(): void {
|
|
170
|
+
try {
|
|
171
|
+
this.ws.close(1011, "subscription closed");
|
|
172
|
+
} catch {
|
|
173
|
+
/* already closing/closed */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
88
178
|
export class SubscriptionManager {
|
|
89
179
|
private subs = new Set<Subscription>();
|
|
90
180
|
private perVaultCount = new Map<string, number>();
|
|
@@ -140,9 +230,15 @@ export class SubscriptionManager {
|
|
|
140
230
|
tagScopeRaw: string[] | null;
|
|
141
231
|
sink: SubscriptionSink;
|
|
142
232
|
maxBuffered?: number;
|
|
233
|
+
/** SSE (default true) tracks unflushed frames; WS passes false. */
|
|
234
|
+
tracksFlush?: boolean;
|
|
235
|
+
/** SSE (default true) counts against the per-vault manager cap; WS passes
|
|
236
|
+
* false — the WS transport caps via the live-socket count at upgrade. */
|
|
237
|
+
countsTowardCap?: boolean;
|
|
143
238
|
}): SubscriptionHandle | null {
|
|
239
|
+
const countsTowardCap = args.countsTowardCap ?? true;
|
|
144
240
|
const current = this.countForVault(args.vaultName);
|
|
145
|
-
if (current >= this.maxPerVault) return null;
|
|
241
|
+
if (countsTowardCap && current >= this.maxPerVault) return null;
|
|
146
242
|
|
|
147
243
|
this.ensureHooks();
|
|
148
244
|
|
|
@@ -152,12 +248,14 @@ export class SubscriptionManager {
|
|
|
152
248
|
tagScopeAllowed: args.tagScopeAllowed,
|
|
153
249
|
tagScopeRaw: args.tagScopeRaw,
|
|
154
250
|
sink: args.sink,
|
|
251
|
+
tracksFlush: args.tracksFlush ?? true,
|
|
252
|
+
countsTowardCap,
|
|
155
253
|
buffered: 0,
|
|
156
254
|
maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
|
|
157
255
|
closed: false,
|
|
158
256
|
};
|
|
159
257
|
this.subs.add(sub);
|
|
160
|
-
this.perVaultCount.set(args.vaultName, current + 1);
|
|
258
|
+
if (countsTowardCap) this.perVaultCount.set(args.vaultName, current + 1);
|
|
161
259
|
|
|
162
260
|
return {
|
|
163
261
|
/** Note that a previously-buffered frame flushed to the wire. */
|
|
@@ -172,9 +270,11 @@ export class SubscriptionManager {
|
|
|
172
270
|
if (sub.closed) return;
|
|
173
271
|
sub.closed = true;
|
|
174
272
|
this.subs.delete(sub);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
273
|
+
if (sub.countsTowardCap) {
|
|
274
|
+
const n = this.perVaultCount.get(sub.vaultName) ?? 0;
|
|
275
|
+
if (n <= 1) this.perVaultCount.delete(sub.vaultName);
|
|
276
|
+
else this.perVaultCount.set(sub.vaultName, n - 1);
|
|
277
|
+
}
|
|
178
278
|
try {
|
|
179
279
|
sub.sink.close();
|
|
180
280
|
} catch {
|
|
@@ -182,33 +282,21 @@ export class SubscriptionManager {
|
|
|
182
282
|
}
|
|
183
283
|
}
|
|
184
284
|
|
|
185
|
-
/**
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
* so it never counts against the buffer bound.
|
|
189
|
-
*/
|
|
190
|
-
keepaliveAll(): void {
|
|
191
|
-
for (const sub of this.subs) {
|
|
192
|
-
if (sub.closed) continue;
|
|
193
|
-
const ok = sub.sink.write(":\n\n");
|
|
194
|
-
if (!ok) this.remove(sub);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Emit a frame to one subscription, enforcing the buffer bound. */
|
|
199
|
-
private emit(sub: Subscription, frame: SseFrame): void {
|
|
285
|
+
/** Emit an `(event, data)` tuple to one subscription, enforcing the buffer
|
|
286
|
+
* bound for flush-tracking (SSE) sinks. */
|
|
287
|
+
private emit(sub: Subscription, event: string, data: unknown): void {
|
|
200
288
|
if (sub.closed) return;
|
|
201
|
-
if (sub.buffered >= sub.maxBuffered) {
|
|
289
|
+
if (sub.tracksFlush && sub.buffered >= sub.maxBuffered) {
|
|
202
290
|
// Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
|
|
203
291
|
this.remove(sub);
|
|
204
292
|
return;
|
|
205
293
|
}
|
|
206
|
-
const ok = sub.sink.
|
|
294
|
+
const ok = sub.sink.send(event, data);
|
|
207
295
|
if (!ok) {
|
|
208
296
|
this.remove(sub);
|
|
209
297
|
return;
|
|
210
298
|
}
|
|
211
|
-
sub.buffered++;
|
|
299
|
+
if (sub.tracksFlush) sub.buffered++;
|
|
212
300
|
}
|
|
213
301
|
|
|
214
302
|
/** Lazily register the three broad hooks (once per manager). */
|
|
@@ -244,7 +332,7 @@ export class SubscriptionManager {
|
|
|
244
332
|
// the client ignores ids it never held. Documented low-sensitivity
|
|
245
333
|
// existence leak (see design §scope-intersection).
|
|
246
334
|
const ref = payload as DeletedNoteRef;
|
|
247
|
-
this.emit(sub,
|
|
335
|
+
this.emit(sub, "remove", { id: ref.id });
|
|
248
336
|
continue;
|
|
249
337
|
}
|
|
250
338
|
|
|
@@ -257,14 +345,14 @@ export class SubscriptionManager {
|
|
|
257
345
|
const matches = sub.matcher.match(note) && inScope;
|
|
258
346
|
|
|
259
347
|
if (matches) {
|
|
260
|
-
this.emit(sub,
|
|
348
|
+
this.emit(sub, "upsert", { note });
|
|
261
349
|
} else if (event === "updated" && inScope) {
|
|
262
350
|
// Left the set (predicate no longer true) BUT still within this
|
|
263
351
|
// token's scope, so the sub could have held it — idempotent remove
|
|
264
352
|
// (client drops the id if held, no-op otherwise). When the note is
|
|
265
353
|
// OUT of scope the sub never had it; emitting would leak its id, so
|
|
266
354
|
// we stay silent.
|
|
267
|
-
this.emit(sub,
|
|
355
|
+
this.emit(sub, "remove", { id: note.id });
|
|
268
356
|
}
|
|
269
357
|
// created that doesn't match → nothing (it was never in the set).
|
|
270
358
|
}
|
|
@@ -286,9 +374,9 @@ export interface SubscriptionHandle {
|
|
|
286
374
|
close: () => void;
|
|
287
375
|
}
|
|
288
376
|
|
|
289
|
-
/** Serialize a snapshot frame (exported for the route + tests). */
|
|
290
|
-
export function snapshotFrame(notes: Note[]):
|
|
291
|
-
return
|
|
377
|
+
/** Serialize a snapshot SSE frame (exported for the SSE route + tests). */
|
|
378
|
+
export function snapshotFrame(notes: Note[]): string {
|
|
379
|
+
return sseFrame("snapshot", { notes });
|
|
292
380
|
}
|
|
293
381
|
|
|
294
382
|
/** Process-wide manager — shared like `defaultHookRegistry`. */
|
package/src/tag-scope.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tag-scope enforcement for tag-scoped tokens (
|
|
2
|
+
* Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
|
|
3
3
|
*
|
|
4
4
|
* A token's `scoped_tags` allowlist narrows its effective access to notes
|
|
5
5
|
* carrying one of the allowlisted tags or a sub-tag thereof. The expansion
|
|
6
6
|
* to descendants happens via the per-vault `_tags/<name>` config-note
|
|
7
7
|
* hierarchy (see core/src/tag-hierarchy.ts).
|
|
8
8
|
*
|
|
9
|
-
* Auth check pseudocode (from
|
|
9
|
+
* Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
|
|
10
10
|
*
|
|
11
11
|
* if (!hasScope(token, ...)) return forbidden();
|
|
12
12
|
* if (token.scoped_tags === null) return ok(); // unscoped
|
|
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Return true iff the note's tag set intersects the expanded allowlist OR
|
|
41
|
-
* — fail-open per
|
|
41
|
+
* — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
|
|
42
42
|
* note's tags has a string-form root inside `rawRoots`. The string-form
|
|
43
43
|
* fallback covers the orphan-sub-tag case: a token allowlisted for
|
|
44
44
|
* `health` should still see `#health/food` even when no `_tags/health/food`
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared live-query frame corpus — MIRRORED VERBATIM from the cloud door's
|
|
3
|
+
* `parachute-cloud/workers/vault/test/fixtures/live-frame-corpus.ts` (the SINGLE
|
|
4
|
+
* source of truth). Kept byte-identical here so the self-host WS/SSE parity test
|
|
5
|
+
* asserts against the EXACT same fixture the cloud door pins — the load-bearing
|
|
6
|
+
* cross-door conformance check (WS-hibernation migration, 2026-07-04). If the
|
|
7
|
+
* canonical corpus changes upstream, re-mirror this file.
|
|
8
|
+
*
|
|
9
|
+
* Each case is a transport-neutral `(event, data)` tuple. The invariant under
|
|
10
|
+
* test: for a given case, the SSE `data:` body and the WS message carry a
|
|
11
|
+
* byte-IDENTICAL serialization of the inner payload (`notes` / `note` / `id`) —
|
|
12
|
+
* the ONLY difference is that the WS message folds the SSE `event:` NAME into a
|
|
13
|
+
* `type` discriminator (`{ type, ...data }`), because a WebSocket message has no
|
|
14
|
+
* separate event-name framing. The snapshot case additionally chunks with a
|
|
15
|
+
* `done` flag on the WS side (transport framing under the ~1 MiB message cap);
|
|
16
|
+
* its note OBJECTS still serialize identically.
|
|
17
|
+
*
|
|
18
|
+
* The notes deliberately include the drift-prone bits: unicode, nested metadata,
|
|
19
|
+
* a null value, an empty array, quotes/newlines in content, and stable key
|
|
20
|
+
* ordering — anything a careless re-serialization would reorder or mangle.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A note-shaped payload (loosely typed — the parity test cares about bytes, not
|
|
24
|
+
* the full Note interface; the wire carries whatever the store returns). */
|
|
25
|
+
export interface CorpusNote {
|
|
26
|
+
id: string;
|
|
27
|
+
path: string;
|
|
28
|
+
content: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
metadata: Record<string, unknown>;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
extension: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const NOTE_A: CorpusNote = {
|
|
37
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
38
|
+
path: "greetings/héllo",
|
|
39
|
+
content: 'line one\nline "two" with quotes\n#greeting [[world]]',
|
|
40
|
+
tags: ["greeting", "watch"],
|
|
41
|
+
metadata: { mood: "warm", priority: 5, pinned: true, note: null, aliases: [] },
|
|
42
|
+
createdAt: "2026-07-04T00:00:00.000Z",
|
|
43
|
+
updatedAt: "2026-07-04T00:00:01.000Z",
|
|
44
|
+
extension: "md",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const NOTE_B: CorpusNote = {
|
|
48
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
49
|
+
path: "meetings/2026-07-04",
|
|
50
|
+
content: "standup — ☂️ parachute cloud",
|
|
51
|
+
tags: ["meeting"],
|
|
52
|
+
metadata: { attendees: ["aaron", "uni"], count: 2 },
|
|
53
|
+
createdAt: "2026-07-04T09:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-07-04T09:30:00.000Z",
|
|
55
|
+
extension: "md",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface FrameCase {
|
|
59
|
+
name: string;
|
|
60
|
+
event: "snapshot" | "upsert" | "remove";
|
|
61
|
+
/** The inner payload — the SSE `data:` body is exactly `JSON.stringify(data)`;
|
|
62
|
+
* the WS message is `JSON.stringify({ type: event, ...data })`. */
|
|
63
|
+
data: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const FRAME_CORPUS: FrameCase[] = [
|
|
67
|
+
{ name: "snapshot (two notes)", event: "snapshot", data: { notes: [NOTE_A, NOTE_B] } },
|
|
68
|
+
{ name: "snapshot (empty set)", event: "snapshot", data: { notes: [] } },
|
|
69
|
+
{ name: "upsert (rich note)", event: "upsert", data: { note: NOTE_A } },
|
|
70
|
+
{ name: "upsert (unicode note)", event: "upsert", data: { note: NOTE_B } },
|
|
71
|
+
{ name: "remove (id)", event: "remove", data: { id: NOTE_A.id } },
|
|
72
|
+
];
|
package/src/token-store.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface Token {
|
|
|
54
54
|
* access is the intersection of `scopes` and notes carrying one of these
|
|
55
55
|
* tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
|
|
56
56
|
* NULL = unscoped, full vault access per `scopes`. See
|
|
57
|
-
*
|
|
57
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
58
58
|
*/
|
|
59
59
|
scoped_tags: string[] | null;
|
|
60
60
|
/**
|
|
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
|
|
|
301
301
|
* Returns display ID + label pairs (no token-hash exposure) so error
|
|
302
302
|
* envelopes can name the offending tokens for the operator. The match is
|
|
303
303
|
* exact on the root name — `scoped_tags` only ever stores roots per
|
|
304
|
-
*
|
|
304
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
305
305
|
*/
|
|
306
306
|
export function findTokensReferencingTag(
|
|
307
307
|
db: Database,
|
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
|