@openparachute/hub 0.3.0-rc.1 → 0.5.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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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
|
-
*
|
|
35
|
-
* - `vaults: []`
|
|
36
|
-
*
|
|
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
|
|
75
|
-
* back to the manifest-name
|
|
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
|
|
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 (
|
|
85
|
-
return
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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;
|