@openparachute/hub 0.7.5-rc.4 → 0.7.5-rc.5

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.
@@ -0,0 +1,276 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
7
+ import { rotateSigningKey } from "../signing-keys.ts";
8
+ import {
9
+ SURFACE_TOKEN_CREATED_VIA,
10
+ SURFACE_TOKEN_TTL_DEFAULT_SECONDS,
11
+ listSurfaceTokens,
12
+ mintSurfaceToken,
13
+ revokeSurfaceToken,
14
+ surfaceScope,
15
+ } from "../surface-token.ts";
16
+ import { createUser } from "../users.ts";
17
+
18
+ const ISSUER = "http://127.0.0.1:1939";
19
+
20
+ interface Harness {
21
+ db: ReturnType<typeof openHubDb>;
22
+ userId: string;
23
+ cleanup: () => void;
24
+ }
25
+
26
+ async function makeHarness(): Promise<Harness> {
27
+ const dir = mkdtempSync(join(tmpdir(), "phub-surftok-"));
28
+ const db = openHubDb(hubDbPath(dir));
29
+ rotateSigningKey(db);
30
+ const u = await createUser(db, "owner", "pw");
31
+ return {
32
+ db,
33
+ userId: u.id,
34
+ cleanup: () => {
35
+ db.close();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ },
38
+ };
39
+ }
40
+
41
+ describe("surfaceScope", () => {
42
+ test("builds the canonical scope", () => {
43
+ expect(surfaceScope("gitcoin-brain", "write")).toBe("surface:gitcoin-brain:write");
44
+ expect(surfaceScope("gitcoin-brain", "read")).toBe("surface:gitcoin-brain:read");
45
+ });
46
+ });
47
+
48
+ describe("mintSurfaceToken", () => {
49
+ test("mints a validatable, registered surface:<name>:write token", async () => {
50
+ const h = await makeHarness();
51
+ try {
52
+ const minted = await mintSurfaceToken(h.db, {
53
+ name: "gitcoin-brain",
54
+ access: "write",
55
+ issuer: ISSUER,
56
+ userId: h.userId,
57
+ });
58
+ expect(minted.scope).toBe("surface:gitcoin-brain:write");
59
+ expect(minted.token.split(".").length).toBe(3);
60
+
61
+ // It validates through the SAME path the git endpoint uses.
62
+ const validated = await validateAccessToken(h.db, minted.token, [ISSUER]);
63
+ expect(validated.payload.scope).toBe("surface:gitcoin-brain:write");
64
+ expect(validated.payload.aud).toBe("surface.gitcoin-brain");
65
+ expect(validated.payload.jti).toBe(minted.jti);
66
+
67
+ // A registry row exists, tagged as a deploy token (so list/revoke find it).
68
+ const listed = listSurfaceTokens(h.db);
69
+ expect(listed.map((r) => r.jti)).toContain(minted.jti);
70
+ } finally {
71
+ h.cleanup();
72
+ }
73
+ });
74
+
75
+ test("read access mints surface:<name>:read", async () => {
76
+ const h = await makeHarness();
77
+ try {
78
+ const minted = await mintSurfaceToken(h.db, {
79
+ name: "docs",
80
+ access: "read",
81
+ issuer: ISSUER,
82
+ });
83
+ expect(minted.scope).toBe("surface:docs:read");
84
+ const validated = await validateAccessToken(h.db, minted.token, [ISSUER]);
85
+ expect(validated.payload.scope).toBe("surface:docs:read");
86
+ } finally {
87
+ h.cleanup();
88
+ }
89
+ });
90
+
91
+ test("defaults to a 90-day TTL; honors an explicit ttlSeconds", async () => {
92
+ const h = await makeHarness();
93
+ try {
94
+ const now = () => new Date("2026-06-30T00:00:00Z");
95
+ const dflt = await mintSurfaceToken(h.db, {
96
+ name: "foo",
97
+ access: "write",
98
+ issuer: ISSUER,
99
+ now,
100
+ });
101
+ const expectedDefault = new Date(
102
+ Date.parse("2026-06-30T00:00:00Z") + SURFACE_TOKEN_TTL_DEFAULT_SECONDS * 1000,
103
+ ).toISOString();
104
+ expect(dflt.expiresAt).toBe(expectedDefault);
105
+
106
+ const custom = await mintSurfaceToken(h.db, {
107
+ name: "bar",
108
+ access: "write",
109
+ issuer: ISSUER,
110
+ ttlSeconds: 3600,
111
+ now,
112
+ });
113
+ expect(custom.expiresAt).toBe(
114
+ new Date(Date.parse("2026-06-30T00:00:00Z") + 3600 * 1000).toISOString(),
115
+ );
116
+ } finally {
117
+ h.cleanup();
118
+ }
119
+ });
120
+
121
+ test("rejects an invalid surface name (path-traversal safety)", async () => {
122
+ const h = await makeHarness();
123
+ try {
124
+ await expect(
125
+ mintSurfaceToken(h.db, { name: "../evil", access: "write", issuer: ISSUER }),
126
+ ).rejects.toThrow(/invalid surface name/);
127
+ await expect(
128
+ mintSurfaceToken(h.db, { name: "a/b", access: "write", issuer: ISSUER }),
129
+ ).rejects.toThrow(/invalid surface name/);
130
+ } finally {
131
+ h.cleanup();
132
+ }
133
+ });
134
+ });
135
+
136
+ describe("listSurfaceTokens", () => {
137
+ test("lists only deploy tokens, narrows by surface, ignores other mints", async () => {
138
+ const h = await makeHarness();
139
+ try {
140
+ const a = await mintSurfaceToken(h.db, { name: "alpha", access: "write", issuer: ISSUER });
141
+ const b = await mintSurfaceToken(h.db, { name: "beta", access: "read", issuer: ISSUER });
142
+
143
+ // A generic cli_mint token that ALSO names a surface scope must NOT appear
144
+ // (deploy tokens are a distinct class, keyed by created_via).
145
+ const other = await signAccessToken(h.db, {
146
+ sub: h.userId,
147
+ scopes: ["surface:alpha:write"],
148
+ audience: "surface",
149
+ clientId: "test",
150
+ issuer: ISSUER,
151
+ });
152
+ recordTokenMint(h.db, {
153
+ jti: other.jti,
154
+ createdVia: "cli_mint",
155
+ subject: "someone",
156
+ clientId: "test",
157
+ scopes: ["surface:alpha:write"],
158
+ expiresAt: other.expiresAt,
159
+ });
160
+
161
+ const all = listSurfaceTokens(h.db);
162
+ const jtis = all.map((r) => r.jti);
163
+ expect(jtis).toContain(a.jti);
164
+ expect(jtis).toContain(b.jti);
165
+ expect(jtis).not.toContain(other.jti);
166
+
167
+ const alphaOnly = listSurfaceTokens(h.db, "alpha");
168
+ expect(alphaOnly.map((r) => r.jti)).toEqual([a.jti]);
169
+ expect(alphaOnly[0]?.access).toBe("write");
170
+ expect(alphaOnly[0]?.name).toBe("alpha");
171
+ expect(alphaOnly[0]?.revokedAt).toBeNull();
172
+ } finally {
173
+ h.cleanup();
174
+ }
175
+ });
176
+
177
+ test("empty when none minted", async () => {
178
+ const h = await makeHarness();
179
+ try {
180
+ expect(listSurfaceTokens(h.db)).toEqual([]);
181
+ expect(listSurfaceTokens(h.db, "nope")).toEqual([]);
182
+ } finally {
183
+ h.cleanup();
184
+ }
185
+ });
186
+ });
187
+
188
+ describe("revokeSurfaceToken", () => {
189
+ test("revokes a deploy token; the endpoint path then rejects it", async () => {
190
+ const h = await makeHarness();
191
+ try {
192
+ const minted = await mintSurfaceToken(h.db, {
193
+ name: "foo",
194
+ access: "write",
195
+ issuer: ISSUER,
196
+ });
197
+ // Valid before revoke.
198
+ await validateAccessToken(h.db, minted.token, [ISSUER]);
199
+
200
+ const res = revokeSurfaceToken(h.db, minted.jti, new Date());
201
+ expect(res.status).toBe("revoked");
202
+
203
+ // Revocation is enforced at validation (the git endpoint's path).
204
+ await expect(validateAccessToken(h.db, minted.token, [ISSUER])).rejects.toThrow(/revoked/);
205
+
206
+ // list reflects the revoked state.
207
+ const row = listSurfaceTokens(h.db, "foo")[0];
208
+ expect(row?.revokedAt).not.toBeNull();
209
+ } finally {
210
+ h.cleanup();
211
+ }
212
+ });
213
+
214
+ test("re-revoke is idempotent (already-revoked)", async () => {
215
+ const h = await makeHarness();
216
+ try {
217
+ const minted = await mintSurfaceToken(h.db, {
218
+ name: "foo",
219
+ access: "write",
220
+ issuer: ISSUER,
221
+ });
222
+ const first = revokeSurfaceToken(h.db, minted.jti, new Date());
223
+ expect(first.status).toBe("revoked");
224
+ const second = revokeSurfaceToken(h.db, minted.jti, new Date());
225
+ expect(second.status).toBe("already-revoked");
226
+ if (second.status === "already-revoked") {
227
+ expect(second.revokedAt).toBeTruthy();
228
+ }
229
+ } finally {
230
+ h.cleanup();
231
+ }
232
+ });
233
+
234
+ test("unknown jti → not-found", async () => {
235
+ const h = await makeHarness();
236
+ try {
237
+ expect(revokeSurfaceToken(h.db, "no-such-jti", new Date()).status).toBe("not-found");
238
+ } finally {
239
+ h.cleanup();
240
+ }
241
+ });
242
+
243
+ test("refuses to revoke a non-deploy-token jti (fails closed)", async () => {
244
+ const h = await makeHarness();
245
+ try {
246
+ const other = await signAccessToken(h.db, {
247
+ sub: h.userId,
248
+ scopes: ["vault:default:read"],
249
+ audience: "vault.default",
250
+ clientId: "test",
251
+ issuer: ISSUER,
252
+ });
253
+ recordTokenMint(h.db, {
254
+ jti: other.jti,
255
+ createdVia: "cli_mint",
256
+ subject: "someone",
257
+ clientId: "test",
258
+ scopes: ["vault:default:read"],
259
+ expiresAt: other.expiresAt,
260
+ });
261
+ const res = revokeSurfaceToken(h.db, other.jti, new Date());
262
+ expect(res.status).toBe("not-surface-token");
263
+ if (res.status === "not-surface-token") {
264
+ expect(res.createdVia).toBe("cli_mint");
265
+ }
266
+ // Confirm it was NOT revoked.
267
+ await validateAccessToken(h.db, other.token, [ISSUER]);
268
+ } finally {
269
+ h.cleanup();
270
+ }
271
+ });
272
+
273
+ test("SURFACE_TOKEN_CREATED_VIA is the stable tag", () => {
274
+ expect(SURFACE_TOKEN_CREATED_VIA).toBe("surface_token");
275
+ });
276
+ });
package/src/cli.ts CHANGED
@@ -1038,6 +1038,12 @@ async function main(argv: string[]): Promise<number> {
1038
1038
  return await mod.hub(rest);
1039
1039
  }
1040
1040
 
1041
+ case "surface": {
1042
+ const mod = await loadCommand("surface", () => import("./commands/surface.ts"));
1043
+ if (!mod) return 1;
1044
+ return await mod.surface(rest);
1045
+ }
1046
+
1041
1047
  case "vault": {
1042
1048
  const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
1043
1049
  if (!mod) return 1;
@@ -23,7 +23,6 @@
23
23
  * `<hub-origin>/account/2fa` (QR + backup codes).
24
24
  */
25
25
 
26
- import { join } from "node:path";
27
26
  import { createInterface } from "node:readline/promises";
28
27
  import {
29
28
  DEFAULT_REAP_AGE_MS,
@@ -36,11 +35,9 @@ import {
36
35
  reapClient,
37
36
  } from "../clients.ts";
38
37
  import { CONFIG_DIR } from "../config.ts";
39
- import { readExposeState } from "../expose-state.ts";
40
38
  import { listGrantsForUser, revokeGrant } from "../grants.ts";
41
- import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
42
39
  import { openHubDb } from "../hub-db.ts";
43
- import { deriveHubOrigin } from "../hub-origin.ts";
40
+ import { resolveHubIssuer } from "../hub-issuer.ts";
44
41
  import { inferAudience } from "../jwt-audience.ts";
45
42
  import {
46
43
  findTokenRowByJti,
@@ -306,27 +303,6 @@ export interface AuthDeps {
306
303
  hubOrigin?: string;
307
304
  }
308
305
 
309
- /**
310
- * Resolve the hub origin used as `iss` for operator tokens. Mirrors
311
- * lifecycle.resolveHubOrigin's order, but falls back to the canonical
312
- * loopback (`http://127.0.0.1:1939`) instead of `undefined` — operator
313
- * tokens MUST carry an issuer, and on first-run before any expose has
314
- * happened the canonical loopback is what services will validate against.
315
- */
316
- function resolveHubIssuer(override: string | undefined, configDir: string): string {
317
- if (override) {
318
- const fromOverride = deriveHubOrigin({ override });
319
- if (fromOverride) return fromOverride;
320
- }
321
- const state = readExposeState(join(configDir, "expose-state.json"));
322
- if (state?.hubOrigin) return state.hubOrigin;
323
- const exposeFqdn = state?.canonicalFqdn;
324
- return (
325
- deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) }) ??
326
- `http://127.0.0.1:${HUB_DEFAULT_PORT}`
327
- );
328
- }
329
-
330
306
  function defaultRotateKey(): { kid: string; createdAt: string } {
331
307
  const db = openHubDb();
332
308
  try {