@openparachute/vault 0.6.4-rc.1 → 0.6.4-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +227 -5
- package/core/src/notes.ts +243 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +4 -1
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/routes.ts +162 -3
- package/src/routing.ts +18 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
package/src/routes.ts
CHANGED
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
contentRangeRequiresContent,
|
|
22
22
|
type ContentRange,
|
|
23
23
|
} from "../core/src/content-range.ts";
|
|
24
|
-
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
24
|
+
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
25
|
+
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
26
|
+
import { logStrictBypass } from "./scopes.ts";
|
|
25
27
|
import * as linkOps from "../core/src/links.ts";
|
|
26
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
27
29
|
import {
|
|
@@ -44,6 +46,52 @@ import { findTokensReferencingTag } from "./token-store.ts";
|
|
|
44
46
|
export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
|
|
45
47
|
|
|
46
48
|
const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write-attribution context (vault#298) for REST writes — the principal
|
|
52
|
+
* (`actor`) and interface (`via`) threaded from the authenticated request into
|
|
53
|
+
* `store.createNote` / `store.updateNote`. Both null for paths without an auth
|
|
54
|
+
* context (the no-op default). See `WriteContext` in core/src/notes.ts.
|
|
55
|
+
*/
|
|
56
|
+
export type WriteCtx = {
|
|
57
|
+
actor: string | null;
|
|
58
|
+
via: string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Migration-bypass (vault#299). True when the caller holds `vault:migrate`
|
|
61
|
+
* — strict-schema enforcement is skipped and the bypass is logged. Defaults
|
|
62
|
+
* to false (full enforcement) for every normal write.
|
|
63
|
+
*/
|
|
64
|
+
bypassStrict?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run the shared strict-schema gate for a REST write (vault#299). Mirrors the
|
|
71
|
+
* MCP path's `enforceStrict` closure: enforce unless the caller holds the
|
|
72
|
+
* migration-bypass scope, and log every bypassed write to the daemon's
|
|
73
|
+
* structured log (the audit-log table, #300, is deferred). Throws
|
|
74
|
+
* `SchemaValidationError` (caught by the route's catch → 422) when not bypassed.
|
|
75
|
+
*/
|
|
76
|
+
function gateStrictWrite(
|
|
77
|
+
store: Store,
|
|
78
|
+
writeCtx: WriteCtx,
|
|
79
|
+
shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
80
|
+
): void {
|
|
81
|
+
enforceStrictWrite(store, shape, {
|
|
82
|
+
bypass: writeCtx.bypassStrict === true,
|
|
83
|
+
onBypass: (violations: ValidationWarning[]) => {
|
|
84
|
+
logStrictBypass({
|
|
85
|
+
actor: writeCtx.actor,
|
|
86
|
+
via: writeCtx.via,
|
|
87
|
+
path: shape.path ?? null,
|
|
88
|
+
tags: shape.tags,
|
|
89
|
+
violations,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
47
95
|
import {
|
|
48
96
|
expandContent,
|
|
49
97
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -567,6 +615,13 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
567
615
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
568
616
|
extension: parseExtensionFilter(url),
|
|
569
617
|
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
618
|
+
// Write-attribution filters (vault#298) — symmetric with the MCP
|
|
619
|
+
// query-notes tool so a REST caller can ask "what did Mathilda write" /
|
|
620
|
+
// "what came in via the meeting-ingest surface" the same way.
|
|
621
|
+
createdBy: parseQuery(url, "created_by") ?? undefined,
|
|
622
|
+
lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
|
|
623
|
+
createdVia: parseQuery(url, "created_via") ?? undefined,
|
|
624
|
+
lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
|
|
570
625
|
...(bracket.dateFilter
|
|
571
626
|
? { dateFilter: bracket.dateFilter }
|
|
572
627
|
: parseQuery(url, "date_field")
|
|
@@ -704,9 +759,10 @@ export async function handleNotes(
|
|
|
704
759
|
subpath: string,
|
|
705
760
|
vault?: string,
|
|
706
761
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
762
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
707
763
|
): Promise<Response> {
|
|
708
764
|
try {
|
|
709
|
-
return await handleNotesInner(req, store, subpath, vault, tagScope);
|
|
765
|
+
return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
|
|
710
766
|
} catch (e: any) {
|
|
711
767
|
const ambig = ambiguousPathResponse(e);
|
|
712
768
|
if (ambig) return ambig;
|
|
@@ -720,6 +776,7 @@ async function handleNotesInner(
|
|
|
720
776
|
subpath: string,
|
|
721
777
|
vault?: string,
|
|
722
778
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
779
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
723
780
|
): Promise<Response> {
|
|
724
781
|
const url = new URL(req.url);
|
|
725
782
|
const method = req.method;
|
|
@@ -1102,6 +1159,13 @@ async function handleNotesInner(
|
|
|
1102
1159
|
const extension = item.extension !== undefined
|
|
1103
1160
|
? validateExtension(item.extension)
|
|
1104
1161
|
: undefined;
|
|
1162
|
+
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1163
|
+
// mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
|
|
1164
|
+
gateStrictWrite(store, writeCtx, {
|
|
1165
|
+
path: item.path,
|
|
1166
|
+
tags: item.tags,
|
|
1167
|
+
metadata: item.metadata,
|
|
1168
|
+
});
|
|
1105
1169
|
const note = await store.createNote(item.content ?? "", {
|
|
1106
1170
|
id: item.id,
|
|
1107
1171
|
path: item.path,
|
|
@@ -1109,6 +1173,9 @@ async function handleNotesInner(
|
|
|
1109
1173
|
metadata: item.metadata,
|
|
1110
1174
|
created_at: item.createdAt ?? item.created_at,
|
|
1111
1175
|
...(extension !== undefined ? { extension } : {}),
|
|
1176
|
+
// Write-attribution (vault#298) — REST batch create.
|
|
1177
|
+
actor: writeCtx.actor,
|
|
1178
|
+
via: writeCtx.via,
|
|
1112
1179
|
});
|
|
1113
1180
|
|
|
1114
1181
|
// Create explicit links
|
|
@@ -1131,6 +1198,13 @@ async function handleNotesInner(
|
|
|
1131
1198
|
409,
|
|
1132
1199
|
);
|
|
1133
1200
|
}
|
|
1201
|
+
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1202
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1203
|
+
return json(
|
|
1204
|
+
{ error_type: "schema_validation", error: "schema_validation", violations: e.violations ?? [], message: e.message },
|
|
1205
|
+
422,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1134
1208
|
if (e && e.code === "INVALID_EXTENSION") {
|
|
1135
1209
|
return json(
|
|
1136
1210
|
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
@@ -1383,8 +1457,17 @@ async function handleNotesInner(
|
|
|
1383
1457
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
1384
1458
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
1385
1459
|
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
1460
|
+
// Write-attribution (vault#298) — REST upsert-create branch.
|
|
1461
|
+
actor: writeCtx.actor,
|
|
1462
|
+
via: writeCtx.via,
|
|
1386
1463
|
};
|
|
1387
1464
|
const content = (body.content as string | undefined) ?? "";
|
|
1465
|
+
// Strict-schema gate (vault#299) — if_missing:"create" is a create.
|
|
1466
|
+
gateStrictWrite(store, writeCtx, {
|
|
1467
|
+
path: createOpts.path ?? undefined,
|
|
1468
|
+
tags: createOpts.tags,
|
|
1469
|
+
metadata: createOpts.metadata,
|
|
1470
|
+
});
|
|
1388
1471
|
const created = await store.createNote(content, createOpts);
|
|
1389
1472
|
if (tagsArr.length > 0) {
|
|
1390
1473
|
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
@@ -1474,7 +1557,19 @@ async function handleNotesInner(
|
|
|
1474
1557
|
&& body.createdAt === undefined
|
|
1475
1558
|
&& body.tags === undefined
|
|
1476
1559
|
&& body.links === undefined;
|
|
1477
|
-
|
|
1560
|
+
// A state_transition is itself a compare-and-set precondition (vault#299
|
|
1561
|
+
// Part B) — a transition-only PATCH needs no if_updated_at/force.
|
|
1562
|
+
const isTransitionOnly = body.state_transition !== undefined
|
|
1563
|
+
&& !hasContent
|
|
1564
|
+
&& !hasAppendPrepend
|
|
1565
|
+
&& !hasContentEdit
|
|
1566
|
+
&& body.path === undefined
|
|
1567
|
+
&& body.metadata === undefined
|
|
1568
|
+
&& body.created_at === undefined
|
|
1569
|
+
&& body.createdAt === undefined
|
|
1570
|
+
&& body.tags === undefined
|
|
1571
|
+
&& body.links === undefined;
|
|
1572
|
+
if (!isAppendOnly && !isTransitionOnly && body.if_updated_at === undefined && body.force !== true) {
|
|
1478
1573
|
return json(
|
|
1479
1574
|
{
|
|
1480
1575
|
error_type: "precondition_required",
|
|
@@ -1566,8 +1661,39 @@ async function handleNotesInner(
|
|
|
1566
1661
|
if (body.if_updated_at !== undefined) {
|
|
1567
1662
|
updates.if_updated_at = body.if_updated_at;
|
|
1568
1663
|
}
|
|
1664
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable with
|
|
1665
|
+
// other field updates; folds into the same atomic UPDATE.
|
|
1666
|
+
const stBody = body.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1667
|
+
if (stBody !== undefined) {
|
|
1668
|
+
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1669
|
+
return json(
|
|
1670
|
+
{ error: "bad_request", message: "`state_transition.field` must be a non-empty string." },
|
|
1671
|
+
400,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
updates.state_transition = { field: stBody.field, from: stBody.from, to: stBody.to };
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1678
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata, incl. a
|
|
1679
|
+
// state_transition's `to`) before the write so a rejection leaves the
|
|
1680
|
+
// note untouched. Throws SchemaValidationError → 422 in the catch.
|
|
1681
|
+
{
|
|
1682
|
+
const removeSet = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
|
|
1683
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1684
|
+
for (const t of (body.tags?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1685
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1686
|
+
const projectedMeta = stBody !== undefined
|
|
1687
|
+
? { ...baseMeta, [stBody.field as string]: stBody.to }
|
|
1688
|
+
: baseMeta;
|
|
1689
|
+
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1690
|
+
}
|
|
1569
1691
|
|
|
1570
1692
|
if (Object.keys(updates).length > 0) {
|
|
1693
|
+
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
1694
|
+
// write columns on the same UPDATE that bumps updated_at.
|
|
1695
|
+
updates.actor = writeCtx.actor;
|
|
1696
|
+
updates.via = writeCtx.via;
|
|
1571
1697
|
await store.updateNote(note.id, updates);
|
|
1572
1698
|
}
|
|
1573
1699
|
|
|
@@ -1661,6 +1787,39 @@ async function handleNotesInner(
|
|
|
1661
1787
|
409,
|
|
1662
1788
|
);
|
|
1663
1789
|
}
|
|
1790
|
+
// State-transition compare-and-set conflict (vault#299 Part B). A
|
|
1791
|
+
// DISTINCT error vocabulary from `conflict` (settled lead #3): the
|
|
1792
|
+
// field VALUE didn't match `from`, not the updated_at token. 409.
|
|
1793
|
+
if (e && e.code === "TRANSITION_CONFLICT") {
|
|
1794
|
+
return json(
|
|
1795
|
+
{
|
|
1796
|
+
error_type: "transition_conflict",
|
|
1797
|
+
error: "transition_conflict",
|
|
1798
|
+
note_id: e.note_id,
|
|
1799
|
+
path: e.note_path ?? null,
|
|
1800
|
+
field: e.field,
|
|
1801
|
+
expected_from: e.expected_from,
|
|
1802
|
+
to: e.to,
|
|
1803
|
+
current: e.current ?? null,
|
|
1804
|
+
message: e.message,
|
|
1805
|
+
},
|
|
1806
|
+
409,
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
// Strict-schema rejection (vault#299 Part A). One error carrying ALL
|
|
1810
|
+
// per-field violations (settled lead #1). 422 Unprocessable Entity —
|
|
1811
|
+
// the note exists / request is well-formed but violates the contract.
|
|
1812
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1813
|
+
return json(
|
|
1814
|
+
{
|
|
1815
|
+
error_type: "schema_validation",
|
|
1816
|
+
error: "schema_validation",
|
|
1817
|
+
violations: e.violations ?? [],
|
|
1818
|
+
message: e.message,
|
|
1819
|
+
},
|
|
1820
|
+
422,
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1664
1823
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
1665
1824
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1666
1825
|
return json(
|
package/src/routing.ts
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
authenticateGlobalRequest,
|
|
53
53
|
extractApiKey,
|
|
54
54
|
} from "./auth.ts";
|
|
55
|
-
import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
55
|
+
import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
56
56
|
import { getVaultStore } from "./vault-store.ts";
|
|
57
57
|
import { handleScopedMcp } from "./mcp-http.ts";
|
|
58
58
|
import {
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
handleStorage,
|
|
72
72
|
handleViewNote,
|
|
73
73
|
type TagScopeCtx,
|
|
74
|
+
type WriteCtx,
|
|
74
75
|
} from "./routes.ts";
|
|
75
76
|
import { handleSubscribe } from "./subscribe.ts";
|
|
76
77
|
import { handleTriggers } from "./triggers-api.ts";
|
|
@@ -812,7 +813,22 @@ export async function route(
|
|
|
812
813
|
raw: auth.scoped_tags,
|
|
813
814
|
};
|
|
814
815
|
|
|
815
|
-
|
|
816
|
+
// Write-attribution context (vault#298). `auth.actor` is the principal;
|
|
817
|
+
// `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
|
|
818
|
+
// `operator` for the env-var bearer). The REST surface IS the `api` channel,
|
|
819
|
+
// so no refinement is needed here — the base via stands (the MCP handler is
|
|
820
|
+
// the one that refines to `mcp`). Threaded only into the write handler.
|
|
821
|
+
// Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
|
|
822
|
+
// notes that violate `strict:true` field constraints (for backfill /
|
|
823
|
+
// migration). Every bypassed write is logged. Orthogonal to read/write/admin
|
|
824
|
+
// — an admin token does NOT bypass unless it also holds `migrate`.
|
|
825
|
+
const writeCtx: WriteCtx = {
|
|
826
|
+
actor: auth.actor,
|
|
827
|
+
via: auth.via,
|
|
828
|
+
bypassStrict: hasMigrateScopeForVault(auth.scopes, vaultName),
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
816
832
|
// Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
|
|
817
833
|
// upsert/remove events over text/event-stream. Auth + tag-scope already
|
|
818
834
|
// resolved above and threaded through, mirroring the /notes branch.
|
package/src/scopes.test.ts
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
SCOPE_READ,
|
|
10
10
|
SCOPE_WRITE,
|
|
11
11
|
SCOPE_ADMIN,
|
|
12
|
+
SCOPE_MIGRATE,
|
|
12
13
|
parseScopes,
|
|
13
14
|
parseScopeFlags,
|
|
14
15
|
hasScope,
|
|
15
16
|
hasScopeForVault,
|
|
17
|
+
hasMigrateScopeForVault,
|
|
16
18
|
findBroadVaultScopes,
|
|
17
19
|
scopeForMethod,
|
|
18
20
|
verbForMethod,
|
|
@@ -20,6 +22,28 @@ import {
|
|
|
20
22
|
serializeScopes,
|
|
21
23
|
} from "./scopes.ts";
|
|
22
24
|
|
|
25
|
+
describe("hasMigrateScopeForVault — migration-bypass capability (vault#299)", () => {
|
|
26
|
+
test("broad vault:migrate satisfies any vault", () => {
|
|
27
|
+
expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "default")).toBe(true);
|
|
28
|
+
expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "anything")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("narrowed vault:<name>:migrate satisfies only that vault", () => {
|
|
32
|
+
expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "gitcoin")).toBe(true);
|
|
33
|
+
expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "default")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("admin does NOT imply migrate — orthogonal axis", () => {
|
|
37
|
+
expect(hasMigrateScopeForVault([SCOPE_ADMIN], "default")).toBe(false);
|
|
38
|
+
expect(hasMigrateScopeForVault(["vault:default:admin"], "default")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("no migrate scope → false", () => {
|
|
42
|
+
expect(hasMigrateScopeForVault([SCOPE_WRITE, SCOPE_READ], "default")).toBe(false);
|
|
43
|
+
expect(hasMigrateScopeForVault([], "default")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
23
47
|
describe("parseScopes", () => {
|
|
24
48
|
test("returns [] for null or empty input", () => {
|
|
25
49
|
expect(parseScopes(null)).toEqual([]);
|
package/src/scopes.ts
CHANGED
|
@@ -28,6 +28,19 @@ export const SCOPE_READ = "vault:read" as const;
|
|
|
28
28
|
export const SCOPE_WRITE = "vault:write" as const;
|
|
29
29
|
export const SCOPE_ADMIN = "vault:admin" as const;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Migration-bypass scope (vault#299). A SEPARATE capability axis — NOT part
|
|
33
|
+
* of the read ⊇ write ⊇ admin inheritance chain. A token holding
|
|
34
|
+
* `vault:migrate` (broad) or `vault:<name>:migrate` (narrowed) may skip
|
|
35
|
+
* `strict:true` schema enforcement so existing non-conforming notes can be
|
|
36
|
+
* migrated/backfilled. It is deliberately orthogonal: an `admin` token does
|
|
37
|
+
* NOT auto-bypass strict validation — bypass must be an explicit, audited
|
|
38
|
+
* grant. A bypass write still needs `write` to actually mutate (migrate is an
|
|
39
|
+
* ADD-ON flag, not a write grant on its own). Every bypassed write is logged
|
|
40
|
+
* (see the write path) since #300 (the audit-log table) is deferred.
|
|
41
|
+
*/
|
|
42
|
+
export const SCOPE_MIGRATE = "vault:migrate" as const;
|
|
43
|
+
|
|
31
44
|
/** All first-class vault scopes in inheritance order (lowest → highest). */
|
|
32
45
|
export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
|
|
33
46
|
export type VaultScope = (typeof VAULT_SCOPES)[number];
|
|
@@ -137,6 +150,56 @@ export function hasScopeForVault(
|
|
|
137
150
|
return false;
|
|
138
151
|
}
|
|
139
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Migration-bypass check (vault#299): does `granted` hold the `migrate`
|
|
155
|
+
* capability for `vaultName`? Accepts broad `vault:migrate` (any vault) or
|
|
156
|
+
* narrowed `vault:<name>:migrate` (this vault only). Orthogonal to the
|
|
157
|
+
* read/write/admin verbs — a plain admin token returns `false` here, by
|
|
158
|
+
* design. The migrate scope does NOT live in `decomposeVaultScope` (it isn't a
|
|
159
|
+
* read/write/admin verb), so it's matched by exact shape here.
|
|
160
|
+
*/
|
|
161
|
+
export function hasMigrateScopeForVault(granted: string[], vaultName: string): boolean {
|
|
162
|
+
for (const s of granted) {
|
|
163
|
+
if (s === SCOPE_MIGRATE) return true; // broad — any vault
|
|
164
|
+
if (s === `vault:${vaultName}:migrate`) return true; // narrowed — this vault
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Structured log line for a migration-bypassed write (vault#299 settled lead
|
|
171
|
+
* #2). Records WHO bypassed (actor/via from MW1 attribution), WHAT note, and
|
|
172
|
+
* WHICH strict violations were waived — enough to reconstruct the bypass for
|
|
173
|
+
* later audit without the #300 audit-log table (deferred). One JSON line so
|
|
174
|
+
* it's grep/jq-able in the daemon log. Lives here (next to the migrate-scope
|
|
175
|
+
* check) so both write transports — REST (routes.ts) and MCP (mcp-tools.ts) —
|
|
176
|
+
* emit the identical line without importing each other.
|
|
177
|
+
*/
|
|
178
|
+
export function logStrictBypass(info: {
|
|
179
|
+
actor: string | null;
|
|
180
|
+
via: string | null;
|
|
181
|
+
path?: string | null;
|
|
182
|
+
tags?: string[];
|
|
183
|
+
violations: { field: string; reason: string; schema: string }[];
|
|
184
|
+
}): void {
|
|
185
|
+
console.warn(
|
|
186
|
+
"[schema-bypass] " +
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
event: "strict_schema_bypass",
|
|
189
|
+
actor: info.actor,
|
|
190
|
+
via: info.via,
|
|
191
|
+
path: info.path ?? null,
|
|
192
|
+
tags: info.tags ?? [],
|
|
193
|
+
violations: info.violations.map((v) => ({
|
|
194
|
+
field: v.field,
|
|
195
|
+
reason: v.reason,
|
|
196
|
+
schema: v.schema,
|
|
197
|
+
})),
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
140
203
|
/**
|
|
141
204
|
* Pick the required scope for a given API request.
|
|
142
205
|
* - GET/HEAD/OPTIONS → read
|
package/src/triggers-api.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type TriggerInput,
|
|
31
31
|
} from "../core/src/triggers-store.ts";
|
|
32
32
|
import { registerVaultTrigger } from "./triggers.ts";
|
|
33
|
+
import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
|
|
33
34
|
import { hasScopeForVault, SCOPE_ADMIN } from "./scopes.ts";
|
|
34
35
|
import type { AuthResult } from "./auth.ts";
|
|
35
36
|
|
|
@@ -135,6 +136,29 @@ function validateInput(body: unknown):
|
|
|
135
136
|
if (typeof b.when !== "object" || b.when === null || Array.isArray(b.when)) {
|
|
136
137
|
return { ok: false, message: "`when` is required and must be an object" };
|
|
137
138
|
}
|
|
139
|
+
// Value-matched metadata predicate (vault#299 Part B): when present, each
|
|
140
|
+
// field must map to an operator-object whose keys are all supported
|
|
141
|
+
// operators. Fail at registration so a typo'd operator (`{ state: { equals:
|
|
142
|
+
// ... } }`) is a 400, not a silently-never-firing trigger.
|
|
143
|
+
const whenMeta = (b.when as Record<string, unknown>).metadata;
|
|
144
|
+
if (whenMeta !== undefined) {
|
|
145
|
+
if (typeof whenMeta !== "object" || whenMeta === null || Array.isArray(whenMeta)) {
|
|
146
|
+
return { ok: false, message: "`when.metadata` must be an object mapping field → operator-object" };
|
|
147
|
+
}
|
|
148
|
+
for (const [field, opObj] of Object.entries(whenMeta as Record<string, unknown>)) {
|
|
149
|
+
if (typeof opObj !== "object" || opObj === null || Array.isArray(opObj)) {
|
|
150
|
+
return { ok: false, message: `\`when.metadata.${field}\` must be an operator-object, e.g. { eq: "published" }` };
|
|
151
|
+
}
|
|
152
|
+
for (const op of Object.keys(opObj as Record<string, unknown>)) {
|
|
153
|
+
if (!SUPPORTED_OPS.includes(op as (typeof SUPPORTED_OPS)[number])) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
message: `\`when.metadata.${field}\` has unknown operator "${op}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
138
162
|
|
|
139
163
|
if (typeof b.action !== "object" || b.action === null || Array.isArray(b.action)) {
|
|
140
164
|
return { ok: false, message: "`action` is required and must be an object" };
|
package/src/triggers.test.ts
CHANGED
|
@@ -95,6 +95,39 @@ describe("buildPredicate", () => {
|
|
|
95
95
|
expect(pred(makeNote({ tags: ["reader"] }))).toBe(false);
|
|
96
96
|
expect(pred(makeNote({ tags: ["reader", "important"] }))).toBe(true);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
// Value-matched metadata grammar (vault#299 Part B).
|
|
100
|
+
it("metadata eq: fires only when the field equals the value", () => {
|
|
101
|
+
const pred = buildPredicate({ metadata: { state: { eq: "published" } } }, "publish_hook");
|
|
102
|
+
expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(true);
|
|
103
|
+
expect(pred(makeNote({ metadata: { state: "drafted" } }))).toBe(false);
|
|
104
|
+
expect(pred(makeNote({ metadata: {} }))).toBe(false); // absent → no fire
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("metadata in: fires on membership", () => {
|
|
108
|
+
const pred = buildPredicate({ metadata: { state: { in: ["produced", "published"] } } }, "h");
|
|
109
|
+
expect(pred(makeNote({ metadata: { state: "produced" } }))).toBe(true);
|
|
110
|
+
expect(pred(makeNote({ metadata: { state: "idea" } }))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("metadata combines with tag + presence filters (all must hold)", () => {
|
|
114
|
+
const pred = buildPredicate(
|
|
115
|
+
{ tags: ["piece"], metadata: { state: { eq: "published" } }, missing_metadata: ["notified_at"] },
|
|
116
|
+
"h",
|
|
117
|
+
);
|
|
118
|
+
expect(pred(makeNote({ tags: ["piece"], metadata: { state: "published" } }))).toBe(true);
|
|
119
|
+
// right state, wrong tag.
|
|
120
|
+
expect(pred(makeNote({ tags: ["other"], metadata: { state: "published" } }))).toBe(false);
|
|
121
|
+
// right tag + state but already notified.
|
|
122
|
+
expect(
|
|
123
|
+
pred(makeNote({ tags: ["piece"], metadata: { state: "published", notified_at: "x" } })),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a malformed operator never throws from the predicate (treated as no-match)", () => {
|
|
128
|
+
const pred = buildPredicate({ metadata: { state: { bogus: "x" } as any } }, "h");
|
|
129
|
+
expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(false);
|
|
130
|
+
});
|
|
98
131
|
});
|
|
99
132
|
|
|
100
133
|
describe("registerTriggers — dispatch modes", async () => {
|
package/src/triggers.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
|
30
30
|
import crypto from "node:crypto";
|
|
31
31
|
import type { Note, Store, Attachment } from "../core/src/types.ts";
|
|
32
32
|
import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
|
|
33
|
+
import { matchesOperator } from "../core/src/query-operators.ts";
|
|
33
34
|
import type { TriggerConfig, TriggerWhen } from "./config.ts";
|
|
34
35
|
import type { StoredTrigger } from "../core/src/triggers-store.ts";
|
|
35
36
|
import { getVaultNameForStore } from "./vault-store.ts";
|
|
@@ -106,6 +107,22 @@ export function buildPredicate(when: TriggerWhen, triggerName: string): (note: N
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// Value-matched metadata filter (vault#299 Part B). Each field's
|
|
111
|
+
// operator-object is evaluated against the note's live metadata with the
|
|
112
|
+
// SAME engine query-notes uses. ALL fields must match (AND). A malformed
|
|
113
|
+
// operator throws at registration-time validation, not here — by the time
|
|
114
|
+
// the predicate runs the shape is known-good, but we guard defensively so
|
|
115
|
+
// a bad config can never crash the hook dispatch loop (treat as no-match).
|
|
116
|
+
if (when.metadata) {
|
|
117
|
+
for (const [field, opObj] of Object.entries(when.metadata)) {
|
|
118
|
+
try {
|
|
119
|
+
if (!matchesOperator(field, meta?.[field], opObj)) return false;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
return true;
|
|
110
127
|
};
|
|
111
128
|
}
|