@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.
@@ -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
@@ -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" };
@@ -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
  }