@openparachute/vault 0.7.9-rc.1 → 0.7.9-rc.2
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/core/src/mcp-manifest.ts +3 -3
- package/core/src/notes.ts +6 -2
- package/core/src/schema.ts +1 -1
- package/core/src/types.ts +3 -1
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +295 -5
- package/src/auth.ts +107 -3
- package/src/mcp-tools.ts +14 -8
- package/src/release-plan.test.ts +302 -0
- package/src/routing.ts +8 -3
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -171,8 +171,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
171
171
|
},
|
|
172
172
|
created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
|
|
173
173
|
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
174
|
-
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
|
|
175
|
-
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
|
|
174
|
+
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
|
|
175
|
+
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
|
|
176
176
|
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
|
|
177
177
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
178
178
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
@@ -341,7 +341,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
341
341
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
342
342
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
|
|
343
343
|
|
|
344
|
-
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via
|
|
344
|
+
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`. When the write was signed with a Nostr key (the hub's NIP-98 door), the \`*Via\` value is \`nostr:<64-hex-pubkey>\` — \`createdBy\` stays the hub USER, so the pubkey is what distinguishes two agents sharing that user.`,
|
|
345
345
|
inputSchema: {
|
|
346
346
|
type: "object",
|
|
347
347
|
properties: {
|
package/core/src/notes.ts
CHANGED
|
@@ -41,8 +41,12 @@ import { generateUlid } from "./ulid.js";
|
|
|
41
41
|
* `created_by` on the first write and `last_updated_by` on every
|
|
42
42
|
* write.
|
|
43
43
|
* via — VIA WHAT: the interface/channel the write arrived through
|
|
44
|
-
* (`mcp`, `surface:<name>`, `agent:<id>`, `
|
|
45
|
-
*
|
|
44
|
+
* (`mcp`, `surface:<name>`, `agent:<id>`, `nostr:<pubkey>`,
|
|
45
|
+
* `operator`/`cli`, `api`). `nostr:<pubkey>` (vault#698) is the
|
|
46
|
+
* Nostr key that SIGNED the request — more specific than the
|
|
47
|
+
* channel, and the only axis distinguishing two agents that share
|
|
48
|
+
* one hub user. Lands in `created_via` / `last_updated_via`
|
|
49
|
+
* symmetrically.
|
|
46
50
|
*
|
|
47
51
|
* Both are independently optional — an internal/import write may carry
|
|
48
52
|
* neither, and a non-JWT operator write carries an `actor`/`via` pair without
|
package/core/src/schema.ts
CHANGED
|
@@ -1282,7 +1282,7 @@ function migrateToV22(db: Database): void {
|
|
|
1282
1282
|
*
|
|
1283
1283
|
* `*_by` is the JWT `sub` (or an operator / `token:<id>` label for non-JWT
|
|
1284
1284
|
* auth); `*_via` is the channel (`mcp`, `surface:<name>`, `agent:<id>`,
|
|
1285
|
-
* `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
|
|
1285
|
+
* `nostr:<pubkey>`, `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
|
|
1286
1286
|
* NOT backfill an author for writes that predate attribution; NULL reads as
|
|
1287
1287
|
* "unknown / pre-attribution," distinct from any real principal.
|
|
1288
1288
|
*
|
package/core/src/types.ts
CHANGED
|
@@ -45,7 +45,9 @@ export interface Note {
|
|
|
45
45
|
* Write-attribution (vault#298) — two axes of provenance, both nullable.
|
|
46
46
|
* `*By` is the principal (a JWT `sub`, or an operator / `token:<id>` label);
|
|
47
47
|
* `*Via` is the interface the write arrived through (`mcp`, `surface:<name>`,
|
|
48
|
-
* `agent:<id>`, `operator`/`cli`, `api`).
|
|
48
|
+
* `agent:<id>`, `nostr:<pubkey>`, `operator`/`cli`, `api`). `nostr:<pubkey>`
|
|
49
|
+
* (vault#698) is the Nostr key that SIGNED the request — the axis that tells
|
|
50
|
+
* two agents apart when they share one hub user. The `created*` pair is set once at
|
|
49
51
|
* create; the `lastUpdated*` pair tracks the most recent mutating write. NULL
|
|
50
52
|
* = unknown / written before attribution existed (legacy rows) or by a path
|
|
51
53
|
* that carried no context — distinct from any real principal.
|
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - WHO (`actor`): JWT `sub` on the hub path; `operator` for the env-var
|
|
7
7
|
* bearer; `token:<id>` for legacy YAML keys.
|
|
8
8
|
* - VIA (`via`): `api` (credential class) on the REST path; `operator` for
|
|
9
|
-
* the env-var bearer;
|
|
9
|
+
* the env-var bearer; `nostr:<pubkey>` when the hub stamped a NIP-98
|
|
10
|
+
* signing key (vault#698); refined to `mcp` by the MCP handler otherwise.
|
|
10
11
|
*
|
|
11
12
|
* The store-layer column behavior + query filters live in
|
|
12
13
|
* core/src/attribution.test.ts. This file pins the AUTH → AuthResult mapping
|
|
@@ -27,10 +28,10 @@ import {
|
|
|
27
28
|
hashKey,
|
|
28
29
|
} from "./config.ts";
|
|
29
30
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
30
|
-
import { authenticateVaultRequest } from "./auth.ts";
|
|
31
|
+
import { authenticateVaultRequest, refineMcpVia } from "./auth.ts";
|
|
31
32
|
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
32
33
|
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
33
|
-
import { parseNotesQueryOpts } from "./routes.ts";
|
|
34
|
+
import { handleNotes, parseNotesQueryOpts } from "./routes.ts";
|
|
34
35
|
import type { AuthResult } from "./auth.ts";
|
|
35
36
|
|
|
36
37
|
// ---------------------------------------------------------------------------
|
|
@@ -68,10 +69,21 @@ function startHubFixture(keys: Keypair[]): { origin: string; stop: () => void }
|
|
|
68
69
|
}
|
|
69
70
|
async function signJwt(
|
|
70
71
|
kp: Keypair,
|
|
71
|
-
opts: {
|
|
72
|
+
opts: {
|
|
73
|
+
iss: string;
|
|
74
|
+
aud: string;
|
|
75
|
+
scope: string;
|
|
76
|
+
sub?: string;
|
|
77
|
+
/** Raw `permissions` claim — vault#698 carries `principal_pubkey` here. */
|
|
78
|
+
permissions?: unknown;
|
|
79
|
+
},
|
|
72
80
|
): Promise<string> {
|
|
73
81
|
const iat = Math.floor(Date.now() / 1000);
|
|
74
|
-
return await new SignJWT({
|
|
82
|
+
return await new SignJWT({
|
|
83
|
+
scope: opts.scope,
|
|
84
|
+
client_id: "test-client",
|
|
85
|
+
...(opts.permissions !== undefined ? { permissions: opts.permissions } : {}),
|
|
86
|
+
})
|
|
75
87
|
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
76
88
|
.setIssuer(opts.iss)
|
|
77
89
|
.setSubject(opts.sub ?? "user-1")
|
|
@@ -348,3 +360,281 @@ describe("attribution threading — REST query filters (symmetric with MCP)", ()
|
|
|
348
360
|
expect(queryOpts?.lastUpdatedVia).toBeUndefined();
|
|
349
361
|
});
|
|
350
362
|
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// vault#698 — Nostr principal attribution
|
|
366
|
+
//
|
|
367
|
+
// The hub's NIP-98 `/mcp` door mints the vault hop token with
|
|
368
|
+
// `permissions.principal_pubkey = <64-hex>`. `created_by` stays the hub USER
|
|
369
|
+
// id (two agents linked to one user share it), so `created_via` /
|
|
370
|
+
// `last_updated_via` are the ONLY axis that tells them apart.
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
const PUBKEY_A = "a".repeat(64);
|
|
374
|
+
const PUBKEY_B = "e6619493" + "b".repeat(56);
|
|
375
|
+
|
|
376
|
+
describe("attribution threading — nostr principal (vault#698)", () => {
|
|
377
|
+
test("hub JWT with permissions.principal_pubkey → via = 'nostr:<pubkey>', actor unchanged", async () => {
|
|
378
|
+
seedVaultNoKey("journal");
|
|
379
|
+
const token = await signJwt(kp, {
|
|
380
|
+
iss: fixture.origin,
|
|
381
|
+
aud: "vault.journal",
|
|
382
|
+
scope: "vault:journal:write",
|
|
383
|
+
sub: "e6619493-hub-user",
|
|
384
|
+
permissions: { principal_pubkey: PUBKEY_A },
|
|
385
|
+
});
|
|
386
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
387
|
+
expect("error" in result).toBe(false);
|
|
388
|
+
if (!("error" in result)) {
|
|
389
|
+
// `created_by` axis is untouched — still the hub user.
|
|
390
|
+
expect(result.actor).toBe("e6619493-hub-user");
|
|
391
|
+
expect(result.via).toBe(`nostr:${PUBKEY_A}`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a malformed principal_pubkey degrades to 'api' — never 401, never stored", async () => {
|
|
396
|
+
seedVaultNoKey("journal");
|
|
397
|
+
// Uppercase hex, wrong length, non-hex, non-string, and an npub — each must
|
|
398
|
+
// fail SOFT (attribution is a label, not an access decision).
|
|
399
|
+
const bad: unknown[] = [
|
|
400
|
+
"A".repeat(64),
|
|
401
|
+
"ab".repeat(20),
|
|
402
|
+
`${"z".repeat(64)}`,
|
|
403
|
+
12345,
|
|
404
|
+
{ hex: PUBKEY_A },
|
|
405
|
+
"npub1qqqqq",
|
|
406
|
+
null,
|
|
407
|
+
];
|
|
408
|
+
for (const value of bad) {
|
|
409
|
+
const token = await signJwt(kp, {
|
|
410
|
+
iss: fixture.origin,
|
|
411
|
+
aud: "vault.journal",
|
|
412
|
+
scope: "vault:journal:write",
|
|
413
|
+
sub: "hub-user",
|
|
414
|
+
permissions: { principal_pubkey: value },
|
|
415
|
+
});
|
|
416
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
417
|
+
expect("error" in result).toBe(false);
|
|
418
|
+
if (!("error" in result)) expect(result.via).toBe("api");
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("principal_pubkey coexists with scoped_tags in the same permissions claim", async () => {
|
|
423
|
+
seedVaultNoKey("journal");
|
|
424
|
+
const token = await signJwt(kp, {
|
|
425
|
+
iss: fixture.origin,
|
|
426
|
+
aud: "vault.journal",
|
|
427
|
+
scope: "vault:journal:write",
|
|
428
|
+
sub: "hub-user",
|
|
429
|
+
permissions: { principal_pubkey: PUBKEY_A, scoped_tags: ["daily"] },
|
|
430
|
+
});
|
|
431
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
432
|
+
expect("error" in result).toBe(false);
|
|
433
|
+
if (!("error" in result)) {
|
|
434
|
+
expect(result.via).toBe(`nostr:${PUBKEY_A}`);
|
|
435
|
+
expect(result.scoped_tags).toEqual(["daily"]);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("ORDERING GUARANTEE: a permissions claim with ONLY principal_pubkey is ignorable — scoped_tags stays unscoped", async () => {
|
|
440
|
+
// This is the "vault ships first" safety argument, pinned as a test: the
|
|
441
|
+
// ONLY pre-existing reader of `permissions` is
|
|
442
|
+
// `parseScopedTagsFromPermissions`, and it returns null (unscoped) when
|
|
443
|
+
// `scoped_tags` is absent. So a hub that emits the new claim against an
|
|
444
|
+
// OLD vault changes nothing.
|
|
445
|
+
seedVaultNoKey("journal");
|
|
446
|
+
const token = await signJwt(kp, {
|
|
447
|
+
iss: fixture.origin,
|
|
448
|
+
aud: "vault.journal",
|
|
449
|
+
scope: "vault:journal:write",
|
|
450
|
+
sub: "hub-user",
|
|
451
|
+
permissions: { principal_pubkey: PUBKEY_A },
|
|
452
|
+
});
|
|
453
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
454
|
+
expect("error" in result).toBe(false);
|
|
455
|
+
if (!("error" in result)) expect(result.scoped_tags).toBeNull();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("refineMcpVia keeps a nostr signer, refines the generic classes, keeps operator", () => {
|
|
459
|
+
expect(refineMcpVia(`nostr:${PUBKEY_A}`)).toBe(`nostr:${PUBKEY_A}`);
|
|
460
|
+
expect(refineMcpVia("operator")).toBe("operator");
|
|
461
|
+
expect(refineMcpVia("api")).toBe("mcp");
|
|
462
|
+
expect(refineMcpVia("token:abc123")).toBe("mcp");
|
|
463
|
+
expect(refineMcpVia(null)).toBe("mcp");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("attribution threading — nostr signer lands on every MCP write path", () => {
|
|
468
|
+
function nostrAuth(hubUserId: string, vaultName: string, pubkey: string): AuthResult {
|
|
469
|
+
return {
|
|
470
|
+
permission: "full",
|
|
471
|
+
scopes: [`vault:${vaultName}:write`, `vault:${vaultName}:read`],
|
|
472
|
+
legacyDerived: false,
|
|
473
|
+
scoped_tags: null,
|
|
474
|
+
vault_name: null,
|
|
475
|
+
caller_jti: null,
|
|
476
|
+
actor: hubUserId,
|
|
477
|
+
via: `nostr:${pubkey}`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("create-note: both *_via columns carry the signer, created_by stays the hub user", async () => {
|
|
482
|
+
seedVaultNoKey("journal");
|
|
483
|
+
const tools = generateScopedMcpTools(
|
|
484
|
+
"journal",
|
|
485
|
+
nostrAuth("e6619493-hub-user", "journal", PUBKEY_A),
|
|
486
|
+
null,
|
|
487
|
+
);
|
|
488
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
489
|
+
const created = (await create.execute({ content: "signed by A" })) as { id: string };
|
|
490
|
+
|
|
491
|
+
const store = getVaultStore("journal");
|
|
492
|
+
const row = store.db
|
|
493
|
+
.prepare(
|
|
494
|
+
"SELECT created_by, created_via, last_updated_by, last_updated_via FROM notes WHERE id = ?",
|
|
495
|
+
)
|
|
496
|
+
.get(created.id) as Record<string, string | null>;
|
|
497
|
+
expect(row.created_by).toBe("e6619493-hub-user");
|
|
498
|
+
expect(row.last_updated_by).toBe("e6619493-hub-user");
|
|
499
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
500
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("create-note BATCH: every item in the batch carries the signer", async () => {
|
|
504
|
+
seedVaultNoKey("journal");
|
|
505
|
+
const tools = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_A), null);
|
|
506
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
507
|
+
await create.execute({
|
|
508
|
+
notes: [
|
|
509
|
+
{ content: "one", path: "Batch/One" },
|
|
510
|
+
{ content: "two", path: "Batch/Two" },
|
|
511
|
+
],
|
|
512
|
+
});
|
|
513
|
+
const store = getVaultStore("journal");
|
|
514
|
+
const rows = store.db
|
|
515
|
+
.prepare("SELECT created_via, last_updated_via FROM notes WHERE path LIKE 'Batch/%'")
|
|
516
|
+
.all() as Array<Record<string, string | null>>;
|
|
517
|
+
expect(rows.length).toBe(2);
|
|
518
|
+
for (const row of rows) {
|
|
519
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
520
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("THE REPORTED BUG: agent B appending to agent A's note is distinguishable", async () => {
|
|
525
|
+
// Observed 2026-09-02: GrokJi's append showed lastUpdatedBy = e6619493-…,
|
|
526
|
+
// byte-identical to ClaudeJi's, because both link to the same hub user and
|
|
527
|
+
// both writes stamped via="mcp".
|
|
528
|
+
seedVaultNoKey("journal");
|
|
529
|
+
const HUB_USER = "e6619493-shared-hub-user";
|
|
530
|
+
const store = getVaultStore("journal");
|
|
531
|
+
|
|
532
|
+
const toolsA = generateScopedMcpTools("journal", nostrAuth(HUB_USER, "journal", PUBKEY_A), null);
|
|
533
|
+
const created = (await toolsA.find((t) => t.name === "create-note")!.execute({
|
|
534
|
+
content: "written by A",
|
|
535
|
+
path: "Shared/Note",
|
|
536
|
+
})) as { id: string };
|
|
537
|
+
|
|
538
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth(HUB_USER, "journal", PUBKEY_B), null);
|
|
539
|
+
await toolsB
|
|
540
|
+
.find((t) => t.name === "update-note")!
|
|
541
|
+
.execute({ id: created.id, append: "\n\nappended by B" });
|
|
542
|
+
|
|
543
|
+
const after = await store.getNote(created.id);
|
|
544
|
+
// Same hub user on both axes — that is correct and unchanged.
|
|
545
|
+
expect(after?.createdBy).toBe(HUB_USER);
|
|
546
|
+
expect(after?.lastUpdatedBy).toBe(HUB_USER);
|
|
547
|
+
// …but the two agents are now distinguishable.
|
|
548
|
+
expect(after?.createdVia).toBe(`nostr:${PUBKEY_A}`);
|
|
549
|
+
expect(after?.lastUpdatedVia).toBe(`nostr:${PUBKEY_B}`);
|
|
550
|
+
expect(after?.content).toContain("appended by B");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("update-note (full replace) bumps last_updated_via to the new signer", async () => {
|
|
554
|
+
seedVaultNoKey("journal");
|
|
555
|
+
const store = getVaultStore("journal");
|
|
556
|
+
const seed = await store.createNote("seed", { actor: "hub-user", via: `nostr:${PUBKEY_A}` });
|
|
557
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
558
|
+
await toolsB
|
|
559
|
+
.find((t) => t.name === "update-note")!
|
|
560
|
+
.execute({ id: seed.id, content: "replaced by B", force: true });
|
|
561
|
+
const after = await store.getNote(seed.id);
|
|
562
|
+
expect(after?.createdVia).toBe(`nostr:${PUBKEY_A}`); // set-once
|
|
563
|
+
expect(after?.lastUpdatedVia).toBe(`nostr:${PUBKEY_B}`);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("update-note if_missing:'create' (upsert-create) stamps the signer on both pairs", async () => {
|
|
567
|
+
seedVaultNoKey("journal");
|
|
568
|
+
const tools = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
569
|
+
const created = (await tools.find((t) => t.name === "update-note")!.execute({
|
|
570
|
+
id: "Upsert/Born",
|
|
571
|
+
content: "born via upsert",
|
|
572
|
+
if_missing: "create",
|
|
573
|
+
})) as { id: string };
|
|
574
|
+
const store = getVaultStore("journal");
|
|
575
|
+
const row = store.db
|
|
576
|
+
.prepare("SELECT created_via, last_updated_via FROM notes WHERE id = ?")
|
|
577
|
+
.get(created.id) as Record<string, string | null>;
|
|
578
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_B}`);
|
|
579
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_B}`);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("REST DOOR PARITY: a REST write carries the signer too (routing.ts passes auth.via unrefined)", async () => {
|
|
583
|
+
// The MCP door refines via `refineMcpVia`; the REST door threads the BASE
|
|
584
|
+
// `auth.via` straight into WriteCtx (routing.ts ~L1145). Since the base
|
|
585
|
+
// value is already `nostr:<pubkey>`, REST gets signer attribution for free
|
|
586
|
+
// — but nothing pinned it, so a future "normalize REST via to api" change
|
|
587
|
+
// would silently drop it on that door only.
|
|
588
|
+
seedVaultNoKey("journal");
|
|
589
|
+
const store = getVaultStore("journal");
|
|
590
|
+
const res = await handleNotes(
|
|
591
|
+
new Request("http://localhost/api/notes", {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { "content-type": "application/json" },
|
|
594
|
+
body: JSON.stringify({ content: "written over REST", path: "Rest/Signed" }),
|
|
595
|
+
}),
|
|
596
|
+
store,
|
|
597
|
+
"",
|
|
598
|
+
"journal",
|
|
599
|
+
{ allowed: null, raw: null },
|
|
600
|
+
{ actor: "e6619493-hub-user", via: `nostr:${PUBKEY_A}` },
|
|
601
|
+
);
|
|
602
|
+
expect(res.status).toBe(201);
|
|
603
|
+
const row = store.db
|
|
604
|
+
.prepare(
|
|
605
|
+
"SELECT created_by, created_via, last_updated_via FROM notes WHERE path = 'Rest/Signed'",
|
|
606
|
+
)
|
|
607
|
+
.get() as Record<string, string | null>;
|
|
608
|
+
expect(row.created_by).toBe("e6619493-hub-user");
|
|
609
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
610
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("the signer is FILTERABLE via created_via / last_updated_via", async () => {
|
|
614
|
+
seedVaultNoKey("journal");
|
|
615
|
+
const store = getVaultStore("journal");
|
|
616
|
+
const toolsA = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_A), null);
|
|
617
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
618
|
+
const byA = (await toolsA.find((t) => t.name === "create-note")!.execute({
|
|
619
|
+
content: "A wrote this",
|
|
620
|
+
path: "Filter/A",
|
|
621
|
+
})) as { id: string };
|
|
622
|
+
await toolsB.find((t) => t.name === "create-note")!.execute({
|
|
623
|
+
content: "B wrote this",
|
|
624
|
+
path: "Filter/B",
|
|
625
|
+
});
|
|
626
|
+
// B then edits A's note — created_via stays A, last_updated_via becomes B.
|
|
627
|
+
await toolsB
|
|
628
|
+
.find((t) => t.name === "update-note")!
|
|
629
|
+
.execute({ id: byA.id, append: " (edited)" });
|
|
630
|
+
|
|
631
|
+
const createdByA = await store.queryNotes({ createdVia: `nostr:${PUBKEY_A}` });
|
|
632
|
+
expect(createdByA.map((n) => n.path)).toEqual(["Filter/A"]);
|
|
633
|
+
|
|
634
|
+
const touchedByB = await store.queryNotes({ lastUpdatedVia: `nostr:${PUBKEY_B}` });
|
|
635
|
+
expect(touchedByB.map((n) => n.path).sort()).toEqual(["Filter/A", "Filter/B"]);
|
|
636
|
+
|
|
637
|
+
// And the old flat label matches nothing now that the signer is recorded.
|
|
638
|
+
expect(await store.queryNotes({ createdVia: "mcp" })).toEqual([]);
|
|
639
|
+
});
|
|
640
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -161,8 +161,12 @@ export interface AuthResult {
|
|
|
161
161
|
* write arrived through, derived PRAGMATICALLY from the credential class
|
|
162
162
|
* here, then REFINED by the request path at the call site (the MCP handler
|
|
163
163
|
* stamps `mcp`; the REST router keeps the credential-class default). Values:
|
|
164
|
-
* `mcp` · `surface:<name>` · `agent:<id>` · `
|
|
165
|
-
* BASE value from auth — the credential class:
|
|
164
|
+
* `mcp` · `surface:<name>` · `agent:<id>` · `nostr:<pubkey>` · `operator` ·
|
|
165
|
+
* `api`. This is the BASE value from auth — the credential class:
|
|
166
|
+
* - Hub JWT with a `permissions.principal_pubkey` claim → `nostr:<pubkey>`
|
|
167
|
+
* (vault#698). The signer is MORE specific than the channel, and unlike
|
|
168
|
+
* every other class it distinguishes two agents that share one hub user,
|
|
169
|
+
* so downstream does NOT refine it away — see `mcp-tools.ts`.
|
|
166
170
|
* - Hub JWT → `"api"` (the generic class; refined to `mcp` etc. downstream
|
|
167
171
|
* once the channel is known).
|
|
168
172
|
* - operator bearer → `"operator"`.
|
|
@@ -464,6 +468,97 @@ class MalformedScopedTagsError extends Error {}
|
|
|
464
468
|
* The only correct fail-closed action for a present-but-unreadable
|
|
465
469
|
* scope is to reject the whole request — never serve it wide.
|
|
466
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* Nostr pubkey shape: 32 bytes, lowercase hex. NIP-01 canonical form.
|
|
473
|
+
*/
|
|
474
|
+
const NOSTR_PUBKEY_RE = /^[0-9a-f]{64}$/;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Write-attribution `via` label for a NIP-98-signed principal.
|
|
478
|
+
*
|
|
479
|
+
* `nostr:<64-hex>` joins the existing open-ended `via` vocabulary
|
|
480
|
+
* (`mcp` · `surface:<name>` · `agent:<id>` · `operator` · `api`) — same
|
|
481
|
+
* `<class>:<id>` shape as `agent:<id>`, so `created_via` / `last_updated_via`
|
|
482
|
+
* stay plain exact-match strings and every existing filter keeps working.
|
|
483
|
+
*/
|
|
484
|
+
export function nostrVia(pubkey: string): string {
|
|
485
|
+
return `nostr:${pubkey}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Refine the credential-class `via` from `AuthResult` for a write that arrived
|
|
490
|
+
* on the MCP channel.
|
|
491
|
+
*
|
|
492
|
+
* `mcp` wins over the generic classes (`api`, a legacy `token:` REST key) —
|
|
493
|
+
* the channel is the more specific fact. It does NOT win over a class that
|
|
494
|
+
* already names a *principal or channel of its own*:
|
|
495
|
+
*
|
|
496
|
+
* - `operator` — the env-var bearer (vault#298).
|
|
497
|
+
* - `nostr:<pubkey>` — the NIP-98 signer (vault#698). Every hub `/mcp`
|
|
498
|
+
* caller is on the `mcp` channel, so `mcp` cannot distinguish two agents
|
|
499
|
+
* sharing a hub user; the key can.
|
|
500
|
+
*/
|
|
501
|
+
export function refineMcpVia(via: string | null | undefined): string {
|
|
502
|
+
// `undefined` is accepted alongside `null` on purpose: `AuthResult` declares
|
|
503
|
+
// `via: string | null`, but several in-repo call sites build the object
|
|
504
|
+
// without the key (attachment tools, test harnesses). The expression this
|
|
505
|
+
// replaced (`auth.via === "operator" ? … : "mcp"`) tolerated that silently,
|
|
506
|
+
// so a `string`-only signature here would turn a missing key into a
|
|
507
|
+
// TypeError at write time. Narrow on `typeof`, not on `!== null`.
|
|
508
|
+
if (via === "operator") return "operator";
|
|
509
|
+
if (typeof via === "string" && via.startsWith("nostr:")) return via;
|
|
510
|
+
return "mcp";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read the signing pubkey out of a validated hub JWT's `permissions` claim
|
|
515
|
+
* (vault#698 / hub#937 — Nostr principal attribution).
|
|
516
|
+
*
|
|
517
|
+
* Wire contract: `permissions: { principal_pubkey: "<64 lowercase hex>" }`.
|
|
518
|
+
* The hub stamps it ONLY on tokens minted for a NIP-98-authenticated caller;
|
|
519
|
+
* password / OAuth / Bearer sessions never carry it.
|
|
520
|
+
*
|
|
521
|
+
* Why it rides inside `permissions` rather than a top-level claim:
|
|
522
|
+
* `@openparachute/scope-guard` returns a FIXED claim surface
|
|
523
|
+
* (`sub`, `scopes`, `aud`, `jti`, `clientId`, `vaultScope`, `permissions`) and
|
|
524
|
+
* drops everything else, and `permissions` is its documented verbatim
|
|
525
|
+
* passthrough. A new top-level claim would be invisible here without a
|
|
526
|
+
* scope-guard release.
|
|
527
|
+
*
|
|
528
|
+
* FAIL-SOFT, deliberately the opposite of `parseScopedTagsFromPermissions`:
|
|
529
|
+
* this claim only *labels* a write, it never widens or narrows access. A
|
|
530
|
+
* missing / malformed / wrong-case / non-hex value returns `null` and the
|
|
531
|
+
* caller falls back to the generic credential class, rather than storing junk
|
|
532
|
+
* in an attribution column or 401-ing a legitimate write.
|
|
533
|
+
*/
|
|
534
|
+
export function parsePrincipalPubkey(
|
|
535
|
+
permissions: Record<string, unknown> | undefined,
|
|
536
|
+
): string | null {
|
|
537
|
+
if (!permissions) return null;
|
|
538
|
+
if (!("principal_pubkey" in permissions)) return null;
|
|
539
|
+
const raw = permissions.principal_pubkey;
|
|
540
|
+
if (typeof raw === "string" && NOSTR_PUBKEY_RE.test(raw)) return raw;
|
|
541
|
+
// PRESENT but unreadable. Fail soft (see above) — but never SILENTLY: the
|
|
542
|
+
// symptom of a dropped claim (`created_via` back to `mcp`) is byte-identical
|
|
543
|
+
// to the symptom of the hub not having shipped the claim at all, which is
|
|
544
|
+
// exactly the bug this feature exists to fix. One warn per distinct bad
|
|
545
|
+
// value makes the two distinguishable in the daemon log. A pubkey is public
|
|
546
|
+
// by construction, so logging the value leaks nothing.
|
|
547
|
+
const seen = typeof raw === "string" ? raw : `<${typeof raw}>`;
|
|
548
|
+
if (!warnedBadPubkeys.has(seen)) {
|
|
549
|
+
warnedBadPubkeys.add(seen);
|
|
550
|
+
console.warn(
|
|
551
|
+
"[attribution] hub JWT permissions.principal_pubkey present but not 64 lowercase hex — " +
|
|
552
|
+
`ignoring, falling back to the generic credential class (saw: ${JSON.stringify(seen)})`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Dedupe key set for the warn above — unbounded growth is bounded in practice
|
|
559
|
+
* by the number of DISTINCT malformed values a misconfigured hub emits (one). */
|
|
560
|
+
const warnedBadPubkeys = new Set<string>();
|
|
561
|
+
|
|
467
562
|
function parseScopedTagsFromPermissions(
|
|
468
563
|
permissions: Record<string, unknown> | undefined,
|
|
469
564
|
): string[] | null {
|
|
@@ -575,6 +670,9 @@ async function authenticateHubJwt(
|
|
|
575
670
|
// Throws MalformedScopedTagsError (caught below → 401) on a present-but-
|
|
576
671
|
// malformed claim so we never widen access on a misread.
|
|
577
672
|
const scoped_tags = parseScopedTagsFromPermissions(claims.permissions);
|
|
673
|
+
// Write-attribution axis 2 (vault#698): the NIP-98 signing pubkey, when
|
|
674
|
+
// the hub stamped one. Fail-soft — see `parsePrincipalPubkey`.
|
|
675
|
+
const principalPubkey = parsePrincipalPubkey(claims.permissions);
|
|
578
676
|
return {
|
|
579
677
|
permission,
|
|
580
678
|
scopes: claims.scopes,
|
|
@@ -596,7 +694,13 @@ async function authenticateHubJwt(
|
|
|
596
694
|
// pragmatic constraint we DON'T manufacture a more specific class; the
|
|
597
695
|
// channel comes from the path instead.
|
|
598
696
|
actor: claims.sub && claims.sub.length > 0 ? claims.sub : null,
|
|
599
|
-
|
|
697
|
+
// VIA: `nostr:<pubkey>` when the hub tells us which key signed the
|
|
698
|
+
// request that produced this token (vault#698 — the NIP-98 `/mcp` door),
|
|
699
|
+
// otherwise the generic `api` credential class the request path refines.
|
|
700
|
+
// `actor` is untouched on purpose: it stays the hub USER id, so two
|
|
701
|
+
// agents sharing a hub user keep one `created_by` and are told apart by
|
|
702
|
+
// `created_via` / `last_updated_via`.
|
|
703
|
+
via: principalPubkey !== null ? nostrVia(principalPubkey) : "api",
|
|
600
704
|
};
|
|
601
705
|
} catch (err) {
|
|
602
706
|
if (err instanceof MalformedScopedTagsError) {
|
package/src/mcp-tools.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
|
18
18
|
import { getVaultStore } from "./vault-store.ts";
|
|
19
19
|
import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
|
|
20
20
|
import type { AuthResult } from "./auth.ts";
|
|
21
|
+
import { refineMcpVia } from "./auth.ts";
|
|
21
22
|
import {
|
|
22
23
|
expandTokenTagScope,
|
|
23
24
|
filterHydratedLinksByTagScope,
|
|
@@ -226,14 +227,19 @@ export function generateScopedMcpTools(
|
|
|
226
227
|
|
|
227
228
|
// Write-attribution (vault#298). Every write through an MCP session arrives
|
|
228
229
|
// on the `mcp` channel — so we REFINE the auth's base `via` (the generic
|
|
229
|
-
// credential class) to `mcp` here, where the path/channel is known.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
// credential class) to `mcp` here, where the path/channel is known. `actor`
|
|
231
|
+
// (the principal) passes through unchanged.
|
|
232
|
+
//
|
|
233
|
+
// Two classes are kept INSTEAD of `mcp`, because each is strictly more
|
|
234
|
+
// informative than the channel:
|
|
235
|
+
// - `operator` — the env-var bearer's credential class IS its channel, and
|
|
236
|
+
// it names cross-container hub→vault writes (vault#298).
|
|
237
|
+
// - `nostr:<pubkey>` — the NIP-98 signer (vault#698). Every agent coming
|
|
238
|
+
// through the hub's `/mcp` door arrives on the `mcp` channel, so `mcp`
|
|
239
|
+
// cannot tell two agents apart; the signing key can. `created_by` is
|
|
240
|
+
// still the shared hub user, so this is the ONLY attribution axis that
|
|
241
|
+
// distinguishes them — flattening it here would reintroduce the bug.
|
|
242
|
+
const writeContext = auth ? { actor: auth.actor, via: refineMcpVia(auth.via) } : undefined;
|
|
237
243
|
|
|
238
244
|
// Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
|
|
239
245
|
// strict-schema enforcement and logs every bypassed write. Orthogonal to
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish-on-merge decision.
|
|
3
|
+
*
|
|
4
|
+
* Release logic that can double-publish or silently drop a release is exactly
|
|
5
|
+
* the kind that should not live untested in a YAML `run:` block. Every case
|
|
6
|
+
* here is one where a wrong answer costs something real. Ported from
|
|
7
|
+
* parachute-app's `scripts/release-plan.test.ts` onto bun:test (this repo's
|
|
8
|
+
* `bun test ./src/` surface).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
compareVersions,
|
|
14
|
+
decidePublish,
|
|
15
|
+
distTagFor,
|
|
16
|
+
readRegistry,
|
|
17
|
+
unpublishedDrift,
|
|
18
|
+
} from "../scripts/release-plan.ts";
|
|
19
|
+
|
|
20
|
+
describe("distTagFor", () => {
|
|
21
|
+
test("prerelease → rc, release → latest", () => {
|
|
22
|
+
expect(distTagFor("0.7.9-rc.2")).toBe("rc");
|
|
23
|
+
expect(distTagFor("0.7.9")).toBe("latest");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("compareVersions", () => {
|
|
28
|
+
test("orders patch versions", () => {
|
|
29
|
+
expect(compareVersions("0.7.5", "0.7.4")).toBeGreaterThan(0);
|
|
30
|
+
expect(compareVersions("0.7.4", "0.7.5")).toBeLessThan(0);
|
|
31
|
+
expect(compareVersions("0.7.5", "0.7.5")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("orders rc chains numerically, not lexically", () => {
|
|
35
|
+
// The lexical trap: "rc.10" < "rc.9" as strings.
|
|
36
|
+
expect(compareVersions("0.7.5-rc.10", "0.7.5-rc.9")).toBeGreaterThan(0);
|
|
37
|
+
expect(compareVersions("0.7.5-rc.5", "0.7.5-rc.6")).toBeLessThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a release sorts above its own prereleases", () => {
|
|
41
|
+
expect(compareVersions("0.7.5", "0.7.5-rc.99")).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("major/minor dominate the prerelease suffix", () => {
|
|
45
|
+
expect(compareVersions("0.8.0-rc.1", "0.7.9")).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("decidePublish", () => {
|
|
50
|
+
test("a fresh version publishes", () => {
|
|
51
|
+
const d = decidePublish("0.7.9-rc.2", {
|
|
52
|
+
versionExists: false,
|
|
53
|
+
currentDistTagVersion: "0.7.9-rc.1",
|
|
54
|
+
});
|
|
55
|
+
expect(d).toMatchObject({ publish: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an already-published version is skipped — the idempotency guarantee", () => {
|
|
59
|
+
const d = decidePublish("0.7.9-rc.1", { versionExists: true });
|
|
60
|
+
expect(d).toMatchObject({ publish: false });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a never-published package SKIPS on a branch push — a first publish is deliberate", () => {
|
|
64
|
+
// surface#220 / hub#930 / app#189: a 404 package used to read "0.1.0 is
|
|
65
|
+
// not on npm" → should_publish=true, and the OIDC publish 404'd. Vault's
|
|
66
|
+
// inline shell had the same hole: curling /vault/$VERSION 404s for both
|
|
67
|
+
// "version missing" and "package missing".
|
|
68
|
+
const d = decidePublish(
|
|
69
|
+
"0.1.0",
|
|
70
|
+
{ versionExists: false, publishedVersions: [] },
|
|
71
|
+
{ branch: "main" },
|
|
72
|
+
);
|
|
73
|
+
expect(d).toMatchObject({ publish: false });
|
|
74
|
+
expect("reason" in d && d.reason).toMatch(/first publish is a deliberate act/);
|
|
75
|
+
expect("reason" in d && d.reason).toMatch(/cannot create a package/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an rc of a never-published package skips too — it's the package, not the channel", () => {
|
|
79
|
+
const d = decidePublish(
|
|
80
|
+
"0.1.0-rc.1",
|
|
81
|
+
{ versionExists: false, publishedVersions: [] },
|
|
82
|
+
{ branch: "next" },
|
|
83
|
+
);
|
|
84
|
+
expect(d).toMatchObject({ publish: false });
|
|
85
|
+
expect("reason" in d && d.reason).toMatch(/nothing is published under this name yet/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("omitted publishedVersions with no dist-tag reads as never-published — skip, don't publish", () => {
|
|
89
|
+
const d = decidePublish("0.1.0-rc.1", { versionExists: false });
|
|
90
|
+
expect(d).toMatchObject({ publish: false });
|
|
91
|
+
expect("reason" in d && d.reason).toMatch(/nothing is published under this name yet/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a never-published package on an rc TAG PUSH still tries — a human said release this", () => {
|
|
95
|
+
const d = decidePublish(
|
|
96
|
+
"0.1.0-rc.1",
|
|
97
|
+
{ versionExists: false, publishedVersions: [] },
|
|
98
|
+
{ isTagPush: true },
|
|
99
|
+
);
|
|
100
|
+
expect(d).toMatchObject({ publish: true });
|
|
101
|
+
expect("reason" in d && d.reason).toMatch(/explicit tag push/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a never-published STABLE on a tag push is still refused by the from-main gate", () => {
|
|
105
|
+
const d = decidePublish(
|
|
106
|
+
"0.1.0",
|
|
107
|
+
{ versionExists: false, publishedVersions: [] },
|
|
108
|
+
{ isTagPush: true },
|
|
109
|
+
);
|
|
110
|
+
expect(d).toMatchObject({ publish: false });
|
|
111
|
+
expect("reason" in d && d.reason).toMatch(/from main only/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an existing package is unaffected — one published version is enough", () => {
|
|
115
|
+
const d = decidePublish(
|
|
116
|
+
"0.7.9-rc.2",
|
|
117
|
+
{
|
|
118
|
+
versionExists: false,
|
|
119
|
+
currentDistTagVersion: "0.7.9-rc.1",
|
|
120
|
+
publishedVersions: ["0.7.9-rc.1"],
|
|
121
|
+
},
|
|
122
|
+
{ branch: "next" },
|
|
123
|
+
);
|
|
124
|
+
expect(d).toMatchObject({ publish: true });
|
|
125
|
+
expect("reason" in d && d.reason).toMatch(/is not on npm/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an unreadable registry is still a REFUSAL, not a never-published skip", () => {
|
|
129
|
+
const ambiguous = decidePublish("0.7.9-rc.1", { ambiguous: true }, { branch: "next" });
|
|
130
|
+
expect(ambiguous).toMatchObject({ refuse: true });
|
|
131
|
+
expect("refuse" in ambiguous && ambiguous.reason).toMatch(/refusing to guess/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("REFUSES to move a dist-tag backwards — the parallel-merge hazard", () => {
|
|
135
|
+
const d = decidePublish("0.7.5-rc.5", {
|
|
136
|
+
versionExists: false,
|
|
137
|
+
currentDistTagVersion: "0.7.5-rc.6",
|
|
138
|
+
});
|
|
139
|
+
expect(d).toMatchObject({ refuse: true });
|
|
140
|
+
expect("refuse" in d && d.reason).toMatch(/OLDER than the current rc/);
|
|
141
|
+
expect("refuse" in d && d.reason).toMatch(/merged out of version order/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("rc and latest are tracked independently", () => {
|
|
145
|
+
const d = decidePublish("0.8.0-rc.1", {
|
|
146
|
+
versionExists: false,
|
|
147
|
+
currentDistTagVersion: "0.7.9-rc.5",
|
|
148
|
+
});
|
|
149
|
+
expect(d).toMatchObject({ publish: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an ambiguous registry REFUSES rather than guessing", () => {
|
|
153
|
+
const d = decidePublish("0.7.9", { ambiguous: true }, { branch: "main" });
|
|
154
|
+
expect(d).toMatchObject({ refuse: true });
|
|
155
|
+
expect("refuse" in d && d.reason).toMatch(/refusing to guess/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an explicit rc tag push overrides the registry checks — a human said release this rc", () => {
|
|
159
|
+
const d = decidePublish(
|
|
160
|
+
"0.7.0-rc.1",
|
|
161
|
+
{
|
|
162
|
+
versionExists: false,
|
|
163
|
+
currentDistTagVersion: "0.8.0",
|
|
164
|
+
},
|
|
165
|
+
{ isTagPush: true },
|
|
166
|
+
);
|
|
167
|
+
expect(d).toMatchObject({ publish: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an rc tag push even overrides ambiguity", () => {
|
|
171
|
+
const d = decidePublish("0.7.9-rc.1", { ambiguous: true }, { isTagPush: true });
|
|
172
|
+
expect(d).toMatchObject({ publish: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("next skips a stable version — stables publish from main only", () => {
|
|
176
|
+
const d = decidePublish("0.7.9", { versionExists: false }, { branch: "next" });
|
|
177
|
+
expect(d).toMatchObject({ publish: false });
|
|
178
|
+
expect("reason" in d && d.reason).toMatch(/stable promotions publish from main only/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("next still publishes an rc", () => {
|
|
182
|
+
const d = decidePublish(
|
|
183
|
+
"0.7.9-rc.1",
|
|
184
|
+
{ versionExists: false, currentDistTagVersion: "0.7.8-rc.5" },
|
|
185
|
+
{ branch: "next" },
|
|
186
|
+
);
|
|
187
|
+
expect(d).toMatchObject({ publish: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("main is unaffected — stable publishes as before", () => {
|
|
191
|
+
const d = decidePublish(
|
|
192
|
+
"0.7.9",
|
|
193
|
+
{
|
|
194
|
+
versionExists: false,
|
|
195
|
+
currentDistTagVersion: "0.7.8",
|
|
196
|
+
publishedVersions: ["0.7.8"],
|
|
197
|
+
},
|
|
198
|
+
{ branch: "main" },
|
|
199
|
+
);
|
|
200
|
+
expect(d).toMatchObject({ publish: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("a tag push of a stable does NOT override the main-only gate", () => {
|
|
204
|
+
const d = decidePublish(
|
|
205
|
+
"0.7.9",
|
|
206
|
+
{ versionExists: false },
|
|
207
|
+
{ branch: "next", isTagPush: true },
|
|
208
|
+
);
|
|
209
|
+
expect(d).toMatchObject({ publish: false });
|
|
210
|
+
expect("reason" in d && d.reason).toMatch(/from main only/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a stable with no branch is refused — fail closed, don't guess the trigger", () => {
|
|
214
|
+
const d = decidePublish("0.7.9", { versionExists: false });
|
|
215
|
+
expect(d).toMatchObject({ publish: false });
|
|
216
|
+
expect("reason" in d && d.reason).toMatch(/from main only/);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("readRegistry", () => {
|
|
221
|
+
const json = (body: unknown, status = 200) =>
|
|
222
|
+
Promise.resolve(new Response(JSON.stringify(body), { status }));
|
|
223
|
+
|
|
224
|
+
test("reads existence + the relevant dist-tag", async () => {
|
|
225
|
+
const v = await readRegistry("@openparachute/vault", "0.7.9-rc.2", (() =>
|
|
226
|
+
json({
|
|
227
|
+
versions: { "0.7.9-rc.1": {}, "0.7.9-rc.2": {} },
|
|
228
|
+
"dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
|
|
229
|
+
})) as unknown as typeof fetch);
|
|
230
|
+
expect(v).toMatchObject({ versionExists: true, currentDistTagVersion: "0.7.9-rc.2" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("picks the dist-tag matching the version's channel", async () => {
|
|
234
|
+
const v = await readRegistry("@openparachute/vault", "0.7.9", (() =>
|
|
235
|
+
json({
|
|
236
|
+
versions: {},
|
|
237
|
+
"dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
|
|
238
|
+
})) as unknown as typeof fetch);
|
|
239
|
+
expect(v).toMatchObject({ currentDistTagVersion: "0.7.8" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a never-published package is not ambiguous — a 404 is knowledge", async () => {
|
|
243
|
+
const v = await readRegistry("@openparachute/new", "0.1.0", (() =>
|
|
244
|
+
json({}, 404)) as unknown as typeof fetch);
|
|
245
|
+
expect(v).toMatchObject({ versionExists: false, publishedVersions: [] });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("the 404 view composes into a skip — the two halves of surface#220 line up", async () => {
|
|
249
|
+
const v = await readRegistry("@openparachute/new", "0.1.0", (() =>
|
|
250
|
+
json({}, 404)) as unknown as typeof fetch);
|
|
251
|
+
expect("ambiguous" in v).toBe(false);
|
|
252
|
+
if ("ambiguous" in v) return;
|
|
253
|
+
const d = decidePublish("0.1.0", v, { branch: "main" });
|
|
254
|
+
expect(d).toMatchObject({ publish: false });
|
|
255
|
+
expect("reason" in d && d.reason).toMatch(/first publish is a deliberate act/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a populated registry returns publishedVersions", async () => {
|
|
259
|
+
const v = await readRegistry("@openparachute/vault", "0.7.9-rc.3", (() =>
|
|
260
|
+
json({
|
|
261
|
+
versions: { "0.7.9-rc.1": {}, "0.7.9-rc.2": {} },
|
|
262
|
+
"dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
|
|
263
|
+
})) as unknown as typeof fetch);
|
|
264
|
+
expect(v).toMatchObject({
|
|
265
|
+
versionExists: false,
|
|
266
|
+
currentDistTagVersion: "0.7.9-rc.2",
|
|
267
|
+
publishedVersions: ["0.7.9-rc.1", "0.7.9-rc.2"],
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a 5xx is ambiguous", async () => {
|
|
272
|
+
const v = await readRegistry("@openparachute/vault", "1.0.0", (() =>
|
|
273
|
+
json({}, 503)) as unknown as typeof fetch);
|
|
274
|
+
expect(v).toMatchObject({ ambiguous: true });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a network throw is ambiguous, not a crash", async () => {
|
|
278
|
+
const v = await readRegistry("@openparachute/vault", "1.0.0", (() =>
|
|
279
|
+
Promise.reject(new Error("ECONNRESET"))) as unknown as typeof fetch);
|
|
280
|
+
expect(v).toMatchObject({ ambiguous: true });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("unpublishedDrift", () => {
|
|
285
|
+
test("no commits → not drifted", () => {
|
|
286
|
+
expect(unpublishedDrift([]).drifted).toBe(false);
|
|
287
|
+
expect(unpublishedDrift(["", " "]).drifted).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("commits → drifted, counted, and LISTED", () => {
|
|
291
|
+
const d = unpublishedDrift(["abc feat: one", "def fix: two"]);
|
|
292
|
+
expect(d.drifted).toBe(true);
|
|
293
|
+
expect(d.count).toBe(2);
|
|
294
|
+
expect(d.summary).toContain("feat: one");
|
|
295
|
+
expect(d.summary).toContain("fix: two");
|
|
296
|
+
expect(d.summary).toMatch(/release PR/i);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("blank lines from git's trailing newline don't inflate the count", () => {
|
|
300
|
+
expect(unpublishedDrift(["abc one", ""]).count).toBe(1);
|
|
301
|
+
});
|
|
302
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -1135,9 +1135,14 @@ export async function route(
|
|
|
1135
1135
|
|
|
1136
1136
|
// Write-attribution context (vault#298). `auth.actor` is the principal;
|
|
1137
1137
|
// `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
|
|
1138
|
-
// `operator` for the env-var bearer
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1138
|
+
// `operator` for the env-var bearer, `nostr:<pubkey>` when the hub stamped a
|
|
1139
|
+
// NIP-98 signing key — vault#698). The REST surface IS the `api` channel, so
|
|
1140
|
+
// no refinement is needed here — the base via stands (the MCP handler is the
|
|
1141
|
+
// one that refines to `mcp`, via `refineMcpVia`). That gives the REST door
|
|
1142
|
+
// signer attribution for free and symmetric with MCP: `nostr:<pubkey>` is
|
|
1143
|
+
// already the base value, so it lands in `created_via`/`last_updated_via`
|
|
1144
|
+
// unchanged. Pinned by src/attribution-threading.test.ts. Threaded only into
|
|
1145
|
+
// the write handler.
|
|
1141
1146
|
// Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
|
|
1142
1147
|
// notes that violate `strict:true` field constraints (for backfill /
|
|
1143
1148
|
// migration). Every bypassed write is logged. Orthogonal to read/write/admin
|