@openparachute/hub 0.7.0 → 0.7.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/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { rethrowIfMissing } from "@openparachute/depcheck";
|
|
4
4
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
@@ -609,6 +609,74 @@ export interface LogsOpts {
|
|
|
609
609
|
* Defaults to the group-aware `defaultAlive` (hub#88).
|
|
610
610
|
*/
|
|
611
611
|
alive?: AliveFn;
|
|
612
|
+
/**
|
|
613
|
+
* Filtered-follow source seam (hub#652) — the byte stream of lines appended
|
|
614
|
+
* to the hub log from attach onward. The production default spawns
|
|
615
|
+
* `tail -n 0 -f <hub.log>` with piped stdout so the `[<svc>] ` filter runs
|
|
616
|
+
* in-process; tests inject a deterministic stream.
|
|
617
|
+
*/
|
|
618
|
+
followStream?: (path: string) => ReadableStream<Uint8Array>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Default `followStream`: tail the file from its end, stdout piped to us. */
|
|
622
|
+
function defaultFollowStream(path: string): ReadableStream<Uint8Array> {
|
|
623
|
+
try {
|
|
624
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
625
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
626
|
+
const proc = Bun.spawn(["tail", "-n", "0", "-f", path], {
|
|
627
|
+
stdin: "ignore",
|
|
628
|
+
stdout: "pipe",
|
|
629
|
+
stderr: "inherit",
|
|
630
|
+
env: process.env,
|
|
631
|
+
});
|
|
632
|
+
return proc.stdout;
|
|
633
|
+
} catch (err) {
|
|
634
|
+
// A missing `tail` (minimal container without coreutils) surfaces the
|
|
635
|
+
// friendly install UX instead of a raw spawn throw (cli.ts top-level
|
|
636
|
+
// catch renders the MissingDependencyError).
|
|
637
|
+
rethrowIfMissing(err, "tail");
|
|
638
|
+
throw err;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Pump a byte stream line-by-line, emitting only lines that carry `prefix`
|
|
644
|
+
* (stripped). Line-buffered the same way the supervisor's `pumpLines` is, so
|
|
645
|
+
* chunk boundaries inside a line don't drop or split matches.
|
|
646
|
+
*/
|
|
647
|
+
async function pumpFilteredLines(
|
|
648
|
+
stream: ReadableStream<Uint8Array>,
|
|
649
|
+
prefix: string,
|
|
650
|
+
output: (line: string) => void,
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
const reader = stream.getReader();
|
|
653
|
+
const decoder = new TextDecoder();
|
|
654
|
+
let buf = "";
|
|
655
|
+
const emit = (line: string): void => {
|
|
656
|
+
if (line.startsWith(prefix)) output(line.slice(prefix.length));
|
|
657
|
+
};
|
|
658
|
+
try {
|
|
659
|
+
while (true) {
|
|
660
|
+
const { done, value } = await reader.read();
|
|
661
|
+
if (done) break;
|
|
662
|
+
buf += decoder.decode(value, { stream: true });
|
|
663
|
+
let nl = buf.indexOf("\n");
|
|
664
|
+
while (nl !== -1) {
|
|
665
|
+
emit(buf.slice(0, nl));
|
|
666
|
+
buf = buf.slice(nl + 1);
|
|
667
|
+
nl = buf.indexOf("\n");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (buf.length > 0) emit(buf);
|
|
671
|
+
} finally {
|
|
672
|
+
reader.releaseLock();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Split a log file's content into lines, dropping the trailing newline. */
|
|
677
|
+
function splitLogLines(content: string): string[] {
|
|
678
|
+
const trimmed = content.replace(/\n$/, "");
|
|
679
|
+
return trimmed === "" ? [] : trimmed.split("\n");
|
|
612
680
|
}
|
|
613
681
|
|
|
614
682
|
export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
@@ -620,11 +688,12 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
620
688
|
const alive = opts.alive ?? defaultAlive;
|
|
621
689
|
|
|
622
690
|
// logs only needs a valid short name to find the log file. First-party
|
|
623
|
-
// wins via the spec lookup; third-party rows match by `entry.name
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// the
|
|
627
|
-
//
|
|
691
|
+
// wins via the spec lookup; third-party rows match by `entry.name` (the
|
|
692
|
+
// same token the supervisor uses as its log prefix); the internal hub is
|
|
693
|
+
// a known short outside of services.json. installDir is irrelevant here —
|
|
694
|
+
// the log file is keyed by short name and exists once the service has
|
|
695
|
+
// run, regardless of how it was registered. We just need to confirm the
|
|
696
|
+
// name maps to something the CLI manages.
|
|
628
697
|
const isFirstParty = getSpec(svc) !== undefined;
|
|
629
698
|
if (!isFirstParty && svc !== HUB_SVC) {
|
|
630
699
|
const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
|
|
@@ -634,19 +703,111 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
634
703
|
}
|
|
635
704
|
}
|
|
636
705
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
706
|
+
// Per-file plain reader (the pre-#652 behavior): tail/print one log file.
|
|
707
|
+
const readPlain = async (path: string): Promise<number> => {
|
|
708
|
+
if (follow) {
|
|
709
|
+
const spawner = opts.tailSpawner ?? {
|
|
710
|
+
spawn(cmd) {
|
|
711
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
712
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
713
|
+
try {
|
|
714
|
+
const proc = Bun.spawn([...cmd], {
|
|
715
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
716
|
+
env: process.env,
|
|
717
|
+
});
|
|
718
|
+
return proc.pid;
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// A missing `tail` (minimal container without coreutils) surfaces
|
|
721
|
+
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
722
|
+
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
723
|
+
rethrowIfMissing(err, "tail");
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
729
|
+
// tail runs until user Ctrl-C; block this process until it exits.
|
|
730
|
+
// When called from the real CLI, process.exit wraps us; in tests a
|
|
731
|
+
// stub spawner returns immediately and we fall through.
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
735
|
+
const tail = splitLogLines(await Bun.file(path).text()).slice(-lines);
|
|
736
|
+
for (const line of tail) log(line);
|
|
737
|
+
return 0;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const legacyPath = logPathFor(svc, configDir);
|
|
741
|
+
const hubLogPath = logPathFor(HUB_SVC, configDir);
|
|
742
|
+
const legacyExists = svc !== HUB_SVC && existsSync(legacyPath);
|
|
743
|
+
const hubLogExists = existsSync(hubLogPath);
|
|
744
|
+
|
|
745
|
+
// Source selection (hub#652): under hub-as-supervisor (Phase 5b) a module's
|
|
746
|
+
// stdout/stderr is multiplexed into the HUB log with a `[<svc>] ` line
|
|
747
|
+
// prefix (supervisor.ts pipeOutput) — the per-service file stops advancing
|
|
748
|
+
// at the cutover. Prefer whichever file is fresher: a pre-supervised
|
|
749
|
+
// install is still actively writing the per-service file (it wins); on a
|
|
750
|
+
// supervised box the hub log is the live stream and the per-service file
|
|
751
|
+
// is a stale remnant (it loses). `logs hub` always reads the hub log
|
|
752
|
+
// unfiltered — the interleaved prefixed stream IS the hub's own log.
|
|
753
|
+
const useHubStream =
|
|
754
|
+
svc !== HUB_SVC &&
|
|
755
|
+
hubLogExists &&
|
|
756
|
+
(!legacyExists || statSync(hubLogPath).mtimeMs >= statSync(legacyPath).mtimeMs);
|
|
757
|
+
|
|
758
|
+
if (!useHubStream) {
|
|
759
|
+
if (!existsSync(legacyPath)) {
|
|
760
|
+
// Distinguish "daemon never started" from "daemon is running but the
|
|
761
|
+
// log file is missing" (hub#335). The latter shape surfaces when a
|
|
762
|
+
// module self-registers + spawns its own logger without going through
|
|
763
|
+
// the hub (no hub-managed log file), or when an operator deletes the
|
|
764
|
+
// log mid-run. Previously both shapes printed the same
|
|
765
|
+
// `parachute start ${svc}` hint, leading operators to think their
|
|
766
|
+
// running daemon hadn't started.
|
|
767
|
+
const state = processState(svc, configDir, alive);
|
|
768
|
+
if (state.status === "running") {
|
|
769
|
+
const whereabouts =
|
|
770
|
+
svc === HUB_SVC
|
|
771
|
+
? `no log file at ${legacyPath}`
|
|
772
|
+
: `no log file at ${legacyPath} and no hub log at ${hubLogPath}`;
|
|
773
|
+
log(
|
|
774
|
+
`${svc} is running (pid ${state.pid}) but ${whereabouts}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
775
|
+
);
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
log(`no logs yet for ${svc}. \`parachute start ${svc}\` to begin.`);
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
return readPlain(legacyPath);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Supervised path (hub#652): the service's lines in the hub log, prefix
|
|
785
|
+
// stripped. Stripped rather than kept: every line would otherwise repeat
|
|
786
|
+
// the name the operator just typed, while the module's own output shape
|
|
787
|
+
// (e.g. surface's `[app-dcr]` sub-prefixes) stays intact — the same shape
|
|
788
|
+
// its per-service file had pre-cutover.
|
|
789
|
+
const prefix = `[${svc}] `;
|
|
790
|
+
const matched = splitLogLines(await Bun.file(hubLogPath).text())
|
|
791
|
+
.filter((l) => l.startsWith(prefix))
|
|
792
|
+
.map((l) => l.slice(prefix.length));
|
|
793
|
+
|
|
794
|
+
if (matched.length === 0 && !follow) {
|
|
795
|
+
if (legacyExists) {
|
|
796
|
+
// Transitional shape: nothing for this service in the hub log, but a
|
|
797
|
+
// (staler) per-service file exists — e.g. the module last ran detached,
|
|
798
|
+
// pre-cutover. Show it, with a note so a stale file isn't mistaken for
|
|
799
|
+
// the live stream (the exact hub#652 trap).
|
|
800
|
+
log(
|
|
801
|
+
`note: no ${svc} lines in the hub log (${hubLogPath}); showing the per-service log at ${legacyPath}.`,
|
|
802
|
+
);
|
|
803
|
+
return readPlain(legacyPath);
|
|
804
|
+
}
|
|
805
|
+
// Keep the hub#335 shapes coherent with the hub-stream source: a live
|
|
806
|
+
// pidfile here means a daemon the hub isn't logging for.
|
|
646
807
|
const state = processState(svc, configDir, alive);
|
|
647
808
|
if (state.status === "running") {
|
|
648
809
|
log(
|
|
649
|
-
`${svc} is running (pid ${state.pid}) but no log file at ${
|
|
810
|
+
`${svc} is running (pid ${state.pid}) but has no lines in the hub log (${hubLogPath}) and no log file at ${legacyPath}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
650
811
|
);
|
|
651
812
|
return 0;
|
|
652
813
|
}
|
|
@@ -654,38 +815,17 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
654
815
|
return 0;
|
|
655
816
|
}
|
|
656
817
|
|
|
818
|
+
for (const line of matched.slice(-lines)) log(line);
|
|
819
|
+
|
|
657
820
|
if (follow) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
});
|
|
667
|
-
return proc.pid;
|
|
668
|
-
} catch (err) {
|
|
669
|
-
// A missing `tail` (minimal container without coreutils) surfaces
|
|
670
|
-
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
671
|
-
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
672
|
-
rethrowIfMissing(err, "tail");
|
|
673
|
-
throw err;
|
|
674
|
-
}
|
|
675
|
-
},
|
|
676
|
-
};
|
|
677
|
-
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
678
|
-
// tail runs until user Ctrl-C; block this process until it exits.
|
|
679
|
-
// When called from the real CLI, process.exit wraps us; in tests a
|
|
680
|
-
// stub spawner returns immediately and we fall through.
|
|
681
|
-
return 0;
|
|
821
|
+
// Print the filtered backlog above, then follow new hub-log lines through
|
|
822
|
+
// the same filter. Runs until the tail is killed (Ctrl-C takes down the
|
|
823
|
+
// whole foreground process group); in tests the injected stream closes.
|
|
824
|
+
if (matched.length === 0) {
|
|
825
|
+
log(`(no prior ${svc} lines — waiting for new output…)`);
|
|
826
|
+
}
|
|
827
|
+
const source = opts.followStream ?? defaultFollowStream;
|
|
828
|
+
await pumpFilteredLines(source(hubLogPath), prefix, log);
|
|
682
829
|
}
|
|
683
|
-
|
|
684
|
-
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
685
|
-
const content = await Bun.file(path).text();
|
|
686
|
-
const trimmed = content.replace(/\n$/, "");
|
|
687
|
-
const allLines = trimmed === "" ? [] : trimmed.split("\n");
|
|
688
|
-
const tail = allLines.slice(-lines);
|
|
689
|
-
for (const line of tail) log(line);
|
|
690
830
|
return 0;
|
|
691
831
|
}
|
package/src/connections-store.ts
CHANGED
|
@@ -45,12 +45,26 @@ export interface ConnectionSink {
|
|
|
45
45
|
|
|
46
46
|
/** What the provisioning engine actually wired, for teardown + display. */
|
|
47
47
|
export interface ConnectionProvisioned {
|
|
48
|
-
/** How the action was provisioned, e.g. `vault-trigger`. */
|
|
48
|
+
/** How the action was provisioned, e.g. `vault-trigger` or `credential`. */
|
|
49
49
|
readonly type: string;
|
|
50
|
-
/** The vault instance the trigger was registered on (vault-trigger)
|
|
50
|
+
/** The vault instance the trigger was registered on (vault-trigger), or
|
|
51
|
+
* the vault a credential connection grants access to (credential). Either
|
|
52
|
+
* way it's the field the vault-delete cascade matches on. */
|
|
51
53
|
readonly vault?: string;
|
|
52
54
|
/** The exact vault trigger name registered — DELETE removes this. */
|
|
53
55
|
readonly triggerName?: string;
|
|
56
|
+
/** Credential connections (H4): the exact scope minted, e.g.
|
|
57
|
+
* `vault:default:read` — renewal re-mints THIS, never request input. */
|
|
58
|
+
readonly scope?: string;
|
|
59
|
+
/** Credential connections (H4): the tag allowlist baked into the minted
|
|
60
|
+
* token's `permissions.scoped_tags`. Empty/absent = vault-wide (read
|
|
61
|
+
* scopes only — writes always carry tags). */
|
|
62
|
+
readonly scopedTags?: readonly string[];
|
|
63
|
+
/** Credential connections (H4): the declared credential key. */
|
|
64
|
+
readonly credentialKey?: string;
|
|
65
|
+
/** Credential connections (H4): the module's daemon-root-relative delivery
|
|
66
|
+
* endpoint — also the best-effort removal-notification target. */
|
|
67
|
+
readonly endpoint?: string;
|
|
54
68
|
/**
|
|
55
69
|
* jtis of the LONG-LIVED tokens minted for this connection (the webhook
|
|
56
70
|
* bearer, and for a channel sink the vault-write reply token). Each is
|
|
@@ -66,6 +80,22 @@ export interface ConnectionProvisioned {
|
|
|
66
80
|
|
|
67
81
|
export interface ConnectionRecord {
|
|
68
82
|
readonly id: string;
|
|
83
|
+
/**
|
|
84
|
+
* Connection kind discriminator (H4). Absent = the original event→action
|
|
85
|
+
* shape; `"credential"` = a standing tag-scoped vault credential held by a
|
|
86
|
+
* module (the source is the granting vault, the sink is the holding
|
|
87
|
+
* module). Optional for back-compat: pre-H4 records read back undefined.
|
|
88
|
+
*/
|
|
89
|
+
readonly kind?: "credential";
|
|
90
|
+
/**
|
|
91
|
+
* Approval state (surface#113 claim/reconcile). Absent = active (every
|
|
92
|
+
* operator-provisioned record, and pre-claim records, read back undefined
|
|
93
|
+
* = active). `"pending"` = a module-initiated CLAIM for a directly-delivered
|
|
94
|
+
* credential, awaiting operator approval in the hub admin Connections view.
|
|
95
|
+
* A pending record grants nothing: renewal refuses it, and only the
|
|
96
|
+
* operator-gated approve endpoint flips it to active.
|
|
97
|
+
*/
|
|
98
|
+
readonly status?: "pending";
|
|
69
99
|
readonly source: ConnectionSource;
|
|
70
100
|
readonly sink: ConnectionSink;
|
|
71
101
|
readonly provisioned: ConnectionProvisioned;
|
package/src/help.ts
CHANGED
|
@@ -583,12 +583,19 @@ export function logsHelp(): string {
|
|
|
583
583
|
Usage:
|
|
584
584
|
parachute logs <service> print the last 200 lines
|
|
585
585
|
parachute logs <service> -f tail the log (like \`tail -f\`)
|
|
586
|
-
parachute logs hub
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
586
|
+
parachute logs hub the full hub log (every module interleaved)
|
|
587
|
+
|
|
588
|
+
Where logs live:
|
|
589
|
+
Supervised modules (the normal shape — hub-as-supervisor) write through
|
|
590
|
+
the hub: the supervisor multiplexes each child's output into
|
|
591
|
+
~/.parachute/hub/logs/hub.log with a \`[<service>]\` line prefix.
|
|
592
|
+
\`parachute logs <service>\` reads that stream filtered to the service's
|
|
593
|
+
lines, prefix stripped (-n caps the MATCHING lines, not raw hub-log
|
|
594
|
+
lines; \`logs hub\` is unfiltered). A legacy per-service file
|
|
595
|
+
(~/.parachute/<service>/logs/<service>.log) is read instead when it is
|
|
596
|
+
fresher than the hub log — the pre-supervised install shape.
|
|
597
|
+
|
|
598
|
+
If no log lines exist yet, prints a hint to \`parachute start <service>\`.
|
|
592
599
|
`;
|
|
593
600
|
}
|
|
594
601
|
|
|
@@ -40,11 +40,15 @@
|
|
|
40
40
|
* OAuth / access-token validation (vault / MCP tokens, `aud: "vault.<name>"`)
|
|
41
41
|
* stays STRICT per-request-issuer and lives on entirely separate code paths
|
|
42
42
|
* (the resource servers' own validators, hub's `/api/auth/*`, etc.). This
|
|
43
|
-
* helper is invoked
|
|
43
|
+
* helper is invoked from the two loopback host-admin module surfaces
|
|
44
44
|
* (`/api/modules` GET — the `status` read; `/api/modules/:short/*` POST — the
|
|
45
45
|
* lifecycle ops), both of which already gate on the non-requestable
|
|
46
46
|
* `parachute:host:admin` / `parachute:host:auth` scopes that no OAuth token
|
|
47
|
-
* can carry
|
|
47
|
+
* can carry, and from the per-UI audience gate's Bearer branch
|
|
48
|
+
* (`src/audience-gate.ts`, H3) — same self-issued-token shape, same
|
|
49
|
+
* iss-∈-bound-origins need (a PWA's token carries the public origin while
|
|
50
|
+
* the proxied request resolves the loopback issuer), with the surface's
|
|
51
|
+
* declared `scopes_required` enforced by the gate on top.
|
|
48
52
|
*/
|
|
49
53
|
import type { Database } from "bun:sqlite";
|
|
50
54
|
import { type ValidatedAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
package/src/hub-db.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
|
|
5
5
|
* clients + auth-codes + grants + browser sessions (v3), TOTP 2FA
|
|
6
6
|
* enrollment on the users row (v11, hub#473), and one-time invite links
|
|
7
|
-
* (v12, the `invites` table).
|
|
7
|
+
* (v12, the `invites` table; v13 adds the pre-named `username` column).
|
|
8
8
|
*
|
|
9
9
|
* Each open() runs `migrate()` to bring the schema up to date. A
|
|
10
10
|
* `schema_version` table records every applied migration so re-opens are
|
|
@@ -426,6 +426,31 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
426
426
|
CREATE INDEX invites_created_at ON invites (created_at);
|
|
427
427
|
`,
|
|
428
428
|
},
|
|
429
|
+
{
|
|
430
|
+
version: 13,
|
|
431
|
+
sql: `
|
|
432
|
+
-- Pre-named invites (Adam/Jonathan scenario). Adds an optional
|
|
433
|
+
-- \`username\` column to \`invites\`: when set, the invite pre-names
|
|
434
|
+
-- the account the redeemer gets — the redemption form shows the name
|
|
435
|
+
-- read-only and the redeem handler ENFORCES it (the form's username
|
|
436
|
+
-- field is ignored). NULL = the redeemer picks their own username
|
|
437
|
+
-- (every pre-v13 invite, and the default for new ones).
|
|
438
|
+
--
|
|
439
|
+
-- Enforced (not just pre-filled) because a pre-named invite is a
|
|
440
|
+
-- *named deliverable*: the admin mints "Jonathan's link" and hands it
|
|
441
|
+
-- to Jonathan; if the redeemer could pick a different name, the
|
|
442
|
+
-- link's identity binding would be decorative — the admin's audit
|
|
443
|
+
-- expectation ("this link = jonathan") and any vault assignment
|
|
444
|
+
-- story told against that name would silently break.
|
|
445
|
+
--
|
|
446
|
+
-- Stored as plain TEXT (already-validated lowercase [a-z0-9_-], the
|
|
447
|
+
-- users.username vocabulary). Mint-time validation rejects names
|
|
448
|
+
-- taken by an existing user or reserved by another pending invite;
|
|
449
|
+
-- the redeem path re-checks authoritatively. No backfill — every
|
|
450
|
+
-- existing invite predates pre-naming and keeps NULL.
|
|
451
|
+
ALTER TABLE invites ADD COLUMN username TEXT;
|
|
452
|
+
`,
|
|
453
|
+
},
|
|
429
454
|
];
|
|
430
455
|
|
|
431
456
|
export function openHubDb(path: string = hubDbPath()): Database {
|