@openparachute/hub 0.7.4-rc.8 → 0.7.4
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/package.json +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +276 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +25 -5
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/commands/hub.ts
CHANGED
|
@@ -32,7 +32,12 @@ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
|
|
|
32
32
|
import { restart } from "../commands/lifecycle.ts";
|
|
33
33
|
import { CONFIG_DIR } from "../config.ts";
|
|
34
34
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_ROOT_REDIRECT,
|
|
37
|
+
isSafeRedirectPath,
|
|
38
|
+
setHubOrigin,
|
|
39
|
+
setRootRedirect,
|
|
40
|
+
} from "../hub-settings.ts";
|
|
36
41
|
import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
37
42
|
import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
|
|
38
43
|
|
|
@@ -347,6 +352,81 @@ async function runCaddyReload(run: Runner): Promise<CommandResult> {
|
|
|
347
352
|
return run(["systemctl", "reload", "caddy"]);
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
/**
|
|
356
|
+
* `parachute hub set-root-redirect <path>` — persist the operator's bare-`/`
|
|
357
|
+
* redirect target into `hub_settings.root_redirect` (tier-1 in
|
|
358
|
+
* `resolveRootRedirect`). Lets a headless box (the canonical use case is a
|
|
359
|
+
* custom-domain hub fronting a team surface) flip its landing page from `/admin`
|
|
360
|
+
* to a surface without a browser session OR a redeploy.
|
|
361
|
+
*
|
|
362
|
+
* `--clear` deletes the row, reverting to the env / `/admin` default.
|
|
363
|
+
*
|
|
364
|
+
* The path is validated through `isSafeRedirectPath` — the SAME open-redirect
|
|
365
|
+
* guard the admin PUT enforces — so the CLI can never plant an off-origin
|
|
366
|
+
* `Location` target either. Returns 0 on success, 1 on a usage / validation /
|
|
367
|
+
* DB-write failure.
|
|
368
|
+
*/
|
|
369
|
+
export async function hubSetRootRedirect(
|
|
370
|
+
args: readonly string[],
|
|
371
|
+
deps: HubCommandDeps = {},
|
|
372
|
+
): Promise<number> {
|
|
373
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
374
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
375
|
+
const err = (line: string) => console.error(line);
|
|
376
|
+
const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
|
|
377
|
+
|
|
378
|
+
const clear = args.includes("--clear");
|
|
379
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
380
|
+
|
|
381
|
+
if (clear) {
|
|
382
|
+
if (positional.length > 0) {
|
|
383
|
+
err("parachute hub set-root-redirect: --clear takes no path argument");
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const db = openDb(configDir);
|
|
387
|
+
try {
|
|
388
|
+
setRootRedirect(db, null);
|
|
389
|
+
} finally {
|
|
390
|
+
db.close();
|
|
391
|
+
}
|
|
392
|
+
log(
|
|
393
|
+
`✓ Cleared the root redirect — \`/\` reverts to env / the ${DEFAULT_ROOT_REDIRECT} default.`,
|
|
394
|
+
);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const raw = positional[0];
|
|
399
|
+
if (raw === undefined) {
|
|
400
|
+
err("usage: parachute hub set-root-redirect <path> (or --clear)");
|
|
401
|
+
err("example: parachute hub set-root-redirect /surface/reading-room");
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
if (positional.length > 1) {
|
|
405
|
+
err(`parachute hub set-root-redirect: unexpected argument "${positional[1]}"`);
|
|
406
|
+
err("usage: parachute hub set-root-redirect <path> (or --clear)");
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!isSafeRedirectPath(raw)) {
|
|
411
|
+
err(`parachute hub set-root-redirect: "${raw}" is not a safe same-origin path`);
|
|
412
|
+
err(" It must start with a single `/` (no `//`, `/\\`, scheme, or whitespace) and");
|
|
413
|
+
err(" not be `/` itself. Example: /surface/reading-room");
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const db = openDb(configDir);
|
|
418
|
+
try {
|
|
419
|
+
setRootRedirect(db, raw);
|
|
420
|
+
} finally {
|
|
421
|
+
db.close();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
log(`✓ Bare \`/\` now redirects to ${raw}.`);
|
|
425
|
+
log(" Stored in hub_settings.root_redirect — takes effect on the next request,");
|
|
426
|
+
log(" no restart needed. Clear it with: parachute hub set-root-redirect --clear");
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
350
430
|
/**
|
|
351
431
|
* `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
|
|
352
432
|
* router over subcommand handlers, each catching its own errors).
|
|
@@ -367,6 +447,16 @@ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): P
|
|
|
367
447
|
return 1;
|
|
368
448
|
}
|
|
369
449
|
}
|
|
450
|
+
if (sub === "set-root-redirect") {
|
|
451
|
+
try {
|
|
452
|
+
return await hubSetRootRedirect(args.slice(1), deps);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error(
|
|
455
|
+
`parachute hub set-root-redirect: ${err instanceof Error ? err.message : String(err)}`,
|
|
456
|
+
);
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
370
460
|
console.error(`parachute hub: unknown subcommand "${sub}"`);
|
|
371
461
|
console.error("");
|
|
372
462
|
console.error(hubHelp());
|
|
@@ -378,6 +468,7 @@ export function hubHelp(): string {
|
|
|
378
468
|
|
|
379
469
|
Usage:
|
|
380
470
|
parachute hub set-origin <url> [--no-caddy] [--no-restart]
|
|
471
|
+
parachute hub set-root-redirect <path> | --clear
|
|
381
472
|
|
|
382
473
|
Subcommands:
|
|
383
474
|
set-origin <url> Persist the canonical public origin (OAuth issuer) to the
|
|
@@ -401,9 +492,19 @@ Subcommands:
|
|
|
401
492
|
Caddyfile rewrite + reload, or --no-restart to skip the
|
|
402
493
|
module restart.
|
|
403
494
|
|
|
495
|
+
set-root-redirect <path>
|
|
496
|
+
Point the bare \`/\` 302 at a same-origin path instead of the
|
|
497
|
+
default /admin (e.g. a team surface). Stored in
|
|
498
|
+
hub_settings.root_redirect; takes effect on the next request,
|
|
499
|
+
no restart. The path must start with a single \`/\` (no \`//\`,
|
|
500
|
+
\`/\\\`, scheme, or whitespace). Pass --clear to revert to the
|
|
501
|
+
env / /admin default. (Env equivalent: PARACHUTE_HUB_ROOT_REDIRECT.)
|
|
502
|
+
|
|
404
503
|
Examples:
|
|
405
504
|
parachute hub set-origin https://box.sslip.io
|
|
406
505
|
parachute hub set-origin https://parachute.example.com
|
|
407
506
|
parachute hub set-origin https://parachute.example.com --no-caddy
|
|
507
|
+
parachute hub set-root-redirect /surface/reading-room
|
|
508
|
+
parachute hub set-root-redirect --clear
|
|
408
509
|
`;
|
|
409
510
|
}
|
|
@@ -31,14 +31,18 @@
|
|
|
31
31
|
* `parachute:host:admin` — exactly the scope the endpoint gates on. This is the
|
|
32
32
|
* same read-never-mint credential path `parachute start/stop/restart <svc>` use.
|
|
33
33
|
*
|
|
34
|
-
* ##
|
|
34
|
+
* ## Last-vault handling (#678)
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* The last/only vault is deleted IDENTICALLY to any other vault: the endpoint
|
|
37
|
+
* runs the full cascade-then-delete and returns 200. There is no special-case
|
|
38
|
+
* here. (Older builds refused the last vault with a `409 last_vault` and steered
|
|
39
|
+
* the operator to the raw `parachute-vault remove --yes` — but that escape hatch
|
|
40
|
+
* SKIPS the cascade, orphaning the very identity artifacts B3 set out to clean
|
|
41
|
+
* up. hub#678 removed that refusal: vault's boot can no longer silently
|
|
42
|
+
* resurrect a fresh first vault because vault's CLI writes an
|
|
43
|
+
* `auto_create: false` marker on last-vault removal and the boot gate honors
|
|
44
|
+
* it.) This command therefore needs no 409 branch — the 200 path renders the
|
|
45
|
+
* cascade summary for the last vault just like every other delete.
|
|
42
46
|
*/
|
|
43
47
|
|
|
44
48
|
import { CONFIG_DIR } from "../config.ts";
|
|
@@ -56,8 +60,9 @@ import {
|
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
62
|
* Injectable seams. Production wires the real operator-token bearer resolver +
|
|
59
|
-
* the global `fetch`; tests inject fakes to assert the request shape +
|
|
60
|
-
*
|
|
63
|
+
* the global `fetch`; tests inject fakes to assert the request shape + that
|
|
64
|
+
* destruction always goes through the hub endpoint (never a direct
|
|
65
|
+
* `parachute-vault` spawn) without a live hub or a real socket.
|
|
61
66
|
*/
|
|
62
67
|
export interface VaultRemoveDeps {
|
|
63
68
|
/**
|
|
@@ -84,6 +89,7 @@ interface CascadeSummaryWire {
|
|
|
84
89
|
grants_dropped?: number;
|
|
85
90
|
user_vaults_removed?: number;
|
|
86
91
|
invites_invalidated?: number;
|
|
92
|
+
vault_cap_removed?: boolean;
|
|
87
93
|
connections_torn_down?: number;
|
|
88
94
|
orphaned_channels?: unknown;
|
|
89
95
|
vault_removed?: boolean;
|
|
@@ -151,6 +157,7 @@ function renderCascadeSummary(
|
|
|
151
157
|
log(` grants dropped: ${n(c.grants_dropped)}`);
|
|
152
158
|
log(` user_vaults removed: ${n(c.user_vaults_removed)}`);
|
|
153
159
|
log(` invites invalidated: ${n(c.invites_invalidated)}`);
|
|
160
|
+
log(` storage cap removed: ${c.vault_cap_removed === true ? "yes" : "no"}`);
|
|
154
161
|
log(` connections torn down: ${n(c.connections_torn_down)}`);
|
|
155
162
|
log(` vault removed: ${c.vault_removed === true ? "yes" : "no"}`);
|
|
156
163
|
log(` vault module restarted:${c.module_restarted === true ? " yes" : " no"}`);
|
|
@@ -302,21 +309,6 @@ export async function vaultRemove(args: string[], deps: VaultRemoveDeps = {}): P
|
|
|
302
309
|
return 0;
|
|
303
310
|
}
|
|
304
311
|
|
|
305
|
-
if (res.status === 409 && error === "last_vault") {
|
|
306
|
-
// CRITICAL GUARDRAIL: print + exit non-zero. Do NOT fall through to spawning
|
|
307
|
-
// `parachute-vault` — that would re-open the orphaned-identity bug B3 closes.
|
|
308
|
-
logError(`parachute vault remove: ${error_description}`);
|
|
309
|
-
logError("");
|
|
310
|
-
logError(
|
|
311
|
-
`The raw mechanics-only path \`parachute-vault remove ${name} --yes\` can delete the last vault,`,
|
|
312
|
-
);
|
|
313
|
-
logError(
|
|
314
|
-
"but it SKIPS the identity cascade — live tokens, grants, and user_vaults rows for that",
|
|
315
|
-
);
|
|
316
|
-
logError("vault would be left orphaned. Create another vault first if you can.");
|
|
317
|
-
return 1;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
312
|
if (res.status === 400 && error === "confirm_mismatch") {
|
|
321
313
|
// Pass the hub's confirm message through.
|
|
322
314
|
logError(`parachute vault remove: ${error_description}`);
|
package/src/cors.ts
CHANGED
|
@@ -89,8 +89,9 @@
|
|
|
89
89
|
* leaking the wrong ACAO and breaking CORS in unpredictable ways.
|
|
90
90
|
* Critical for cache correctness.
|
|
91
91
|
*
|
|
92
|
-
* Access-Control-Allow-Methods: GET, POST, OPTIONS
|
|
93
|
-
* The union of methods the in-scope route family supports
|
|
92
|
+
* Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
|
|
93
|
+
* The union of methods the in-scope route family supports (DELETE for the
|
|
94
|
+
* RFC 7592 `DELETE /oauth/clients/<id>` deregistration, hub#640). Per-route
|
|
94
95
|
* could be narrower (e.g. /oauth/token is POST-only), but advertising
|
|
95
96
|
* the union is the simpler shape and browsers don't enforce a per-route
|
|
96
97
|
* check anyway — the *actual* request method gates execution at the
|
|
@@ -137,7 +138,10 @@ const CORS_STATIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = {
|
|
|
137
138
|
* `corsPreflightResponse`.
|
|
138
139
|
*/
|
|
139
140
|
const CORS_STATIC_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
|
|
140
|
-
|
|
141
|
+
// DELETE is in the union for RFC 7592 client deregistration
|
|
142
|
+
// (`DELETE /oauth/clients/<id>`, hub#640). A cross-origin browser caller
|
|
143
|
+
// (vs the server-side surface daemon) would otherwise fail the preflight.
|
|
144
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
141
145
|
"access-control-allow-headers": "Authorization, Content-Type, X-Requested-With",
|
|
142
146
|
"access-control-max-age": "86400",
|
|
143
147
|
};
|
package/src/help.ts
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
parachute install <service> install and register a service
|
|
18
18
|
services: ${services}
|
|
19
19
|
parachute status show installed services, run state, health
|
|
20
|
+
parachute doctor run health checks + tell you the one thing to fix
|
|
20
21
|
parachute start [service] start a module via the supervisor (or ensure the hub is up)
|
|
21
22
|
parachute stop [service] stop a module via the supervisor (or stop the hub unit)
|
|
22
23
|
parachute restart [service] restart a module via the supervisor (or restart the hub unit)
|
|
@@ -367,6 +368,58 @@ Example:
|
|
|
367
368
|
`;
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
export function doctorHelp(): string {
|
|
372
|
+
return `parachute doctor — health / diagnostics for your Parachute install
|
|
373
|
+
|
|
374
|
+
Usage:
|
|
375
|
+
parachute doctor [--json]
|
|
376
|
+
parachute doctor --fix [--yes]
|
|
377
|
+
|
|
378
|
+
What it does:
|
|
379
|
+
Runs a set of independent health checks and prints a grouped report
|
|
380
|
+
(✓ pass / ⚠ warn / ✗ fail), each with a one-line detail and — where there
|
|
381
|
+
is one — a copy-pasteable fix-it command. The single command that answers
|
|
382
|
+
"is my Parachute healthy, and if not, what's the one thing to fix?"
|
|
383
|
+
|
|
384
|
+
Checks (each PASSES on a fresh / fully-current install — doctor positively
|
|
385
|
+
detects a known-bad condition and never treats "not configured" as broken):
|
|
386
|
+
- Hub supervisor reachable on :1939 (/health).
|
|
387
|
+
- Each CONFIGURED module alive via its loopback /health (2xx or 401 = live).
|
|
388
|
+
- services.json parses + required fields valid (a missing file is the
|
|
389
|
+
fresh pre-install state, not a failure).
|
|
390
|
+
- Services on canonical ports — flags any KNOWN module whose port has
|
|
391
|
+
drifted off its canonical slot, or two services sharing one port
|
|
392
|
+
(legacy services.json written before the validation gate). A
|
|
393
|
+
third-party service with no canonical port is never flagged.
|
|
394
|
+
- operator.token exists, parses, and its issuer matches the hub (the
|
|
395
|
+
recurring "not signed in to the hub" / issuer-mismatch class).
|
|
396
|
+
- Each first-party module bin is executable (catches the lost-+x-bit
|
|
397
|
+
start-failure class).
|
|
398
|
+
- Migration: legacy detached install? known cruft at the ecosystem root?
|
|
399
|
+
(allowlist detectors only — a fresh root flags nothing).
|
|
400
|
+
- Exposure: if exposed, is the public origin reachable? If not exposed,
|
|
401
|
+
"loopback only" is reported as benign info, never a warning.
|
|
402
|
+
- Version freshness (cosmetic) — drift is WARN at most, never a failure.
|
|
403
|
+
|
|
404
|
+
Flags:
|
|
405
|
+
--json emit a single JSON object instead of the human report
|
|
406
|
+
--fix repair canonical-port drift in services.json — and ONLY that.
|
|
407
|
+
It is NOT a "fix everything" flag; every other check stays
|
|
408
|
+
report-only. Shows the old→new diff first, then confirms before
|
|
409
|
+
writing (a TTY prompts; --yes skips the prompt; a non-TTY without
|
|
410
|
+
--yes bails without writing). Idempotent: a clean file is a no-op.
|
|
411
|
+
Duplicate-port collisions are reported, not auto-resolved.
|
|
412
|
+
--yes skip the --fix confirmation prompt (required to apply in a
|
|
413
|
+
non-interactive shell)
|
|
414
|
+
|
|
415
|
+
Exit codes:
|
|
416
|
+
0 no failures (warnings are advisory and still exit 0); --fix: applied or
|
|
417
|
+
nothing-to-fix
|
|
418
|
+
1 one or more checks failed; or --fix bailed (non-TTY without --yes /
|
|
419
|
+
aborted at the prompt / unreadable services.json)
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
370
423
|
export function exposeHelp(): string {
|
|
371
424
|
return `parachute expose — route your services behind HTTPS on a network layer
|
|
372
425
|
|
package/src/hub-db.ts
CHANGED
|
@@ -558,6 +558,20 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
558
558
|
);
|
|
559
559
|
`,
|
|
560
560
|
},
|
|
561
|
+
{
|
|
562
|
+
version: 16,
|
|
563
|
+
sql: `
|
|
564
|
+
-- Index tokens by client_id for the OAuth client GC reaper (#640). The
|
|
565
|
+
-- reaper's gate runs a correlated NOT EXISTS (SELECT 1 FROM tokens WHERE
|
|
566
|
+
-- client_id = ? AND ...) per candidate client; tokens previously had no
|
|
567
|
+
-- client_id index (only user_id / refresh_token_hash / family_id /
|
|
568
|
+
-- revoked_at / subject), so each check was a full tokens-table walk. Under
|
|
569
|
+
-- the DCR reconnect churn this GC targets — thousands of dead token rows
|
|
570
|
+
-- accumulating before a sweep — that is O(total tokens) per client. This
|
|
571
|
+
-- makes it O(tokens for that client). IF NOT EXISTS so re-opens are inert.
|
|
572
|
+
CREATE INDEX IF NOT EXISTS tokens_client ON tokens (client_id);
|
|
573
|
+
`,
|
|
574
|
+
},
|
|
561
575
|
];
|
|
562
576
|
|
|
563
577
|
export function openHubDb(path: string = hubDbPath()): Database {
|