@assembly-js/app-foundation 0.0.25-rc.3 → 0.0.25-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.
package/dist/client.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './session-exchange.js';
2
2
  export * from './network-capture.js';
3
3
  export * from './paint-beacon.js';
4
4
  export * from './origin-bootstrap.js';
5
+ export * from './cookieless-bearer.js';
5
6
  export * from './client-companies.js';
6
7
  export * from './pagination.js';
7
8
  export * from './theme.js';
package/dist/client.js CHANGED
@@ -12,6 +12,8 @@ export * from './paint-beacon.js';
12
12
  // origin with the bridge allowlist, and schedule the post-paint beacon. See
13
13
  // each module's comment.
14
14
  export * from './origin-bootstrap.js';
15
+ // In-memory bearer for browsers that refuse the cookie; inert everywhere else.
16
+ export * from './cookieless-bearer.js';
15
17
  export * from './client-companies.js';
16
18
  export * from './pagination.js';
17
19
  export * from './theme.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Whether the browser is refusing to store the bearer cookie. Either the probe
3
+ * or the portal-origin cookie being readable proves the jar works — proxies that
4
+ * predate the probe write only the latter. A false "blocked" costs at most one
5
+ * mint, since a working cookie keeps authenticating regardless. Off-browser it
6
+ * returns false: no cookie jar is not the same as a blocked one.
7
+ */
8
+ export declare function cookiesAreBlocked(cookieString?: string): boolean;
9
+ /** The in-memory bearer, or null when the cookie path is carrying auth. */
10
+ export declare function getCookielessBearer(): string | null;
11
+ /**
12
+ * Adopt a freshly minted bearer for the cookie-less path and make sure the
13
+ * fetch patch is live. Called after a re-exchange returns one in its body.
14
+ */
15
+ export declare function setCookielessBearer(value: string | null): void;
16
+ /**
17
+ * Headers that ask the re-exchange route to return the bearer in its body.
18
+ * Shared with the renewal path in lib-client so both describe the cookie-less
19
+ * request the same way.
20
+ */
21
+ export declare function cookielessReexchangeHeaders(): Record<string, string>;
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+ // Carries the bearer for browsers that refuse to store it: Safari blocks every
3
+ // cross-site-iframe cookie and has no CHIPS, so the cookie is dropped on write
4
+ // and only the first paint (which gets the bearer as a request header) works.
5
+ //
6
+ // window.fetch is the patch point because Next routes RSC navigations, server
7
+ // actions and app fetches through it alike, and server actions cannot carry a
8
+ // custom header any other way. The bearer stays in memory so it cannot outlive
9
+ // the document or leak into history.
10
+ import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, CLIENT_BEARER_HEADER, COOKIE_PROBE_NAME, } from './session-exchange.js';
11
+ import { PORTAL_ORIGIN_COOKIE_NAME, readCookie } from './origin-bootstrap.js';
12
+ let bearer = null;
13
+ let patched = false;
14
+ /**
15
+ * Whether the browser is refusing to store the bearer cookie. Either the probe
16
+ * or the portal-origin cookie being readable proves the jar works — proxies that
17
+ * predate the probe write only the latter. A false "blocked" costs at most one
18
+ * mint, since a working cookie keeps authenticating regardless. Off-browser it
19
+ * returns false: no cookie jar is not the same as a blocked one.
20
+ */
21
+ export function cookiesAreBlocked(cookieString) {
22
+ if (cookieString === undefined && typeof document === 'undefined') {
23
+ return false;
24
+ }
25
+ return (readCookie(COOKIE_PROBE_NAME, cookieString) === null &&
26
+ readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString) === null);
27
+ }
28
+ /** The in-memory bearer, or null when the cookie path is carrying auth. */
29
+ export function getCookielessBearer() {
30
+ return bearer;
31
+ }
32
+ /**
33
+ * Adopt a freshly minted bearer for the cookie-less path and make sure the
34
+ * fetch patch is live. Called after a re-exchange returns one in its body.
35
+ */
36
+ export function setCookielessBearer(value) {
37
+ bearer = value || null;
38
+ if (bearer)
39
+ patchFetch();
40
+ }
41
+ // Same-origin only: the bearer must never ride a request to a third party the
42
+ // app happens to call. A relative URL resolves against the document, so it is
43
+ // same-origin by construction.
44
+ function isSameOrigin(input) {
45
+ if (typeof window === 'undefined')
46
+ return false;
47
+ try {
48
+ const raw = typeof input === 'string'
49
+ ? input
50
+ : input instanceof URL
51
+ ? input.href
52
+ : input.url;
53
+ return new URL(raw, window.location.href).origin === window.location.origin;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ // Patched once, and only after the cookie has been proven unavailable. A
60
+ // Request argument carries immutable headers, so it is rebuilt rather than
61
+ // mutated. The CSRF header rides along because a patched request may be a
62
+ // server action, which cannot set it itself.
63
+ function patchFetch() {
64
+ if (patched || typeof window === 'undefined')
65
+ return;
66
+ patched = true;
67
+ const originalFetch = window.fetch.bind(window);
68
+ window.fetch = function assemblyCookielessFetch(input, init) {
69
+ if (!bearer || !isSameOrigin(input)) {
70
+ return originalFetch(input, init);
71
+ }
72
+ if (input instanceof Request && !init) {
73
+ const headers = new Headers(input.headers);
74
+ headers.set(CLIENT_BEARER_HEADER, bearer);
75
+ headers.set(APP_CSRF_HEADER, '1');
76
+ return originalFetch(new Request(input, { headers }));
77
+ }
78
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
79
+ headers.set(CLIENT_BEARER_HEADER, bearer);
80
+ headers.set(APP_CSRF_HEADER, '1');
81
+ return originalFetch(input, { ...init, headers });
82
+ };
83
+ }
84
+ /**
85
+ * Headers that ask the re-exchange route to return the bearer in its body.
86
+ * Shared with the renewal path in lib-client so both describe the cookie-less
87
+ * request the same way.
88
+ */
89
+ export function cookielessReexchangeHeaders() {
90
+ return bearer
91
+ ? { [CLIENT_BEARER_HEADER]: bearer, [BEARER_IN_BODY_HEADER]: '1' }
92
+ : { [BEARER_IN_BODY_HEADER]: '1' };
93
+ }
@@ -1,2 +1,10 @@
1
1
  export declare function reexchangeBearer(): Promise<boolean>;
2
2
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
+ /**
4
+ * Mint an in-memory bearer at load when the browser is refusing to store the
5
+ * cookie, so the first client-side navigation is already authenticated instead
6
+ * of 404ing its way into a renewal. A no-op wherever the cookie works.
7
+ *
8
+ * Exported for tests; runs once at module load in the browser.
9
+ */
10
+ export declare function bootstrapCookielessBearer(): Promise<boolean>;
@@ -6,6 +6,7 @@
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
8
  import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
9
+ import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
9
10
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
10
11
  // Single-flight gate for renewals. Session tokens are single-use for exchange:
11
12
  // when the 60-minute bearer expires, every in-flight fetch on the page 401s at
@@ -33,6 +34,10 @@ async function renewBearer() {
33
34
  const previousUrlToken = typeof window === 'undefined'
34
35
  ? null
35
36
  : new URL(window.location.href).searchParams.get('token');
37
+ // Re-read per attempt rather than caching: the first call on a cookie-less
38
+ // browser is the bootstrap (no bearer yet), and the probe is the only thing
39
+ // that distinguishes it from a browser whose cookie simply expired.
40
+ const cookieless = cookiesAreBlocked();
36
41
  let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
37
42
  // Proactively refresh: we're recovering from an expiry, so avoid re-minting
38
43
  // from a session token that may itself be stale.
@@ -46,18 +51,33 @@ async function renewBearer() {
46
51
  bridgeRefreshFailed = true;
47
52
  }
48
53
  if (!token) {
49
- logRenewOutcome('no_token', { bridgeRefreshFailed });
54
+ logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
50
55
  return false;
51
56
  }
52
57
  try {
53
58
  const res = await fetch(REEXCHANGE_PATH, {
54
59
  method: 'POST',
55
- headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
60
+ headers: {
61
+ 'x-assembly-token': token,
62
+ [APP_CSRF_HEADER]: '1',
63
+ ...(cookieless ? cookielessReexchangeHeaders() : {}),
64
+ },
56
65
  });
57
66
  logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
58
67
  bridgeRefreshFailed,
59
68
  status: res.status,
69
+ cookieless,
60
70
  });
71
+ // On the cookie-less path the fresh bearer comes back in the body, because
72
+ // the Set-Cookie beside it is dropped exactly like the first one was. A body
73
+ // without one is a route that predates this path answering the ordinary way:
74
+ // the cookie was renewed if this browser keeps cookies, so the route's own
75
+ // verdict stands rather than failing a renewal that succeeded.
76
+ if (res.ok && cookieless) {
77
+ const body = await res.json().catch(() => null);
78
+ if (body?.bearer)
79
+ setCookielessBearer(body.bearer);
80
+ }
61
81
  if (res.ok && previousUrlToken !== null) {
62
82
  try {
63
83
  const url = new URL(window.location.href);
@@ -74,7 +94,7 @@ async function renewBearer() {
74
94
  return res.ok;
75
95
  }
76
96
  catch {
77
- logRenewOutcome('unavailable', { bridgeRefreshFailed });
97
+ logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
78
98
  return false;
79
99
  }
80
100
  }
@@ -106,3 +126,21 @@ export async function authFetch(input, init) {
106
126
  }
107
127
  return fetch(input, { ...init, headers });
108
128
  }
129
+ /**
130
+ * Mint an in-memory bearer at load when the browser is refusing to store the
131
+ * cookie, so the first client-side navigation is already authenticated instead
132
+ * of 404ing its way into a renewal. A no-op wherever the cookie works.
133
+ *
134
+ * Exported for tests; runs once at module load in the browser.
135
+ */
136
+ export function bootstrapCookielessBearer() {
137
+ if (typeof window === 'undefined')
138
+ return Promise.resolve(false);
139
+ if (!cookiesAreBlocked() || getCookielessBearer()) {
140
+ return Promise.resolve(false);
141
+ }
142
+ return reexchangeBearer();
143
+ }
144
+ // Self-running, mirroring origin-bootstrap: module evaluation precedes
145
+ // hydration, so the bearer is in hand before any component's first fetch.
146
+ void bootstrapCookielessBearer();
@@ -17,9 +17,22 @@ export const PAINTED_BEACON_PATH = '/__assembly/painted';
17
17
  export const PAINTED_BEACON_EVENT = 'assembly.painted';
18
18
  let scheduled = false;
19
19
  let fired = false;
20
+ // Set by the app's error boundaries while they are the document's content.
21
+ // A paint of the error boundary is not a paint of the app, and the parent
22
+ // treats this beacon as proof the app is up — so it stays quiet and releases
23
+ // the schedule latch, so the boundary can re-install it once the app has
24
+ // rendered in its place.
25
+ const ERROR_DOCUMENT_FLAG = '__assemblyErrorDocument';
26
+ function isErrorDocument() {
27
+ return window[ERROR_DOCUMENT_FLAG] === true;
28
+ }
20
29
  function firePaintBeacon() {
21
30
  if (fired)
22
31
  return;
32
+ if (isErrorDocument()) {
33
+ scheduled = false;
34
+ return;
35
+ }
23
36
  fired = true;
24
37
  if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
25
38
  navigator.sendBeacon(PAINTED_BEACON_PATH);
@@ -5,13 +5,17 @@ export declare function classifyExchangeFailure(status: number): MintFailure;
5
5
  export declare function serializeBearerCookie(value: string, maxAgeSeconds: number, partitioned: boolean): string;
6
6
  export declare const BEARER_HEADER_NAME = "x-assembly-request-bearer";
7
7
  export declare const APP_CSRF_HEADER = "x-assembly-app";
8
+ export declare const CLIENT_BEARER_HEADER = "x-assembly-client-bearer";
9
+ export declare const BEARER_IN_BODY_HEADER = "x-assembly-bearer-in-body";
10
+ export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
11
+ export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
8
12
  export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
9
13
  export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
10
14
  export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
11
15
  export declare function parseExchangeRejection(value: string | null): ExchangeRejectedViewer | undefined;
12
16
  export declare function isSessionExchangeEnabled(): boolean;
13
17
  export declare const COOKIE_OUTCOME_EVENT = "assembly.cookie_outcome";
14
- export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${string}`;
18
+ export type CookieOutcome = 'served_from_cookie' | 'served_from_client_bearer' | 'minted' | `fail_closed:${string}`;
15
19
  export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
16
20
  rejectedToken?: string;
17
21
  }): void;
@@ -20,6 +24,7 @@ export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
20
24
  export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
21
25
  bridgeRefreshFailed?: boolean;
22
26
  status?: number;
27
+ cookieless?: boolean;
23
28
  }): void;
24
29
  export declare function isDocumentRequest(request: {
25
30
  method: string;
@@ -47,6 +47,38 @@ export const BEARER_HEADER_NAME = 'x-assembly-request-bearer';
47
47
  // presence is sufficient: a cross-site request physically cannot carry a
48
48
  // custom header without a CORS preflight, and this app grants none.
49
49
  export const APP_CSRF_HEADER = 'x-assembly-app';
50
+ // Bearer carried by the client instead of the cookie, for browsers that refuse
51
+ // to store it: Safari blocks every cross-site-iframe cookie and does not
52
+ // implement CHIPS, so nothing the proxy sets is ever sent back. Distinct from
53
+ // BEARER_HEADER_NAME so an inbound value can never be mistaken for the one the
54
+ // proxy forwards to the render; both are stripped from every inbound request.
55
+ export const CLIENT_BEARER_HEADER = 'x-assembly-client-bearer';
56
+ // Asks the re-exchange route to return the minted bearer in its response body.
57
+ // Sent only by a client that has proven the cookie is unavailable, so a browser
58
+ // whose cookie works keeps the original response shape and never receives a
59
+ // bearer anywhere a script could read it.
60
+ export const BEARER_IN_BODY_HEADER = 'x-assembly-bearer-in-body';
61
+ // Client-readable twin of the bearer cookie: same attributes, no HttpOnly, no
62
+ // value worth having. The bearer itself is HttpOnly, so its absence is
63
+ // invisible to the client; a browser that dropped this one dropped the bearer
64
+ // beside it, which is what the client tests to detect cookie-less mode.
65
+ export const COOKIE_PROBE_NAME = 'assembly-cookie-probe';
66
+ // The probe answers "can this browser store our cookies at all", which does not
67
+ // stop being true when the bearer expires. Giving it the bearer's TTL would make
68
+ // a cookie-capable browser look cookie-less at every renewal, so it outlives the
69
+ // bearer and is cleared explicitly instead.
70
+ const COOKIE_PROBE_TTL_SECONDS = 24 * 60 * 60;
71
+ // Serialize the probe to accompany a bearer write or clear. Attributes mirror
72
+ // serializeBearerCookie (minus HttpOnly, and minus its TTL) so the two share a
73
+ // fate in any browser that refuses the cookie outright — a probe that survived
74
+ // where the bearer was refused would make the detection lie.
75
+ export function serializeCookieProbe(partitioned, clear = false) {
76
+ const maxAge = clear ? 0 : COOKIE_PROBE_TTL_SECONDS;
77
+ let cookie = `${COOKIE_PROBE_NAME}=1; Path=/; Secure; SameSite=None; Max-Age=${maxAge}`;
78
+ if (partitioned)
79
+ cookie += '; Partitioned';
80
+ return cookie;
81
+ }
50
82
  // Marker header on 401s that mean "the bearer expired — re-exchange and retry",
51
83
  // emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
52
84
  // absence on a denial is what lets authFetch skip renewal for genuine
@@ -99,12 +131,15 @@ export function logRenewOutcome(outcome,
99
131
  // `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
100
132
  // we fell back to the token already in hand — on its own that is survivable,
101
133
  // but paired with a rejection it names the parent as the failing side.
134
+ // `cookieless` marks a renewal on the header-borne path, so its success rate
135
+ // can be read separately from the cookie population's.
102
136
  detail) {
103
137
  console.log(JSON.stringify({
104
138
  event_type: RENEW_OUTCOME_EVENT,
105
139
  outcome,
106
140
  ...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
107
141
  ...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
142
+ ...(detail?.cookieless ? { cookieless: true } : {}),
108
143
  }));
109
144
  }
110
145
  // Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.25-rc.3",
3
+ "version": "0.0.25-rc.5",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -117,6 +117,9 @@ export const GET = cronRoute(async () => {
117
117
  ran++;
118
118
  } catch (err) {
119
119
  // One failing row must not poison the batch; leave it for the next tick.
120
+ // The error log is how the platform learns an action failed inside a
121
+ // tick that still returns 200 — keep it.
122
+ console.error('scheduled action failed', claimed.action, err);
120
123
  await db
121
124
  .update(scheduledActions)
122
125
  .set({ status: 'pending', claimedAt: null, lastError: err instanceof Error ? err.message : String(err) })