@openparachute/vault 0.3.0-rc.1 → 0.3.1
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/hooks.ts +111 -3
- package/core/src/store.ts +3 -1
- package/docs/auth-model.md +340 -0
- package/package.json +1 -1
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +56 -17
- package/src/scopes.test.ts +158 -0
- package/src/scopes.ts +148 -0
- package/src/server.ts +19 -7
- package/src/transcription-worker.test.ts +282 -1
- package/src/transcription-worker.ts +171 -16
package/src/cli.ts
CHANGED
|
@@ -77,8 +77,10 @@ import {
|
|
|
77
77
|
resolveServerPath,
|
|
78
78
|
} from "./daemon.ts";
|
|
79
79
|
import { confirm, ask, askPassword, choose } from "./prompt.ts";
|
|
80
|
+
import { resolveBindHostname } from "./bind.ts";
|
|
80
81
|
import { generateToken, createToken, listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
|
|
81
82
|
import type { TokenPermission } from "./token-store.ts";
|
|
83
|
+
import { resolveCreateTokenFlags, VAULT_SCOPES } from "./scopes.ts";
|
|
82
84
|
import { getVaultStore } from "./vault-store.ts";
|
|
83
85
|
import { upsertService, ServicesManifestError } from "./services-manifest.ts";
|
|
84
86
|
import {
|
|
@@ -135,7 +137,7 @@ if (!SKIP_MIGRATION.has(command)) {
|
|
|
135
137
|
|
|
136
138
|
switch (command) {
|
|
137
139
|
case "init":
|
|
138
|
-
await cmdInit();
|
|
140
|
+
await cmdInit(cmdArgs);
|
|
139
141
|
break;
|
|
140
142
|
case "create":
|
|
141
143
|
cmdCreate(cmdArgs);
|
|
@@ -216,9 +218,16 @@ switch (command) {
|
|
|
216
218
|
// Command implementations
|
|
217
219
|
// ---------------------------------------------------------------------------
|
|
218
220
|
|
|
219
|
-
async function cmdInit() {
|
|
221
|
+
async function cmdInit(args: string[] = []) {
|
|
220
222
|
ensureConfigDirSync();
|
|
221
223
|
|
|
224
|
+
// Flags: --mcp installs MCP in ~/.claude.json without prompting;
|
|
225
|
+
// --no-mcp skips it without prompting. If both passed, --no-mcp wins
|
|
226
|
+
// (safer default). Neither → prompt in a TTY, default-yes in a
|
|
227
|
+
// non-TTY for back-compat with existing piped install scripts.
|
|
228
|
+
const flagMcpOn = args.includes("--mcp");
|
|
229
|
+
const flagMcpOff = args.includes("--no-mcp");
|
|
230
|
+
|
|
222
231
|
const isMac = process.platform === "darwin";
|
|
223
232
|
const isLinux = process.platform === "linux";
|
|
224
233
|
const isFirstRun = !existsSync(ENV_PATH);
|
|
@@ -339,11 +348,31 @@ async function cmdInit() {
|
|
|
339
348
|
console.log(` Server path: ${serverPath}`);
|
|
340
349
|
console.log(` Wrapper: ~/.parachute/vault/start.sh`);
|
|
341
350
|
}
|
|
342
|
-
|
|
351
|
+
const bindHost = resolveBindHostname(process.env);
|
|
352
|
+
console.log(` Listening on http://${bindHost}:${globalConfig.port || DEFAULT_PORT}`);
|
|
353
|
+
|
|
354
|
+
// 7. Install MCP for Claude Code (with token for auth) — user confirms
|
|
355
|
+
// unless --mcp / --no-mcp explicitly passed. Writing to ~/.claude.json
|
|
356
|
+
// is a side effect some users don't want; default-yes in a TTY since
|
|
357
|
+
// most users installing vault want Claude Code to see it, but ask.
|
|
358
|
+
let addMcp: boolean;
|
|
359
|
+
if (flagMcpOff) {
|
|
360
|
+
addMcp = false;
|
|
361
|
+
} else if (flagMcpOn) {
|
|
362
|
+
addMcp = true;
|
|
363
|
+
} else if (process.stdin.isTTY) {
|
|
364
|
+
addMcp = await confirm("Add Vault MCP to Claude Code (~/.claude.json)?", true);
|
|
365
|
+
} else {
|
|
366
|
+
addMcp = true; // non-interactive: preserve the installable-via-pipe default
|
|
367
|
+
}
|
|
343
368
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
369
|
+
if (addMcp) {
|
|
370
|
+
installMcpConfig(apiKey);
|
|
371
|
+
console.log(` MCP server added to ~/.claude.json`);
|
|
372
|
+
} else {
|
|
373
|
+
console.log(" Skipped adding MCP to ~/.claude.json.");
|
|
374
|
+
console.log(" Run `parachute-vault mcp-install` later if you want it.");
|
|
375
|
+
}
|
|
347
376
|
|
|
348
377
|
// 8. Summary
|
|
349
378
|
console.log("\n---");
|
|
@@ -357,7 +386,7 @@ async function cmdInit() {
|
|
|
357
386
|
}
|
|
358
387
|
|
|
359
388
|
console.log(`\nConfig: ${CONFIG_DIR}`);
|
|
360
|
-
console.log(`Server: http
|
|
389
|
+
console.log(`Server: http://${bindHost}:${port}`);
|
|
361
390
|
|
|
362
391
|
console.log(`\nUsage examples:`);
|
|
363
392
|
console.log(` curl http://localhost:${port}/health`);
|
|
@@ -818,7 +847,8 @@ function cmdTokens(args: string[]) {
|
|
|
818
847
|
return;
|
|
819
848
|
}
|
|
820
849
|
|
|
821
|
-
// parachute-vault tokens create --vault <name>
|
|
850
|
+
// parachute-vault tokens create --vault <name>
|
|
851
|
+
// [--scope vault:read,vault:write | --read | --permission full|read]
|
|
822
852
|
// [--expires <duration>] [--label <label>]
|
|
823
853
|
if (subcmd === "create") {
|
|
824
854
|
const vaultFlag = args.indexOf("--vault");
|
|
@@ -830,15 +860,17 @@ function cmdTokens(args: string[]) {
|
|
|
830
860
|
process.exit(1);
|
|
831
861
|
}
|
|
832
862
|
|
|
833
|
-
// --read
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
if (
|
|
839
|
-
console.error(
|
|
863
|
+
// Combining --scope / --read / --permission is always an error: a
|
|
864
|
+
// user minting a token expects exactly one narrowing signal, and
|
|
865
|
+
// silently picking one would mint the opposite of what the other
|
|
866
|
+
// reading intended. See resolveCreateTokenFlags.
|
|
867
|
+
const resolved = resolveCreateTokenFlags(args);
|
|
868
|
+
if (resolved.error) {
|
|
869
|
+
console.error(resolved.error);
|
|
840
870
|
process.exit(1);
|
|
841
871
|
}
|
|
872
|
+
const scopes = resolved.scopes;
|
|
873
|
+
const permission: TokenPermission = resolved.permission;
|
|
842
874
|
|
|
843
875
|
const expiresFlag = args.indexOf("--expires");
|
|
844
876
|
let expiresAt: string | null = null;
|
|
@@ -859,12 +891,15 @@ function cmdTokens(args: string[]) {
|
|
|
859
891
|
createToken(store.db, fullToken, {
|
|
860
892
|
label,
|
|
861
893
|
permission,
|
|
894
|
+
scopes,
|
|
862
895
|
expires_at: expiresAt,
|
|
863
896
|
});
|
|
864
897
|
|
|
898
|
+
const displayScopes = scopes ?? [...VAULT_SCOPES];
|
|
865
899
|
console.log(`Created token for vault "${vaultName}":`);
|
|
866
900
|
console.log(` Token: ${fullToken}`);
|
|
867
901
|
console.log(` Permission: ${permission}`);
|
|
902
|
+
console.log(` Scopes: ${displayScopes.join(" ")}`);
|
|
868
903
|
if (expiresAt) console.log(` Expires: ${expiresAt}`);
|
|
869
904
|
console.log(` Label: ${label}`);
|
|
870
905
|
console.log();
|
|
@@ -2031,7 +2066,7 @@ data, and debugging.
|
|
|
2031
2066
|
── Standard use ───────────────────────────────────────────────────────
|
|
2032
2067
|
|
|
2033
2068
|
Setup:
|
|
2034
|
-
parachute-vault init
|
|
2069
|
+
parachute-vault init [--mcp | --no-mcp] Set up everything (one command, idempotent)
|
|
2035
2070
|
parachute-vault doctor Diagnose install/config issues
|
|
2036
2071
|
parachute-vault uninstall [--wipe] [--yes]
|
|
2037
2072
|
Remove daemon + MCP entry; --wipe also removes vaults, .env,
|
|
@@ -2050,7 +2085,11 @@ Tokens:
|
|
|
2050
2085
|
parachute-vault tokens List all tokens
|
|
2051
2086
|
parachute-vault tokens create Create a full-access token in the default vault
|
|
2052
2087
|
parachute-vault tokens create --vault <name> Create a token in a specific vault
|
|
2053
|
-
parachute-vault tokens create --read Read-only token
|
|
2088
|
+
parachute-vault tokens create --read Read-only token (shorthand for --scope vault:read)
|
|
2089
|
+
parachute-vault tokens create --scope vault:write
|
|
2090
|
+
Narrow the token's scopes. Accepts a comma-separated
|
|
2091
|
+
list or repeated --scope flags. Valid scopes:
|
|
2092
|
+
vault:read, vault:write, vault:admin.
|
|
2054
2093
|
parachute-vault tokens create --label x Set a label
|
|
2055
2094
|
parachute-vault tokens create --expires 30d Expiring token
|
|
2056
2095
|
parachute-vault tokens revoke <token-id> Revoke a token (default vault)
|
package/src/scopes.test.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
SCOPE_WRITE,
|
|
11
11
|
SCOPE_ADMIN,
|
|
12
12
|
parseScopes,
|
|
13
|
+
parseScopeFlags,
|
|
14
|
+
resolveCreateTokenFlags,
|
|
13
15
|
hasScope,
|
|
14
16
|
scopeForMethod,
|
|
15
17
|
legacyPermissionToScopes,
|
|
@@ -123,6 +125,162 @@ describe("legacyPermissionToScopes", () => {
|
|
|
123
125
|
});
|
|
124
126
|
});
|
|
125
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
|
+
|
|
126
284
|
describe("serializeScopes — round-trips with parseScopes", () => {
|
|
127
285
|
test("joins with spaces", () => {
|
|
128
286
|
expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
|
package/src/scopes.ts
CHANGED
|
@@ -103,3 +103,151 @@ export function legacyPermissionToScopes(permission: string): string[] {
|
|
|
103
103
|
export function serializeScopes(scopes: string[]): string {
|
|
104
104
|
return scopes.join(" ");
|
|
105
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
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
|
|
19
19
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
20
|
-
import { getVaultStore } from "./vault-store.ts";
|
|
20
|
+
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
21
21
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
22
22
|
import { registerTriggers } from "./triggers.ts";
|
|
23
23
|
import { route } from "./routing.ts";
|
|
24
|
-
import { startTranscriptionWorker, type TranscriptionWorker } from "./transcription-worker.ts";
|
|
24
|
+
import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
|
|
25
25
|
import { assetsDir } from "./routes.ts";
|
|
26
26
|
import { resolveScribeAuthToken } from "./scribe-env.ts";
|
|
27
|
+
import { resolveBindHostname } from "./bind.ts";
|
|
27
28
|
|
|
28
29
|
// Register webhook triggers from global config. Replaces the old hardcoded
|
|
29
30
|
// tts-hook and transcription-hook with config-driven webhooks.
|
|
@@ -59,6 +60,12 @@ function safeHost(url: string): string | null {
|
|
|
59
60
|
try { return new URL(url).host; } catch { return null; }
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Load .env before anything reads process.env — otherwise SCRIBE_URL and
|
|
64
|
+
// friends configured in ~/.parachute/vault/.env are invisible to the
|
|
65
|
+
// transcription-worker check and the trigger double-fire warning below.
|
|
66
|
+
ensureConfigDirSync();
|
|
67
|
+
loadEnvFile();
|
|
68
|
+
|
|
62
69
|
registerConfiguredTriggers();
|
|
63
70
|
|
|
64
71
|
/**
|
|
@@ -78,14 +85,18 @@ if (process.env.SCRIBE_URL) {
|
|
|
78
85
|
getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
|
|
79
86
|
getContextPredicates: (vault) => readVaultConfig(vault)?.transcription?.context,
|
|
80
87
|
});
|
|
88
|
+
// Event-driven hot path — the `attachment:created` hook fires the worker
|
|
89
|
+
// in a microtask instead of waiting for the 30s sweep.
|
|
90
|
+
registerTranscriptionHook(
|
|
91
|
+
defaultHookRegistry,
|
|
92
|
+
transcriptionWorker,
|
|
93
|
+
(store) => getVaultNameForStore(store as never),
|
|
94
|
+
);
|
|
81
95
|
console.log(`[transcribe] worker started → ${process.env.SCRIBE_URL}`);
|
|
82
96
|
} else {
|
|
83
97
|
console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
ensureConfigDirSync();
|
|
87
|
-
loadEnvFile();
|
|
88
|
-
|
|
89
100
|
// Auto-init: create a default vault if none exist (first run in Docker)
|
|
90
101
|
if (listVaults().length === 0) {
|
|
91
102
|
const globalConfig = readGlobalConfig();
|
|
@@ -159,10 +170,11 @@ for (const vaultName of listVaults()) {
|
|
|
159
170
|
|
|
160
171
|
const globalConfig = readGlobalConfig();
|
|
161
172
|
const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
|
|
173
|
+
const hostname = resolveBindHostname();
|
|
162
174
|
|
|
163
175
|
const server = Bun.serve({
|
|
164
176
|
port,
|
|
165
|
-
hostname
|
|
177
|
+
hostname,
|
|
166
178
|
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
167
179
|
async fetch(req, server) {
|
|
168
180
|
const url = new URL(req.url);
|
|
@@ -208,7 +220,7 @@ const server = Bun.serve({
|
|
|
208
220
|
},
|
|
209
221
|
});
|
|
210
222
|
|
|
211
|
-
console.log(`Parachute Vault server listening on http
|
|
223
|
+
console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
|
|
212
224
|
|
|
213
225
|
// Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
|
|
214
226
|
async function shutdown(signal: string): Promise<void> {
|