@assembly-js/app-foundation 0.8.2 → 0.10.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.
package/dist/assembly.js CHANGED
@@ -270,8 +270,10 @@ export function authUnavailableResponse() {
270
270
  // from the trusted build-env baseUrl, never a token/bearer claim. When the
271
271
  // caller has a verified identity, getTokenPayload() answers with it rather than
272
272
  // with the SDK's read of the bearer's claims.
273
- async function getBearerAssembly(bearer, identity) {
273
+ async function getBearerAssembly(bearer, identity, options) {
274
274
  const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
275
+ if (options?.crossAppAsInstaller)
276
+ routeCrossAppFetchThroughInstaller(assembly);
275
277
  if (identity) {
276
278
  // Includes the embed-context ids the SDK's bearer-mode payload omits, so
277
279
  // payload.notificationId still marks the Notification Center surface.
@@ -325,7 +327,7 @@ export async function getOfflineAssembly() {
325
327
  throw new Error('getOfflineAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
326
328
  }
327
329
  const minted = await mintBearerFromAppKey();
328
- if (!minted) {
330
+ if (!minted.ok) {
329
331
  throw new Error('getOfflineAssembly: failed to mint an app-scoped bearer from the app API key');
330
332
  }
331
333
  return getBearerAssembly(minted.bearer);
@@ -340,7 +342,7 @@ async function mintInstallerBearer() {
340
342
  throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
341
343
  }
342
344
  const minted = await mintBearerFromAppKey();
343
- if (!minted) {
345
+ if (!minted.ok) {
344
346
  throw new Error('getInstallerAssembly: failed to mint an app-scoped bearer from the app API key');
345
347
  }
346
348
  return minted.bearer;
@@ -377,11 +379,156 @@ async function getInstallerBearer() {
377
379
  * shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
378
380
  * a viewer cookie does not make this throw — that is the point of it.
379
381
  *
380
- * Does not attach the viewer's bearer. Each request mints once; a second call
381
- * in the same request reuses that bearer.
382
+ * Does not attach the viewer's bearer to its own calls. Each request mints once;
383
+ * a second call in the same request reuses that bearer. A call through `fetch` to
384
+ * another app (`/v1/apps/...`) is the exception: in a client's request it is made
385
+ * as that client (see getCrossAppBearer), in a staff request as this app.
382
386
  */
383
387
  export async function getInstallerAssembly() {
384
- return getBearerAssembly(await getInstallerBearer());
388
+ return getBearerAssembly(await getInstallerBearer(), undefined, {
389
+ crossAppAsInstaller: true,
390
+ });
391
+ }
392
+ // The platform's cross-app proxy lives under this prefix; nothing else gets a parent.
393
+ const CROSS_APP_PATH_PREFIX = '/v1/apps/';
394
+ // The platform's code for a mint whose parent bearer it refused outright, as
395
+ // opposed to one that merely expired (which a re-exchange fixes).
396
+ const PARENT_INVALID_CODE = 'parent_invalid';
397
+ const PARENT_EXPIRED_CODE = 'parent_expired';
398
+ // One cross-app bearer per request, keyed on the memoized headers object like the
399
+ // installer bearer: a process-wide cache would hand one client's bearer to the next.
400
+ const crossAppBearerByRequest = new WeakMap();
401
+ // Whether `path`, as the SDK will concatenate it onto the base, lands on the
402
+ // cross-app proxy. Resolved with URL first so `/v1/apps/../session` and encoded
403
+ // dot-segments are judged by where they actually go.
404
+ function isCrossAppPath(path) {
405
+ const base = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
406
+ try {
407
+ const resolved = new URL(`${base}${path.startsWith('/') ? path : `/${path}`}`);
408
+ const baseUrl = new URL(base);
409
+ if (resolved.origin !== baseUrl.origin)
410
+ return false;
411
+ const basePath = baseUrl.pathname.replace(/\/+$/, '');
412
+ return resolved.pathname.slice(basePath.length).startsWith(CROSS_APP_PATH_PREFIX);
413
+ }
414
+ catch {
415
+ return false;
416
+ }
417
+ }
418
+ // The viewer bearer to present as the mint's parent, or undefined when this
419
+ // request has no person to speak for. The decision rests only on what the
420
+ // platform verifies for the one credential the request carries: the header when
421
+ // present, else the cookie, never the cookie after a rejected header. A viewer
422
+ // that is present but cannot be verified throws instead of quietly becoming app
423
+ // authority.
424
+ async function resolveCrossAppParent(headerStore, cookieStore) {
425
+ const candidate = headerStore.get(BEARER_HEADER_NAME) ||
426
+ cookieStore.get(BEARER_COOKIE_NAME)?.value ||
427
+ undefined;
428
+ const legacyViewer = Boolean(headerStore.get(LEGACY_TOKEN_HEADER) || headerStore.get(LEGACY_WORKSPACE_HEADER));
429
+ if (!candidate) {
430
+ if (legacyViewer)
431
+ throw new TokenInvalidError('Assembly bearer is required');
432
+ return undefined;
433
+ }
434
+ // Exchange off: a viewer is present and nothing can verify it.
435
+ if (!isSessionExchangeEnabled()) {
436
+ throw new TokenInvalidError('Assembly bearer is required');
437
+ }
438
+ const result = await verifyBearer(candidate);
439
+ if (!result.ok) {
440
+ if (result.reason === 'unavailable')
441
+ throw new AuthUnavailableError();
442
+ throw new TokenInvalidError('Assembly bearer is required');
443
+ }
444
+ const { identity } = result;
445
+ // The platform refuses an impersonation or assistant-run bearer as a parent.
446
+ if (identity.tokenKind !== 'viewer')
447
+ return undefined;
448
+ // Staff stay app authority; only a client is forwarded.
449
+ if (!identity.clientId)
450
+ return undefined;
451
+ return candidate;
452
+ }
453
+ async function mintCrossAppBearer(headerStore, cookieStore) {
454
+ const parent = await resolveCrossAppParent(headerStore, cookieStore);
455
+ // Without a person this is the plain installer bearer, so a request that also
456
+ // calls the installer's other endpoints still mints once.
457
+ if (!parent)
458
+ return { bearer: await getInstallerBearer() };
459
+ if (!process.env.ASSEMBLY_API_KEY) {
460
+ throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
461
+ }
462
+ const minted = await mintBearerFromAppKey({ parent });
463
+ if (minted.ok)
464
+ return { bearer: minted.bearer };
465
+ // Never retried without the parent: that would be the fallback to app authority.
466
+ if (minted.code === PARENT_EXPIRED_CODE) {
467
+ throw new TokenInvalidError('Assembly bearer expired');
468
+ }
469
+ if (minted.code === PARENT_INVALID_CODE && minted.status !== undefined) {
470
+ return {
471
+ refusal: {
472
+ status: minted.status,
473
+ body: minted.body ?? JSON.stringify({ code: PARENT_INVALID_CODE }),
474
+ },
475
+ };
476
+ }
477
+ // A throttled mint says nothing about the parent, so it is an outage, not a refusal.
478
+ if (minted.status === undefined || minted.status >= 500 || minted.status === 429) {
479
+ throw new AuthUnavailableError();
480
+ }
481
+ throw new Error('getInstallerAssembly: failed to mint a cross-app bearer from the app API key');
482
+ }
483
+ // The bearer for a call to another app: the installer's, carrying the client when
484
+ // this is a client's request. Throws where the request cannot be read (`'use cache'`,
485
+ // prerender, `after()`), because a bearer minted there would be unattributed while
486
+ // the page may still be serving a client.
487
+ async function getCrossAppBearer() {
488
+ let headerStore;
489
+ let cookieStore;
490
+ try {
491
+ [headerStore, cookieStore] = await Promise.all([headers(), cookies()]);
492
+ }
493
+ catch {
494
+ throw new Error('A call to another app needs the current request, which cannot be read here (a cached component, prerender or after()). Make the call from a page, server action or route handler.');
495
+ }
496
+ const existing = crossAppBearerByRequest.get(headerStore);
497
+ if (existing)
498
+ return existing;
499
+ const pending = mintCrossAppBearer(headerStore, cookieStore);
500
+ crossAppBearerByRequest.set(headerStore, pending);
501
+ try {
502
+ return await pending;
503
+ }
504
+ catch (err) {
505
+ // A failed mint must not stick for the rest of the request.
506
+ crossAppBearerByRequest.delete(headerStore);
507
+ throw err;
508
+ }
509
+ }
510
+ function refusalResponse(refusal) {
511
+ return new Response(refusal.body, {
512
+ status: refusal.status,
513
+ headers: { 'Content-Type': 'application/json' },
514
+ });
515
+ }
516
+ // Wraps `fetch` on an installer SDK object so a raw call to another app uses the
517
+ // cross-app bearer, and every other path is left exactly as the SDK sends it.
518
+ function routeCrossAppFetchThroughInstaller(assembly) {
519
+ const original = assembly.fetch;
520
+ if (typeof original !== 'function')
521
+ return;
522
+ assembly.fetch = async (path, init) => {
523
+ if (!isCrossAppPath(path))
524
+ return original.call(assembly, path, init);
525
+ const outcome = await getCrossAppBearer();
526
+ if ('refusal' in outcome)
527
+ return refusalResponse(outcome.refusal);
528
+ const headers = new Headers(init?.headers);
529
+ headers.set('Authorization', `Bearer ${outcome.bearer}`);
530
+ return original.call(assembly, path, { ...init, headers, cache: 'no-store' });
531
+ };
385
532
  }
386
533
  /**
387
534
  * Options for calling another app's API through the platform, shaped for an
@@ -389,19 +536,30 @@ export async function getInstallerAssembly() {
389
536
  * `createClient`. The bearer is resolved on every call, so a client built at
390
537
  * module scope still authenticates as the current request.
391
538
  *
392
- * `installer` calls as this app, with the same per-request mint as
393
- * getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
394
- * which the platform scopes to that contact and refuses for a team member.
539
+ * `installer` calls as this app. In a client's request it also carries that
540
+ * client, so the platform checks the client could open the other app and the
541
+ * other app sees a client; in a staff request it is this app alone. `viewer`
542
+ * forwards the signed-in user's verified bearer, which the platform scopes to
543
+ * that contact and refuses for a team member.
395
544
  */
396
545
  export function appProxyOptions(appId, caller) {
397
546
  const origin = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
398
547
  return {
399
548
  baseUrl: `${origin}/v1/apps/${encodeURIComponent(appId)}/api`,
400
549
  fetch: async (input) => {
401
- const bearer = caller === 'installer' ? await getInstallerBearer() : await requireViewerBearer();
550
+ let bearer;
551
+ if (caller === 'installer') {
552
+ const outcome = await getCrossAppBearer();
553
+ if ('refusal' in outcome)
554
+ return refusalResponse(outcome.refusal);
555
+ bearer = outcome.bearer;
556
+ }
557
+ else {
558
+ bearer = await requireViewerBearer();
559
+ }
402
560
  const request = new Request(input);
403
561
  request.headers.set('Authorization', `Bearer ${bearer}`);
404
- return fetch(request);
562
+ return fetch(request, { cache: 'no-store' });
405
563
  },
406
564
  };
407
565
  }
@@ -22,8 +22,35 @@ export interface MintSessionOptions {
22
22
  }
23
23
  export declare function mintBearerFromSessionToken(token: string, options?: MintSessionOptions): Promise<MintOutcome>;
24
24
  export declare function fetchSessionFlags(bearer: string): Promise<string[] | null>;
25
+ /** The person a minted bearer is attributed to, echoed by the platform. */
26
+ export interface MintedOnBehalfOf {
27
+ /** "client" or "internal_user". */
28
+ kind: string;
29
+ clientId?: string;
30
+ internalUserId?: string;
31
+ companyId?: string;
32
+ }
25
33
  export interface MintedAppBearer {
26
34
  bearer: string;
27
35
  bearerExpiresAt: number;
36
+ /** Present only when the mint carried a parent the platform accepted. */
37
+ onBehalfOf?: MintedOnBehalfOf;
38
+ }
39
+ export type AppKeyMintOutcome = ({
40
+ ok: true;
41
+ } & MintedAppBearer) | {
42
+ ok: false;
43
+ /** HTTP status of a refusal; absent when no response arrived (no key,
44
+ * network failure, timeout) or the 2xx body was unusable. */
45
+ status?: number;
46
+ /** The platform's error code, when its body carried one. */
47
+ code?: string;
48
+ /** The platform's raw error body, so a refusal can be handed on verbatim. */
49
+ body?: string;
50
+ };
51
+ export interface MintAppKeyOptions {
52
+ /** Viewer bearer of the person whose request this app is serving. The platform
53
+ * refuses a present-but-invalid parent; it is never silently dropped. */
54
+ parent?: string;
28
55
  }
29
- export declare function mintBearerFromAppKey(): Promise<MintedAppBearer | null>;
56
+ export declare function mintBearerFromAppKey(options?: MintAppKeyOptions): Promise<AppKeyMintOutcome>;
@@ -145,20 +145,38 @@ export async function fetchSessionFlags(bearer) {
145
145
  clearTimeout(timeoutId);
146
146
  }
147
147
  }
148
+ function parseOnBehalfOf(value) {
149
+ if (!value || typeof value !== 'object')
150
+ return undefined;
151
+ const v = value;
152
+ if (typeof v.kind !== 'string' || v.kind === '')
153
+ return undefined;
154
+ const str = (x) => (typeof x === 'string' && x !== '' ? x : undefined);
155
+ return {
156
+ kind: v.kind,
157
+ clientId: str(v.clientId),
158
+ internalUserId: str(v.internalUserId),
159
+ companyId: str(v.companyId),
160
+ };
161
+ }
148
162
  // Mints a short-lived, APP-scoped bearer from the app API key ALONE by calling
149
163
  // platform-api POST /v1/token (client-credentials). There is NO session token
150
164
  // and NO user in the loop: this is the OFFLINE/background path (cron, webhooks)
151
165
  // where no iframe ever handed us a session token to exchange. The app key mints
152
166
  // the bearer once; every subsequent data call then uses that bearer alone.
153
167
  //
168
+ // With `parent`, the mint is attributed to the person that parent names (see
169
+ // MintAppKeyOptions). Callers decide whether a parent exists; this only sends it.
170
+ //
154
171
  // Uses a runtime-agnostic fetch (safe in BOTH the Edge middleware and Node route-
155
172
  // handler runtimes) with the same abort-based timeout as the session exchange,
156
- // and fails closed (null) on any error — including a malformed 2xx — so a caller
157
- // never proceeds with a missing or bad credential.
158
- export async function mintBearerFromAppKey() {
173
+ // and fails closed on any error — including a malformed 2xx — so a caller
174
+ // never proceeds with a missing or bad credential. The failure carries the
175
+ // platform's status and code so a caller can tell a refused parent from an outage.
176
+ export async function mintBearerFromAppKey(options) {
159
177
  const apiKey = process.env.ASSEMBLY_API_KEY;
160
178
  if (!apiKey)
161
- return null;
179
+ return { ok: false };
162
180
  // Client-credentials mint: POST /v1/token authenticated by the app key sent as
163
181
  // X-API-Key. Unlike mintBearerFromSessionToken (which decrypts a session token
164
182
  // and lets the SDK derive the header), there is no token here, and the SDK only
@@ -174,16 +192,31 @@ export async function mintBearerFromAppKey() {
174
192
  // Present only in the app's sandbox (written to .env.local, never to the deployed
175
193
  // env); it lets the platform tell a sandbox call from a deployed one.
176
194
  const sandboxGrant = process.env.ASSEMBLY_SANDBOX_GRANT;
195
+ const body = {
196
+ ...(sandboxGrant ? { sandboxGrant } : {}),
197
+ ...(options?.parent ? { parent: options.parent } : {}),
198
+ };
199
+ const hasBody = Object.keys(body).length > 0;
177
200
  const resp = await fetch(`${base}/v1/token`, {
178
201
  method: 'POST',
179
- headers: sandboxGrant
202
+ headers: hasBody
180
203
  ? { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }
181
204
  : { 'X-API-Key': apiKey },
182
- ...(sandboxGrant ? { body: JSON.stringify({ sandboxGrant }) } : {}),
205
+ ...(hasBody ? { body: JSON.stringify(body) } : {}),
183
206
  signal: controller.signal,
184
207
  });
185
- if (!resp.ok)
186
- return null;
208
+ if (!resp.ok) {
209
+ let text;
210
+ let code;
211
+ try {
212
+ text = await resp.text();
213
+ const parsed = JSON.parse(text);
214
+ if (typeof parsed?.code === 'string')
215
+ code = parsed.code;
216
+ }
217
+ catch { }
218
+ return { ok: false, status: resp.status, code, body: text };
219
+ }
187
220
  const json = (await resp.json());
188
221
  // Fail closed on a malformed or already-expired 2xx: a missing/mistyped token
189
222
  // would yield a client that sends `Authorization: Bearer undefined`, a
@@ -195,13 +228,19 @@ export async function mintBearerFromAppKey() {
195
228
  typeof json.tokenExpiresAt !== 'number' ||
196
229
  !Number.isFinite(json.tokenExpiresAt) ||
197
230
  json.tokenExpiresAt * 1_000 <= now) {
198
- return null;
231
+ return { ok: false, status: resp.status };
199
232
  }
200
233
  const bearerExpiresAt = Math.min(json.tokenExpiresAt * 1_000, now + BEARER_TTL_MS);
201
- return { bearer: json.token, bearerExpiresAt };
234
+ const onBehalfOf = parseOnBehalfOf(json.onBehalfOf);
235
+ return {
236
+ ok: true,
237
+ bearer: json.token,
238
+ bearerExpiresAt,
239
+ ...(onBehalfOf ? { onBehalfOf } : {}),
240
+ };
202
241
  }
203
242
  catch {
204
- return null;
243
+ return { ok: false };
205
244
  }
206
245
  finally {
207
246
  clearTimeout(timeoutId);
package/dist/proxy.js CHANGED
@@ -121,7 +121,7 @@ async function fetchWorkspaceThemeForIdentity(workspaceId, bearer) {
121
121
  // or future change surfaces an exception.
122
122
  try {
123
123
  const minted = await mintBearerFromAppKey();
124
- if (!minted?.bearer)
124
+ if (!minted.ok)
125
125
  return null;
126
126
  return await fetchWorkspaceThemeWithBearer(minted.bearer, workspaceId);
127
127
  }
package/dist/scripts.js CHANGED
@@ -4644,33 +4644,66 @@ var COOKIE_PROBE_TTL_SECONDS = 24 * 60 * 60;
4644
4644
  // src/mint-bearer.ts
4645
4645
  var mintBaseUrl = process.env.ASSEMBLY_API_BASE_URL || void 0;
4646
4646
  var EXCHANGE_TIMEOUT_MS = 15e3;
4647
- async function mintBearerFromAppKey() {
4647
+ function parseOnBehalfOf(value) {
4648
+ if (!value || typeof value !== "object") return void 0;
4649
+ const v = value;
4650
+ if (typeof v.kind !== "string" || v.kind === "") return void 0;
4651
+ const str = (x) => typeof x === "string" && x !== "" ? x : void 0;
4652
+ return {
4653
+ kind: v.kind,
4654
+ clientId: str(v.clientId),
4655
+ internalUserId: str(v.internalUserId),
4656
+ companyId: str(v.companyId)
4657
+ };
4658
+ }
4659
+ async function mintBearerFromAppKey(options) {
4648
4660
  const apiKey = process.env.ASSEMBLY_API_KEY;
4649
- if (!apiKey) return null;
4661
+ if (!apiKey) return { ok: false };
4650
4662
  const base = (mintBaseUrl ?? OpenAPI2.BASE).replace(/\/$/, "");
4651
4663
  const now = Date.now();
4652
4664
  const controller = new AbortController();
4653
4665
  const timeoutId = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
4654
4666
  try {
4655
4667
  const sandboxGrant = process.env.ASSEMBLY_SANDBOX_GRANT;
4668
+ const body = {
4669
+ ...sandboxGrant ? { sandboxGrant } : {},
4670
+ ...options?.parent ? { parent: options.parent } : {}
4671
+ };
4672
+ const hasBody = Object.keys(body).length > 0;
4656
4673
  const resp = await fetch(`${base}/v1/token`, {
4657
4674
  method: "POST",
4658
- headers: sandboxGrant ? { "X-API-Key": apiKey, "Content-Type": "application/json" } : { "X-API-Key": apiKey },
4659
- ...sandboxGrant ? { body: JSON.stringify({ sandboxGrant }) } : {},
4675
+ headers: hasBody ? { "X-API-Key": apiKey, "Content-Type": "application/json" } : { "X-API-Key": apiKey },
4676
+ ...hasBody ? { body: JSON.stringify(body) } : {},
4660
4677
  signal: controller.signal
4661
4678
  });
4662
- if (!resp.ok) return null;
4679
+ if (!resp.ok) {
4680
+ let text;
4681
+ let code;
4682
+ try {
4683
+ text = await resp.text();
4684
+ const parsed = JSON.parse(text);
4685
+ if (typeof parsed?.code === "string") code = parsed.code;
4686
+ } catch {
4687
+ }
4688
+ return { ok: false, status: resp.status, code, body: text };
4689
+ }
4663
4690
  const json = await resp.json();
4664
4691
  if (typeof json.token !== "string" || json.token.length === 0 || typeof json.tokenExpiresAt !== "number" || !Number.isFinite(json.tokenExpiresAt) || json.tokenExpiresAt * 1e3 <= now) {
4665
- return null;
4692
+ return { ok: false, status: resp.status };
4666
4693
  }
4667
4694
  const bearerExpiresAt = Math.min(
4668
4695
  json.tokenExpiresAt * 1e3,
4669
4696
  now + BEARER_TTL_MS
4670
4697
  );
4671
- return { bearer: json.token, bearerExpiresAt };
4698
+ const onBehalfOf = parseOnBehalfOf(json.onBehalfOf);
4699
+ return {
4700
+ ok: true,
4701
+ bearer: json.token,
4702
+ bearerExpiresAt,
4703
+ ...onBehalfOf ? { onBehalfOf } : {}
4704
+ };
4672
4705
  } catch {
4673
- return null;
4706
+ return { ok: false };
4674
4707
  } finally {
4675
4708
  clearTimeout(timeoutId);
4676
4709
  }
@@ -4689,7 +4722,7 @@ async function getOfflineAssembly() {
4689
4722
  );
4690
4723
  }
4691
4724
  const minted = await mintBearerFromAppKey();
4692
- if (!minted) {
4725
+ if (!minted.ok) {
4693
4726
  throw new Error(
4694
4727
  "getOfflineAssembly: failed to mint an app-scoped bearer from the app API key"
4695
4728
  );
@@ -9,6 +9,11 @@ export interface VerifiedIdentity {
9
9
  callerAppId?: string;
10
10
  installerUserId?: string;
11
11
  tokenKind?: string;
12
+ /** Apps an app-to-app call passed through to reach this request, read from the
13
+ * verified bearer's own claims. Advisory: display and logging only, never a
14
+ * decision (the platform's depth cap is the control). */
15
+ chain?: string[];
16
+ hops?: number;
12
17
  /** Epoch SECONDS at which the verified bearer expires. */
13
18
  expiresAt: number;
14
19
  }
@@ -24,6 +29,7 @@ export declare const VIEWER_BEARER_HEADER = "X-Assembly-Viewer-Bearer";
24
29
  export declare const VERIFY_OUTCOME_EVENT = "assembly.verify_outcome";
25
30
  /**
26
31
  * Resolves a viewer bearer to the identity the platform vouches for. Identity
27
- * must come from here, never from the bearer's decoded claims.
32
+ * must come from here, never from the bearer's decoded claims. The one
33
+ * exception is the advisory call chain, which only the bearer carries.
28
34
  */
29
35
  export declare function verifyBearer(bearer: string): Promise<VerifyResult>;
@@ -156,11 +156,45 @@ async function verifyUncached(bearer, key, startedAt) {
156
156
  logOutcome(result.reason, startedAt);
157
157
  return result;
158
158
  }
159
+ // Reads the advisory call-chain claims off a bearer the platform has just
160
+ // verified. Never used to decide anything: a bearer that fails to decode simply
161
+ // has no chain.
162
+ function readChainClaims(bearer) {
163
+ try {
164
+ const segment = bearer.split('.')[1];
165
+ const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
166
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
167
+ const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0))));
168
+ const chain = Array.isArray(claims.chain)
169
+ ? claims.chain.filter((a) => typeof a === 'string' && a !== '')
170
+ : [];
171
+ const hops = typeof claims.hops === 'number' && Number.isInteger(claims.hops) && claims.hops > 0
172
+ ? claims.hops
173
+ : undefined;
174
+ return {
175
+ ...(chain.length ? { chain } : {}),
176
+ ...(hops ? { hops } : {}),
177
+ };
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
159
183
  /**
160
184
  * Resolves a viewer bearer to the identity the platform vouches for. Identity
161
- * must come from here, never from the bearer's decoded claims.
185
+ * must come from here, never from the bearer's decoded claims. The one
186
+ * exception is the advisory call chain, which only the bearer carries.
162
187
  */
163
188
  export async function verifyBearer(bearer) {
189
+ const result = await verifyBearerWithPlatform(bearer);
190
+ if (!result.ok)
191
+ return result;
192
+ return {
193
+ ok: true,
194
+ identity: Object.freeze({ ...result.identity, ...readChainClaims(bearer) }),
195
+ };
196
+ }
197
+ async function verifyBearerWithPlatform(bearer) {
164
198
  const startedAt = Date.now();
165
199
  // The decode is a no-network precheck: shape, expiry and workspace claim.
166
200
  if (!decodeBearer(bearer)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.8.2",
3
+ "version": "0.10.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [