@openparachute/hub 0.3.0-rc.1 → 0.5.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.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * RSA-2048 signing keys backing the hub's JWT issuance + JWKS endpoint.
3
+ *
4
+ * Lifecycle:
5
+ * - One *active* key at a time (`retired_at IS NULL`). Used to sign new
6
+ * JWTs.
7
+ * - On rotation, the old key gets `retired_at` stamped and a fresh one
8
+ * becomes active. Retired keys keep validating tokens issued before the
9
+ * rotation, but only for a limited window.
10
+ *
11
+ * Retention: `JWKS_RETENTION_MS = 24h`. Access tokens are 15-min JWTs, so
12
+ * the cryptographic window is tiny — but JWKS responses are typically cached
13
+ * by clients for up to ~24h, and we don't want a client's stale cache to
14
+ * blackhole valid signatures during that window. 24h is the upper bound on
15
+ * any reasonable client cache; older retired rows stay in the DB for audit
16
+ * but stop appearing in JWKS.
17
+ */
18
+ import type { Database } from "bun:sqlite";
19
+ import { createHash, generateKeyPairSync } from "node:crypto";
20
+
21
+ export const JWKS_RETENTION_MS = 24 * 60 * 60 * 1000;
22
+ export const SIGNING_ALGORITHM = "RS256";
23
+
24
+ export interface SigningKey {
25
+ kid: string;
26
+ publicKeyPem: string;
27
+ privateKeyPem: string;
28
+ algorithm: string;
29
+ createdAt: string;
30
+ retiredAt: string | null;
31
+ }
32
+
33
+ interface Row {
34
+ kid: string;
35
+ public_key_pem: string;
36
+ private_key_pem: string;
37
+ algorithm: string;
38
+ created_at: string;
39
+ retired_at: string | null;
40
+ }
41
+
42
+ function rowToKey(r: Row): SigningKey {
43
+ return {
44
+ kid: r.kid,
45
+ publicKeyPem: r.public_key_pem,
46
+ privateKeyPem: r.private_key_pem,
47
+ algorithm: r.algorithm,
48
+ createdAt: r.created_at,
49
+ retiredAt: r.retired_at,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `kid = base64url(SHA-256(public_key_pem))` — stable, content-addressed,
55
+ * impossible to clash within a database that already has the public key as
56
+ * a unique column.
57
+ */
58
+ export function computeKid(publicKeyPem: string): string {
59
+ return createHash("sha256").update(publicKeyPem).digest("base64url");
60
+ }
61
+
62
+ /**
63
+ * Returns the active signing key, generating + inserting a fresh keypair on
64
+ * an empty database. Idempotent: subsequent calls return the same row.
65
+ */
66
+ export function getActiveSigningKey(db: Database, now: () => Date = () => new Date()): SigningKey {
67
+ const existing = db
68
+ .query("SELECT * FROM signing_keys WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1")
69
+ .get() as Row | null;
70
+ if (existing) return rowToKey(existing);
71
+ return rotateSigningKey(db, now);
72
+ }
73
+
74
+ /**
75
+ * Generates a new RSA-2048 keypair, retires every currently-active key, and
76
+ * returns the new active key. The retire+insert runs in a single transaction
77
+ * so a partial failure can't leave the DB with zero active keys.
78
+ */
79
+ export function rotateSigningKey(db: Database, now: () => Date = () => new Date()): SigningKey {
80
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
81
+ const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
82
+ const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
83
+ const kid = computeKid(publicKeyPem);
84
+ const stamp = now().toISOString();
85
+
86
+ db.transaction(() => {
87
+ db.prepare("UPDATE signing_keys SET retired_at = ? WHERE retired_at IS NULL").run(stamp);
88
+ db.prepare(
89
+ `INSERT INTO signing_keys (kid, public_key_pem, private_key_pem, algorithm, created_at, retired_at)
90
+ VALUES (?, ?, ?, ?, ?, NULL)`,
91
+ ).run(kid, publicKeyPem, privateKeyPem, SIGNING_ALGORITHM, stamp);
92
+ })();
93
+
94
+ return {
95
+ kid,
96
+ publicKeyPem,
97
+ privateKeyPem,
98
+ algorithm: SIGNING_ALGORITHM,
99
+ createdAt: stamp,
100
+ retiredAt: null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Public keys to advertise on `/.well-known/jwks.json`: every active key plus
106
+ * any retired key whose `retired_at` is within `JWKS_RETENTION_MS`. Older
107
+ * retired rows stay in the DB for audit/debug — they just stop being
108
+ * advertised.
109
+ */
110
+ export function getAllPublicKeys(db: Database, now: () => Date = () => new Date()): SigningKey[] {
111
+ const cutoff = new Date(now().getTime() - JWKS_RETENTION_MS).toISOString();
112
+ const rows = db
113
+ .query(
114
+ `SELECT * FROM signing_keys
115
+ WHERE retired_at IS NULL OR retired_at >= ?
116
+ ORDER BY created_at DESC`,
117
+ )
118
+ .all(cutoff) as Row[];
119
+ return rows.map(rowToKey);
120
+ }
package/src/users.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ /**
4
+ * User accounts for the hub. Single-user-mode by default — `createUser`
5
+ * refuses to create a second account unless `allowMulti` is set, so the
6
+ * launch posture is "one account per hub" without baking that assumption
7
+ * into the schema. Multi-user grows by setting the flag at the call site,
8
+ * not by altering the table.
9
+ *
10
+ * Password hashing: argon2id via `@node-rs/argon2`. Pure-Rust prebuilts,
11
+ * Bun-friendly (no node-gyp). Defaults are RFC 9106 second-recommended
12
+ * parameters (m=19MiB, t=2, p=1) — fine for an interactive single-user
13
+ * login.
14
+ *
15
+ * IDs are `crypto.randomUUID()` — UUIDv4. The brief called for ULIDs but
16
+ * for the hub's access pattern (≤handful of accounts, no time-ordered
17
+ * scan) UUIDv4's extra ~5 bytes of metadata are not load-bearing. Easy
18
+ * to swap if a downstream integration needs the ULID prefix.
19
+ */
20
+ import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
21
+
22
+ export interface User {
23
+ id: string;
24
+ username: string;
25
+ passwordHash: string;
26
+ createdAt: string;
27
+ updatedAt: string;
28
+ }
29
+
30
+ export class SingleUserModeError extends Error {
31
+ constructor() {
32
+ super(
33
+ "a user already exists; pass --allow-multi to create additional accounts (forward-compat for multi-user mode)",
34
+ );
35
+ this.name = "SingleUserModeError";
36
+ }
37
+ }
38
+
39
+ export class UsernameTakenError extends Error {
40
+ constructor(username: string) {
41
+ super(`username "${username}" is already in use`);
42
+ this.name = "UsernameTakenError";
43
+ }
44
+ }
45
+
46
+ export class UserNotFoundError extends Error {
47
+ constructor(ref: string) {
48
+ super(`user "${ref}" not found`);
49
+ this.name = "UserNotFoundError";
50
+ }
51
+ }
52
+
53
+ interface Row {
54
+ id: string;
55
+ username: string;
56
+ password_hash: string;
57
+ created_at: string;
58
+ updated_at: string;
59
+ }
60
+
61
+ function rowToUser(r: Row): User {
62
+ return {
63
+ id: r.id,
64
+ username: r.username,
65
+ passwordHash: r.password_hash,
66
+ createdAt: r.created_at,
67
+ updatedAt: r.updated_at,
68
+ };
69
+ }
70
+
71
+ export interface CreateUserOpts {
72
+ /** Allow creating an additional user when one already exists. Off by default. */
73
+ allowMulti?: boolean;
74
+ now?: () => Date;
75
+ }
76
+
77
+ export async function createUser(
78
+ db: Database,
79
+ username: string,
80
+ password: string,
81
+ opts: CreateUserOpts = {},
82
+ ): Promise<User> {
83
+ const count = (db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users").get() ?? { n: 0 })
84
+ .n;
85
+ if (count > 0 && !opts.allowMulti) throw new SingleUserModeError();
86
+
87
+ const id = randomUUID();
88
+ const passwordHash = await argonHash(password);
89
+ const stamp = (opts.now?.() ?? new Date()).toISOString();
90
+ try {
91
+ db.prepare(
92
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
93
+ ).run(id, username, passwordHash, stamp, stamp);
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ if (msg.includes("UNIQUE") && msg.includes("users.username")) {
97
+ throw new UsernameTakenError(username);
98
+ }
99
+ throw err;
100
+ }
101
+ return { id, username, passwordHash, createdAt: stamp, updatedAt: stamp };
102
+ }
103
+
104
+ export function getUserByUsername(db: Database, username: string): User | null {
105
+ const row = db.query<Row, [string]>("SELECT * FROM users WHERE username = ?").get(username);
106
+ return row ? rowToUser(row) : null;
107
+ }
108
+
109
+ export function getUserById(db: Database, id: string): User | null {
110
+ const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(id);
111
+ return row ? rowToUser(row) : null;
112
+ }
113
+
114
+ export function listUsers(db: Database): User[] {
115
+ const rows = db.query<Row, []>("SELECT * FROM users ORDER BY created_at ASC").all();
116
+ return rows.map(rowToUser);
117
+ }
118
+
119
+ export function userCount(db: Database): number {
120
+ return (db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users").get() ?? { n: 0 }).n;
121
+ }
122
+
123
+ export async function verifyPassword(user: User, password: string): Promise<boolean> {
124
+ return argonVerify(user.passwordHash, password);
125
+ }
126
+
127
+ /**
128
+ * Updates the password for an existing user. Throws `UserNotFoundError` if
129
+ * the id has no row. Single-user-mode flows look up by username first and
130
+ * pass the resolved id here.
131
+ */
132
+ export async function setPassword(
133
+ db: Database,
134
+ userId: string,
135
+ newPassword: string,
136
+ now: () => Date = () => new Date(),
137
+ ): Promise<void> {
138
+ const passwordHash = await argonHash(newPassword);
139
+ const stamp = now().toISOString();
140
+ const result = db
141
+ .prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?")
142
+ .run(passwordHash, stamp, userId);
143
+ if (result.changes === 0) throw new UserNotFoundError(userId);
144
+ }
package/src/well-known.ts CHANGED
@@ -12,6 +12,13 @@ export interface WellKnownVaultEntry {
12
12
  name: string;
13
13
  url: string;
14
14
  version: string;
15
+ /**
16
+ * Where the vault's admin SPA lives. Path-or-URL per
17
+ * `parachute-patterns/patterns/module-json-extensibility.md`. Hub renders
18
+ * a "Manage" link when present. Sourced from the vault module's
19
+ * `.parachute/module.json:managementUrl`.
20
+ */
21
+ managementUrl?: string;
15
22
  }
16
23
 
17
24
  /**
@@ -31,23 +38,18 @@ export interface WellKnownServicesEntry {
31
38
  /**
32
39
  * Canonical `/.well-known/parachute.json` shape.
33
40
  *
34
- * Three parts, all additive so old clients keep working:
35
- * - `vaults: []` always an array; vault is the ecosystem's only
36
- * multi-tenant service.
41
+ * Two parts:
42
+ * - `vaults: []`, `notes: []`, `agent: []`, every kind is a plural
43
+ * array, so consumers always read `notes[0]` if they want "the one" and
44
+ * the multi-install case is visible at every call site (closes #92).
37
45
  * - `services: []` — flat list the hub page iterates. Scales to N frontends
38
46
  * without the consumer needing to know every shortName.
39
- * - Top-level flat keys (`notes`, `scribe`, …) — kept for back-compat with
40
- * clients that predate `services[]`.
41
47
  */
42
48
  export type WellKnownDocument = {
43
49
  vaults: WellKnownVaultEntry[];
44
50
  services: WellKnownServicesEntry[];
45
51
  } & {
46
- [shortName: string]:
47
- | WellKnownVaultEntry[]
48
- | WellKnownServicesEntry[]
49
- | WellKnownServiceEntry
50
- | undefined;
52
+ [shortName: string]: WellKnownVaultEntry[] | WellKnownServicesEntry[] | WellKnownServiceEntry[];
51
53
  };
52
54
 
53
55
  export const WELL_KNOWN_DIR = join(CONFIG_DIR, "well-known");
@@ -71,25 +73,40 @@ export function isVaultEntry(entry: ServiceEntry): boolean {
71
73
  }
72
74
 
73
75
  /**
74
- * Derive a vault instance name. Prefer a `/vault/<name>` path segment; fall
75
- * back to the manifest-name suffix (`parachute-vault-work` → `work`); last
76
- * resort is "default".
76
+ * Derive a vault instance name from a single mount path + manifest name.
77
+ * Prefer a `/vault/<name>` path segment; fall back to the manifest-name
78
+ * suffix (`parachute-vault-work` → `work`); last resort is "default".
77
79
  */
78
- export function vaultInstanceName(entry: ServiceEntry): string {
79
- const path = entry.paths[0];
80
+ export function vaultInstanceNameFor(name: string, path: string | undefined): string {
80
81
  if (path) {
81
82
  const match = path.match(/^\/vault\/([^/]+)/);
82
83
  if (match?.[1]) return match[1];
83
84
  }
84
- if (entry.name.startsWith(`${VAULT_MANIFEST_PREFIX}-`)) {
85
- return entry.name.slice(VAULT_MANIFEST_PREFIX.length + 1);
85
+ if (name.startsWith(`${VAULT_MANIFEST_PREFIX}-`)) {
86
+ return name.slice(VAULT_MANIFEST_PREFIX.length + 1);
86
87
  }
87
88
  return "default";
88
89
  }
89
90
 
91
+ /**
92
+ * Back-compat wrapper that resolves a vault instance name from the entry's
93
+ * first mount path. Prefer `vaultInstanceNameFor(name, path)` when iterating
94
+ * a multi-path entry.
95
+ */
96
+ export function vaultInstanceName(entry: ServiceEntry): string {
97
+ return vaultInstanceNameFor(entry.name, entry.paths[0]);
98
+ }
99
+
90
100
  export interface BuildWellKnownOpts {
91
101
  services: readonly ServiceEntry[];
92
102
  canonicalOrigin: string;
103
+ /**
104
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:managementUrl`,
105
+ * if any. Synchronous so the well-known build stays a pure transform; the
106
+ * caller (hub-server.ts) loads manifests once per request and passes them
107
+ * in. Returning `undefined` means "no admin SPA" and hub renders no link.
108
+ */
109
+ managementUrlFor?: (entry: ServiceEntry) => string | undefined;
93
110
  }
94
111
 
95
112
  /** Join a base origin and a path without double slashes — "/" stays "/". */
@@ -102,15 +119,34 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
102
119
  const base = opts.canonicalOrigin.replace(/\/$/, "");
103
120
  const doc: WellKnownDocument = { vaults: [], services: [] };
104
121
  for (const s of opts.services) {
105
- const path = s.paths[0] ?? "/";
106
- const url = new URL(path, `${base}/`).toString();
107
- const infoPath = joinInfoPath(path);
108
- const infoUrl = new URL(infoPath, `${base}/`).toString();
109
- doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
110
- if (isVaultEntry(s)) {
111
- doc.vaults.push({ name: vaultInstanceName(s), url, version: s.version });
112
- } else {
113
- doc[shortName(s.name)] = { url, version: s.version };
122
+ // Vault services are mounted at one path per vault instance — a single
123
+ // ServiceEntry with `paths: ["/vault/default", "/vault/techne"]` represents
124
+ // two distinct vault instances behind the same backend. Iterate each path
125
+ // so consumers (parachute-agent vault picker, hub page) see every instance
126
+ // (closes #141). Non-vault services keep the legacy paths[0] semantic;
127
+ // multi-path on those is treated as aliases rather than separate
128
+ // installs.
129
+ const isVault = isVaultEntry(s);
130
+ const pathsToEmit = isVault && s.paths.length > 0 ? s.paths : [s.paths[0] ?? "/"];
131
+ for (const path of pathsToEmit) {
132
+ const url = new URL(path, `${base}/`).toString();
133
+ const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
134
+ doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
135
+ if (isVault) {
136
+ const managementUrl = opts.managementUrlFor?.(s);
137
+ const entry: WellKnownVaultEntry = {
138
+ name: vaultInstanceNameFor(s.name, path),
139
+ url,
140
+ version: s.version,
141
+ };
142
+ if (managementUrl !== undefined) entry.managementUrl = managementUrl;
143
+ doc.vaults.push(entry);
144
+ } else {
145
+ const key = shortName(s.name);
146
+ const bucket = (doc[key] as WellKnownServiceEntry[] | undefined) ?? [];
147
+ bucket.push({ url, version: s.version });
148
+ doc[key] = bucket;
149
+ }
114
150
  }
115
151
  }
116
152
  return doc;