@estiva-app/identity 0.1.0 → 0.1.2

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,81 @@
1
1
  # @estiva-app/identity
2
2
 
3
+ ## 0.1.2 — 2026-09-07
4
+
5
+ **No wire behaviour, no break.** `scheduleRenewal` is new and the guard on
6
+ `refreshAccessToken` only prevents a call that should never have been made, so a
7
+ consumer on `^0.1.0` needs no change — ADR 0002 §4b puts the break on MINOR
8
+ within `0.x`.
9
+
10
+ Two of SHA-4's five "hard-won behaviour that must survive" items did not, in
11
+ fact, survive — and both for the same reason. They lived in
12
+ `peek-app/src/auth/useEstivaIdAuth.ts`, the `ConvexProviderWithAuth` adapter,
13
+ which is deliberately the one file kept *out* of this package. So Peek kept them
14
+ and Ship, the second consumer whose entire purpose was to catch exactly this,
15
+ silently did not get them. Wiring a second consumer proves a package is not
16
+ shaped like the first app; it does not prove the first app handed everything
17
+ over.
18
+
19
+ - **`refreshAccessToken` now coalesces concurrent callers.** A refresh token is
20
+ single-use and Estiva ID reads a replay as theft, revoking the whole chain — so
21
+ two renewals in the air at once do not waste a round trip, they sign somebody
22
+ out of everything. Two callers is not hypothetical: Ship signs the NIP-98 auth
23
+ event and the content event as separate `POST /sign` calls, and a token that
24
+ expires between them answers `401` to both, each of which renews.
25
+
26
+ Peek has had this since PEEK-108 as a `refreshing` ref in its hook. Ship has
27
+ never had it. It belongs here, where every caller of every consumer gets it
28
+ without having thought about it. Coalescing, not caching: the promise is
29
+ dropped the moment it settles, and a test asserts the *next* expiry renews
30
+ again — a guard that held the promise forever would pass the first test and be
31
+ a worse bug than the one it fixed.
32
+
33
+ - **`scheduleRenewal(handlers)` renews before expiry rather than reacting to
34
+ it.** `tokenFrom` has always stored an expiry 30 seconds early; nothing in the
35
+ package acted on it. Reacting to expiry means somebody's next action is what
36
+ discovers the session ended. Renewing early means they notice nothing, which
37
+ is the whole point of PEEK-108.
38
+
39
+ The timer is injected (`setTimer`/`clearTimer`) for the same reason `fetch` and
40
+ `navigate` are: "we scheduled it" and "we did nothing" are otherwise the same
41
+ observation. The handle type never reaches the published `.d.ts` — it is
42
+ `number` in a browser and an object in Node, and naming either fails
43
+ `check-identity.yml`, correctly.
44
+
45
+ **Known limitation, documented here rather than rediscovered per app:** a
46
+ `setTimeout` is throttled in a background tab — to once a minute in Chrome and
47
+ Safari, and frozen outright after five minutes hidden — so a backgrounded tab
48
+ can miss its window. It degrades safely, because the refresh token long
49
+ outlives the access token: a late renewal is still a renewal, and anything that
50
+ beats the timer takes the existing `401`-and-retry path. The guard above is
51
+ what stops the two racing.
52
+
53
+ ## 0.1.1 — 2026-08-28
54
+
55
+ **No wire behaviour**, and no break: `pendingStore` is optional and defaults to
56
+ `storage`, so a consumer on `^0.1.0` needs no change. Additive, so a PATCH —
57
+ ADR 0002 §4b puts the break on MINOR within `0.x`.
58
+
59
+ - **`pendingStore`**, a second storage seam. Wiring Ship — the second consumer —
60
+ found that the package assumed **one** store because it was extracted from
61
+ Peek, which uses one. Ship uses two, with different lifetimes on purpose: its
62
+ *session* lives in `localStorage` so it outlives a tab, and its *in-flight PKCE
63
+ credentials* live in `sessionStorage` because the flow starts and ends in one
64
+ tab, which is also why the silent-attempt guard belongs there.
65
+
66
+ This is the exact "a library pulled from one app comes out shaped like that
67
+ app" failure SHA-4 names, found by the mechanism SHA-4 prescribes for finding
68
+ it. The ticket said wiring Ship was the point rather than a formality; it was
69
+ right, and this is what it caught.
70
+
71
+ `clearSession` now spans both stores — the token from `storage`, the shell
72
+ reason from `pendingStore` — while still leaving the verifier, state and
73
+ returnTo alone. PEEK-167's separation had to survive the split, or the split
74
+ would have reintroduced the bug the separation exists to prevent. A test
75
+ asserts exactly that, and a control asserts the single-store default still
76
+ behaves as Peek expects, so none of it can pass on a package that quietly
77
+ ignores the new parameter.
78
+
3
79
  ## 0.1.0 — 2026-08-27
4
80
 
5
81
  First release. SHA-4, extracted during REW-2.
package/README.md CHANGED
@@ -38,13 +38,21 @@ package come out Peek-shaped, which is the whole risk SHA-4 names.
38
38
  | --- | --- | --- |
39
39
  | `clientId` | `estiva-peek` | `estiva-ship` |
40
40
  | `redirectUri` | a fixed `/auth/callback` | its current pathname — Ship routes on `location.hash` |
41
- | `storage` | `sessionStorage`, which dies with the tab | `localStorage` |
41
+ | `storage` — the session | `sessionStorage`, dies with the tab | `localStorage`, outlives a tab |
42
+ | `pendingStore` — in-flight PKCE credentials | defaults to `storage` | `sessionStorage`, because the flow starts and ends in one tab |
42
43
  | `keyPrefix` | `peek.estivaId` | `ship.estiva-id` |
43
44
  | `navigate` | the one genuinely untestable act, so it is observable | |
45
+ | `setTimer` / `clearTimer` | the timers `scheduleRenewal` runs on, injected for the same reason | |
44
46
 
45
- **Do not unify `storage`.** NIP-RS read-state slots (CRO-4) must survive a restart
46
- and belong in `localStorage`; an access token does not. Different lifetimes,
47
- different homes.
47
+ **Do not unify the two stores.** NIP-RS read-state slots (CRO-4) must survive a
48
+ restart and belong in `localStorage`; an access token does not. Different
49
+ lifetimes, different homes.
50
+
51
+ `pendingStore` exists because wiring the second consumer found the package
52
+ assuming one store — it was extracted from Peek, which uses one. That is the "a
53
+ library pulled from one app comes out shaped like that app" failure SHA-4 names,
54
+ caught by the mechanism SHA-4 prescribes. Worth knowing as evidence that the
55
+ second-consumer rule earns its keep rather than being ceremony.
48
56
 
49
57
  An app instantiates once and re-exports its own names, so call sites do not churn:
50
58
 
@@ -60,6 +68,44 @@ const client = createEstivaId({
60
68
  export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client
61
69
  ```
62
70
 
71
+ ## Renewal is scheduled, not reactive
72
+
73
+ ```ts
74
+ const schedule = client.scheduleRenewal({
75
+ onRenewed: (token) => setSignedIn(true),
76
+ onEnded: () => setSignedIn(false), // drop to the shell
77
+ })
78
+ // later, on sign-out or unmount
79
+ schedule.cancel()
80
+ ```
81
+
82
+ `tokenFrom` stores an expiry 30 seconds early on purpose, and `scheduleRenewal`
83
+ is what acts on it. Reacting to expiry instead means somebody's *next action* is
84
+ what discovers the session is over — a failed publish, a blank list, at best a
85
+ retry they can feel. Renewing early means they notice nothing, which is the whole
86
+ point of PEEK-108.
87
+
88
+ **Two renewals must never be in the air at once.** A refresh token is single-use
89
+ and Estiva ID reads a replay as evidence of theft, revoking the whole chain — so
90
+ a duplicate renewal does not waste a round trip, it signs somebody out of
91
+ everything. `refreshAccessToken` coalesces concurrent callers into one request,
92
+ and it does so for every caller, not only the scheduler: Ship signs the NIP-98
93
+ auth event and the content event as two separate `POST /sign` calls, and a token
94
+ that expires between them answers `401` to both.
95
+
96
+ **Known limitation: `setTimeout` is throttled in a background tab.** Chrome and
97
+ Safari clamp a hidden tab's timers to roughly once a minute, and may freeze them
98
+ outright after five minutes hidden, so a backgrounded tab can miss its renewal
99
+ window. This is recorded here rather than rediscovered once per app.
100
+
101
+ It degrades safely. The refresh token outlives the access token by a long way, so
102
+ a late renewal is still a renewal, and anything that beats the late timer takes
103
+ the existing `401`-and-retry path — which is exactly why the coalescing guard
104
+ above is not an optimisation. A `visibilitychange` listener that re-armed on
105
+ foreground would tighten it and is deliberately absent: `document` is a global
106
+ this package does not touch (ADR 0002 §4a). An app that wants it can `cancel()`
107
+ and call `scheduleRenewal` again.
108
+
63
109
  ## The prediction this package falsified
64
110
 
65
111
  Before starting, the expectation on the ticket was that identity **could not**
package/dist/client.d.ts CHANGED
@@ -38,11 +38,14 @@
38
38
  * `app_credentials` with separate `redirectUris` and separate kind ceilings.
39
39
  * - **`redirectUri`** — Peek registers a fixed `/auth/callback`; Ship routes on
40
40
  * `location.hash` and registers its current pathname. Neither is more correct.
41
- * - **`storage`** — Peek keeps its token in `sessionStorage`, which dies with the
42
- * tab and keeps a bearer token off a shared machine's disk. Ship uses
43
- * `localStorage`. **Do not unify this**: NIP-RS slots (CRO-4) must survive a
44
- * restart and belong in `localStorage`, while an access token does not.
45
- * Different lifetimes, different homes.
41
+ * - **`storage`** and **`pendingStore`** — two stores, because Ship needs two.
42
+ * Peek keeps everything in `sessionStorage`, which dies with the tab and keeps
43
+ * a bearer token off a shared machine's disk. Ship keeps its *session* in
44
+ * `localStorage` so it outlives a tab, and its *in-flight PKCE credentials* in
45
+ * `sessionStorage` because the flow starts and ends in one tab. The package
46
+ * assumed one store until the second consumer was wired — see `pendingStore`.
47
+ * **Do not unify them**: NIP-RS slots (CRO-4) must survive a restart and belong
48
+ * in `localStorage`, while an access token does not.
46
49
  * - **`keyPrefix`** — so two apps on one origin cannot read each other's keys.
47
50
  * - **`navigate`** — the one genuinely untestable act. Injected so a test can
48
51
  * observe where the flow *would* go.
@@ -68,14 +71,45 @@ export interface EstivaIdConfig {
68
71
  * **exactly** — a trailing slash is a refused sign-in.
69
72
  */
70
73
  redirectUri: () => string;
71
- /** See the note on storage above. Returns null outside a browser. */
74
+ /**
75
+ * Where the **session** lives — the token and nothing else.
76
+ *
77
+ * Peek uses `sessionStorage`: it dies with the tab, which keeps a bearer token
78
+ * off a shared machine's disk. Ship uses `localStorage` so a session survives
79
+ * a tab close. Returns null outside a browser.
80
+ */
72
81
  storage: () => KeyValueStore | null;
82
+ /**
83
+ * Where an **in-flight sign-in's** single-use credentials live — the verifier,
84
+ * the state, the returnTo, the shell reason and the silent-attempt guard.
85
+ *
86
+ * Defaults to {@link storage}, which is what Peek wants: one store for both.
87
+ *
88
+ * **Ship needs them separated, and that is why this exists.** Its session
89
+ * belongs in `localStorage` so it outlives a tab, while the PKCE credentials
90
+ * are per-tab by nature — the flow starts and ends in one tab, and the guard's
91
+ * whole point is that a genuinely new tab is entitled to a fresh attempt.
92
+ *
93
+ * This parameter was added by wiring the second consumer. The package had
94
+ * assumed one store because it was extracted from Peek, which uses one: the
95
+ * exact "comes out shaped like the app it came from" failure SHA-4 names, found
96
+ * by the mechanism SHA-4 prescribes for finding it.
97
+ */
98
+ pendingStore?: () => KeyValueStore | null;
73
99
  /** Namespaces this app's keys, e.g. `peek.estivaId`. */
74
100
  keyPrefix: string;
75
101
  /** Defaults to the runtime's `location.assign`-equivalent via `navigate`. */
76
102
  navigate?: (url: string) => void;
77
103
  now?: () => number;
78
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;
79
113
  }
80
114
  export interface StoredToken {
81
115
  accessToken: string;
@@ -97,6 +131,25 @@ export interface StoredToken {
97
131
  }
98
132
  export declare class SignInError extends Error {
99
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
+ }
100
153
  export interface EstivaIdClient {
101
154
  redirectUri: () => string;
102
155
  codeChallenge: (verifier: string) => Promise<string>;
@@ -108,6 +161,7 @@ export interface EstivaIdClient {
108
161
  returnTo: string;
109
162
  }>;
110
163
  refreshAccessToken: () => Promise<StoredToken | null>;
164
+ scheduleRenewal: (handlers?: RenewalHandlers) => RenewalSchedule;
111
165
  storedToken: () => StoredToken | null;
112
166
  validToken: (now?: number) => StoredToken | null;
113
167
  hasSession: () => boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;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,qEAAqE;IACrE,OAAO,EAAE,MAAM,aAAa,GAAG,IAAI,CAAA;IACnC,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,CA4WrE"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -38,20 +38,30 @@
38
38
  * `app_credentials` with separate `redirectUris` and separate kind ceilings.
39
39
  * - **`redirectUri`** — Peek registers a fixed `/auth/callback`; Ship routes on
40
40
  * `location.hash` and registers its current pathname. Neither is more correct.
41
- * - **`storage`** — Peek keeps its token in `sessionStorage`, which dies with the
42
- * tab and keeps a bearer token off a shared machine's disk. Ship uses
43
- * `localStorage`. **Do not unify this**: NIP-RS slots (CRO-4) must survive a
44
- * restart and belong in `localStorage`, while an access token does not.
45
- * Different lifetimes, different homes.
41
+ * - **`storage`** and **`pendingStore`** — two stores, because Ship needs two.
42
+ * Peek keeps everything in `sessionStorage`, which dies with the tab and keeps
43
+ * a bearer token off a shared machine's disk. Ship keeps its *session* in
44
+ * `localStorage` so it outlives a tab, and its *in-flight PKCE credentials* in
45
+ * `sessionStorage` because the flow starts and ends in one tab. The package
46
+ * assumed one store until the second consumer was wired — see `pendingStore`.
47
+ * **Do not unify them**: NIP-RS slots (CRO-4) must survive a restart and belong
48
+ * in `localStorage`, while an access token does not.
46
49
  * - **`keyPrefix`** — so two apps on one origin cannot read each other's keys.
47
50
  * - **`navigate`** — the one genuinely untestable act. Injected so a test can
48
51
  * observe where the flow *would* go.
49
52
  */
50
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;
51
59
  export class SignInError extends Error {
52
60
  }
53
61
  export function createEstivaId(config) {
54
62
  const now = config.now ?? (() => Date.now());
63
+ /** In-flight credentials, which may live somewhere shorter than the session. */
64
+ const pending = config.pendingStore ?? config.storage;
55
65
  const send = () => config.fetch ?? fetch;
56
66
  const go = (url) => {
57
67
  if (!config.navigate)
@@ -126,11 +136,11 @@ export function createEstivaId(config) {
126
136
  * Anything that wants both says so, and only `beginSignOut` does.
127
137
  */
128
138
  const clearSession = () => {
129
- const s = config.storage();
130
- if (!s)
131
- return;
132
- for (const k of [TOKEN_KEY, REASON_KEY])
133
- s.removeItem(k);
139
+ config.storage()?.removeItem(TOKEN_KEY);
140
+ // The reason lives with the in-flight credentials, which may be a different
141
+ // store — see `pendingStore`. Still cleared here: a reason outliving the
142
+ // session it described is a lie, and that was true before the split.
143
+ pending()?.removeItem(REASON_KEY);
134
144
  };
135
145
  /**
136
146
  * Abandon a sign-in that has not come back.
@@ -141,18 +151,18 @@ export function createEstivaId(config) {
141
151
  * different challenge. Destroying a live one is the expensive direction.
142
152
  */
143
153
  const clearPendingSignIn = () => {
144
- const s = config.storage();
154
+ const s = pending();
145
155
  if (!s)
146
156
  return;
147
157
  for (const k of [VERIFIER_KEY, STATE_KEY, RETURN_KEY])
148
158
  s.removeItem(k);
149
159
  };
150
160
  const stashShellReason = (reason) => {
151
- config.storage()?.setItem(REASON_KEY, reason);
161
+ pending()?.setItem(REASON_KEY, reason);
152
162
  };
153
163
  /** Read once. Single-use: a stale reason outliving its cause is a lie. */
154
164
  const takeShellReason = () => {
155
- const s = config.storage();
165
+ const s = pending();
156
166
  const raw = s?.getItem(REASON_KEY);
157
167
  if (raw)
158
168
  s?.removeItem(REASON_KEY);
@@ -166,12 +176,89 @@ export function createEstivaId(config) {
166
176
  * puts people on a default screen instead of the link they opened (PEEK-123).
167
177
  */
168
178
  const takeReturnTo = () => {
169
- const s = config.storage();
179
+ const s = pending();
170
180
  const raw = s?.getItem(RETURN_KEY);
171
181
  if (raw)
172
182
  s?.removeItem(RETURN_KEY);
173
183
  return raw ?? null;
174
184
  };
185
+ /**
186
+ * Renew the access token without leaving the page (PEEK-108).
187
+ *
188
+ * This is the whole reason the grant exists. Before it, an expired token
189
+ * could only be replaced by a top-level navigation through `/authorize`,
190
+ * which costs whatever the person had on screen.
191
+ *
192
+ * Returns `null` for every failure, and the failures are not worth
193
+ * distinguishing: Estiva ID answers `invalid_grant` for all of them by
194
+ * design, and the caller's only move either way is to fall back to the
195
+ * shell. The stored token is cleared on refusal so a spent refresh token is
196
+ * never presented twice — a replay is what Estiva ID treats as theft.
197
+ */
198
+ const performRefresh = async () => {
199
+ const s = config.storage();
200
+ const current = storedToken();
201
+ if (!s || !current?.refreshToken)
202
+ return null;
203
+ let response;
204
+ try {
205
+ response = await send()(new URL('/token', config.base).toString(), {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json' },
208
+ body: JSON.stringify({
209
+ grantType: 'refresh_token',
210
+ refreshToken: current.refreshToken,
211
+ clientId: config.clientId,
212
+ }),
213
+ });
214
+ }
215
+ catch {
216
+ // Unreachable rather than refused. The token in hand may still be fine,
217
+ // so this deliberately does not clear the session — a flaky network must
218
+ // not sign somebody out.
219
+ return null;
220
+ }
221
+ if (!response.ok) {
222
+ clearSession();
223
+ return null;
224
+ }
225
+ const body = (await response.json());
226
+ if (!body.access_token) {
227
+ clearSession();
228
+ return null;
229
+ }
230
+ const next = tokenFrom(body, body.pubkey ?? current.pubkey);
231
+ s.setItem(TOKEN_KEY, JSON.stringify(next));
232
+ return next;
233
+ };
234
+ /**
235
+ * The renewal in flight, if there is one.
236
+ *
237
+ * **A refresh token is single-use and a replay is read as theft**, which
238
+ * revokes the whole chain — so two renewals must never be in the air at once.
239
+ * Two easily can be: Ship signs the NIP-98 auth event and the content event as
240
+ * separate `POST /sign` calls, and a token that expires between them answers
241
+ * `401` to both, each of which renews.
242
+ *
243
+ * Peek has guarded this since PEEK-108 with a `refreshing` ref in its Convex
244
+ * adapter, and SHA-4 lists the guard as one of the behaviours that must
245
+ * survive the extraction — but the guard is the one part that did not travel,
246
+ * because it lived in the hook rather than the client. So Peek kept it and
247
+ * Ship never had it. It belongs here, where every caller of every consumer
248
+ * gets it whether or not they thought about it.
249
+ *
250
+ * Coalescing rather than caching: the promise is dropped the moment it
251
+ * settles, so the *next* expiry renews again.
252
+ */
253
+ let refreshing = null;
254
+ const refreshAccessToken = () => {
255
+ if (refreshing)
256
+ return refreshing;
257
+ refreshing = performRefresh().finally(() => {
258
+ refreshing = null;
259
+ });
260
+ return refreshing;
261
+ };
175
262
  return {
176
263
  redirectUri: config.redirectUri,
177
264
  codeChallenge,
@@ -202,7 +289,7 @@ export function createEstivaId(config) {
202
289
  * code that just arrived belongs to anything this tab did.
203
290
  */
204
291
  hasPendingSignIn: () => {
205
- const s = config.storage();
292
+ const s = pending();
206
293
  return Boolean(s?.getItem(VERIFIER_KEY) && s.getItem(STATE_KEY));
207
294
  },
208
295
  /**
@@ -214,7 +301,7 @@ export function createEstivaId(config) {
214
301
  * compared.
215
302
  */
216
303
  async beginSignIn(returnTo, { silent = false } = {}) {
217
- const s = config.storage();
304
+ const s = pending();
218
305
  if (!s)
219
306
  throw new Error('sign-in requires a browser');
220
307
  const verifier = randomUrlSafe();
@@ -268,8 +355,9 @@ export function createEstivaId(config) {
268
355
  * on this side of the exchange.
269
356
  */
270
357
  async completeSignIn(search) {
271
- const s = config.storage();
272
- if (!s)
358
+ const s = pending();
359
+ const sessionStore = config.storage();
360
+ if (!s || !sessionStore)
273
361
  throw new SignInError('sign-in requires a browser');
274
362
  const params = new URLSearchParams(search);
275
363
  const error = params.get('error');
@@ -316,59 +404,92 @@ export function createEstivaId(config) {
316
404
  if (!body.access_token || !body.pubkey)
317
405
  throw new SignInError('Estiva ID returned no token.');
318
406
  const token = tokenFrom(body, body.pubkey);
319
- s.setItem(TOKEN_KEY, JSON.stringify(token));
407
+ sessionStore.setItem(TOKEN_KEY, JSON.stringify(token));
320
408
  // The attempt worked, so the next boot is entitled to a fresh silent one.
321
409
  clearGuard(s);
322
410
  return { token, returnTo };
323
411
  },
412
+ refreshAccessToken,
324
413
  /**
325
- * Renew the access token without leaving the page (PEEK-108).
414
+ * Renew shortly **before** the token expires, rather than reacting to it
415
+ * having expired.
416
+ *
417
+ * SHA-4 lists this as one of the behaviours that must survive the
418
+ * extraction, and it is the one that reads as an optimisation and is not.
419
+ * Reacting to expiry means somebody's next action is the thing that
420
+ * discovers the session is over — a failed publish, a blank list, or at best
421
+ * a retry they can feel. Renewing 30 seconds early (`tokenFrom` already
422
+ * subtracts them) means they notice nothing at all.
423
+ *
424
+ * It did not travel with the rest of SHA-4: the timer lived in Peek's Convex
425
+ * adapter, so Peek kept it and Ship, whose whole point was to be the second
426
+ * consumer, silently did not get it. Ship renews only when `POST /sign`
427
+ * answers `401`, which is precisely "on failure".
428
+ *
429
+ * ## The timer is a `setTimeout`, and browsers throttle those
430
+ *
431
+ * A background tab gets its timers clamped — to once a minute in Chrome and
432
+ * Safari, and after five minutes of being hidden they may be frozen outright.
433
+ * So a tab left in the background can miss its renewal window entirely.
326
434
  *
327
- * This is the whole reason the grant exists. Before it, an expired token
328
- * could only be replaced by a top-level navigation through `/authorize`,
329
- * which costs whatever the person had on screen.
435
+ * This is a known limitation rather than a bug, and it is recorded here so
436
+ * it is not rediscovered once per app. It degrades safely: a late renewal is
437
+ * still a valid renewal, because the refresh token outlives the access token
438
+ * by a long way, and a call that beats the late timer gets the existing
439
+ * `401`-and-retry path. What it must not do is renew *twice* in the scramble,
440
+ * which is what the guard on `refreshAccessToken` is for.
330
441
  *
331
- * Returns `null` for every failure, and the failures are not worth
332
- * distinguishing: Estiva ID answers `invalid_grant` for all of them by
333
- * design, and the caller's only move either way is to fall back to the
334
- * shell. The stored token is cleared on refusal so a spent refresh token is
335
- * never presented twice — a replay is what Estiva ID treats as theft.
442
+ * A `visibilitychange` listener that re-arms on foreground would tighten
443
+ * this, and is deliberately not here: `document` is a global this package
444
+ * does not touch (ADR 0002 §4a), and an app that wants it can `cancel()` and
445
+ * call this again.
336
446
  */
337
- async refreshAccessToken() {
338
- const s = config.storage();
339
- const current = storedToken();
340
- if (!s || !current?.refreshToken)
341
- return null;
342
- let response;
343
- try {
344
- response = await send()(new URL('/token', config.base).toString(), {
345
- method: 'POST',
346
- headers: { 'content-type': 'application/json' },
347
- body: JSON.stringify({
348
- grantType: 'refresh_token',
349
- refreshToken: current.refreshToken,
350
- clientId: config.clientId,
351
- }),
352
- });
353
- }
354
- catch {
355
- // Unreachable rather than refused. The token in hand may still be fine,
356
- // so this deliberately does not clear the session — a flaky network must
357
- // not sign somebody out.
358
- return null;
359
- }
360
- if (!response.ok) {
361
- clearSession();
362
- return null;
363
- }
364
- const body = (await response.json());
365
- if (!body.access_token) {
366
- clearSession();
367
- return null;
368
- }
369
- const next = tokenFrom(body, body.pubkey ?? current.pubkey);
370
- s.setItem(TOKEN_KEY, JSON.stringify(next));
371
- return next;
447
+ scheduleRenewal(handlers = {}) {
448
+ const setTimer = config.setTimer ?? ((run, ms) => setTimeout(run, ms));
449
+ const clearTimer = config.clearTimer ?? ((handle) => clearTimeout(handle));
450
+ let handle = null;
451
+ let cancelled = false;
452
+ const arm = () => {
453
+ const token = storedToken();
454
+ // Nothing to renew. Not an ending — the caller is simply signed out, and
455
+ // an app that signs in later starts a new schedule.
456
+ if (!token)
457
+ return;
458
+ /*
459
+ Clamped at both ends. A negative delay is a token that already expired
460
+ — renew now rather than never, since the refresh token almost certainly
461
+ has not. The ceiling is `setTimeout`'s: a delay above 2^31-1 ms
462
+ overflows to a negative int32 and fires immediately, so a nonsensical
463
+ `expires_in` would otherwise become a hot loop against `/token` instead
464
+ of one very distant timer.
465
+ */
466
+ const delay = Math.min(Math.max(0, token.expiresAt - now()), MAX_TIMER_MS);
467
+ handle = setTimer(() => {
468
+ handle = null;
469
+ void refreshAccessToken().then((next) => {
470
+ if (cancelled)
471
+ return;
472
+ if (!next) {
473
+ handlers.onEnded?.();
474
+ return;
475
+ }
476
+ handlers.onRenewed?.(next);
477
+ // Re-armed from the *new* expiry rather than a fixed interval, so
478
+ // the schedule follows whatever lifetime Estiva ID actually issued.
479
+ arm();
480
+ });
481
+ }, delay);
482
+ };
483
+ arm();
484
+ return {
485
+ cancel() {
486
+ cancelled = true;
487
+ if (handle !== null) {
488
+ clearTimer(handle);
489
+ handle = null;
490
+ }
491
+ },
492
+ };
372
493
  },
373
494
  /**
374
495
  * 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":"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"}
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.0";
49
+ export declare const IDENTITY_VERSION = "0.1.2";
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.0';
49
+ export const IDENTITY_VERSION = '0.1.2';
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.0",
3
+ "version": "0.1.2",
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
@@ -38,11 +38,14 @@
38
38
  * `app_credentials` with separate `redirectUris` and separate kind ceilings.
39
39
  * - **`redirectUri`** — Peek registers a fixed `/auth/callback`; Ship routes on
40
40
  * `location.hash` and registers its current pathname. Neither is more correct.
41
- * - **`storage`** — Peek keeps its token in `sessionStorage`, which dies with the
42
- * tab and keeps a bearer token off a shared machine's disk. Ship uses
43
- * `localStorage`. **Do not unify this**: NIP-RS slots (CRO-4) must survive a
44
- * restart and belong in `localStorage`, while an access token does not.
45
- * Different lifetimes, different homes.
41
+ * - **`storage`** and **`pendingStore`** — two stores, because Ship needs two.
42
+ * Peek keeps everything in `sessionStorage`, which dies with the tab and keeps
43
+ * a bearer token off a shared machine's disk. Ship keeps its *session* in
44
+ * `localStorage` so it outlives a tab, and its *in-flight PKCE credentials* in
45
+ * `sessionStorage` because the flow starts and ends in one tab. The package
46
+ * assumed one store until the second consumer was wired — see `pendingStore`.
47
+ * **Do not unify them**: NIP-RS slots (CRO-4) must survive a restart and belong
48
+ * in `localStorage`, while an access token does not.
46
49
  * - **`keyPrefix`** — so two apps on one origin cannot read each other's keys.
47
50
  * - **`navigate`** — the one genuinely untestable act. Injected so a test can
48
51
  * observe where the flow *would* go.
@@ -70,6 +73,19 @@ declare const URL: {
70
73
  }
71
74
  declare const URLSearchParams: { new (init: string): { get(name: string): string | null } }
72
75
 
76
+ /**
77
+ * The timer pair, for {@link EstivaIdClient.scheduleRenewal}.
78
+ *
79
+ * Declared here for the same reason as everything above, and returning
80
+ * `unknown` for one more: the handle's real type is `number` in a browser and an
81
+ * object in Node, and naming either would put an ambient type in the published
82
+ * `.d.ts` — which `check-identity.yml` fails the build over, correctly. Nothing
83
+ * outside this module ever sees a handle; `scheduleRenewal` hands back a
84
+ * `cancel` function instead.
85
+ */
86
+ declare const setTimeout: (run: () => void, ms: number) => unknown
87
+ declare const clearTimeout: (handle: unknown) => void
88
+
73
89
  /** Just enough of `fetch` to redeem a code. A parameter as well as a global. */
74
90
  export type FetchLike = (
75
91
  url: string,
@@ -88,14 +104,45 @@ export interface EstivaIdConfig {
88
104
  * **exactly** — a trailing slash is a refused sign-in.
89
105
  */
90
106
  redirectUri: () => string
91
- /** See the note on storage above. Returns null outside a browser. */
107
+ /**
108
+ * Where the **session** lives — the token and nothing else.
109
+ *
110
+ * Peek uses `sessionStorage`: it dies with the tab, which keeps a bearer token
111
+ * off a shared machine's disk. Ship uses `localStorage` so a session survives
112
+ * a tab close. Returns null outside a browser.
113
+ */
92
114
  storage: () => KeyValueStore | null
115
+ /**
116
+ * Where an **in-flight sign-in's** single-use credentials live — the verifier,
117
+ * the state, the returnTo, the shell reason and the silent-attempt guard.
118
+ *
119
+ * Defaults to {@link storage}, which is what Peek wants: one store for both.
120
+ *
121
+ * **Ship needs them separated, and that is why this exists.** Its session
122
+ * belongs in `localStorage` so it outlives a tab, while the PKCE credentials
123
+ * are per-tab by nature — the flow starts and ends in one tab, and the guard's
124
+ * whole point is that a genuinely new tab is entitled to a fresh attempt.
125
+ *
126
+ * This parameter was added by wiring the second consumer. The package had
127
+ * assumed one store because it was extracted from Peek, which uses one: the
128
+ * exact "comes out shaped like the app it came from" failure SHA-4 names, found
129
+ * by the mechanism SHA-4 prescribes for finding it.
130
+ */
131
+ pendingStore?: () => KeyValueStore | null
93
132
  /** Namespaces this app's keys, e.g. `peek.estivaId`. */
94
133
  keyPrefix: string
95
134
  /** Defaults to the runtime's `location.assign`-equivalent via `navigate`. */
96
135
  navigate?: (url: string) => void
97
136
  now?: () => number
98
137
  fetch?: FetchLike
138
+ /**
139
+ * The timers `scheduleRenewal` runs on, injected for exactly the reason
140
+ * `fetch` and `navigate` are: a test cannot wait ten minutes to find out
141
+ * whether the renewal was scheduled, and "we scheduled it" and "we did
142
+ * nothing" are otherwise the same observation.
143
+ */
144
+ setTimer?: (run: () => void, ms: number) => unknown
145
+ clearTimer?: (handle: unknown) => void
99
146
  }
100
147
 
101
148
  export interface StoredToken {
@@ -117,8 +164,35 @@ export interface StoredToken {
117
164
  refreshToken?: string
118
165
  }
119
166
 
167
+ /**
168
+ * `setTimeout`'s ceiling. Above 2^31-1 ms the delay overflows to a negative
169
+ * int32 and the timer fires immediately.
170
+ */
171
+ const MAX_TIMER_MS = 2_147_483_647
172
+
120
173
  export class SignInError extends Error {}
121
174
 
175
+ /** What a scheduled renewal reports back. Both optional; neither is required to be useful. */
176
+ export interface RenewalHandlers {
177
+ /** A renewal succeeded. The session continues, on a later expiry. */
178
+ onRenewed?: (token: StoredToken) => void
179
+ /**
180
+ * A renewal failed, and the session is over.
181
+ *
182
+ * The only honest thing an app can do here is drop to the shell. Note this
183
+ * fires on refusal *and* on an unreachable network — `refreshAccessToken`
184
+ * distinguishes them for the purpose of clearing the stored token, but from a
185
+ * scheduler's point of view a renewal that did not happen is a renewal that
186
+ * did not happen, and the shell's own probe is what sorts out which it was.
187
+ */
188
+ onEnded?: () => void
189
+ }
190
+
191
+ export interface RenewalSchedule {
192
+ /** Stop renewing. Idempotent, and safe to call after the session has ended. */
193
+ cancel: () => void
194
+ }
195
+
122
196
  interface TokenResponse {
123
197
  access_token?: string
124
198
  expires_in?: number
@@ -132,6 +206,7 @@ export interface EstivaIdClient {
132
206
  beginSignIn: (returnTo: string, options?: { silent?: boolean }) => Promise<void>
133
207
  completeSignIn: (search: string) => Promise<{ token: StoredToken; returnTo: string }>
134
208
  refreshAccessToken: () => Promise<StoredToken | null>
209
+ scheduleRenewal: (handlers?: RenewalHandlers) => RenewalSchedule
135
210
  storedToken: () => StoredToken | null
136
211
  validToken: (now?: number) => StoredToken | null
137
212
  hasSession: () => boolean
@@ -147,6 +222,8 @@ export interface EstivaIdClient {
147
222
 
148
223
  export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
149
224
  const now = config.now ?? (() => Date.now())
225
+ /** In-flight credentials, which may live somewhere shorter than the session. */
226
+ const pending = config.pendingStore ?? config.storage
150
227
  const send = () => config.fetch ?? fetch
151
228
  const go = (url: string) => {
152
229
  if (!config.navigate) throw new Error('createEstivaId: navigate is required to leave for Estiva ID')
@@ -225,9 +302,11 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
225
302
  * Anything that wants both says so, and only `beginSignOut` does.
226
303
  */
227
304
  const clearSession = (): void => {
228
- const s = config.storage()
229
- if (!s) return
230
- for (const k of [TOKEN_KEY, REASON_KEY]) s.removeItem(k)
305
+ config.storage()?.removeItem(TOKEN_KEY)
306
+ // The reason lives with the in-flight credentials, which may be a different
307
+ // store — see `pendingStore`. Still cleared here: a reason outliving the
308
+ // session it described is a lie, and that was true before the split.
309
+ pending()?.removeItem(REASON_KEY)
231
310
  }
232
311
 
233
312
  /**
@@ -239,18 +318,18 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
239
318
  * different challenge. Destroying a live one is the expensive direction.
240
319
  */
241
320
  const clearPendingSignIn = (): void => {
242
- const s = config.storage()
321
+ const s = pending()
243
322
  if (!s) return
244
323
  for (const k of [VERIFIER_KEY, STATE_KEY, RETURN_KEY]) s.removeItem(k)
245
324
  }
246
325
 
247
326
  const stashShellReason = (reason: ShellReason): void => {
248
- config.storage()?.setItem(REASON_KEY, reason)
327
+ pending()?.setItem(REASON_KEY, reason)
249
328
  }
250
329
 
251
330
  /** Read once. Single-use: a stale reason outliving its cause is a lie. */
252
331
  const takeShellReason = (): ShellReason | null => {
253
- const s = config.storage()
332
+ const s = pending()
254
333
  const raw = s?.getItem(REASON_KEY)
255
334
  if (raw) s?.removeItem(REASON_KEY)
256
335
  return (raw as ShellReason | null) ?? null
@@ -264,12 +343,94 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
264
343
  * puts people on a default screen instead of the link they opened (PEEK-123).
265
344
  */
266
345
  const takeReturnTo = (): string | null => {
267
- const s = config.storage()
346
+ const s = pending()
268
347
  const raw = s?.getItem(RETURN_KEY)
269
348
  if (raw) s?.removeItem(RETURN_KEY)
270
349
  return raw ?? null
271
350
  }
272
351
 
352
+ /**
353
+ * Renew the access token without leaving the page (PEEK-108).
354
+ *
355
+ * This is the whole reason the grant exists. Before it, an expired token
356
+ * could only be replaced by a top-level navigation through `/authorize`,
357
+ * which costs whatever the person had on screen.
358
+ *
359
+ * Returns `null` for every failure, and the failures are not worth
360
+ * distinguishing: Estiva ID answers `invalid_grant` for all of them by
361
+ * design, and the caller's only move either way is to fall back to the
362
+ * shell. The stored token is cleared on refusal so a spent refresh token is
363
+ * never presented twice — a replay is what Estiva ID treats as theft.
364
+ */
365
+ const performRefresh = async (): Promise<StoredToken | null> => {
366
+ const s = config.storage()
367
+ const current = storedToken()
368
+ if (!s || !current?.refreshToken) return null
369
+
370
+ let response: Awaited<ReturnType<FetchLike>>
371
+ try {
372
+ response = await send()(new URL('/token', config.base).toString(), {
373
+ method: 'POST',
374
+ headers: { 'content-type': 'application/json' },
375
+ body: JSON.stringify({
376
+ grantType: 'refresh_token',
377
+ refreshToken: current.refreshToken,
378
+ clientId: config.clientId,
379
+ }),
380
+ })
381
+ } catch {
382
+ // Unreachable rather than refused. The token in hand may still be fine,
383
+ // so this deliberately does not clear the session — a flaky network must
384
+ // not sign somebody out.
385
+ return null
386
+ }
387
+
388
+ if (!response.ok) {
389
+ clearSession()
390
+ return null
391
+ }
392
+
393
+ const body = (await response.json()) as TokenResponse
394
+ if (!body.access_token) {
395
+ clearSession()
396
+ return null
397
+ }
398
+
399
+ const next = tokenFrom(body, body.pubkey ?? current.pubkey)
400
+ s.setItem(TOKEN_KEY, JSON.stringify(next))
401
+ return next
402
+ }
403
+
404
+ /**
405
+ * The renewal in flight, if there is one.
406
+ *
407
+ * **A refresh token is single-use and a replay is read as theft**, which
408
+ * revokes the whole chain — so two renewals must never be in the air at once.
409
+ * Two easily can be: Ship signs the NIP-98 auth event and the content event as
410
+ * separate `POST /sign` calls, and a token that expires between them answers
411
+ * `401` to both, each of which renews.
412
+ *
413
+ * Peek has guarded this since PEEK-108 with a `refreshing` ref in its Convex
414
+ * adapter, and SHA-4 lists the guard as one of the behaviours that must
415
+ * survive the extraction — but the guard is the one part that did not travel,
416
+ * because it lived in the hook rather than the client. So Peek kept it and
417
+ * Ship never had it. It belongs here, where every caller of every consumer
418
+ * gets it whether or not they thought about it.
419
+ *
420
+ * Coalescing rather than caching: the promise is dropped the moment it
421
+ * settles, so the *next* expiry renews again.
422
+ */
423
+ let refreshing: Promise<StoredToken | null> | null = null
424
+
425
+ const refreshAccessToken = (): Promise<StoredToken | null> => {
426
+ if (refreshing) return refreshing
427
+ refreshing = performRefresh().finally(() => {
428
+ refreshing = null
429
+ })
430
+ return refreshing
431
+ }
432
+
433
+
273
434
  return {
274
435
  redirectUri: config.redirectUri,
275
436
  codeChallenge,
@@ -303,7 +464,7 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
303
464
  * code that just arrived belongs to anything this tab did.
304
465
  */
305
466
  hasPendingSignIn: () => {
306
- const s = config.storage()
467
+ const s = pending()
307
468
  return Boolean(s?.getItem(VERIFIER_KEY) && s.getItem(STATE_KEY))
308
469
  },
309
470
 
@@ -316,7 +477,7 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
316
477
  * compared.
317
478
  */
318
479
  async beginSignIn(returnTo: string, { silent = false }: { silent?: boolean } = {}): Promise<void> {
319
- const s = config.storage()
480
+ const s = pending()
320
481
  if (!s) throw new Error('sign-in requires a browser')
321
482
 
322
483
  const verifier = randomUrlSafe()
@@ -373,8 +534,9 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
373
534
  * on this side of the exchange.
374
535
  */
375
536
  async completeSignIn(search: string): Promise<{ token: StoredToken; returnTo: string }> {
376
- const s = config.storage()
377
- if (!s) throw new SignInError('sign-in requires a browser')
537
+ const s = pending()
538
+ const sessionStore = config.storage()
539
+ if (!s || !sessionStore) throw new SignInError('sign-in requires a browser')
378
540
 
379
541
  const params = new URLSearchParams(search)
380
542
  const error = params.get('error')
@@ -427,64 +589,101 @@ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
427
589
  if (!body.access_token || !body.pubkey) throw new SignInError('Estiva ID returned no token.')
428
590
 
429
591
  const token = tokenFrom(body, body.pubkey)
430
- s.setItem(TOKEN_KEY, JSON.stringify(token))
592
+ sessionStore.setItem(TOKEN_KEY, JSON.stringify(token))
431
593
  // The attempt worked, so the next boot is entitled to a fresh silent one.
432
594
  clearGuard(s)
433
595
  return { token, returnTo }
434
596
  },
435
597
 
598
+ refreshAccessToken,
599
+
436
600
  /**
437
- * Renew the access token without leaving the page (PEEK-108).
601
+ * Renew shortly **before** the token expires, rather than reacting to it
602
+ * having expired.
438
603
  *
439
- * This is the whole reason the grant exists. Before it, an expired token
440
- * could only be replaced by a top-level navigation through `/authorize`,
441
- * which costs whatever the person had on screen.
604
+ * SHA-4 lists this as one of the behaviours that must survive the
605
+ * extraction, and it is the one that reads as an optimisation and is not.
606
+ * Reacting to expiry means somebody's next action is the thing that
607
+ * discovers the session is over — a failed publish, a blank list, or at best
608
+ * a retry they can feel. Renewing 30 seconds early (`tokenFrom` already
609
+ * subtracts them) means they notice nothing at all.
442
610
  *
443
- * Returns `null` for every failure, and the failures are not worth
444
- * distinguishing: Estiva ID answers `invalid_grant` for all of them by
445
- * design, and the caller's only move either way is to fall back to the
446
- * shell. The stored token is cleared on refusal so a spent refresh token is
447
- * never presented twice — a replay is what Estiva ID treats as theft.
611
+ * It did not travel with the rest of SHA-4: the timer lived in Peek's Convex
612
+ * adapter, so Peek kept it and Ship, whose whole point was to be the second
613
+ * consumer, silently did not get it. Ship renews only when `POST /sign`
614
+ * answers `401`, which is precisely "on failure".
615
+ *
616
+ * ## The timer is a `setTimeout`, and browsers throttle those
617
+ *
618
+ * A background tab gets its timers clamped — to once a minute in Chrome and
619
+ * Safari, and after five minutes of being hidden they may be frozen outright.
620
+ * So a tab left in the background can miss its renewal window entirely.
621
+ *
622
+ * This is a known limitation rather than a bug, and it is recorded here so
623
+ * it is not rediscovered once per app. It degrades safely: a late renewal is
624
+ * still a valid renewal, because the refresh token outlives the access token
625
+ * by a long way, and a call that beats the late timer gets the existing
626
+ * `401`-and-retry path. What it must not do is renew *twice* in the scramble,
627
+ * which is what the guard on `refreshAccessToken` is for.
628
+ *
629
+ * A `visibilitychange` listener that re-arms on foreground would tighten
630
+ * this, and is deliberately not here: `document` is a global this package
631
+ * does not touch (ADR 0002 §4a), and an app that wants it can `cancel()` and
632
+ * call this again.
448
633
  */
449
- async refreshAccessToken(): Promise<StoredToken | null> {
450
- const s = config.storage()
451
- const current = storedToken()
452
- if (!s || !current?.refreshToken) return null
453
-
454
- let response: Awaited<ReturnType<FetchLike>>
455
- try {
456
- response = await send()(new URL('/token', config.base).toString(), {
457
- method: 'POST',
458
- headers: { 'content-type': 'application/json' },
459
- body: JSON.stringify({
460
- grantType: 'refresh_token',
461
- refreshToken: current.refreshToken,
462
- clientId: config.clientId,
463
- }),
464
- })
465
- } catch {
466
- // Unreachable rather than refused. The token in hand may still be fine,
467
- // so this deliberately does not clear the session — a flaky network must
468
- // not sign somebody out.
469
- return null
634
+ scheduleRenewal(handlers: RenewalHandlers = {}): RenewalSchedule {
635
+ const setTimer = config.setTimer ?? ((run, ms) => setTimeout(run, ms))
636
+ const clearTimer = config.clearTimer ?? ((handle) => clearTimeout(handle))
637
+
638
+ let handle: unknown = null
639
+ let cancelled = false
640
+
641
+ const arm = (): void => {
642
+ const token = storedToken()
643
+ // Nothing to renew. Not an ending — the caller is simply signed out, and
644
+ // an app that signs in later starts a new schedule.
645
+ if (!token) return
646
+
647
+ /*
648
+ Clamped at both ends. A negative delay is a token that already expired
649
+ — renew now rather than never, since the refresh token almost certainly
650
+ has not. The ceiling is `setTimeout`'s: a delay above 2^31-1 ms
651
+ overflows to a negative int32 and fires immediately, so a nonsensical
652
+ `expires_in` would otherwise become a hot loop against `/token` instead
653
+ of one very distant timer.
654
+ */
655
+ const delay = Math.min(Math.max(0, token.expiresAt - now()), MAX_TIMER_MS)
656
+
657
+ handle = setTimer(() => {
658
+ handle = null
659
+ void refreshAccessToken().then((next) => {
660
+ if (cancelled) return
661
+ if (!next) {
662
+ handlers.onEnded?.()
663
+ return
664
+ }
665
+ handlers.onRenewed?.(next)
666
+ // Re-armed from the *new* expiry rather than a fixed interval, so
667
+ // the schedule follows whatever lifetime Estiva ID actually issued.
668
+ arm()
669
+ })
670
+ }, delay)
470
671
  }
471
672
 
472
- if (!response.ok) {
473
- clearSession()
474
- return null
475
- }
673
+ arm()
476
674
 
477
- const body = (await response.json()) as TokenResponse
478
- if (!body.access_token) {
479
- clearSession()
480
- return null
675
+ return {
676
+ cancel(): void {
677
+ cancelled = true
678
+ if (handle !== null) {
679
+ clearTimer(handle)
680
+ handle = null
681
+ }
682
+ },
481
683
  }
482
-
483
- const next = tokenFrom(body, body.pubkey ?? current.pubkey)
484
- s.setItem(TOKEN_KEY, JSON.stringify(next))
485
- return next
486
684
  },
487
685
 
686
+
488
687
  /**
489
688
  * Leave for Estiva ID to end the session *there* (PEEK-122).
490
689
  *
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.0'
49
+ export const IDENTITY_VERSION = '0.1.2'
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'