@openparachute/vault 0.2.4 → 0.3.0
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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for scope primitives — parse, match, inheritance, legacy
|
|
3
|
+
* permission fallback. Integration tests for scope enforcement at the
|
|
4
|
+
* HTTP boundary live in routing.test.ts + vault.test.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
SCOPE_READ,
|
|
10
|
+
SCOPE_WRITE,
|
|
11
|
+
SCOPE_ADMIN,
|
|
12
|
+
parseScopes,
|
|
13
|
+
parseScopeFlags,
|
|
14
|
+
resolveCreateTokenFlags,
|
|
15
|
+
hasScope,
|
|
16
|
+
scopeForMethod,
|
|
17
|
+
legacyPermissionToScopes,
|
|
18
|
+
serializeScopes,
|
|
19
|
+
} from "./scopes.ts";
|
|
20
|
+
|
|
21
|
+
describe("parseScopes", () => {
|
|
22
|
+
test("returns [] for null or empty input", () => {
|
|
23
|
+
expect(parseScopes(null)).toEqual([]);
|
|
24
|
+
expect(parseScopes(undefined)).toEqual([]);
|
|
25
|
+
expect(parseScopes("")).toEqual([]);
|
|
26
|
+
expect(parseScopes(" ")).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("splits on whitespace and trims", () => {
|
|
30
|
+
expect(parseScopes("vault:read vault:write")).toEqual([SCOPE_READ, SCOPE_WRITE]);
|
|
31
|
+
expect(parseScopes(" vault:read vault:write ")).toEqual([SCOPE_READ, SCOPE_WRITE]);
|
|
32
|
+
expect(parseScopes("vault:read\tvault:write\nvault:admin")).toEqual([
|
|
33
|
+
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN,
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("collapses vault:<name>:<verb> synonym to vault:<verb>", () => {
|
|
38
|
+
expect(parseScopes("vault:journal:read")).toEqual([SCOPE_READ]);
|
|
39
|
+
expect(parseScopes("vault:journal:write vault:work:admin")).toEqual([
|
|
40
|
+
SCOPE_WRITE, SCOPE_ADMIN,
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("preserves unrecognized scopes verbatim", () => {
|
|
45
|
+
expect(parseScopes("profile email")).toEqual(["profile", "email"]);
|
|
46
|
+
expect(parseScopes("vault:unknown:frob")).toEqual(["vault:unknown:frob"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("empty name segment does NOT collapse (vault::read stays literal)", () => {
|
|
50
|
+
// Guard against a hand-crafted DB row with `vault::read` satisfying a
|
|
51
|
+
// `vault:read` check by accident. Only reachable via direct DB write,
|
|
52
|
+
// not API input, but the parser stays honest.
|
|
53
|
+
expect(parseScopes("vault::read")).toEqual(["vault::read"]);
|
|
54
|
+
expect(hasScope(parseScopes("vault::read"), SCOPE_READ)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("hasScope — inheritance admin ⊇ write ⊇ read", () => {
|
|
59
|
+
test("exact match succeeds", () => {
|
|
60
|
+
expect(hasScope([SCOPE_READ], SCOPE_READ)).toBe(true);
|
|
61
|
+
expect(hasScope([SCOPE_WRITE], SCOPE_WRITE)).toBe(true);
|
|
62
|
+
expect(hasScope([SCOPE_ADMIN], SCOPE_ADMIN)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("vault:write satisfies vault:read", () => {
|
|
66
|
+
expect(hasScope([SCOPE_WRITE], SCOPE_READ)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("vault:admin satisfies vault:read and vault:write", () => {
|
|
70
|
+
expect(hasScope([SCOPE_ADMIN], SCOPE_READ)).toBe(true);
|
|
71
|
+
expect(hasScope([SCOPE_ADMIN], SCOPE_WRITE)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("vault:read does NOT satisfy vault:write or vault:admin", () => {
|
|
75
|
+
expect(hasScope([SCOPE_READ], SCOPE_WRITE)).toBe(false);
|
|
76
|
+
expect(hasScope([SCOPE_READ], SCOPE_ADMIN)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("vault:write does NOT satisfy vault:admin", () => {
|
|
80
|
+
expect(hasScope([SCOPE_WRITE], SCOPE_ADMIN)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("empty granted list fails", () => {
|
|
84
|
+
expect(hasScope([], SCOPE_READ)).toBe(false);
|
|
85
|
+
expect(hasScope([], SCOPE_WRITE)).toBe(false);
|
|
86
|
+
expect(hasScope([], SCOPE_ADMIN)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("non-vault scopes require exact match — no inheritance", () => {
|
|
90
|
+
expect(hasScope(["profile"], "profile")).toBe(true);
|
|
91
|
+
expect(hasScope(["profile"], "email")).toBe(false);
|
|
92
|
+
expect(hasScope([SCOPE_ADMIN], "profile")).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("scopeForMethod", () => {
|
|
97
|
+
test("read methods → vault:read", () => {
|
|
98
|
+
expect(scopeForMethod("GET")).toBe(SCOPE_READ);
|
|
99
|
+
expect(scopeForMethod("HEAD")).toBe(SCOPE_READ);
|
|
100
|
+
expect(scopeForMethod("OPTIONS")).toBe(SCOPE_READ);
|
|
101
|
+
expect(scopeForMethod("get")).toBe(SCOPE_READ); // case-insensitive
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("write methods → vault:write", () => {
|
|
105
|
+
expect(scopeForMethod("POST")).toBe(SCOPE_WRITE);
|
|
106
|
+
expect(scopeForMethod("PATCH")).toBe(SCOPE_WRITE);
|
|
107
|
+
expect(scopeForMethod("PUT")).toBe(SCOPE_WRITE);
|
|
108
|
+
expect(scopeForMethod("DELETE")).toBe(SCOPE_WRITE);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("unknown method falls back to vault:write (default-deny)", () => {
|
|
112
|
+
expect(scopeForMethod("TRACE")).toBe(SCOPE_WRITE);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("legacyPermissionToScopes", () => {
|
|
117
|
+
test("'read' → [vault:read]", () => {
|
|
118
|
+
expect(legacyPermissionToScopes("read")).toEqual([SCOPE_READ]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("'full' and anything else → [read, write, admin]", () => {
|
|
122
|
+
expect(legacyPermissionToScopes("full")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
|
|
123
|
+
expect(legacyPermissionToScopes("admin")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
|
|
124
|
+
expect(legacyPermissionToScopes("write")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("parseScopeFlags", () => {
|
|
129
|
+
test("returns null scopes when --scope is absent", () => {
|
|
130
|
+
expect(parseScopeFlags([])).toEqual({ scopes: null, error: null });
|
|
131
|
+
expect(parseScopeFlags(["--vault", "default", "--label", "x"]))
|
|
132
|
+
.toEqual({ scopes: null, error: null });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("single --scope with one value", () => {
|
|
136
|
+
expect(parseScopeFlags(["--scope", "vault:read"]))
|
|
137
|
+
.toEqual({ scopes: [SCOPE_READ], error: null });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("single --scope with comma-separated values", () => {
|
|
141
|
+
expect(parseScopeFlags(["--scope", "vault:read,vault:write"]))
|
|
142
|
+
.toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("repeated --scope flags", () => {
|
|
146
|
+
expect(parseScopeFlags(["--scope", "vault:read", "--scope", "vault:write"]))
|
|
147
|
+
.toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("dedupes while preserving first-occurrence order", () => {
|
|
151
|
+
expect(parseScopeFlags(["--scope", "vault:write,vault:read,vault:write"]))
|
|
152
|
+
.toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], error: null });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("trims whitespace around each scope", () => {
|
|
156
|
+
expect(parseScopeFlags(["--scope", " vault:read , vault:write "]))
|
|
157
|
+
.toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("rejects unknown scopes with a helpful error", () => {
|
|
161
|
+
const result = parseScopeFlags(["--scope", "vault:frob"]);
|
|
162
|
+
expect(result.scopes).toBeNull();
|
|
163
|
+
expect(result.error).toContain("Unknown scope");
|
|
164
|
+
expect(result.error).toContain("vault:frob");
|
|
165
|
+
expect(result.error).toContain("vault:read, vault:write, vault:admin");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("rejects a mixed list with one invalid scope", () => {
|
|
169
|
+
const result = parseScopeFlags(["--scope", "vault:read,admin"]);
|
|
170
|
+
expect(result.scopes).toBeNull();
|
|
171
|
+
expect(result.error).toContain("admin");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("rejects --scope with no value (end of argv)", () => {
|
|
175
|
+
const result = parseScopeFlags(["--scope"]);
|
|
176
|
+
expect(result.scopes).toBeNull();
|
|
177
|
+
expect(result.error).toContain("--scope requires a value");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("rejects --scope followed by another flag", () => {
|
|
181
|
+
const result = parseScopeFlags(["--scope", "--label", "x"]);
|
|
182
|
+
expect(result.scopes).toBeNull();
|
|
183
|
+
expect(result.error).toContain("--scope requires a value");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("rejects --scope with an empty value", () => {
|
|
187
|
+
const result = parseScopeFlags(["--scope", ""]);
|
|
188
|
+
expect(result.scopes).toBeNull();
|
|
189
|
+
expect(result.error).toContain("empty");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("rejects --scope with only commas", () => {
|
|
193
|
+
const result = parseScopeFlags(["--scope", ",,"]);
|
|
194
|
+
expect(result.scopes).toBeNull();
|
|
195
|
+
expect(result.error).toContain("empty");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("resolveCreateTokenFlags", () => {
|
|
200
|
+
test("no flags → full scope, full permission (historical default)", () => {
|
|
201
|
+
expect(resolveCreateTokenFlags([]))
|
|
202
|
+
.toEqual({ scopes: undefined, permission: "full", error: null });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("--read alone → [vault:read], read permission", () => {
|
|
206
|
+
expect(resolveCreateTokenFlags(["--read"]))
|
|
207
|
+
.toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("--scope vault:read alone → read permission", () => {
|
|
211
|
+
expect(resolveCreateTokenFlags(["--scope", "vault:read"]))
|
|
212
|
+
.toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("--scope vault:write,vault:read → full permission (any write surface → full)", () => {
|
|
216
|
+
expect(resolveCreateTokenFlags(["--scope", "vault:write,vault:read"]))
|
|
217
|
+
.toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], permission: "full", error: null });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("--scope vault:admin alone → full permission", () => {
|
|
221
|
+
expect(resolveCreateTokenFlags(["--scope", "vault:admin"]))
|
|
222
|
+
.toEqual({ scopes: [SCOPE_ADMIN], permission: "full", error: null });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("--permission read → no scopes (token-store default), read permission", () => {
|
|
226
|
+
expect(resolveCreateTokenFlags(["--permission", "read"]))
|
|
227
|
+
.toEqual({ scopes: undefined, permission: "read", error: null });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("--permission full → no scopes, full permission", () => {
|
|
231
|
+
expect(resolveCreateTokenFlags(["--permission", "full"]))
|
|
232
|
+
.toEqual({ scopes: undefined, permission: "full", error: null });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("--scope + --read errors and mentions both flags", () => {
|
|
236
|
+
const result = resolveCreateTokenFlags(["--scope", "vault:write", "--read"]);
|
|
237
|
+
expect(result.scopes).toBeUndefined();
|
|
238
|
+
expect(result.error).toContain("--scope");
|
|
239
|
+
expect(result.error).toContain("--read");
|
|
240
|
+
expect(result.error).toContain("cannot be combined");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("--scope + --permission errors", () => {
|
|
244
|
+
const result = resolveCreateTokenFlags(["--scope", "vault:read", "--permission", "full"]);
|
|
245
|
+
expect(result.scopes).toBeUndefined();
|
|
246
|
+
expect(result.error).toContain("--scope");
|
|
247
|
+
expect(result.error).toContain("--permission");
|
|
248
|
+
expect(result.error).toContain("cannot be combined");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("--read + --permission errors", () => {
|
|
252
|
+
const result = resolveCreateTokenFlags(["--read", "--permission", "full"]);
|
|
253
|
+
expect(result.scopes).toBeUndefined();
|
|
254
|
+
expect(result.error).toContain("--read");
|
|
255
|
+
expect(result.error).toContain("--permission");
|
|
256
|
+
expect(result.error).toContain("cannot be combined");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("invalid --permission value errors with prefer-scope hint", () => {
|
|
260
|
+
const result = resolveCreateTokenFlags(["--permission", "admin"]);
|
|
261
|
+
expect(result.scopes).toBeUndefined();
|
|
262
|
+
expect(result.error).toContain("admin");
|
|
263
|
+
expect(result.error).toContain("full");
|
|
264
|
+
expect(result.error).toContain("read");
|
|
265
|
+
expect(result.error).toContain("--scope");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("--permission with no value errors", () => {
|
|
269
|
+
expect(resolveCreateTokenFlags(["--permission"]).error).toContain("requires a value");
|
|
270
|
+
expect(resolveCreateTokenFlags(["--permission", "--label", "x"]).error).toContain("requires a value");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("surfaces parseScopeFlags errors unchanged", () => {
|
|
274
|
+
const result = resolveCreateTokenFlags(["--scope", "vault:frob"]);
|
|
275
|
+
expect(result.error).toContain("Unknown scope");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("ignores unrelated flags", () => {
|
|
279
|
+
const result = resolveCreateTokenFlags(["--vault", "journal", "--label", "r", "--read"]);
|
|
280
|
+
expect(result).toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("serializeScopes — round-trips with parseScopes", () => {
|
|
285
|
+
test("joins with spaces", () => {
|
|
286
|
+
expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
|
|
287
|
+
expect(serializeScopes([])).toBe("");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("serialize then parse is the identity (for known scopes)", () => {
|
|
291
|
+
const scopes = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN];
|
|
292
|
+
expect(parseScopes(serializeScopes(scopes))).toEqual(scopes);
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/scopes.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope primitives for Phase 2 enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Tokens carry OAuth-standard whitespace-separated scopes. This module parses,
|
|
5
|
+
* normalizes, and matches them — including the `admin ⊇ write ⊇ read`
|
|
6
|
+
* inheritance rule and the `vault:<name>:<verb>` future-shape synonym
|
|
7
|
+
* (narrowed per-vault scopes are Phase 2+; today we treat them as equivalent
|
|
8
|
+
* to `vault:<verb>`).
|
|
9
|
+
*
|
|
10
|
+
* Legacy back-compat: tokens without any `vault:*` scope — but with a
|
|
11
|
+
* 0.2.x-era `permission = "full" | "read"` — are mapped to the appropriate
|
|
12
|
+
* scope set on the fly. `legacyPermissionToScopes` is marked deprecated and
|
|
13
|
+
* should be removed one release after enforcement lands.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const SCOPE_READ = "vault:read" as const;
|
|
17
|
+
export const SCOPE_WRITE = "vault:write" as const;
|
|
18
|
+
export const SCOPE_ADMIN = "vault:admin" as const;
|
|
19
|
+
|
|
20
|
+
/** All first-class vault scopes in inheritance order (lowest → highest). */
|
|
21
|
+
export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
|
|
22
|
+
export type VaultScope = (typeof VAULT_SCOPES)[number];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a whitespace-separated scope string into a normalized scope list.
|
|
26
|
+
*
|
|
27
|
+
* Normalization:
|
|
28
|
+
* - Empty / null → []
|
|
29
|
+
* - Trim + split on any whitespace
|
|
30
|
+
* - `vault:<name>:<verb>` collapses to `vault:<verb>` (per-vault narrowing
|
|
31
|
+
* is Phase 2+; today it's treated as a synonym)
|
|
32
|
+
* - Unrecognized scopes are preserved as-is (they just won't match anything)
|
|
33
|
+
*/
|
|
34
|
+
export function parseScopes(raw: string | null | undefined): string[] {
|
|
35
|
+
if (!raw) return [];
|
|
36
|
+
return raw
|
|
37
|
+
.split(/\s+/)
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((s) => normalizeScope(s));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeScope(scope: string): string {
|
|
44
|
+
// `vault:<name>:<verb>` → `vault:<verb>` (synonym collapse). Reject an empty
|
|
45
|
+
// name segment (`vault::read`) — preserve it as-is so it can't accidentally
|
|
46
|
+
// satisfy a `vault:read` check. Only reachable via direct DB write, but the
|
|
47
|
+
// one-liner keeps the parser honest.
|
|
48
|
+
const parts = scope.split(":");
|
|
49
|
+
if (parts.length === 3 && parts[0] === "vault" && parts[1].length > 0) {
|
|
50
|
+
const verb = parts[2];
|
|
51
|
+
if (verb === "read" || verb === "write" || verb === "admin") {
|
|
52
|
+
return `vault:${verb}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return scope;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return true iff `granted` satisfies `required` under the inheritance rule
|
|
60
|
+
* `admin ⊇ write ⊇ read`. Exact-match required for non-vault scopes.
|
|
61
|
+
*/
|
|
62
|
+
export function hasScope(granted: string[], required: string): boolean {
|
|
63
|
+
if (granted.includes(required)) return true;
|
|
64
|
+
|
|
65
|
+
// Inheritance: admin ⊇ write ⊇ read
|
|
66
|
+
if (required === SCOPE_READ) {
|
|
67
|
+
return granted.includes(SCOPE_WRITE) || granted.includes(SCOPE_ADMIN);
|
|
68
|
+
}
|
|
69
|
+
if (required === SCOPE_WRITE) {
|
|
70
|
+
return granted.includes(SCOPE_ADMIN);
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pick the required scope for a given API request.
|
|
77
|
+
* - GET/HEAD/OPTIONS → read
|
|
78
|
+
* - POST/PATCH/PUT/DELETE → write
|
|
79
|
+
*
|
|
80
|
+
* Admin-gated endpoints (like `/.parachute/config`) don't go through this
|
|
81
|
+
* helper — they call `hasScope(auth.scopes, SCOPE_ADMIN)` directly.
|
|
82
|
+
*/
|
|
83
|
+
export function scopeForMethod(method: string): VaultScope {
|
|
84
|
+
const m = method.toUpperCase();
|
|
85
|
+
if (m === "GET" || m === "HEAD" || m === "OPTIONS") return SCOPE_READ;
|
|
86
|
+
return SCOPE_WRITE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Map a 0.2.x legacy `permission` column value to scopes. Kept for back-compat
|
|
91
|
+
* during the one-release-cycle deprecation window — after that, every token
|
|
92
|
+
* row will carry an explicit `scopes` column and this can go.
|
|
93
|
+
*
|
|
94
|
+
* @deprecated Remove one release after v0.4 scope enforcement lands.
|
|
95
|
+
*/
|
|
96
|
+
export function legacyPermissionToScopes(permission: string): string[] {
|
|
97
|
+
// "full", "admin", "write" all historically meant unrestricted access
|
|
98
|
+
if (permission === "read") return [SCOPE_READ];
|
|
99
|
+
return [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Serialize a scope list to an OAuth-standard whitespace-separated string. */
|
|
103
|
+
export function serializeScopes(scopes: string[]): string {
|
|
104
|
+
return scopes.join(" ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse `--scope` flag values from an argv list into a validated scope list.
|
|
109
|
+
*
|
|
110
|
+
* Accepts repeatable `--scope vault:read --scope vault:write` and
|
|
111
|
+
* comma-separated `--scope vault:read,vault:write` (and a mix of the two).
|
|
112
|
+
* Scopes are validated against `VAULT_SCOPES` — we refuse to mint a token
|
|
113
|
+
* with a scope the server has no way to enforce.
|
|
114
|
+
*
|
|
115
|
+
* Return shape: `{scopes}` is `null` when no `--scope` appears anywhere, so
|
|
116
|
+
* the caller can distinguish "flag not set" from "flag set to empty." On
|
|
117
|
+
* validation failure, `error` is a human-readable message suitable for
|
|
118
|
+
* `console.error` + `process.exit(1)`.
|
|
119
|
+
*/
|
|
120
|
+
export function parseScopeFlags(
|
|
121
|
+
args: string[],
|
|
122
|
+
): { scopes: string[] | null; error: string | null } {
|
|
123
|
+
const validList = VAULT_SCOPES.join(", ");
|
|
124
|
+
const raw: string[] = [];
|
|
125
|
+
for (let i = 0; i < args.length; i++) {
|
|
126
|
+
if (args[i] !== "--scope") continue;
|
|
127
|
+
const val = args[i + 1];
|
|
128
|
+
if (val === undefined || val.startsWith("--")) {
|
|
129
|
+
return { scopes: null, error: `--scope requires a value. Valid scopes: ${validList}` };
|
|
130
|
+
}
|
|
131
|
+
raw.push(val);
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
if (raw.length === 0) return { scopes: null, error: null };
|
|
135
|
+
|
|
136
|
+
const expanded = raw
|
|
137
|
+
.flatMap((v) => v.split(","))
|
|
138
|
+
.map((s) => s.trim())
|
|
139
|
+
.filter((s) => s.length > 0);
|
|
140
|
+
if (expanded.length === 0) {
|
|
141
|
+
return { scopes: null, error: `--scope value was empty. Valid scopes: ${validList}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const validSet = new Set<string>(VAULT_SCOPES);
|
|
145
|
+
const invalid = expanded.filter((s) => !validSet.has(s));
|
|
146
|
+
if (invalid.length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
scopes: null,
|
|
149
|
+
error: `Unknown scope(s): ${invalid.join(", ")}. Valid scopes: ${validList}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const deduped: string[] = [];
|
|
155
|
+
for (const s of expanded) {
|
|
156
|
+
if (!seen.has(s)) {
|
|
157
|
+
seen.add(s);
|
|
158
|
+
deduped.push(s);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { scopes: deduped, error: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve `parachute vault tokens create` argv into a concrete scope set +
|
|
166
|
+
* legacy `permission` column value, or an actionable error.
|
|
167
|
+
*
|
|
168
|
+
* Precedence is **exclusive**: `--scope`, `--read`, and `--permission` all
|
|
169
|
+
* narrow the token, but combining them is always an error — a user who
|
|
170
|
+
* writes `--scope vault:write --read` almost certainly expects one of the
|
|
171
|
+
* two to win, and silently picking would mint the opposite of what at
|
|
172
|
+
* least one reading intended. Fail loud for anything token-minting.
|
|
173
|
+
*
|
|
174
|
+
* With no narrowing flag, falls back to a full-scope token for back-compat.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveCreateTokenFlags(args: string[]): {
|
|
177
|
+
scopes: string[] | undefined;
|
|
178
|
+
permission: "full" | "read";
|
|
179
|
+
error: string | null;
|
|
180
|
+
} {
|
|
181
|
+
const scopeResult = parseScopeFlags(args);
|
|
182
|
+
if (scopeResult.error) {
|
|
183
|
+
return { scopes: undefined, permission: "full", error: scopeResult.error };
|
|
184
|
+
}
|
|
185
|
+
const hasScopeFlag = scopeResult.scopes !== null;
|
|
186
|
+
const hasReadFlag = args.includes("--read");
|
|
187
|
+
const permIdx = args.indexOf("--permission");
|
|
188
|
+
const hasPermFlag = permIdx !== -1;
|
|
189
|
+
|
|
190
|
+
if (hasScopeFlag && hasReadFlag) {
|
|
191
|
+
return {
|
|
192
|
+
scopes: undefined,
|
|
193
|
+
permission: "full",
|
|
194
|
+
error:
|
|
195
|
+
"--scope and --read cannot be combined. Pick one:\n" +
|
|
196
|
+
" --read # shorthand for --scope vault:read\n" +
|
|
197
|
+
" --scope vault:read # equivalent, explicit\n" +
|
|
198
|
+
" --scope vault:write # write scope",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (hasScopeFlag && hasPermFlag) {
|
|
202
|
+
return {
|
|
203
|
+
scopes: undefined,
|
|
204
|
+
permission: "full",
|
|
205
|
+
error:
|
|
206
|
+
"--scope and --permission cannot be combined. --scope is the canonical way to narrow a token; --permission is legacy.",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (hasReadFlag && hasPermFlag) {
|
|
210
|
+
return {
|
|
211
|
+
scopes: undefined,
|
|
212
|
+
permission: "full",
|
|
213
|
+
error: "--read and --permission cannot be combined. --read is a shorthand for --permission read.",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hasPermFlag) {
|
|
218
|
+
const rawPerm = args[permIdx + 1];
|
|
219
|
+
if (!rawPerm || rawPerm.startsWith("--")) {
|
|
220
|
+
return {
|
|
221
|
+
scopes: undefined,
|
|
222
|
+
permission: "full",
|
|
223
|
+
error: `--permission requires a value ("full" or "read"). Prefer --scope for new scripts.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!["full", "read"].includes(rawPerm)) {
|
|
227
|
+
return {
|
|
228
|
+
scopes: undefined,
|
|
229
|
+
permission: "full",
|
|
230
|
+
error: `Invalid --permission: ${rawPerm}. Must be "full" or "read". Prefer --scope for new scripts.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (scopeResult.scopes) {
|
|
236
|
+
const scopes = scopeResult.scopes;
|
|
237
|
+
const permission: "full" | "read" =
|
|
238
|
+
scopes.includes(SCOPE_WRITE) || scopes.includes(SCOPE_ADMIN) ? "full" : "read";
|
|
239
|
+
return { scopes, permission, error: null };
|
|
240
|
+
}
|
|
241
|
+
if (hasReadFlag) {
|
|
242
|
+
return { scopes: [SCOPE_READ], permission: "read", error: null };
|
|
243
|
+
}
|
|
244
|
+
if (hasPermFlag) {
|
|
245
|
+
const rawPerm = args[permIdx + 1];
|
|
246
|
+
return {
|
|
247
|
+
scopes: undefined,
|
|
248
|
+
permission: rawPerm === "read" ? "read" : "full",
|
|
249
|
+
error: null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { scopes: undefined, permission: "full", error: null };
|
|
253
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolveScribeAuthToken } from "./scribe-env.ts";
|
|
3
|
+
|
|
4
|
+
function captureWarn() {
|
|
5
|
+
const calls: unknown[][] = [];
|
|
6
|
+
return { logger: { warn: (...args: unknown[]) => calls.push(args) }, calls };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("resolveScribeAuthToken", () => {
|
|
10
|
+
test("returns SCRIBE_AUTH_TOKEN when set (canonical)", () => {
|
|
11
|
+
const { logger, calls } = captureWarn();
|
|
12
|
+
const token = resolveScribeAuthToken(
|
|
13
|
+
{ SCRIBE_AUTH_TOKEN: "canonical-v1" } as NodeJS.ProcessEnv,
|
|
14
|
+
logger,
|
|
15
|
+
);
|
|
16
|
+
expect(token).toBe("canonical-v1");
|
|
17
|
+
// Canonical path is silent — no deprecation warning.
|
|
18
|
+
expect(calls.length).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("prefers canonical over legacy when both set", () => {
|
|
22
|
+
const { logger, calls } = captureWarn();
|
|
23
|
+
const token = resolveScribeAuthToken(
|
|
24
|
+
{ SCRIBE_AUTH_TOKEN: "new", SCRIBE_TOKEN: "old" } as NodeJS.ProcessEnv,
|
|
25
|
+
logger,
|
|
26
|
+
);
|
|
27
|
+
expect(token).toBe("new");
|
|
28
|
+
expect(calls.length).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("falls back to SCRIBE_TOKEN with deprecation warning", () => {
|
|
32
|
+
const { logger, calls } = captureWarn();
|
|
33
|
+
const token = resolveScribeAuthToken(
|
|
34
|
+
{ SCRIBE_TOKEN: "legacy-v0" } as NodeJS.ProcessEnv,
|
|
35
|
+
logger,
|
|
36
|
+
);
|
|
37
|
+
expect(token).toBe("legacy-v0");
|
|
38
|
+
expect(calls.length).toBe(1);
|
|
39
|
+
expect(String(calls[0][0])).toContain("SCRIBE_TOKEN is deprecated");
|
|
40
|
+
expect(String(calls[0][0])).toContain("SCRIBE_AUTH_TOKEN");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns undefined when neither is set (loopback back-compat)", () => {
|
|
44
|
+
const { logger, calls } = captureWarn();
|
|
45
|
+
const token = resolveScribeAuthToken({} as NodeJS.ProcessEnv, logger);
|
|
46
|
+
expect(token).toBeUndefined();
|
|
47
|
+
expect(calls.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-var plumbing for the scribe integration (transcription worker + triggers).
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so the boot-time token resolution in server.ts is
|
|
5
|
+
* testable without running the rest of server.ts (which has side effects:
|
|
6
|
+
* triggers, auto-init, Bun.serve). Keep this module pure and dependency-free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the scribe auth token. `SCRIBE_AUTH_TOKEN` is the canonical name
|
|
11
|
+
* (matches the CLI's install-time auto-wire); `SCRIBE_TOKEN` is a legacy alias
|
|
12
|
+
* kept for one release — when only the legacy name is set, we warn once so
|
|
13
|
+
* users notice and rename.
|
|
14
|
+
*
|
|
15
|
+
* Returns `undefined` when neither is set; callers must treat that as "no
|
|
16
|
+
* Authorization header" (back-compat with loopback-trust deployments).
|
|
17
|
+
*/
|
|
18
|
+
export function resolveScribeAuthToken(
|
|
19
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
20
|
+
logger: { warn: (...args: unknown[]) => void } = console,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
const canonical = env.SCRIBE_AUTH_TOKEN;
|
|
23
|
+
if (canonical) return canonical;
|
|
24
|
+
const legacy = env.SCRIBE_TOKEN;
|
|
25
|
+
if (legacy) {
|
|
26
|
+
logger.warn(
|
|
27
|
+
"[transcribe] SCRIBE_TOKEN is deprecated; rename to SCRIBE_AUTH_TOKEN. " +
|
|
28
|
+
"The legacy name will stop being read in the next release.",
|
|
29
|
+
);
|
|
30
|
+
return legacy;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|