@openparachute/hub 0.7.4-rc.10 → 0.7.4-rc.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.10",
3
+ "version": "0.7.4-rc.11",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -353,6 +353,88 @@ describe("handleAuthorizeGet", () => {
353
353
  }
354
354
  });
355
355
 
356
+ // hub#314 — same-hub vs external trust marker reaches the rendered consent
357
+ // screen via `client.sameHub`. An unnamed `vault:read` request from a
358
+ // same-hub client falls through to consent (the auto-approve gate requires
359
+ // `!hasUnnamedVault`), so we can assert the marker on the GET render.
360
+ test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
361
+ const { db, cleanup } = await makeDb();
362
+ try {
363
+ const user = await createUser(db, "owner", "pw");
364
+ const session = createSession(db, { userId: user.id });
365
+ // registerClient defaults sameHub:false → external (third-party DCR).
366
+ const reg = registerClient(db, {
367
+ redirectUris: ["https://app.example/cb"],
368
+ clientName: "ThirdPartyApp",
369
+ });
370
+ const { challenge } = makePkce();
371
+ const req = new Request(
372
+ authorizeUrl({
373
+ client_id: reg.client.clientId,
374
+ redirect_uri: "https://app.example/cb",
375
+ response_type: "code",
376
+ code_challenge: challenge,
377
+ code_challenge_method: "S256",
378
+ scope: "vault:read",
379
+ }),
380
+ {
381
+ headers: {
382
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
383
+ },
384
+ },
385
+ );
386
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
387
+ expect(res.status).toBe(200);
388
+ const html = await res.text();
389
+ // Element form — the `.badge-trust-*` CSS class names are always in the
390
+ // inlined <style>; the rendered ELEMENT only appears when the marker fires.
391
+ expect(html).toContain('class="badge badge-trust-external"');
392
+ expect(html).toContain("third-party app that registered itself");
393
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
394
+ } finally {
395
+ cleanup();
396
+ }
397
+ });
398
+
399
+ test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
400
+ const { db, cleanup } = await makeDb();
401
+ try {
402
+ const user = await createUser(db, "owner", "pw");
403
+ const session = createSession(db, { userId: user.id });
404
+ const reg = registerClient(db, {
405
+ redirectUris: ["https://app.example/cb"],
406
+ clientName: "FirstPartyApp",
407
+ sameHub: true,
408
+ });
409
+ const { challenge } = makePkce();
410
+ const req = new Request(
411
+ authorizeUrl({
412
+ client_id: reg.client.clientId,
413
+ redirect_uri: "https://app.example/cb",
414
+ response_type: "code",
415
+ code_challenge: challenge,
416
+ code_challenge_method: "S256",
417
+ // Unnamed vault verb → bypasses the same-hub auto-approve gate
418
+ // (`!hasUnnamedVault`) and falls through to the consent render.
419
+ scope: "vault:read",
420
+ }),
421
+ {
422
+ headers: {
423
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
424
+ },
425
+ },
426
+ );
427
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
428
+ expect(res.status).toBe(200);
429
+ const html = await res.text();
430
+ expect(html).toContain('class="badge badge-trust-same-hub"');
431
+ expect(html).toContain("Registered through this hub");
432
+ expect(html).not.toContain('class="badge badge-trust-external"');
433
+ } finally {
434
+ cleanup();
435
+ }
436
+ });
437
+
356
438
  test("rejects unknown client_id with 400", async () => {
357
439
  const { db, cleanup } = await makeDb();
358
440
  try {
@@ -10142,6 +10224,62 @@ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
10142
10224
  }
10143
10225
  });
10144
10226
 
10227
+ // GET render (hub#703, folded into hub#314): a user with MIXED authority —
10228
+ // admin on vault A (role=write → holds admin) but only read on vault B
10229
+ // (direct INSERT role=read) — does NOT see the selector. The user could pick
10230
+ // either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
10231
+ // `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
10232
+ // includes "admin")`) fails on vault B and the selector is suppressed. The
10233
+ // suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
10234
+ // ~1226); this test closes the coverage gap with no code change.
10235
+ test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
10236
+ const { db, cleanup } = await makeDb();
10237
+ try {
10238
+ await createUser(db, "owner", "pw");
10239
+ const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
10240
+ // Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
10241
+ // admin], so the user holds admin on A. (setUserVaults hardcodes write.)
10242
+ setUserVaults(db, mixed.id, ["work"]);
10243
+ // Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
10244
+ // setUserVaults DELETEs first, so this INSERT must come after it to keep A.
10245
+ db.prepare(
10246
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10247
+ ).run(mixed.id, "other", new Date().toISOString());
10248
+ const session = createSession(db, { userId: mixed.id });
10249
+ const reg = registerClient(db, {
10250
+ redirectUris: ["https://app.example/cb"],
10251
+ status: "approved",
10252
+ });
10253
+ const { challenge } = makePkce();
10254
+ const res = handleAuthorizeGet(
10255
+ db,
10256
+ new Request(
10257
+ authorizeUrl({
10258
+ client_id: reg.client.clientId,
10259
+ redirect_uri: "https://app.example/cb",
10260
+ response_type: "code",
10261
+ code_challenge: challenge,
10262
+ code_challenge_method: "S256",
10263
+ scope: "vault:read",
10264
+ }),
10265
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10266
+ ),
10267
+ selDeps,
10268
+ );
10269
+ expect(res.status).toBe(200);
10270
+ const html = await res.text();
10271
+ // Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
10272
+ expect(html).not.toContain("Access level");
10273
+ expect(html).not.toContain('name="verb_select"');
10274
+ // Sanity: the multi-vault picker DID render (two assigned vaults), so the
10275
+ // suppression is specifically the verb selector, not the whole flow.
10276
+ expect(html).toContain('name="vault_pick" value="work"');
10277
+ expect(html).toContain('name="vault_pick" value="other"');
10278
+ } finally {
10279
+ cleanup();
10280
+ }
10281
+ });
10282
+
10145
10283
  // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10146
10284
  // see the selector — they can't authorize a vault scope at all.
10147
10285
  test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
@@ -306,6 +306,58 @@ describe("renderConsent", () => {
306
306
  expect(html).not.toContain("Access level");
307
307
  expect(html).not.toContain('name="verb_select"');
308
308
  });
309
+
310
+ // hub#314 — same-hub vs external trust marker. The `.badge-trust-*` class
311
+ // names are always present in the inlined <style> block, so assertions target
312
+ // the RENDERED ELEMENT form (`class="badge badge-trust-*"`) + the copy text,
313
+ // which only appear in the consent body when the marker actually renders.
314
+ test("renders the first-party trust marker for a same-hub client", () => {
315
+ const html = renderConsent({
316
+ params: PARAMS,
317
+ csrfToken: CSRF,
318
+ clientId: "c",
319
+ clientName: "App",
320
+ scopes: ["vault:read"],
321
+ sameHub: true,
322
+ });
323
+ expect(html).toContain('class="badge badge-trust-same-hub"');
324
+ expect(html).toContain(">First-party<");
325
+ expect(html).toContain("Registered through this hub");
326
+ // The external badge / copy must NOT appear for a same-hub client.
327
+ expect(html).not.toContain('class="badge badge-trust-external"');
328
+ expect(html).not.toContain("third-party app that registered itself");
329
+ });
330
+
331
+ test("renders the external trust marker for a third-party DCR client", () => {
332
+ const html = renderConsent({
333
+ params: PARAMS,
334
+ csrfToken: CSRF,
335
+ clientId: "c",
336
+ clientName: "App",
337
+ scopes: ["vault:read"],
338
+ sameHub: false,
339
+ });
340
+ expect(html).toContain('class="badge badge-trust-external"');
341
+ expect(html).toContain(">External<");
342
+ expect(html).toContain("third-party app that registered itself");
343
+ // The first-party badge / copy must NOT appear for an external client.
344
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
345
+ expect(html).not.toContain("Registered through this hub");
346
+ });
347
+
348
+ test("renders no trust marker when provenance is unknown (sameHub omitted)", () => {
349
+ const html = renderConsent({
350
+ params: PARAMS,
351
+ csrfToken: CSRF,
352
+ clientId: "c",
353
+ clientName: "App",
354
+ scopes: ["vault:read"],
355
+ // sameHub omitted → undefined → no badge
356
+ });
357
+ expect(html).not.toContain('class="trust-marker');
358
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
359
+ expect(html).not.toContain('class="badge badge-trust-external"');
360
+ });
309
361
  });
310
362
 
311
363
  describe("renderError", () => {
@@ -2984,6 +2984,11 @@ function consentProps(
2984
2984
  blockApproveForStaleAssignment:
2985
2985
  staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
2986
2986
  userCanAuthorizeRequest,
2987
+ // hub#314 — surface the client's provenance (same-hub first-party vs
2988
+ // external third-party DCR) so the operator sees the trust level on the
2989
+ // consent screen. Clean DB-backed signal: the `same_hub` column written
2990
+ // at DCR time (bearer hub:admin / same-origin session → true).
2991
+ sameHub: client.sameHub,
2987
2992
  };
2988
2993
  }
2989
2994
 
package/src/oauth-ui.ts CHANGED
@@ -162,6 +162,21 @@ export interface ConsentViewProps {
162
162
  * the unnamed verb(s) on the picked vault; it never touches any other scope.
163
163
  */
164
164
  ownerVerbSelector?: OwnerVerbSelector;
165
+ /**
166
+ * hub#314 — same-hub vs external trust marker. True when the requesting
167
+ * client was registered through this hub's own flow / first-party install
168
+ * (`OAuthClient.sameHub` — bearer `hub:admin` OR session-cookie +
169
+ * same-origin DCR). False for a third-party Dynamic Client Registration
170
+ * (an external app, e.g. Claude.ai, that self-registered). Drives a small
171
+ * trust badge in the consent card header so the operator knows the trust
172
+ * level of the app they're approving before they click Approve.
173
+ *
174
+ * Omitted / undefined → no badge (provenance unknown; only the GET-handler
175
+ * call site, which always has the client row, populates it). Provenance is
176
+ * a clean DB-backed signal — see the `same_hub` column on `clients` and the
177
+ * `consentProps` call site in `oauth-handlers.ts`.
178
+ */
179
+ sameHub?: boolean;
165
180
  }
166
181
 
167
182
  export interface OwnerVerbSelector {
@@ -354,6 +369,7 @@ export function renderConsent(props: ConsentViewProps): string {
354
369
  blockApproveForStaleAssignment,
355
370
  userCanAuthorizeRequest,
356
371
  ownerVerbSelector,
372
+ sameHub,
357
373
  } = props;
358
374
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
359
375
  // the operator sees the scope shape that will appear in the token. Raw
@@ -418,6 +434,24 @@ export function renderConsent(props: ConsentViewProps): string {
418
434
  before authorizing vault access.
419
435
  </p>`
420
436
  : "";
437
+ // hub#314 — same-hub vs external trust marker. `sameHub === true` means the
438
+ // client was registered through this hub's own flow (first-party / operator-
439
+ // authenticated DCR); `false` means a third-party app self-registered via
440
+ // public Dynamic Client Registration. `undefined` → no badge (provenance
441
+ // unknown to the caller). The badge sits in the header so the operator sees
442
+ // the trust level before reading the scope list.
443
+ const trustMarker =
444
+ sameHub === undefined
445
+ ? ""
446
+ : sameHub
447
+ ? `<p class="trust-marker trust-marker-same-hub">
448
+ <span class="badge badge-trust-same-hub">First-party</span>
449
+ <span class="trust-marker-text">Registered through this hub.</span>
450
+ </p>`
451
+ : `<p class="trust-marker trust-marker-external">
452
+ <span class="badge badge-trust-external">External</span>
453
+ <span class="trust-marker-text">A third-party app that registered itself. Approve only if you recognise it.</span>
454
+ </p>`;
421
455
  const body = `
422
456
  <div class="card">
423
457
  <div class="card-header">
@@ -429,6 +463,7 @@ export function renderConsent(props: ConsentViewProps): string {
429
463
  <p class="subtitle">
430
464
  This app is requesting access to your Parachute account.
431
465
  </p>
466
+ ${trustMarker}
432
467
  <p class="client-meta">
433
468
  <span class="client-meta-label">client_id</span>
434
469
  <code>${escapeHtml(clientId)}</code>
@@ -1579,6 +1614,22 @@ const STYLES = `
1579
1614
  .badge-send { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
1580
1615
  .badge-admin { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
1581
1616
 
1617
+ /* hub#314 — same-hub vs external trust marker on the consent header. The
1618
+ first-party badge uses the accent (calm/trusted); external uses the danger
1619
+ tint so a third-party DCR client stands out without being alarmist. */
1620
+ .trust-marker {
1621
+ display: flex;
1622
+ align-items: baseline;
1623
+ gap: 0.45rem;
1624
+ flex-wrap: wrap;
1625
+ margin: 0.75rem 0 0;
1626
+ font-size: 0.85rem;
1627
+ color: ${PALETTE.fgMuted};
1628
+ }
1629
+ .trust-marker-text { flex: 1; min-width: 12rem; }
1630
+ .badge-trust-same-hub { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
1631
+ .badge-trust-external { background: ${PALETTE.dangerSoft}; color: ${PALETTE.danger}; }
1632
+
1582
1633
  @media (max-width: 480px) {
1583
1634
  main { padding: 0.75rem; }
1584
1635
  .card { padding: 1.5rem 1.25rem; border-radius: 10px; }