@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
|
@@ -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", () => {
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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; }
|