@assembly-js/app-foundation 0.5.1 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.5.3
23
+ - [fix] Sign-in renewal is now bounded to one attempt per 30 seconds per tab, so a burst of expired requests renews once and a late response can no longer undo a fresh renewal.
24
+
25
+ ## 0.5.2
26
+ - [fix] The builder preview no longer counts a page marked as an error document in its server HTML as the app having painted, even when it streams in before the page finishes hydrating.
27
+
22
28
  ## 0.5.1
23
29
  - [guidance] The build agent now detects PDF form fields with `detectPdfFields` and gives XFA, text-only and empty PDFs their own UI instead of reporting "no fields".
24
30
 
@@ -1,4 +1,5 @@
1
1
  import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
2
+ export declare function resetRenewalStateForTests(): void;
2
3
  export declare function reexchangeBearer(): Promise<boolean>;
3
4
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
4
5
  export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
@@ -5,7 +5,8 @@
5
5
  // never be dragged into the Edge middleware bundle that imports ./client
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
- import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
8
+ import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
9
+ import { readCookie } from './origin-bootstrap.js';
9
10
  import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
10
11
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
11
12
  // Single-flight gate for renewals. Session tokens are single-use for exchange:
@@ -13,18 +14,65 @@ const REEXCHANGE_PATH = '/api/assembly-reexchange';
13
14
  // once, and uncoordinated renewals would race each other for the token — the
14
15
  // first mints the bearer and the losers surface their original 401 even though
15
16
  // a valid bearer was just installed. All concurrent callers share this one
16
- // promise instead; it is cleared on settle so a later expiry renews afresh.
17
+ // promise instead.
17
18
  let renewalInFlight = null;
19
+ // After a renewal POST settles, its verdict stands for this long so a burst of
20
+ // 401s (and a failing route that keeps answering 401) costs one mint, not one
21
+ // per response.
22
+ const RENEWAL_WINDOW_MS = 30_000;
23
+ let lastVerdict = null;
24
+ let probeEverSet = false;
25
+ const probeIsSet = () => {
26
+ const set = readCookie(COOKIE_PROBE_NAME) !== null;
27
+ if (set)
28
+ probeEverSet = true;
29
+ return set;
30
+ };
31
+ // The probe is gone by the time the first 401 triggers a renewal, so it has to
32
+ // be seen while the page still holds a valid bearer: at load and before each
33
+ // request.
34
+ probeIsSet();
35
+ // Test seam: module state otherwise outlives a single test.
36
+ export function resetRenewalStateForTests() {
37
+ renewalInFlight = null;
38
+ lastVerdict = null;
39
+ probeEverSet = false;
40
+ }
41
+ // A late 401 response carries the proxy's cookie deletion and can erase the
42
+ // bearer a sibling call just renewed. The probe is cleared together with the
43
+ // bearer and is readable from script (the bearer is HttpOnly), so its absence
44
+ // is the only signal that the cached success no longer holds.
45
+ function verdictInWindow() {
46
+ if (!lastVerdict || Date.now() - lastVerdict.at >= RENEWAL_WINDOW_MS) {
47
+ return null;
48
+ }
49
+ if (lastVerdict.ok && lastVerdict.probeSeen && !probeIsSet())
50
+ return null;
51
+ return lastVerdict.ok;
52
+ }
18
53
  // Silently renews the bearer cookie after a 60-minute expiry. Asks the bridge
19
54
  // for the freshest session token (the parent keeps it alive) and posts it to the
20
55
  // re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
21
- // page reload. Returns true iff a fresh bearer was set. This is the only place
22
- // app code touches the raw session token, to re-mint and keep the URL aligned
23
- // with the cookie; it is never sent to the platform API.
56
+ // page reload. Returns true iff a fresh bearer is in place. Inside the window
57
+ // after a POST it answers from that POST's outcome without sending another, so
58
+ // authFetch gets false for the window after a failed renewal instead of a fresh
59
+ // mint per 401. A no_token outcome sends nothing and starts no window. This is
60
+ // the only place app code touches the raw session token, to re-mint and keep the
61
+ // URL aligned with the cookie; it is never sent to the platform API.
24
62
  export function reexchangeBearer() {
25
63
  if (renewalInFlight)
26
64
  return renewalInFlight;
27
- const renewal = renewBearer().finally(() => {
65
+ probeIsSet();
66
+ const cached = verdictInWindow();
67
+ if (cached !== null)
68
+ return Promise.resolve(cached);
69
+ const renewal = renewBearer()
70
+ .then(({ ok, sent }) => {
71
+ probeIsSet();
72
+ lastVerdict = sent ? { at: Date.now(), ok, probeSeen: probeEverSet } : null;
73
+ return ok;
74
+ })
75
+ .finally(() => {
28
76
  renewalInFlight = null;
29
77
  });
30
78
  renewalInFlight = renewal;
@@ -52,7 +100,7 @@ async function renewBearer() {
52
100
  }
53
101
  if (!token) {
54
102
  logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
55
- return false;
103
+ return { ok: false, sent: false };
56
104
  }
57
105
  try {
58
106
  const res = await fetch(REEXCHANGE_PATH, {
@@ -91,11 +139,11 @@ async function renewBearer() {
91
139
  // A history update failure must not invalidate the cookie just renewed.
92
140
  }
93
141
  }
94
- return res.ok;
142
+ return { ok: res.ok, sent: true };
95
143
  }
96
144
  catch {
97
145
  logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
98
- return false;
146
+ return { ok: false, sent: true };
99
147
  }
100
148
  }
101
149
  // Whether a denial is worth a renew-and-retry. A 401 is an authentication
@@ -116,6 +164,7 @@ function shouldRenewBearer(res) {
116
164
  // once via reexchangeBearer and retries; a genuine forbidden just returns.
117
165
  export async function authFetch(input, init) {
118
166
  const headers = { ...(init?.headers ?? {}), [APP_CSRF_HEADER]: '1' };
167
+ probeIsSet();
119
168
  const res = await fetch(input, { ...init, headers });
120
169
  if (!shouldRenewBearer(res)) {
121
170
  return res;
@@ -17,8 +17,12 @@ let fired = false;
17
17
  // the schedule latch, so the boundary can re-install it once the app has
18
18
  // rendered in its place.
19
19
  const ERROR_DOCUMENT_FLAG = '__assemblyErrorDocument';
20
+ // The same pages also carry this attribute in their server HTML. A page streamed
21
+ // in under Suspense can reach load before React hydrates it and sets the flag.
22
+ const ERROR_DOCUMENT_SELECTOR = '[data-assembly-error-document]';
20
23
  function isErrorDocument() {
21
- return window[ERROR_DOCUMENT_FLAG] === true;
24
+ return (window[ERROR_DOCUMENT_FLAG] === true ||
25
+ document.querySelector(ERROR_DOCUMENT_SELECTOR) !== null);
22
26
  }
23
27
  function firePaintBeacon() {
24
28
  if (fired)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [