@estiva-app/identity 0.1.1 → 0.1.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
@@ -1,5 +1,94 @@
1
1
  # @estiva-app/identity
2
2
 
3
+ ## 0.1.3 — 2026-09-07
4
+
5
+ **Fixes a dead end reported from production.** No API change; `expiresAt` is now
6
+ derived differently, so a consumer needs no code change to get the fix.
7
+
8
+ `expires_in` is a **duration**, so acting on it means adding it to the local
9
+ clock. The service decides with the token's own `exp` claim, against **its**
10
+ clock. Those two answers agree only for as long as the clocks do.
11
+
12
+ When they diverge in the unsafe direction — a browser clock ahead of the
13
+ service, which is what a machine waking from a night's sleep readily produces —
14
+ the app holds a token it believes is live and every server refuses. And it does
15
+ not merely retry badly, it cannot recover at all:
16
+
17
+ - `POST /sign` answers `401 Invalid token: "exp" claim timestamp check failed`.
18
+ - `validToken()` keeps returning that same token, because by its own arithmetic
19
+ the token is fine.
20
+ - So every caller that asks gets the dead token again — including the one Convex
21
+ asks — and nothing ever renews.
22
+ - Peek's shell then sees a live token plus an unauthenticated verdict and calls
23
+ it `token_rejected`, whose copy says a configuration problem that signing in
24
+ again will not fix. It was an expiry, and signing in again was exactly what
25
+ fixed it. Clearing site data was the only way out.
26
+
27
+ **Both bounds are now computed and the earlier wins.** `exp` is what the service
28
+ will actually check, so it is the one that matters; `expires_in` is kept because
29
+ it covers a token with no readable `exp`, and a clock running *behind* the
30
+ service's, where `exp` alone would be the more generous of the two. The 30s
31
+ hold-back applies to whichever wins.
32
+
33
+ Reading the claim is not verification — that is the resource server's job and it
34
+ holds the key. This reads one number out of a payload the app already has, so
35
+ that "is this token still good" is answered by the same fact the service will
36
+ answer it with. A payload that will not parse yields no bound and the token is
37
+ still held: a token this cannot read is still a token.
38
+
39
+ 5 new tests, 59 to 64, including two controls that the `expires_in` fallback is
40
+ untouched. Reverting the clamp fails exactly the two tests that depend on it.
41
+
42
+ ## 0.1.2 — 2026-09-07
43
+
44
+ **No wire behaviour, no break.** `scheduleRenewal` is new and the guard on
45
+ `refreshAccessToken` only prevents a call that should never have been made, so a
46
+ consumer on `^0.1.0` needs no change — ADR 0002 §4b puts the break on MINOR
47
+ within `0.x`.
48
+
49
+ Two of SHA-4's five "hard-won behaviour that must survive" items did not, in
50
+ fact, survive — and both for the same reason. They lived in
51
+ `peek-app/src/auth/useEstivaIdAuth.ts`, the `ConvexProviderWithAuth` adapter,
52
+ which is deliberately the one file kept *out* of this package. So Peek kept them
53
+ and Ship, the second consumer whose entire purpose was to catch exactly this,
54
+ silently did not get them. Wiring a second consumer proves a package is not
55
+ shaped like the first app; it does not prove the first app handed everything
56
+ over.
57
+
58
+ - **`refreshAccessToken` now coalesces concurrent callers.** A refresh token is
59
+ single-use and Estiva ID reads a replay as theft, revoking the whole chain — so
60
+ two renewals in the air at once do not waste a round trip, they sign somebody
61
+ out of everything. Two callers is not hypothetical: Ship signs the NIP-98 auth
62
+ event and the content event as separate `POST /sign` calls, and a token that
63
+ expires between them answers `401` to both, each of which renews.
64
+
65
+ Peek has had this since PEEK-108 as a `refreshing` ref in its hook. Ship has
66
+ never had it. It belongs here, where every caller of every consumer gets it
67
+ without having thought about it. Coalescing, not caching: the promise is
68
+ dropped the moment it settles, and a test asserts the *next* expiry renews
69
+ again — a guard that held the promise forever would pass the first test and be
70
+ a worse bug than the one it fixed.
71
+
72
+ - **`scheduleRenewal(handlers)` renews before expiry rather than reacting to
73
+ it.** `tokenFrom` has always stored an expiry 30 seconds early; nothing in the
74
+ package acted on it. Reacting to expiry means somebody's next action is what
75
+ discovers the session ended. Renewing early means they notice nothing, which
76
+ is the whole point of PEEK-108.
77
+
78
+ The timer is injected (`setTimer`/`clearTimer`) for the same reason `fetch` and
79
+ `navigate` are: "we scheduled it" and "we did nothing" are otherwise the same
80
+ observation. The handle type never reaches the published `.d.ts` — it is
81
+ `number` in a browser and an object in Node, and naming either fails
82
+ `check-identity.yml`, correctly.
83
+
84
+ **Known limitation, documented here rather than rediscovered per app:** a
85
+ `setTimeout` is throttled in a background tab — to once a minute in Chrome and
86
+ Safari, and frozen outright after five minutes hidden — so a backgrounded tab
87
+ can miss its window. It degrades safely, because the refresh token long
88
+ outlives the access token: a late renewal is still a renewal, and anything that
89
+ beats the timer takes the existing `401`-and-retry path. The guard above is
90
+ what stops the two racing.
91
+
3
92
  ## 0.1.1 — 2026-08-28
4
93
 
5
94
  **No wire behaviour**, and no break: `pendingStore` is optional and defaults to
package/README.md CHANGED
@@ -7,7 +7,8 @@ renewal, sign-out, and the auth-shell state machine.
7
7
  npm install @estiva-app/identity
8
8
  ```
9
9
 
10
- No runtime dependencies. CI enforces that.
10
+ Nothing from outside the `@estiva-app` foundation, no data layer, and no UI.
11
+ CI enforces all three.
11
12
 
12
13
  ## The seam this package exists for
13
14
 
@@ -42,6 +43,7 @@ package come out Peek-shaped, which is the whole risk SHA-4 names.
42
43
  | `pendingStore` — in-flight PKCE credentials | defaults to `storage` | `sessionStorage`, because the flow starts and ends in one tab |
43
44
  | `keyPrefix` | `peek.estivaId` | `ship.estiva-id` |
44
45
  | `navigate` | the one genuinely untestable act, so it is observable | |
46
+ | `setTimer` / `clearTimer` | the timers `scheduleRenewal` runs on, injected for the same reason | |
45
47
 
46
48
  **Do not unify the two stores.** NIP-RS read-state slots (CRO-4) must survive a
47
49
  restart and belong in `localStorage`; an access token does not. Different
@@ -67,6 +69,44 @@ const client = createEstivaId({
67
69
  export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client
68
70
  ```
69
71
 
72
+ ## Renewal is scheduled, not reactive
73
+
74
+ ```ts
75
+ const schedule = client.scheduleRenewal({
76
+ onRenewed: (token) => setSignedIn(true),
77
+ onEnded: () => setSignedIn(false), // drop to the shell
78
+ })
79
+ // later, on sign-out or unmount
80
+ schedule.cancel()
81
+ ```
82
+
83
+ `tokenFrom` stores an expiry 30 seconds early on purpose, and `scheduleRenewal`
84
+ is what acts on it. Reacting to expiry instead means somebody's *next action* is
85
+ what discovers the session is over — a failed publish, a blank list, at best a
86
+ retry they can feel. Renewing early means they notice nothing, which is the whole
87
+ point of PEEK-108.
88
+
89
+ **Two renewals must never be in the air at once.** A refresh token is single-use
90
+ and Estiva ID reads a replay as evidence of theft, revoking the whole chain — so
91
+ a duplicate renewal does not waste a round trip, it signs somebody out of
92
+ everything. `refreshAccessToken` coalesces concurrent callers into one request,
93
+ and it does so for every caller, not only the scheduler: Ship signs the NIP-98
94
+ auth event and the content event as two separate `POST /sign` calls, and a token
95
+ that expires between them answers `401` to both.
96
+
97
+ **Known limitation: `setTimeout` is throttled in a background tab.** Chrome and
98
+ Safari clamp a hidden tab's timers to roughly once a minute, and may freeze them
99
+ outright after five minutes hidden, so a backgrounded tab can miss its renewal
100
+ window. This is recorded here rather than rediscovered once per app.
101
+
102
+ It degrades safely. The refresh token outlives the access token by a long way, so
103
+ a late renewal is still a renewal, and anything that beats the late timer takes
104
+ the existing `401`-and-retry path — which is exactly why the coalescing guard
105
+ above is not an optimisation. A `visibilitychange` listener that re-armed on
106
+ foreground would tighten it and is deliberately absent: `document` is a global
107
+ this package does not touch (ADR 0002 §4a). An app that wants it can `cancel()`
108
+ and call `scheduleRenewal` again.
109
+
70
110
  ## The prediction this package falsified
71
111
 
72
112
  Before starting, the expectation on the ticket was that identity **could not**
package/dist/client.d.ts CHANGED
@@ -102,6 +102,14 @@ export interface EstivaIdConfig {
102
102
  navigate?: (url: string) => void;
103
103
  now?: () => number;
104
104
  fetch?: FetchLike;
105
+ /**
106
+ * The timers `scheduleRenewal` runs on, injected for exactly the reason
107
+ * `fetch` and `navigate` are: a test cannot wait ten minutes to find out
108
+ * whether the renewal was scheduled, and "we scheduled it" and "we did
109
+ * nothing" are otherwise the same observation.
110
+ */
111
+ setTimer?: (run: () => void, ms: number) => unknown;
112
+ clearTimer?: (handle: unknown) => void;
105
113
  }
106
114
  export interface StoredToken {
107
115
  accessToken: string;
@@ -123,6 +131,25 @@ export interface StoredToken {
123
131
  }
124
132
  export declare class SignInError extends Error {
125
133
  }
134
+ /** What a scheduled renewal reports back. Both optional; neither is required to be useful. */
135
+ export interface RenewalHandlers {
136
+ /** A renewal succeeded. The session continues, on a later expiry. */
137
+ onRenewed?: (token: StoredToken) => void;
138
+ /**
139
+ * A renewal failed, and the session is over.
140
+ *
141
+ * The only honest thing an app can do here is drop to the shell. Note this
142
+ * fires on refusal *and* on an unreachable network — `refreshAccessToken`
143
+ * distinguishes them for the purpose of clearing the stored token, but from a
144
+ * scheduler's point of view a renewal that did not happen is a renewal that
145
+ * did not happen, and the shell's own probe is what sorts out which it was.
146
+ */
147
+ onEnded?: () => void;
148
+ }
149
+ export interface RenewalSchedule {
150
+ /** Stop renewing. Idempotent, and safe to call after the session has ended. */
151
+ cancel: () => void;
152
+ }
126
153
  export interface EstivaIdClient {
127
154
  redirectUri: () => string;
128
155
  codeChallenge: (verifier: string) => Promise<string>;
@@ -134,6 +161,7 @@ export interface EstivaIdClient {
134
161
  returnTo: string;
135
162
  }>;
136
163
  refreshAccessToken: () => Promise<StoredToken | null>;
164
+ scheduleRenewal: (handlers?: RenewalHandlers) => RenewalSchedule;
137
165
  storedToken: () => StoredToken | null;
138
166
  validToken: (now?: number) => StoredToken | null;
139
167
  hasSession: () => boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAyB,MAAM,YAAY,CAAA;AAuBxF,gFAAgF;AAChF,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KACpE,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,CAAA;AAIvE,MAAM,WAAW,cAAc;IAC7B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,WAAW,EAAE,MAAM,MAAM,CAAA;IACzB;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,aAAa,GAAG,IAAI,CAAA;IACnC;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,EAAE,MAAM,aAAa,GAAG,IAAI,CAAA;IACzC,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,SAAS,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAA;IACnB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd;;;;;;;;;;OAUG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,qBAAa,WAAY,SAAQ,KAAK;CAAG;AASzC,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,MAAM,CAAA;IACzB,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACpD,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAChF,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,WAAW,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACrF,kBAAkB,EAAE,MAAM,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IACrD,WAAW,EAAE,MAAM,WAAW,GAAG,IAAI,CAAA;IACrC,UAAU,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,WAAW,GAAG,IAAI,CAAA;IAChD,UAAU,EAAE,MAAM,OAAO,CAAA;IACzB,kBAAkB,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC5C,YAAY,EAAE,MAAM,IAAI,CAAA;IACxB,YAAY,EAAE,MAAM,IAAI,CAAA;IACxB,gBAAgB,EAAE,MAAM,OAAO,CAAA;IAC/B,kBAAkB,EAAE,MAAM,IAAI,CAAA;IAC9B,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,gBAAgB,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IAC/C,eAAe,EAAE,MAAM,WAAW,GAAG,IAAI,CAAA;CAC1C;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,CAiXrE"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -51,6 +51,11 @@
51
51
  * observe where the flow *would* go.
52
52
  */
53
53
  import { clearGuard, markGuard } from './shell.js';
54
+ /**
55
+ * `setTimeout`'s ceiling. Above 2^31-1 ms the delay overflows to a negative
56
+ * int32 and the timer fires immediately.
57
+ */
58
+ const MAX_TIMER_MS = 2_147_483_647;
54
59
  export class SignInError extends Error {
55
60
  }
56
61
  export function createEstivaId(config) {
@@ -81,18 +86,68 @@ export function createEstivaId(config) {
81
86
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
82
87
  return base64Url(new Uint8Array(digest));
83
88
  };
89
+ /**
90
+ * The `exp` claim, in epoch ms, or null if the token does not carry a readable one.
91
+ *
92
+ * Not verification — that is the resource server's job and it holds the key.
93
+ * This reads one number out of a payload the app already holds, for one
94
+ * purpose: so that "is this token still good" is answered by the same fact the
95
+ * service will answer it with.
96
+ *
97
+ * Parse failures return null rather than throwing. A token this cannot read is
98
+ * still a token, and refusing to hold one because its middle segment is not
99
+ * base64url would be a worse failure than the one this prevents.
100
+ */
101
+ const expFromJwt = (accessToken) => {
102
+ const payload = accessToken.split('.')[1];
103
+ if (!payload)
104
+ return null;
105
+ try {
106
+ const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
107
+ const exp = JSON.parse(json).exp;
108
+ return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ };
84
114
  /**
85
115
  * Shape a token response into what is kept.
86
116
  *
87
117
  * The expiry is deliberately pessimistic by 30s, so a token is never presented
88
118
  * in the window where it is technically alive and about to not be.
119
+ *
120
+ * ## Why `exp` and not just `expires_in`
121
+ *
122
+ * `expires_in` describes a *duration*, so acting on it means adding it to the
123
+ * local clock — and the service decides using the token's own `exp` against
124
+ * *its* clock. Those two answers agree only while the clocks do.
125
+ *
126
+ * When they diverge in the unsafe direction the app believes a dead token is
127
+ * live, and the failure is genuinely nasty: `POST /sign` answers
128
+ * `401 Invalid token: "exp" claim timestamp check failed`, while `validToken`
129
+ * keeps returning that same token to every caller that asks — including the
130
+ * one Convex asks — so nothing ever renews and nothing ever recovers. Peek got
131
+ * into exactly this state overnight on a machine that had slept, and the only
132
+ * way out was clearing site data and signing in again.
133
+ *
134
+ * So both bounds are computed and **the earlier wins**. `exp` is what the
135
+ * service will actually check; `expires_in` still covers a token with no
136
+ * readable `exp`, and a clock that is *behind* the service's, where `exp`
137
+ * alone would be the more generous of the two.
89
138
  */
90
- const tokenFrom = (body, pubkey) => ({
91
- accessToken: body.access_token,
92
- expiresAt: now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000,
93
- pubkey,
94
- ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
95
- });
139
+ const tokenFrom = (body, pubkey) => {
140
+ const accessToken = body.access_token;
141
+ const fromDuration = now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000;
142
+ const exp = expFromJwt(accessToken);
143
+ const fromClaim = exp === null ? null : exp - 30_000;
144
+ return {
145
+ accessToken,
146
+ expiresAt: fromClaim === null ? fromDuration : Math.min(fromDuration, fromClaim),
147
+ pubkey,
148
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
149
+ };
150
+ };
96
151
  const storedToken = () => {
97
152
  const raw = config.storage()?.getItem(TOKEN_KEY);
98
153
  if (!raw)
@@ -177,6 +232,83 @@ export function createEstivaId(config) {
177
232
  s?.removeItem(RETURN_KEY);
178
233
  return raw ?? null;
179
234
  };
235
+ /**
236
+ * Renew the access token without leaving the page (PEEK-108).
237
+ *
238
+ * This is the whole reason the grant exists. Before it, an expired token
239
+ * could only be replaced by a top-level navigation through `/authorize`,
240
+ * which costs whatever the person had on screen.
241
+ *
242
+ * Returns `null` for every failure, and the failures are not worth
243
+ * distinguishing: Estiva ID answers `invalid_grant` for all of them by
244
+ * design, and the caller's only move either way is to fall back to the
245
+ * shell. The stored token is cleared on refusal so a spent refresh token is
246
+ * never presented twice — a replay is what Estiva ID treats as theft.
247
+ */
248
+ const performRefresh = async () => {
249
+ const s = config.storage();
250
+ const current = storedToken();
251
+ if (!s || !current?.refreshToken)
252
+ return null;
253
+ let response;
254
+ try {
255
+ response = await send()(new URL('/token', config.base).toString(), {
256
+ method: 'POST',
257
+ headers: { 'content-type': 'application/json' },
258
+ body: JSON.stringify({
259
+ grantType: 'refresh_token',
260
+ refreshToken: current.refreshToken,
261
+ clientId: config.clientId,
262
+ }),
263
+ });
264
+ }
265
+ catch {
266
+ // Unreachable rather than refused. The token in hand may still be fine,
267
+ // so this deliberately does not clear the session — a flaky network must
268
+ // not sign somebody out.
269
+ return null;
270
+ }
271
+ if (!response.ok) {
272
+ clearSession();
273
+ return null;
274
+ }
275
+ const body = (await response.json());
276
+ if (!body.access_token) {
277
+ clearSession();
278
+ return null;
279
+ }
280
+ const next = tokenFrom(body, body.pubkey ?? current.pubkey);
281
+ s.setItem(TOKEN_KEY, JSON.stringify(next));
282
+ return next;
283
+ };
284
+ /**
285
+ * The renewal in flight, if there is one.
286
+ *
287
+ * **A refresh token is single-use and a replay is read as theft**, which
288
+ * revokes the whole chain — so two renewals must never be in the air at once.
289
+ * Two easily can be: Ship signs the NIP-98 auth event and the content event as
290
+ * separate `POST /sign` calls, and a token that expires between them answers
291
+ * `401` to both, each of which renews.
292
+ *
293
+ * Peek has guarded this since PEEK-108 with a `refreshing` ref in its Convex
294
+ * adapter, and SHA-4 lists the guard as one of the behaviours that must
295
+ * survive the extraction — but the guard is the one part that did not travel,
296
+ * because it lived in the hook rather than the client. So Peek kept it and
297
+ * Ship never had it. It belongs here, where every caller of every consumer
298
+ * gets it whether or not they thought about it.
299
+ *
300
+ * Coalescing rather than caching: the promise is dropped the moment it
301
+ * settles, so the *next* expiry renews again.
302
+ */
303
+ let refreshing = null;
304
+ const refreshAccessToken = () => {
305
+ if (refreshing)
306
+ return refreshing;
307
+ refreshing = performRefresh().finally(() => {
308
+ refreshing = null;
309
+ });
310
+ return refreshing;
311
+ };
180
312
  return {
181
313
  redirectUri: config.redirectUri,
182
314
  codeChallenge,
@@ -327,54 +459,87 @@ export function createEstivaId(config) {
327
459
  clearGuard(s);
328
460
  return { token, returnTo };
329
461
  },
462
+ refreshAccessToken,
330
463
  /**
331
- * Renew the access token without leaving the page (PEEK-108).
464
+ * Renew shortly **before** the token expires, rather than reacting to it
465
+ * having expired.
466
+ *
467
+ * SHA-4 lists this as one of the behaviours that must survive the
468
+ * extraction, and it is the one that reads as an optimisation and is not.
469
+ * Reacting to expiry means somebody's next action is the thing that
470
+ * discovers the session is over — a failed publish, a blank list, or at best
471
+ * a retry they can feel. Renewing 30 seconds early (`tokenFrom` already
472
+ * subtracts them) means they notice nothing at all.
473
+ *
474
+ * It did not travel with the rest of SHA-4: the timer lived in Peek's Convex
475
+ * adapter, so Peek kept it and Ship, whose whole point was to be the second
476
+ * consumer, silently did not get it. Ship renews only when `POST /sign`
477
+ * answers `401`, which is precisely "on failure".
478
+ *
479
+ * ## The timer is a `setTimeout`, and browsers throttle those
332
480
  *
333
- * This is the whole reason the grant exists. Before it, an expired token
334
- * could only be replaced by a top-level navigation through `/authorize`,
335
- * which costs whatever the person had on screen.
481
+ * A background tab gets its timers clamped — to once a minute in Chrome and
482
+ * Safari, and after five minutes of being hidden they may be frozen outright.
483
+ * So a tab left in the background can miss its renewal window entirely.
336
484
  *
337
- * Returns `null` for every failure, and the failures are not worth
338
- * distinguishing: Estiva ID answers `invalid_grant` for all of them by
339
- * design, and the caller's only move either way is to fall back to the
340
- * shell. The stored token is cleared on refusal so a spent refresh token is
341
- * never presented twice — a replay is what Estiva ID treats as theft.
485
+ * This is a known limitation rather than a bug, and it is recorded here so
486
+ * it is not rediscovered once per app. It degrades safely: a late renewal is
487
+ * still a valid renewal, because the refresh token outlives the access token
488
+ * by a long way, and a call that beats the late timer gets the existing
489
+ * `401`-and-retry path. What it must not do is renew *twice* in the scramble,
490
+ * which is what the guard on `refreshAccessToken` is for.
491
+ *
492
+ * A `visibilitychange` listener that re-arms on foreground would tighten
493
+ * this, and is deliberately not here: `document` is a global this package
494
+ * does not touch (ADR 0002 §4a), and an app that wants it can `cancel()` and
495
+ * call this again.
342
496
  */
343
- async refreshAccessToken() {
344
- const s = config.storage();
345
- const current = storedToken();
346
- if (!s || !current?.refreshToken)
347
- return null;
348
- let response;
349
- try {
350
- response = await send()(new URL('/token', config.base).toString(), {
351
- method: 'POST',
352
- headers: { 'content-type': 'application/json' },
353
- body: JSON.stringify({
354
- grantType: 'refresh_token',
355
- refreshToken: current.refreshToken,
356
- clientId: config.clientId,
357
- }),
358
- });
359
- }
360
- catch {
361
- // Unreachable rather than refused. The token in hand may still be fine,
362
- // so this deliberately does not clear the session — a flaky network must
363
- // not sign somebody out.
364
- return null;
365
- }
366
- if (!response.ok) {
367
- clearSession();
368
- return null;
369
- }
370
- const body = (await response.json());
371
- if (!body.access_token) {
372
- clearSession();
373
- return null;
374
- }
375
- const next = tokenFrom(body, body.pubkey ?? current.pubkey);
376
- s.setItem(TOKEN_KEY, JSON.stringify(next));
377
- return next;
497
+ scheduleRenewal(handlers = {}) {
498
+ const setTimer = config.setTimer ?? ((run, ms) => setTimeout(run, ms));
499
+ const clearTimer = config.clearTimer ?? ((handle) => clearTimeout(handle));
500
+ let handle = null;
501
+ let cancelled = false;
502
+ const arm = () => {
503
+ const token = storedToken();
504
+ // Nothing to renew. Not an ending — the caller is simply signed out, and
505
+ // an app that signs in later starts a new schedule.
506
+ if (!token)
507
+ return;
508
+ /*
509
+ Clamped at both ends. A negative delay is a token that already expired
510
+ — renew now rather than never, since the refresh token almost certainly
511
+ has not. The ceiling is `setTimeout`'s: a delay above 2^31-1 ms
512
+ overflows to a negative int32 and fires immediately, so a nonsensical
513
+ `expires_in` would otherwise become a hot loop against `/token` instead
514
+ of one very distant timer.
515
+ */
516
+ const delay = Math.min(Math.max(0, token.expiresAt - now()), MAX_TIMER_MS);
517
+ handle = setTimer(() => {
518
+ handle = null;
519
+ void refreshAccessToken().then((next) => {
520
+ if (cancelled)
521
+ return;
522
+ if (!next) {
523
+ handlers.onEnded?.();
524
+ return;
525
+ }
526
+ handlers.onRenewed?.(next);
527
+ // Re-armed from the *new* expiry rather than a fixed interval, so
528
+ // the schedule follows whatever lifetime Estiva ID actually issued.
529
+ arm();
530
+ });
531
+ }, delay);
532
+ };
533
+ arm();
534
+ return {
535
+ cancel() {
536
+ cancelled = true;
537
+ if (handle !== null) {
538
+ clearTimer(handle);
539
+ handle = null;
540
+ }
541
+ },
542
+ };
378
543
  },
379
544
  /**
380
545
  * Leave for Estiva ID to end the session *there* (PEEK-122).
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,EAAwC,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAoHxF;;;GAGG;AACH,MAAM,YAAY,GAAG,aAAa,CAAA;AAElC,MAAM,OAAO,WAAY,SAAQ,KAAK;CAAG;AAkDzC,MAAM,UAAU,cAAc,CAAC,MAAsB;IACnD,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;IAC5C,gFAAgF;IAChF,MAAM,OAAO,GAAG,MAAM,CAAC,YAAY,IAAI,MAAM,CAAC,OAAO,CAAA;IACrD,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,IAAI,KAAK,CAAA;IACxC,MAAM,EAAE,GAAG,CAAC,GAAW,EAAE,EAAE;QACzB,IAAI,CAAC,MAAM,CAAC,QAAQ;YAAE,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAA;QACpG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;IACtB,CAAC,CAAA;IAED,MAAM,YAAY,GAAG,GAAG,MAAM,CAAC,SAAS,eAAe,CAAA;IACvD,MAAM,SAAS,GAAG,GAAG,MAAM,CAAC,SAAS,QAAQ,CAAA;IAC7C,MAAM,UAAU,GAAG,GAAG,MAAM,CAAC,SAAS,WAAW,CAAA;IACjD,MAAM,SAAS,GAAG,GAAG,MAAM,CAAC,SAAS,QAAQ,CAAA;IAC7C,sFAAsF;IACtF,MAAM,UAAU,GAAG,GAAG,MAAM,CAAC,SAAS,cAAc,CAAA;IAEpD,MAAM,SAAS,GAAG,CAAC,KAAiB,EAAU,EAAE;QAC9C,IAAI,CAAC,GAAG,EAAE,CAAA;QACV,KAAK,MAAM,CAAC,IAAI,KAAK;YAAE,CAAC,IAAI,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAClD,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;IAC3E,CAAC,CAAA;IAED,MAAM,aAAa,GAAG,CAAC,KAAK,GAAG,EAAE,EAAU,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAEtG,uFAAuF;IACvF,MAAM,aAAa,GAAG,KAAK,EAAE,QAAgB,EAAmB,EAAE;QAChE,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAA;QACxF,OAAO,SAAS,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;IAC1C,CAAC,CAAA;IAED;;;;;;;;;;;OAWG;IACH,MAAM,UAAU,GAAG,CAAC,WAAmB,EAAiB,EAAE;QACxD,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QACzC,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAA;QACzB,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAA;YAChE,MAAM,GAAG,GAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAuB,CAAC,GAAG,CAAA;YACvD,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;QAC5E,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC,CAAA;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,MAAM,SAAS,GAAG,CAAC,IAAmB,EAAE,MAAc,EAAe,EAAE;QACrE,MAAM,WAAW,GAAG,IAAI,CAAC,YAAsB,CAAA;QAC/C,MAAM,YAAY,GAAG,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,IAAI,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAA;QAC9E,MAAM,GAAG,GAAG,UAAU,CAAC,WAAW,CAAC,CAAA;QACnC,MAAM,SAAS,GAAG,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,MAAM,CAAA;QACpD,OAAO;YACL,WAAW;YACX,SAAS,EAAE,SAAS,KAAK,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,SAAS,CAAC;YAChF,MAAM;YACN,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpE,CAAA;IACH,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,GAAuB,EAAE;QAC3C,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,EAAE,EAAE,OAAO,CAAC,SAAS,CAAC,CAAA;QAChD,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAgB,CAAA;YAC7C,OAAO,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAA;QAC/D,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC,CAAA;IAED,MAAM,UAAU,GAAG,CAAC,EAAE,GAAG,GAAG,EAAE,EAAsB,EAAE;QACpD,MAAM,CAAC,GAAG,WAAW,EAAE,CAAA;QACvB,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACzC,CAAC,CAAA;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,MAAM,YAAY,GAAG,GAAS,EAAE;QAC9B,MAAM,CAAC,OAAO,EAAE,EAAE,UAAU,CAAC,SAAS,CAAC,CAAA;QACvC,4EAA4E;QAC5E,yEAAyE;QACzE,qEAAqE;QACrE,OAAO,EAAE,EAAE,UAAU,CAAC,UAAU,CAAC,CAAA;IACnC,CAAC,CAAA;IAED;;;;;;;OAOG;IACH,MAAM,kBAAkB,GAAG,GAAS,EAAE;QACpC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;QACnB,IAAI,CAAC,CAAC;YAAE,OAAM;QACd,KAAK,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,EAAE,UAAU,CAAC;YAAE,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;IACxE,CAAC,CAAA;IAED,MAAM,gBAAgB,GAAG,CAAC,MAAmB,EAAQ,EAAE;QACrD,OAAO,EAAE,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IACxC,CAAC,CAAA;IAED,0EAA0E;IAC1E,MAAM,eAAe,GAAG,GAAuB,EAAE;QAC/C,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;QACnB,MAAM,GAAG,GAAG,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAA;QAClC,IAAI,GAAG;YAAE,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAA;QAClC,OAAQ,GAA0B,IAAI,IAAI,CAAA;IAC5C,CAAC,CAAA;IAED;;;;;;OAMG;IACH,MAAM,YAAY,GAAG,GAAkB,EAAE;QACvC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;QACnB,MAAM,GAAG,GAAG,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAA;QAClC,IAAI,GAAG;YAAE,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAA;QAClC,OAAO,GAAG,IAAI,IAAI,CAAA;IACpB,CAAC,CAAA;IAED;;;;;;;;;;;;OAYG;IACH,MAAM,cAAc,GAAG,KAAK,IAAiC,EAAE;QAC7D,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE,CAAA;QAC1B,MAAM,OAAO,GAAG,WAAW,EAAE,CAAA;QAC7B,IAAI,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY;YAAE,OAAO,IAAI,CAAA;QAE7C,IAAI,QAAwC,CAAA;QAC5C,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,IAAI,EAAE,CAAC,IAAI,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE;gBACjE,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,SAAS,EAAE,eAAe;oBAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;oBAClC,QAAQ,EAAE,MAAM,CAAC,QAAQ;iBAC1B,CAAC;aACH,CAAC,CAAA;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,wEAAwE;YACxE,yEAAyE;YACzE,yBAAyB;YACzB,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,YAAY,EAAE,CAAA;YACd,OAAO,IAAI,CAAA;QACb,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAkB,CAAA;QACrD,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACvB,YAAY,EAAE,CAAA;YACd,OAAO,IAAI,CAAA;QACb,CAAC;QAED,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;QAC3D,CAAC,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;QAC1C,OAAO,IAAI,CAAA;IACb,CAAC,CAAA;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,UAAU,GAAuC,IAAI,CAAA;IAEzD,MAAM,kBAAkB,GAAG,GAAgC,EAAE;QAC3D,IAAI,UAAU;YAAE,OAAO,UAAU,CAAA;QACjC,UAAU,GAAG,cAAc,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE;YACzC,UAAU,GAAG,IAAI,CAAA;QACnB,CAAC,CAAC,CAAA;QACF,OAAO,UAAU,CAAA;IACnB,CAAC,CAAA;IAGD,OAAO;QACL,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,aAAa;QACb,WAAW;QACX,UAAU;QACV,YAAY;QACZ,kBAAkB;QAClB,gBAAgB;QAChB,eAAe;QACf,YAAY;QAEZ,UAAU,EAAE,GAAG,EAAE,CAAC,UAAU,EAAE,KAAK,IAAI;QAEvC;;;;;;;;WAQG;QACH,kBAAkB,EAAE,GAAG,EAAE,CAAC,UAAU,EAAE,EAAE,WAAW;QAEnD;;;;;;;WAOG;QACH,gBAAgB,EAAE,GAAG,EAAE;YACrB,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;YACnB,OAAO,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAA;QAClE,CAAC;QAED;;;;;;;WAOG;QACH,KAAK,CAAC,WAAW,CAAC,QAAgB,EAAE,EAAE,MAAM,GAAG,KAAK,KAA2B,EAAE;YAC/E,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;YACnB,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAA;YAErD,MAAM,QAAQ,GAAG,aAAa,EAAE,CAAA;YAChC,MAAM,KAAK,GAAG,aAAa,CAAC,EAAE,CAAC,CAAA;YAC/B,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAA;YACjC,CAAC,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;YAC3B,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;YAE/B;;;;;;;;;;;;;;;;;;;cAmBE;YACF,IAAI,MAAM;gBAAE,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAA;YAE/B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,CAAA;YAC9C,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;YAClD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,cAAc,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAA;YAC1D,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,eAAe,EAAE,MAAM,CAAC,CAAA;YAC7C,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,gBAAgB,EAAE,MAAM,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAA;YACrE,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,uBAAuB,EAAE,MAAM,CAAC,CAAA;YACrD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;YAEpC;;;;cAIE;YACF,IAAI,MAAM;gBAAE,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;YAElD,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAA;QACpB,CAAC;QAED;;;;;;WAMG;QACH,KAAK,CAAC,cAAc,CAAC,MAAc;YACjC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAA;YACnB,MAAM,YAAY,GAAG,MAAM,CAAC,OAAO,EAAE,CAAA;YACrC,IAAI,CAAC,CAAC,IAAI,CAAC,YAAY;gBAAE,MAAM,IAAI,WAAW,CAAC,4BAA4B,CAAC,CAAA;YAE5E,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,MAAM,CAAC,CAAA;YAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YACjC,IAAI,KAAK;gBAAE,MAAM,IAAI,WAAW,CAAC,kCAAkC,KAAK,IAAI,CAAC,CAAA;YAE7E,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YAC/B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YACjC,MAAM,aAAa,GAAG,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;YAC1C,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,YAAY,CAAC,CAAA;YACxC,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,IAAI,GAAG,CAAA;YAE7C,2EAA2E;YAC3E,kEAAkE;YAClE,CAAC,CAAC,UAAU,CAAC,SAAS,CAAC,CAAA;YACvB,CAAC,CAAC,UAAU,CAAC,YAAY,CAAC,CAAA;YAC1B,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,CAAA;YAExB,IAAI,CAAC,IAAI;gBAAE,MAAM,IAAI,WAAW,CAAC,4CAA4C,CAAC,CAAA;YAC9E,IAAI,CAAC,QAAQ,IAAI,CAAC,aAAa,EAAE,CAAC;gBAChC,MAAM,IAAI,WAAW,CAAC,+DAA+D,CAAC,CAAA;YACxF,CAAC;YACD,IAAI,KAAK,KAAK,aAAa,EAAE,CAAC;gBAC5B,mEAAmE;gBACnE,uEAAuE;gBACvE,MAAM,IAAI,WAAW,CAAC,oDAAoD,CAAC,CAAA;YAC7E,CAAC;YAED,MAAM,QAAQ,GAAG,MAAM,IAAI,EAAE,CAAC,IAAI,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE;gBACvE,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,mCAAmC;gBACnC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,SAAS,EAAE,oBAAoB;oBAC/B,IAAI;oBACJ,QAAQ,EAAE,MAAM,CAAC,QAAQ;oBACzB,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE;oBACjC,YAAY,EAAE,QAAQ;iBACvB,CAAC;aACH,CAAC,CAAA;YAEF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,WAAW,CACnB,QAAQ,CAAC,MAAM,KAAK,GAAG;oBACrB,CAAC,CAAC,oFAAoF;oBACtF,CAAC,CAAC,sBAAsB,QAAQ,CAAC,MAAM,uBAAuB,CACjE,CAAA;YACH,CAAC;YAED,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAkB,CAAA;YACrD,IAAI,CAAC,IAAI,CAAC,YAAY,IAAI,CAAC,IAAI,CAAC,MAAM;gBAAE,MAAM,IAAI,WAAW,CAAC,8BAA8B,CAAC,CAAA;YAE7F,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;YAC1C,YAAY,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;YACtD,0EAA0E;YAC1E,UAAU,CAAC,CAAC,CAAC,CAAA;YACb,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAA;QAC5B,CAAC;QAED,kBAAkB;QAElB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;WAiCG;QACH,eAAe,CAAC,WAA4B,EAAE;YAC5C,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAA;YACtE,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAA;YAE1E,IAAI,MAAM,GAAY,IAAI,CAAA;YAC1B,IAAI,SAAS,GAAG,KAAK,CAAA;YAErB,MAAM,GAAG,GAAG,GAAS,EAAE;gBACrB,MAAM,KAAK,GAAG,WAAW,EAAE,CAAA;gBAC3B,yEAAyE;gBACzE,oDAAoD;gBACpD,IAAI,CAAC,KAAK;oBAAE,OAAM;gBAElB;;;;;;;kBAOE;gBACF,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,EAAE,YAAY,CAAC,CAAA;gBAE1E,MAAM,GAAG,QAAQ,CAAC,GAAG,EAAE;oBACrB,MAAM,GAAG,IAAI,CAAA;oBACb,KAAK,kBAAkB,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;wBACtC,IAAI,SAAS;4BAAE,OAAM;wBACrB,IAAI,CAAC,IAAI,EAAE,CAAC;4BACV,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAA;4BACpB,OAAM;wBACR,CAAC;wBACD,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,CAAA;wBAC1B,kEAAkE;wBAClE,oEAAoE;wBACpE,GAAG,EAAE,CAAA;oBACP,CAAC,CAAC,CAAA;gBACJ,CAAC,EAAE,KAAK,CAAC,CAAA;YACX,CAAC,CAAA;YAED,GAAG,EAAE,CAAA;YAEL,OAAO;gBACL,MAAM;oBACJ,SAAS,GAAG,IAAI,CAAA;oBAChB,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;wBACpB,UAAU,CAAC,MAAM,CAAC,CAAA;wBAClB,MAAM,GAAG,IAAI,CAAA;oBACf,CAAC;gBACH,CAAC;aACF,CAAA;QACH,CAAC;QAGD;;;;;;;;;;;WAWG;QACH,YAAY;YACV,YAAY,EAAE,CAAA;YACd,wEAAwE;YACxE,wCAAwC;YACxC,kBAAkB,EAAE,CAAA;YACpB,gBAAgB,CAAC,YAAY,CAAC,CAAA;YAC9B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,CAAA;YAC3C,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;YAClD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,0BAA0B,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAA;YACtE,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAA;QACpB,CAAC;KACF,CAAA;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -46,7 +46,7 @@
46
46
  * The thing that made it possible was not cleverness — it was that a browser API
47
47
  * an app must inject is also a browser API a test can observe.
48
48
  */
49
- export declare const IDENTITY_VERSION = "0.1.1";
49
+ export declare const IDENTITY_VERSION = "0.1.3";
50
50
  export { type KeyValueStore, type ShellPhase, type ShellReason, type ShellState, type BootObservation, type BootAction, type ShellEvent, isRecoverable, SHELL_COPY, CONTINUE_LABEL, RETRY_LABEL, PROBE_TIMEOUT_MS, GUARD_TTL_MS, GUARD_KEY, decideBoot, reasonForCallbackError, readGuard, markGuard, clearGuard, logShellEvent, } from './shell.js';
51
- export { type EstivaIdConfig, type EstivaIdClient, type StoredToken, type FetchLike, SignInError, createEstivaId, } from './client.js';
51
+ export { type EstivaIdConfig, type EstivaIdClient, type StoredToken, type FetchLike, type RenewalHandlers, type RenewalSchedule, SignInError, createEstivaId, } from './client.js';
52
52
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@
46
46
  * The thing that made it possible was not cleverness — it was that a browser API
47
47
  * an app must inject is also a browser API a test can observe.
48
48
  */
49
- export const IDENTITY_VERSION = '0.1.1';
49
+ export const IDENTITY_VERSION = '0.1.3';
50
50
  export { isRecoverable, SHELL_COPY, CONTINUE_LABEL, RETRY_LABEL, PROBE_TIMEOUT_MS, GUARD_TTL_MS, GUARD_KEY, decideBoot, reasonForCallbackError, readGuard, markGuard, clearGuard, logShellEvent, } from './shell.js';
51
51
  export { SignInError, createEstivaId, } from './client.js';
52
52
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC,OAAO,EAQL,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EAKL,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC,OAAO,EAQL,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EAOL,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/identity",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Estiva ID sign-in: authorization code + PKCE, token storage, silent renewal, sign-out, and the auth-shell state machine. Knows nothing about any app's data layer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -67,12 +67,27 @@ declare const crypto: {
67
67
  subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> }
68
68
  }
69
69
  declare const btoa: (binary: string) => string
70
+ /** Reading the `exp` claim out of a token the app already holds. */
71
+ declare const atob: (base64: string) => string
70
72
  declare const TextEncoder: { new (): { encode(input: string): Uint8Array } }
71
73
  declare const URL: {
72
74
  new (url: string, base?: string): { searchParams: { set(k: string, v: string): void }; toString(): string }
73
75
  }
74
76
  declare const URLSearchParams: { new (init: string): { get(name: string): string | null } }
75
77
 
78
+ /**
79
+ * The timer pair, for {@link EstivaIdClient.scheduleRenewal}.
80
+ *
81
+ * Declared here for the same reason as everything above, and returning
82
+ * `unknown` for one more: the handle's real type is `number` in a browser and an
83
+ * object in Node, and naming either would put an ambient type in the published
84
+ * `.d.ts` — which `check-identity.yml` fails the build over, correctly. Nothing
85
+ * outside this module ever sees a handle; `scheduleRenewal` hands back a
86
+ * `cancel` function instead.
87
+ */
88
+ declare const setTimeout: (run: () => void, ms: number) => unknown
89
+ declare const clearTimeout: (handle: unknown) => void
90
+
76
91
  /** Just enough of `fetch` to redeem a code. A parameter as well as a global. */
77
92
  export type FetchLike = (
78
93
  url: string,
@@ -122,6 +137,14 @@ export interface EstivaIdConfig {
122
137
  navigate?: (url: string) => void
123
138
  now?: () => number
124
139
  fetch?: FetchLike
140
+ /**
141
+ * The timers `scheduleRenewal` runs on, injected for exactly the reason
142
+ * `fetch` and `navigate` are: a test cannot wait ten minutes to find out
143
+ * whether the renewal was scheduled, and "we scheduled it" and "we did
144
+ * nothing" are otherwise the same observation.
145
+ */
146
+ setTimer?: (run: () => void, ms: number) => unknown
147
+ clearTimer?: (handle: unknown) => void
125
148
  }
126
149
 
127
150
  export interface StoredToken {
@@ -143,8 +166,35 @@ export interface StoredToken {
143
166
  refreshToken?: string
144
167
  }
145
168
 
169
+ /**
170
+ * `setTimeout`'s ceiling. Above 2^31-1 ms the delay overflows to a negative
171
+ * int32 and the timer fires immediately.
172
+ */
173
+ const MAX_TIMER_MS = 2_147_483_647
174
+
146
175
  export class SignInError extends Error {}
147
176
 
177
+ /** What a scheduled renewal reports back. Both optional; neither is required to be useful. */
178
+ export interface RenewalHandlers {
179
+ /** A renewal succeeded. The session continues, on a later expiry. */
180
+ onRenewed?: (token: StoredToken) => void
181
+ /**
182
+ * A renewal failed, and the session is over.
183
+ *
184
+ * The only honest thing an app can do here is drop to the shell. Note this
185
+ * fires on refusal *and* on an unreachable network — `refreshAccessToken`
186
+ * distinguishes them for the purpose of clearing the stored token, but from a
187
+ * scheduler's point of view a renewal that did not happen is a renewal that
188
+ * did not happen, and the shell's own probe is what sorts out which it was.
189
+ */
190
+ onEnded?: () => void
191
+ }
192
+
193
+ export interface RenewalSchedule {
194
+ /** Stop renewing. Idempotent, and safe to call after the session has ended. */
195
+ cancel: () => void
196
+ }
197
+
148
198
  interface TokenResponse {
149
199
  access_token?: string
150
200
  expires_in?: number
@@ -158,6 +208,7 @@ export interface EstivaIdClient {
158
208
  beginSignIn: (returnTo: string, options?: { silent?: boolean }) => Promise<void>
159
209
  completeSignIn: (search: string) => Promise<{ token: StoredToken; returnTo: string }>
160
210
  refreshAccessToken: () => Promise<StoredToken | null>
211
+ scheduleRenewal: (handlers?: RenewalHandlers) => RenewalSchedule
161
212
  storedToken: () => StoredToken | null
162
213
  validToken: (now?: number) => StoredToken | null
163
214
  hasSession: () => boolean
@@ -202,18 +253,67 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
202
253
  return base64Url(new Uint8Array(digest))
203
254
  }
204
255
 
256
+ /**
257
+ * The `exp` claim, in epoch ms, or null if the token does not carry a readable one.
258
+ *
259
+ * Not verification — that is the resource server's job and it holds the key.
260
+ * This reads one number out of a payload the app already holds, for one
261
+ * purpose: so that "is this token still good" is answered by the same fact the
262
+ * service will answer it with.
263
+ *
264
+ * Parse failures return null rather than throwing. A token this cannot read is
265
+ * still a token, and refusing to hold one because its middle segment is not
266
+ * base64url would be a worse failure than the one this prevents.
267
+ */
268
+ const expFromJwt = (accessToken: string): number | null => {
269
+ const payload = accessToken.split('.')[1]
270
+ if (!payload) return null
271
+ try {
272
+ const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
273
+ const exp = (JSON.parse(json) as { exp?: unknown }).exp
274
+ return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null
275
+ } catch {
276
+ return null
277
+ }
278
+ }
279
+
205
280
  /**
206
281
  * Shape a token response into what is kept.
207
282
  *
208
283
  * The expiry is deliberately pessimistic by 30s, so a token is never presented
209
284
  * in the window where it is technically alive and about to not be.
285
+ *
286
+ * ## Why `exp` and not just `expires_in`
287
+ *
288
+ * `expires_in` describes a *duration*, so acting on it means adding it to the
289
+ * local clock — and the service decides using the token's own `exp` against
290
+ * *its* clock. Those two answers agree only while the clocks do.
291
+ *
292
+ * When they diverge in the unsafe direction the app believes a dead token is
293
+ * live, and the failure is genuinely nasty: `POST /sign` answers
294
+ * `401 Invalid token: "exp" claim timestamp check failed`, while `validToken`
295
+ * keeps returning that same token to every caller that asks — including the
296
+ * one Convex asks — so nothing ever renews and nothing ever recovers. Peek got
297
+ * into exactly this state overnight on a machine that had slept, and the only
298
+ * way out was clearing site data and signing in again.
299
+ *
300
+ * So both bounds are computed and **the earlier wins**. `exp` is what the
301
+ * service will actually check; `expires_in` still covers a token with no
302
+ * readable `exp`, and a clock that is *behind* the service's, where `exp`
303
+ * alone would be the more generous of the two.
210
304
  */
211
- const tokenFrom = (body: TokenResponse, pubkey: string): StoredToken => ({
212
- accessToken: body.access_token as string,
213
- expiresAt: now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000,
214
- pubkey,
215
- ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
216
- })
305
+ const tokenFrom = (body: TokenResponse, pubkey: string): StoredToken => {
306
+ const accessToken = body.access_token as string
307
+ const fromDuration = now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000
308
+ const exp = expFromJwt(accessToken)
309
+ const fromClaim = exp === null ? null : exp - 30_000
310
+ return {
311
+ accessToken,
312
+ expiresAt: fromClaim === null ? fromDuration : Math.min(fromDuration, fromClaim),
313
+ pubkey,
314
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
315
+ }
316
+ }
217
317
 
218
318
  const storedToken = (): StoredToken | null => {
219
319
  const raw = config.storage()?.getItem(TOKEN_KEY)
@@ -300,6 +400,88 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
300
400
  return raw ?? null
301
401
  }
302
402
 
403
+ /**
404
+ * Renew the access token without leaving the page (PEEK-108).
405
+ *
406
+ * This is the whole reason the grant exists. Before it, an expired token
407
+ * could only be replaced by a top-level navigation through `/authorize`,
408
+ * which costs whatever the person had on screen.
409
+ *
410
+ * Returns `null` for every failure, and the failures are not worth
411
+ * distinguishing: Estiva ID answers `invalid_grant` for all of them by
412
+ * design, and the caller's only move either way is to fall back to the
413
+ * shell. The stored token is cleared on refusal so a spent refresh token is
414
+ * never presented twice — a replay is what Estiva ID treats as theft.
415
+ */
416
+ const performRefresh = async (): Promise<StoredToken | null> => {
417
+ const s = config.storage()
418
+ const current = storedToken()
419
+ if (!s || !current?.refreshToken) return null
420
+
421
+ let response: Awaited<ReturnType<FetchLike>>
422
+ try {
423
+ response = await send()(new URL('/token', config.base).toString(), {
424
+ method: 'POST',
425
+ headers: { 'content-type': 'application/json' },
426
+ body: JSON.stringify({
427
+ grantType: 'refresh_token',
428
+ refreshToken: current.refreshToken,
429
+ clientId: config.clientId,
430
+ }),
431
+ })
432
+ } catch {
433
+ // Unreachable rather than refused. The token in hand may still be fine,
434
+ // so this deliberately does not clear the session — a flaky network must
435
+ // not sign somebody out.
436
+ return null
437
+ }
438
+
439
+ if (!response.ok) {
440
+ clearSession()
441
+ return null
442
+ }
443
+
444
+ const body = (await response.json()) as TokenResponse
445
+ if (!body.access_token) {
446
+ clearSession()
447
+ return null
448
+ }
449
+
450
+ const next = tokenFrom(body, body.pubkey ?? current.pubkey)
451
+ s.setItem(TOKEN_KEY, JSON.stringify(next))
452
+ return next
453
+ }
454
+
455
+ /**
456
+ * The renewal in flight, if there is one.
457
+ *
458
+ * **A refresh token is single-use and a replay is read as theft**, which
459
+ * revokes the whole chain — so two renewals must never be in the air at once.
460
+ * Two easily can be: Ship signs the NIP-98 auth event and the content event as
461
+ * separate `POST /sign` calls, and a token that expires between them answers
462
+ * `401` to both, each of which renews.
463
+ *
464
+ * Peek has guarded this since PEEK-108 with a `refreshing` ref in its Convex
465
+ * adapter, and SHA-4 lists the guard as one of the behaviours that must
466
+ * survive the extraction — but the guard is the one part that did not travel,
467
+ * because it lived in the hook rather than the client. So Peek kept it and
468
+ * Ship never had it. It belongs here, where every caller of every consumer
469
+ * gets it whether or not they thought about it.
470
+ *
471
+ * Coalescing rather than caching: the promise is dropped the moment it
472
+ * settles, so the *next* expiry renews again.
473
+ */
474
+ let refreshing: Promise<StoredToken | null> | null = null
475
+
476
+ const refreshAccessToken = (): Promise<StoredToken | null> => {
477
+ if (refreshing) return refreshing
478
+ refreshing = performRefresh().finally(() => {
479
+ refreshing = null
480
+ })
481
+ return refreshing
482
+ }
483
+
484
+
303
485
  return {
304
486
  redirectUri: config.redirectUri,
305
487
  codeChallenge,
@@ -464,58 +646,95 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
464
646
  return { token, returnTo }
465
647
  },
466
648
 
649
+ refreshAccessToken,
650
+
467
651
  /**
468
- * Renew the access token without leaving the page (PEEK-108).
652
+ * Renew shortly **before** the token expires, rather than reacting to it
653
+ * having expired.
469
654
  *
470
- * This is the whole reason the grant exists. Before it, an expired token
471
- * could only be replaced by a top-level navigation through `/authorize`,
472
- * which costs whatever the person had on screen.
655
+ * SHA-4 lists this as one of the behaviours that must survive the
656
+ * extraction, and it is the one that reads as an optimisation and is not.
657
+ * Reacting to expiry means somebody's next action is the thing that
658
+ * discovers the session is over — a failed publish, a blank list, or at best
659
+ * a retry they can feel. Renewing 30 seconds early (`tokenFrom` already
660
+ * subtracts them) means they notice nothing at all.
473
661
  *
474
- * Returns `null` for every failure, and the failures are not worth
475
- * distinguishing: Estiva ID answers `invalid_grant` for all of them by
476
- * design, and the caller's only move either way is to fall back to the
477
- * shell. The stored token is cleared on refusal so a spent refresh token is
478
- * never presented twice — a replay is what Estiva ID treats as theft.
662
+ * It did not travel with the rest of SHA-4: the timer lived in Peek's Convex
663
+ * adapter, so Peek kept it and Ship, whose whole point was to be the second
664
+ * consumer, silently did not get it. Ship renews only when `POST /sign`
665
+ * answers `401`, which is precisely "on failure".
666
+ *
667
+ * ## The timer is a `setTimeout`, and browsers throttle those
668
+ *
669
+ * A background tab gets its timers clamped — to once a minute in Chrome and
670
+ * Safari, and after five minutes of being hidden they may be frozen outright.
671
+ * So a tab left in the background can miss its renewal window entirely.
672
+ *
673
+ * This is a known limitation rather than a bug, and it is recorded here so
674
+ * it is not rediscovered once per app. It degrades safely: a late renewal is
675
+ * still a valid renewal, because the refresh token outlives the access token
676
+ * by a long way, and a call that beats the late timer gets the existing
677
+ * `401`-and-retry path. What it must not do is renew *twice* in the scramble,
678
+ * which is what the guard on `refreshAccessToken` is for.
679
+ *
680
+ * A `visibilitychange` listener that re-arms on foreground would tighten
681
+ * this, and is deliberately not here: `document` is a global this package
682
+ * does not touch (ADR 0002 §4a), and an app that wants it can `cancel()` and
683
+ * call this again.
479
684
  */
480
- async refreshAccessToken(): Promise<StoredToken | null> {
481
- const s = config.storage()
482
- const current = storedToken()
483
- if (!s || !current?.refreshToken) return null
484
-
485
- let response: Awaited<ReturnType<FetchLike>>
486
- try {
487
- response = await send()(new URL('/token', config.base).toString(), {
488
- method: 'POST',
489
- headers: { 'content-type': 'application/json' },
490
- body: JSON.stringify({
491
- grantType: 'refresh_token',
492
- refreshToken: current.refreshToken,
493
- clientId: config.clientId,
494
- }),
495
- })
496
- } catch {
497
- // Unreachable rather than refused. The token in hand may still be fine,
498
- // so this deliberately does not clear the session — a flaky network must
499
- // not sign somebody out.
500
- return null
685
+ scheduleRenewal(handlers: RenewalHandlers = {}): RenewalSchedule {
686
+ const setTimer = config.setTimer ?? ((run, ms) => setTimeout(run, ms))
687
+ const clearTimer = config.clearTimer ?? ((handle) => clearTimeout(handle))
688
+
689
+ let handle: unknown = null
690
+ let cancelled = false
691
+
692
+ const arm = (): void => {
693
+ const token = storedToken()
694
+ // Nothing to renew. Not an ending — the caller is simply signed out, and
695
+ // an app that signs in later starts a new schedule.
696
+ if (!token) return
697
+
698
+ /*
699
+ Clamped at both ends. A negative delay is a token that already expired
700
+ — renew now rather than never, since the refresh token almost certainly
701
+ has not. The ceiling is `setTimeout`'s: a delay above 2^31-1 ms
702
+ overflows to a negative int32 and fires immediately, so a nonsensical
703
+ `expires_in` would otherwise become a hot loop against `/token` instead
704
+ of one very distant timer.
705
+ */
706
+ const delay = Math.min(Math.max(0, token.expiresAt - now()), MAX_TIMER_MS)
707
+
708
+ handle = setTimer(() => {
709
+ handle = null
710
+ void refreshAccessToken().then((next) => {
711
+ if (cancelled) return
712
+ if (!next) {
713
+ handlers.onEnded?.()
714
+ return
715
+ }
716
+ handlers.onRenewed?.(next)
717
+ // Re-armed from the *new* expiry rather than a fixed interval, so
718
+ // the schedule follows whatever lifetime Estiva ID actually issued.
719
+ arm()
720
+ })
721
+ }, delay)
501
722
  }
502
723
 
503
- if (!response.ok) {
504
- clearSession()
505
- return null
506
- }
724
+ arm()
507
725
 
508
- const body = (await response.json()) as TokenResponse
509
- if (!body.access_token) {
510
- clearSession()
511
- return null
726
+ return {
727
+ cancel(): void {
728
+ cancelled = true
729
+ if (handle !== null) {
730
+ clearTimer(handle)
731
+ handle = null
732
+ }
733
+ },
512
734
  }
513
-
514
- const next = tokenFrom(body, body.pubkey ?? current.pubkey)
515
- s.setItem(TOKEN_KEY, JSON.stringify(next))
516
- return next
517
735
  },
518
736
 
737
+
519
738
  /**
520
739
  * Leave for Estiva ID to end the session *there* (PEEK-122).
521
740
  *
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@
46
46
  * The thing that made it possible was not cleverness — it was that a browser API
47
47
  * an app must inject is also a browser API a test can observe.
48
48
  */
49
- export const IDENTITY_VERSION = '0.1.1'
49
+ export const IDENTITY_VERSION = '0.1.3'
50
50
 
51
51
  export {
52
52
  type KeyValueStore,
@@ -76,6 +76,8 @@ export {
76
76
  type EstivaIdClient,
77
77
  type StoredToken,
78
78
  type FetchLike,
79
+ type RenewalHandlers,
80
+ type RenewalSchedule,
79
81
  SignInError,
80
82
  createEstivaId,
81
83
  } from './client.js'