@openparachute/hub 0.6.5-rc.7 → 0.7.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/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/api-tokens.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* "expires_at": "ISO-8601",
|
|
24
24
|
* "revoked_at": "ISO-8601" | null,
|
|
25
25
|
* "created_at": "ISO-8601",
|
|
26
|
-
* "created_via": "oauth_refresh" | "cli_mint" | "operator_mint",
|
|
26
|
+
* "created_via": "oauth_refresh" | "cli_mint" | "operator_mint" | "connection_provision",
|
|
27
27
|
* "permissions": "<json-string>" | null
|
|
28
28
|
* }
|
|
29
29
|
* ],
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
* - `created_via=<value>` — narrow by mint provenance. One of
|
|
46
46
|
* `oauth_refresh` (OAuth refresh-token rotation), `operator_mint`
|
|
47
47
|
* (operator-token rotation via `parachute auth rotate-operator`),
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* `cli_mint` (CLI / `POST /api/auth/mint-token`), or
|
|
49
|
+
* `connection_provision` (long-lived tokens the Connections engine
|
|
50
|
+
* mints — see admin-connections.ts). Powers the admin UI's
|
|
51
|
+
* "by source" filter pills (hub#212 Phase F).
|
|
50
52
|
*
|
|
51
53
|
* Why bearer-gated rather than session-cookie-gated: matches the rest
|
|
52
54
|
* of `/api/auth/*` (mint-token, revoke-token), so an automation client
|
|
@@ -149,14 +151,15 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
|
|
|
149
151
|
if (
|
|
150
152
|
createdViaParam === "oauth_refresh" ||
|
|
151
153
|
createdViaParam === "operator_mint" ||
|
|
152
|
-
createdViaParam === "cli_mint"
|
|
154
|
+
createdViaParam === "cli_mint" ||
|
|
155
|
+
createdViaParam === "connection_provision"
|
|
153
156
|
) {
|
|
154
157
|
createdVia = createdViaParam;
|
|
155
158
|
} else if (createdViaParam !== null) {
|
|
156
159
|
return jsonError(
|
|
157
160
|
400,
|
|
158
161
|
"invalid_request",
|
|
159
|
-
"created_via must be one of: oauth_refresh | operator_mint | cli_mint",
|
|
162
|
+
"created_via must be one of: oauth_refresh | operator_mint | cli_mint | connection_provision",
|
|
160
163
|
);
|
|
161
164
|
}
|
|
162
165
|
const cursor = url.searchParams.get("cursor");
|
|
@@ -22,11 +22,12 @@ import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
|
22
22
|
import { ModuleManifestError } from "../module-manifest.ts";
|
|
23
23
|
import {
|
|
24
24
|
type ServiceSpec,
|
|
25
|
+
canonicalPortForManifest,
|
|
25
26
|
getSpec,
|
|
26
27
|
getSpecFromInstallDir,
|
|
27
28
|
shortNameForManifest,
|
|
28
29
|
} from "../service-spec.ts";
|
|
29
|
-
import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
|
|
30
|
+
import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
|
|
30
31
|
import { enrichedPath } from "../spawn-path.ts";
|
|
31
32
|
import type { Supervisor } from "../supervisor.ts";
|
|
32
33
|
|
|
@@ -69,6 +70,77 @@ export interface BuildSpawnRequestOpts {
|
|
|
69
70
|
readonly extraEnv?: Record<string, string>;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Snap a fixed-port first-party module's services.json `port` back to its
|
|
75
|
+
* compiled-in canonical value when it has DRIFTED, and persist the correction.
|
|
76
|
+
* Returns the entry to spawn with — reconciled when a drift was repaired, the
|
|
77
|
+
* original otherwise.
|
|
78
|
+
*
|
|
79
|
+
* Why (channel#41): the hub is the port authority, and a first-party module
|
|
80
|
+
* with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
|
|
81
|
+
* vault / scribe / surface / channel / notes / runner) has exactly ONE correct
|
|
82
|
+
* port: its canonical one. The injected `PORT`, the supervisor's readiness
|
|
83
|
+
* probe, and the reverse-proxy target are ALL derived from the services.json
|
|
84
|
+
* row's `port`. So once a transiently-wrong port lands in that row (the channel
|
|
85
|
+
* row was observed live carrying `19415` instead of `1941`), it self-perpetuates:
|
|
86
|
+
* the supervisor probes/proxies the wrong port forever and `/channel/*` routes
|
|
87
|
+
* to a dead port. The canonical port is authoritative, so we reconcile the row
|
|
88
|
+
* before spawn rather than honoring the drift.
|
|
89
|
+
*
|
|
90
|
+
* Safety for the other fixed-port modules (vault / scribe / surface): the
|
|
91
|
+
* canonical value is exactly what each ALREADY self-registers, so on the
|
|
92
|
+
* common path `entry.port === canonical` and this is a no-op. There is no
|
|
93
|
+
* legitimate "first-party module intentionally on a non-canonical port" case —
|
|
94
|
+
* install-time `assignPort` only walks off canonical on a genuine collision,
|
|
95
|
+
* and that's a same-range fallback for a DIFFERENT module, never a steady state
|
|
96
|
+
* for the canonical owner. If another row genuinely already owns the canonical
|
|
97
|
+
* port we DON'T rewrite (it would trip the write-side duplicate-port guard and
|
|
98
|
+
* isn't ours to take) — we leave the drift in place and let the supervisor's
|
|
99
|
+
* port-squatter detection surface it. Third-party modules (no canonical) are
|
|
100
|
+
* never touched.
|
|
101
|
+
*/
|
|
102
|
+
export function reconcilePortToCanonical(
|
|
103
|
+
entry: ServiceEntry,
|
|
104
|
+
manifestPath: string,
|
|
105
|
+
log: (line: string) => void = () => {},
|
|
106
|
+
): ServiceEntry {
|
|
107
|
+
const canonical = canonicalPortForManifest(entry.name);
|
|
108
|
+
if (canonical === undefined || entry.port === canonical) return entry;
|
|
109
|
+
|
|
110
|
+
// Don't steal a canonical port another row legitimately holds — that would
|
|
111
|
+
// trip `upsertService`'s duplicate-port guard. Re-read live so we see the
|
|
112
|
+
// current on-disk state (another module may have just registered).
|
|
113
|
+
const others = readManifestLenient(manifestPath).services.filter((s) => s.name !== entry.name);
|
|
114
|
+
if (others.some((s) => s.port === canonical)) {
|
|
115
|
+
log(
|
|
116
|
+
`[supervisor] ${entry.name}: services.json port ${entry.port} ≠ canonical ${canonical}, ` +
|
|
117
|
+
`but ${canonical} is held by another row — not reconciling; surfacing the drift.`,
|
|
118
|
+
);
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const reconciled: ServiceEntry = { ...entry, port: canonical };
|
|
123
|
+
try {
|
|
124
|
+
upsertService(reconciled, manifestPath);
|
|
125
|
+
log(
|
|
126
|
+
`[supervisor] ${entry.name}: reconciled drifted services.json port ${entry.port} → canonical ${canonical} (channel#41).`,
|
|
127
|
+
);
|
|
128
|
+
return reconciled;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Best-effort: a failed reconcile must not block the boot. The write can
|
|
131
|
+
// fail for an I/O reason (permissions, races against an external writer) OR
|
|
132
|
+
// because `upsertService`'s duplicate-port guard tripped — e.g. another row
|
|
133
|
+
// raced onto the canonical port between the check above and this write.
|
|
134
|
+
// Either way, spawn with the canonical port in-memory so at least the child
|
|
135
|
+
// binds + the probe checks the right port this run; the proxy still reads
|
|
136
|
+
// the (stale) row until the next reconcile succeeds.
|
|
137
|
+
log(
|
|
138
|
+
`[supervisor] ${entry.name}: could not reconcile services.json port to canonical (${entry.port} → ${canonical}; write failed or canonical held): ${err} — spawning with canonical in-memory.`,
|
|
139
|
+
);
|
|
140
|
+
return reconciled;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
72
144
|
/**
|
|
73
145
|
* Build the `Supervisor.start` request for a single module, identically on
|
|
74
146
|
* both the serve-boot path and the `POST /api/modules/:short/start` handler.
|
|
@@ -166,7 +238,12 @@ export async function bootSupervisedModules(
|
|
|
166
238
|
continue;
|
|
167
239
|
}
|
|
168
240
|
|
|
169
|
-
|
|
241
|
+
// Snap a drifted fixed-port row back to canonical before we derive PORT /
|
|
242
|
+
// probe / proxy from it (channel#41). No-op on the common path where the
|
|
243
|
+
// module already sits on its canonical port.
|
|
244
|
+
const spawnEntry = reconcilePortToCanonical(entry, opts.manifestPath, log);
|
|
245
|
+
|
|
246
|
+
const cmd = spec.startCmd?.(spawnEntry);
|
|
170
247
|
if (!cmd || cmd.length === 0) {
|
|
171
248
|
log(`[supervisor] ${short}: spec resolved but no startCmd — skipping (CLI-only module).`);
|
|
172
249
|
results.push({
|
|
@@ -182,7 +259,7 @@ export async function bootSupervisedModules(
|
|
|
182
259
|
// .env merge, and PARACHUTE_HUB_ORIGIN propagation (hub#365) all live in the
|
|
183
260
|
// shared `buildModuleSpawnRequest` so the `POST /api/modules/:short/start`
|
|
184
261
|
// handler builds an identical request (design 2026-06-01 §3.3).
|
|
185
|
-
const req = buildModuleSpawnRequest(short,
|
|
262
|
+
const req = buildModuleSpawnRequest(short, spawnEntry, cmd, {
|
|
186
263
|
configDir: opts.configDir,
|
|
187
264
|
...(opts.hubOrigin !== undefined ? { hubOrigin: opts.hubOrigin } : {}),
|
|
188
265
|
});
|
package/src/commands/setup.ts
CHANGED
|
@@ -74,7 +74,7 @@ interface ServiceChoice {
|
|
|
74
74
|
manifestName: string;
|
|
75
75
|
/** Per-service URL composer used in the final-summary banner. Optional. */
|
|
76
76
|
urlForEntry?: (entry: ServiceEntry) => string | undefined;
|
|
77
|
-
/** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes
|
|
77
|
+
/** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes). */
|
|
78
78
|
spec?: ServiceSpec;
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -114,9 +114,9 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
114
114
|
* the service has a row in services.json.
|
|
115
115
|
*
|
|
116
116
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
117
|
-
* shorts (notes
|
|
118
|
-
*
|
|
119
|
-
* self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
117
|
+
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
118
|
+
* (vault / scribe / runner / channel / surface) ship `.parachute/module.json`
|
|
119
|
+
* and self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
120
120
|
* from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
|
|
121
121
|
*/
|
|
122
122
|
function surveyServices(manifestPath: string): ServiceChoice[] {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connections.json` — the persisted registry of operator-created Connections
|
|
3
|
+
* (2026-06-09 modular-UI architecture, P5).
|
|
4
|
+
*
|
|
5
|
+
* A **connection** wires "when [EVENT] in [source module] (filter) → do [ACTION]
|
|
6
|
+
* in [sink module]". The hub is the only thing with cross-module authority
|
|
7
|
+
* (mint tokens, register vault triggers), so connections are hub-native + the
|
|
8
|
+
* record of what got provisioned lives here, in the hub state dir.
|
|
9
|
+
*
|
|
10
|
+
* One file, a flat array of records. Each record carries enough to (a) render
|
|
11
|
+
* the Connections list and (b) tear down what was provisioned — notably the
|
|
12
|
+
* `provisioned.triggerName` + `provisioned.vault` so DELETE can remove the exact
|
|
13
|
+
* vault trigger that was registered. We keep the store deliberately small +
|
|
14
|
+
* synchronous (Bun file I/O); the cardinality is "a handful of connections per
|
|
15
|
+
* hub", not a hot path.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** The source side — an event a module emits, with an operator-set filter. */
|
|
21
|
+
export interface ConnectionSource {
|
|
22
|
+
/** Source module short name, e.g. `vault`. */
|
|
23
|
+
readonly module: string;
|
|
24
|
+
/** For vault events, which vault instance the event is scoped to. */
|
|
25
|
+
readonly vault?: string;
|
|
26
|
+
/** Event key declared in the source module's `module.json`, e.g. `note.created`. */
|
|
27
|
+
readonly event: string;
|
|
28
|
+
/**
|
|
29
|
+
* Operator-set filter, shaped by the event's `filterSchema`. For a vault
|
|
30
|
+
* event this maps to the trigger predicate (`tags` / `has_metadata` /
|
|
31
|
+
* `missing_metadata` / `has_content`).
|
|
32
|
+
*/
|
|
33
|
+
readonly filter?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The sink side — an action a module accepts (the sink is ALWAYS an action). */
|
|
37
|
+
export interface ConnectionSink {
|
|
38
|
+
/** Sink module short name, e.g. `channel`. */
|
|
39
|
+
readonly module: string;
|
|
40
|
+
/** Action key declared in the sink module's `module.json`, e.g. `message.deliver`. */
|
|
41
|
+
readonly action: string;
|
|
42
|
+
/** Action params, shaped by the action's `inputSchema` (e.g. `{ channel }`). */
|
|
43
|
+
readonly params?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What the provisioning engine actually wired, for teardown + display. */
|
|
47
|
+
export interface ConnectionProvisioned {
|
|
48
|
+
/** How the action was provisioned, e.g. `vault-trigger`. */
|
|
49
|
+
readonly type: string;
|
|
50
|
+
/** The vault instance the trigger was registered on (vault-trigger). */
|
|
51
|
+
readonly vault?: string;
|
|
52
|
+
/** The exact vault trigger name registered — DELETE removes this. */
|
|
53
|
+
readonly triggerName?: string;
|
|
54
|
+
/**
|
|
55
|
+
* jtis of the LONG-LIVED tokens minted for this connection (the webhook
|
|
56
|
+
* bearer, and for a channel sink the vault-write reply token). Each is
|
|
57
|
+
* registered in the hub's tokens table (`created_via='connection_provision'`)
|
|
58
|
+
* at mint time so teardown can revoke them — an unregistered long-lived
|
|
59
|
+
* token is unrevocable by construction (hub-module-boundary charter,
|
|
60
|
+
* registered-mint rule). Records written before this field existed read back
|
|
61
|
+
* as `undefined`; their tokens were never registered and ride to expiry
|
|
62
|
+
* (surfaced as `legacy: true` in the list wire shape).
|
|
63
|
+
*/
|
|
64
|
+
readonly mintedJtis?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConnectionRecord {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly source: ConnectionSource;
|
|
70
|
+
readonly sink: ConnectionSink;
|
|
71
|
+
readonly provisioned: ConnectionProvisioned;
|
|
72
|
+
readonly createdAt: string;
|
|
73
|
+
/**
|
|
74
|
+
* Provenance — WHO requested this connection (modular-UI R2, module-initiated
|
|
75
|
+
* connections). A module-owned config UI that creates a connection on the
|
|
76
|
+
* operator's behalf (e.g. the channel admin page's "link to a vault" flow)
|
|
77
|
+
* labels itself here (e.g. `"channel"`); a connection built by hand in the
|
|
78
|
+
* hub's own Connections builder is `"custom"`. Lets the operator see which
|
|
79
|
+
* connections a module initiated vs which they wired themselves. Optional for
|
|
80
|
+
* back-compat: records written before R2 read back as `undefined`, which the
|
|
81
|
+
* SPA treats as `"custom"`.
|
|
82
|
+
*/
|
|
83
|
+
readonly requestedBy?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ConnectionsFile {
|
|
87
|
+
connections: ConnectionRecord[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emptyFile(): ConnectionsFile {
|
|
91
|
+
return { connections: [] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read the store. A missing/garbage file reads as empty (fresh hub). */
|
|
95
|
+
export function readConnections(storePath: string): ConnectionRecord[] {
|
|
96
|
+
let buf: string;
|
|
97
|
+
try {
|
|
98
|
+
buf = readFileSync(storePath, "utf8");
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
let parsed: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(buf);
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
109
|
+
const arr = (parsed as { connections?: unknown }).connections;
|
|
110
|
+
if (!Array.isArray(arr)) return [];
|
|
111
|
+
// Lenient: drop any malformed row rather than failing the whole read, so one
|
|
112
|
+
// bad hand-edit doesn't take down the Connections view (mirrors the
|
|
113
|
+
// services.json lenient-read posture).
|
|
114
|
+
return arr.filter((r): r is ConnectionRecord => {
|
|
115
|
+
if (!r || typeof r !== "object") return false;
|
|
116
|
+
const rec = r as Record<string, unknown>;
|
|
117
|
+
return (
|
|
118
|
+
typeof rec.id === "string" &&
|
|
119
|
+
!!rec.source &&
|
|
120
|
+
typeof rec.source === "object" &&
|
|
121
|
+
!!rec.sink &&
|
|
122
|
+
typeof rec.sink === "object"
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeAll(storePath: string, records: ConnectionRecord[]): void {
|
|
128
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
129
|
+
const file: ConnectionsFile = { connections: records };
|
|
130
|
+
// Written WITHOUT 0o600 because this file holds NO secrets — the provisioned
|
|
131
|
+
// webhook bearer lives only in the vault trigger's row, never here; records
|
|
132
|
+
// carry source/sink/trigger-name metadata only. Consistent with the default
|
|
133
|
+
// perms on services.json / expose-state.json.
|
|
134
|
+
writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Upsert by id (replace an existing record with the same id, else append). */
|
|
138
|
+
export function putConnection(storePath: string, record: ConnectionRecord): void {
|
|
139
|
+
const records = readConnections(storePath);
|
|
140
|
+
const idx = records.findIndex((r) => r.id === record.id);
|
|
141
|
+
if (idx >= 0) records[idx] = record;
|
|
142
|
+
else records.push(record);
|
|
143
|
+
writeAll(storePath, records);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Remove a connection by id. Returns the removed record, or null if absent. */
|
|
147
|
+
export function removeConnection(storePath: string, id: string): ConnectionRecord | null {
|
|
148
|
+
const records = readConnections(storePath);
|
|
149
|
+
const idx = records.findIndex((r) => r.id === id);
|
|
150
|
+
if (idx < 0) return null;
|
|
151
|
+
const [removed] = records.splice(idx, 1);
|
|
152
|
+
writeAll(storePath, records);
|
|
153
|
+
return removed ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getConnection(storePath: string, id: string): ConnectionRecord | null {
|
|
157
|
+
return readConnections(storePath).find((r) => r.id === id) ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Re-export for the unused-import linter when only the type is consumed. */
|
|
161
|
+
export const _emptyConnectionsFile = emptyFile;
|
package/src/grants.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { Database } from "bun:sqlite";
|
|
29
|
+
import { vaultScopeName } from "./scope-explanations.ts";
|
|
29
30
|
|
|
30
31
|
export interface Grant {
|
|
31
32
|
userId: string;
|
|
@@ -324,3 +325,52 @@ export function revokeGrant(db: Database, userId: string, clientId: string): boo
|
|
|
324
325
|
.run(userId, clientId);
|
|
325
326
|
return res.changes > 0;
|
|
326
327
|
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): REWRITE
|
|
331
|
+
* every grant whose scope list names the deleted vault, removing the
|
|
332
|
+
* `vault:<name>:<verb>` entries; DROP the row only when the rewrite leaves
|
|
333
|
+
* it empty.
|
|
334
|
+
*
|
|
335
|
+
* Rewrite-not-drop matters: a grant row is keyed (user, client) and its
|
|
336
|
+
* scope set spans every vault the user ever approved for that client —
|
|
337
|
+
* dropping the whole row over a single vault would silently revoke the
|
|
338
|
+
* client's consent on the user's OTHER vaults (over-revocation).
|
|
339
|
+
*
|
|
340
|
+
* Matching is the exact `vaultScopeName` segment comparison (regex on the
|
|
341
|
+
* `vault:<name>:<read|write|admin>` shape), never SQL LIKE — `_` in a vault
|
|
342
|
+
* name is a LIKE wildcard. Unnamed scopes (`vault:read`) and non-vault
|
|
343
|
+
* scopes are preserved.
|
|
344
|
+
*/
|
|
345
|
+
export function rewriteGrantsRemovingVault(
|
|
346
|
+
db: Database,
|
|
347
|
+
vaultName: string,
|
|
348
|
+
): { rewritten: number; dropped: number } {
|
|
349
|
+
return db.transaction(() => {
|
|
350
|
+
const rows = db
|
|
351
|
+
.prepare("SELECT user_id, client_id, scopes, granted_at FROM grants")
|
|
352
|
+
.all() as GrantRow[];
|
|
353
|
+
let rewritten = 0;
|
|
354
|
+
let dropped = 0;
|
|
355
|
+
for (const row of rows) {
|
|
356
|
+
const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
|
|
357
|
+
const kept = scopes.filter((s) => vaultScopeName(s) !== vaultName);
|
|
358
|
+
if (kept.length === scopes.length) continue; // doesn't name the vault
|
|
359
|
+
if (kept.length === 0) {
|
|
360
|
+
db.prepare("DELETE FROM grants WHERE user_id = ? AND client_id = ?").run(
|
|
361
|
+
row.user_id,
|
|
362
|
+
row.client_id,
|
|
363
|
+
);
|
|
364
|
+
dropped++;
|
|
365
|
+
} else {
|
|
366
|
+
db.prepare("UPDATE grants SET scopes = ? WHERE user_id = ? AND client_id = ?").run(
|
|
367
|
+
kept.join(" "),
|
|
368
|
+
row.user_id,
|
|
369
|
+
row.client_id,
|
|
370
|
+
);
|
|
371
|
+
rewritten++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { rewritten, dropped };
|
|
375
|
+
})();
|
|
376
|
+
}
|
package/src/hub-db-liveness.ts
CHANGED
|
@@ -383,25 +383,41 @@ export function createDbHolder(initial: Database, deps: DbHolderDeps): DbHolder
|
|
|
383
383
|
const verdict = classifyPathLiveness({ expected: currentInode, current: pathInode });
|
|
384
384
|
if (verdict === "ok" || verdict === "unknown") return verdict;
|
|
385
385
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
386
|
+
if (verdict === "gone") {
|
|
387
|
+
// The whole state dir was wiped under the running hub (`rm -rf
|
|
388
|
+
// ~/.parachute`). We must NOT reopen-in-place here: `reopen` is
|
|
389
|
+
// `openHubDb`, which `mkdirSync`'s the dir back + opens a fresh EMPTY db,
|
|
390
|
+
// so its SELECT-1 verify would PASS and we'd "heal" into a half-recovered
|
|
391
|
+
// hub — empty db, but stale in-memory state, wiped well-known files, and
|
|
392
|
+
// supervised modules whose own state dirs are gone yet never re-spawned
|
|
393
|
+
// (#619 follow-up). The correct recovery for a full wipe is a clean
|
|
394
|
+
// process exit so the platform manager (systemd / launchd / container)
|
|
395
|
+
// restarts `parachute serve`, which re-bootstraps everything (well-known,
|
|
396
|
+
// admin seed, supervisor re-spawn). This restores the #610 design intent
|
|
397
|
+
// ("we exit, letting the platform manager restart") that the shared
|
|
398
|
+
// reopen-or-exit path silently defeated via openHubDb's mkdir-recursive.
|
|
399
|
+
log(
|
|
400
|
+
`parachute hub: db path ${deps.dbPath} no longer exists (state dir wiped under a running hub, #610); exiting so the platform manager restarts the hub with a freshly bootstrapped state dir.`,
|
|
401
|
+
);
|
|
402
|
+
exit(1);
|
|
403
|
+
return verdict;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// "replaced": the db FILE was swapped underneath us (e.g. a restore copied
|
|
407
|
+
// a new file over the same path) while the rest of the state dir is intact.
|
|
408
|
+
// Adopting the fresh inode in-place via reopen-or-exit is correct here — a
|
|
409
|
+
// process restart would be heavier than needed.
|
|
391
410
|
//
|
|
392
|
-
// ONE-TICK /health ANOMALY (intentional):
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// string can't cascade.
|
|
411
|
+
// ONE-TICK /health ANOMALY (intentional): the reopenOrExit below heals
|
|
412
|
+
// SYNCHRONOUSLY, but we still RETURN "replaced" for this one call — so the
|
|
413
|
+
// /health request that drove this probe reports `db:"error: path-replaced"`
|
|
414
|
+
// even though the handle is now healthy; the very next request reads `ok`.
|
|
415
|
+
// We don't mask it (returning "ok" here would hide that a heal just
|
|
416
|
+
// happened, which is exactly what monitoring wants to see). It's safe
|
|
417
|
+
// because #591's adoption probe checks only HTTP 200 (`res.ok`), not the
|
|
418
|
+
// specific `db` string, so a single transient error string can't cascade.
|
|
401
419
|
reopenOrExit(
|
|
402
|
-
|
|
403
|
-
? `db path ${deps.dbPath} no longer exists (state dir wiped under a running hub, #610)`
|
|
404
|
-
: `db path ${deps.dbPath} now resolves to a different inode (DB file replaced underneath the open handle, #610)`,
|
|
420
|
+
`db path ${deps.dbPath} now resolves to a different inode (DB file replaced underneath the open handle, #610)`,
|
|
405
421
|
);
|
|
406
422
|
return verdict;
|
|
407
423
|
},
|