@openparachute/vault 0.6.4-rc.2 → 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/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/notes.ts +128 -4
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +2 -11
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +15 -3
- package/src/routes.ts +133 -3
- package/src/routing.ts +10 -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/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
|
}
|