@estiva-app/identity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Signing in with Estiva ID — authorization code + PKCE.
3
+ *
4
+ * The app does not hold a password or a key. It sends the person to the identity
5
+ * service and gets back a token whose `sub` is their Nostr pubkey.
6
+ *
7
+ * ## Why a redirect rather than a fetch
8
+ *
9
+ * `POST /token` is reachable cross-origin, but the *session* that authorises the
10
+ * redirect is a `SameSite=Lax` cookie on the identity origin. Lax cookies ride
11
+ * top-level navigations and nothing else, so the only way to prove who you are
12
+ * is to actually go there. That is deliberate on their side (PEEK-90): the origin
13
+ * holding every custodied key does not relax its cookie or send
14
+ * `Access-Control-Allow-Credentials`.
15
+ *
16
+ * **A hidden iframe does not work**, and the reason is the same property: `Lax`
17
+ * withholds the cookie from anything that is not a top-level navigation. The
18
+ * refresh-token grant exists because of this. Do not "simplify" it back.
19
+ *
20
+ * ## The bits that must match exactly
21
+ *
22
+ * `redirect_uri` is compared **exact-string** against the app's registered list —
23
+ * a trailing slash is a different URI and gets a 400 before anything else
24
+ * happens. An identity provider that prefix-matched would be an open redirector
25
+ * handing out authorization codes.
26
+ *
27
+ * The request body is **camelCase JSON** (`codeVerifier`, not `code_verifier`)
28
+ * while the response is snake_case (`access_token`). That is genuinely the
29
+ * contract, not a typo — see `docs/API.md` in estiva-id.
30
+ *
31
+ * ## Everything is injected, and each injection is a real difference
32
+ *
33
+ * This is a factory rather than a module of free functions because the two
34
+ * consumers genuinely differ, and hardcoding either would have made the package
35
+ * come out shaped like whichever app it was extracted from:
36
+ *
37
+ * - **`clientId`** — `estiva-peek` and `estiva-ship` are separate rows in
38
+ * `app_credentials` with separate `redirectUris` and separate kind ceilings.
39
+ * - **`redirectUri`** — Peek registers a fixed `/auth/callback`; Ship routes on
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.
46
+ * - **`keyPrefix`** — so two apps on one origin cannot read each other's keys.
47
+ * - **`navigate`** — the one genuinely untestable act. Injected so a test can
48
+ * observe where the flow *would* go.
49
+ */
50
+ import { type KeyValueStore, type ShellReason, clearGuard, markGuard } from './shell.js'
51
+
52
+ /**
53
+ * `crypto`, `btoa`, `TextEncoder`, `URL`, `URLSearchParams` and `fetch` are not
54
+ * in `lib.es2022`, and this package compiles with `types: []` and no `lib: dom`
55
+ * so one published `.d.ts` works in every consumer (ADR 0002 §4a).
56
+ *
57
+ * Declared inside this module, so nothing lands in a consumer's global scope,
58
+ * and read inside function bodies, so importing this module touches no global.
59
+ * The same pattern `@estiva-app/protocol` uses, and it turned out to stretch
60
+ * further than expected — see the README on the prediction it falsified.
61
+ */
62
+ declare const crypto: {
63
+ getRandomValues(array: Uint8Array): Uint8Array
64
+ subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> }
65
+ }
66
+ declare const btoa: (binary: string) => string
67
+ declare const TextEncoder: { new (): { encode(input: string): Uint8Array } }
68
+ declare const URL: {
69
+ new (url: string, base?: string): { searchParams: { set(k: string, v: string): void }; toString(): string }
70
+ }
71
+ declare const URLSearchParams: { new (init: string): { get(name: string): string | null } }
72
+
73
+ /** Just enough of `fetch` to redeem a code. A parameter as well as a global. */
74
+ export type FetchLike = (
75
+ url: string,
76
+ init: { method: string; headers: Record<string, string>; body: string },
77
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
78
+
79
+ declare const fetch: FetchLike
80
+
81
+ export interface EstivaIdConfig {
82
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
83
+ base: string
84
+ /** This app's `client_id` row in `app_credentials`. Never defaulted. */
85
+ clientId: string
86
+ /**
87
+ * Where Estiva ID sends people back to. Must match a registered entry
88
+ * **exactly** — a trailing slash is a refused sign-in.
89
+ */
90
+ redirectUri: () => string
91
+ /** See the note on storage above. Returns null outside a browser. */
92
+ storage: () => KeyValueStore | null
93
+ /** Namespaces this app's keys, e.g. `peek.estivaId`. */
94
+ keyPrefix: string
95
+ /** Defaults to the runtime's `location.assign`-equivalent via `navigate`. */
96
+ navigate?: (url: string) => void
97
+ now?: () => number
98
+ fetch?: FetchLike
99
+ }
100
+
101
+ export interface StoredToken {
102
+ accessToken: string
103
+ /** Epoch ms. Derived from `expires_in` at receipt, not sent by the server. */
104
+ expiresAt: number
105
+ pubkey: string
106
+ /**
107
+ * The credential that renews the one above without a redirect (PEEK-108).
108
+ *
109
+ * Absent when Estiva ID issued none — a deployment predating the grant. The
110
+ * app then behaves exactly as it did before, which is what lets the two sides
111
+ * ship independently.
112
+ *
113
+ * **Single-use.** Estiva ID rotates it on every redemption and treats a replay
114
+ * as evidence of theft, revoking the whole chain. So it is replaced, never
115
+ * kept alongside its successor.
116
+ */
117
+ refreshToken?: string
118
+ }
119
+
120
+ export class SignInError extends Error {}
121
+
122
+ interface TokenResponse {
123
+ access_token?: string
124
+ expires_in?: number
125
+ pubkey?: string
126
+ refresh_token?: string
127
+ }
128
+
129
+ export interface EstivaIdClient {
130
+ redirectUri: () => string
131
+ codeChallenge: (verifier: string) => Promise<string>
132
+ beginSignIn: (returnTo: string, options?: { silent?: boolean }) => Promise<void>
133
+ completeSignIn: (search: string) => Promise<{ token: StoredToken; returnTo: string }>
134
+ refreshAccessToken: () => Promise<StoredToken | null>
135
+ storedToken: () => StoredToken | null
136
+ validToken: (now?: number) => StoredToken | null
137
+ hasSession: () => boolean
138
+ currentAccessToken: () => string | undefined
139
+ beginSignOut: () => void
140
+ clearSession: () => void
141
+ hasPendingSignIn: () => boolean
142
+ clearPendingSignIn: () => void
143
+ takeReturnTo: () => string | null
144
+ stashShellReason: (reason: ShellReason) => void
145
+ takeShellReason: () => ShellReason | null
146
+ }
147
+
148
+ export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
149
+ const now = config.now ?? (() => Date.now())
150
+ const send = () => config.fetch ?? fetch
151
+ const go = (url: string) => {
152
+ if (!config.navigate) throw new Error('createEstivaId: navigate is required to leave for Estiva ID')
153
+ config.navigate(url)
154
+ }
155
+
156
+ const VERIFIER_KEY = `${config.keyPrefix}.codeVerifier`
157
+ const STATE_KEY = `${config.keyPrefix}.state`
158
+ const RETURN_KEY = `${config.keyPrefix}.returnTo`
159
+ const TOKEN_KEY = `${config.keyPrefix}.token`
160
+ /** Why the last round trip came back empty, handed from the callback to the shell. */
161
+ const REASON_KEY = `${config.keyPrefix}.shellReason`
162
+
163
+ const base64Url = (bytes: Uint8Array): string => {
164
+ let s = ''
165
+ for (const b of bytes) s += String.fromCharCode(b)
166
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
167
+ }
168
+
169
+ const randomUrlSafe = (bytes = 32): string => base64Url(crypto.getRandomValues(new Uint8Array(bytes)))
170
+
171
+ /** `base64url(sha256(verifier))` — the S256 challenge. `plain` is refused upstream. */
172
+ const codeChallenge = async (verifier: string): Promise<string> => {
173
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
174
+ return base64Url(new Uint8Array(digest))
175
+ }
176
+
177
+ /**
178
+ * Shape a token response into what is kept.
179
+ *
180
+ * The expiry is deliberately pessimistic by 30s, so a token is never presented
181
+ * in the window where it is technically alive and about to not be.
182
+ */
183
+ const tokenFrom = (body: TokenResponse, pubkey: string): StoredToken => ({
184
+ accessToken: body.access_token as string,
185
+ expiresAt: now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000,
186
+ pubkey,
187
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
188
+ })
189
+
190
+ const storedToken = (): StoredToken | null => {
191
+ const raw = config.storage()?.getItem(TOKEN_KEY)
192
+ if (!raw) return null
193
+ try {
194
+ const parsed = JSON.parse(raw) as StoredToken
195
+ return parsed.accessToken && parsed.expiresAt ? parsed : null
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ const validToken = (at = now()): StoredToken | null => {
202
+ const t = storedToken()
203
+ return t && t.expiresAt > at ? t : null
204
+ }
205
+
206
+ /**
207
+ * Forget this app's session. **Not** an in-flight sign-in — and that
208
+ * separation is the whole point (PEEK-167).
209
+ *
210
+ * This used to clear the verifier, state and `returnTo` as well, which reads
211
+ * as thoroughness and is a bug. Those three are not session state: they are
212
+ * the credentials of an authorization request that is *currently in the air*,
213
+ * owned by the `beginSignIn` that wrote them and spent by the
214
+ * `completeSignIn` that comes back. Clearing them on somebody else's schedule
215
+ * does not tidy anything up — it destroys a sign-in that is still happening.
216
+ *
217
+ * The path that made it matter: a boot whose token has expired renews, fails,
218
+ * clears the session, drops to the shell, and the shell probes and writes a
219
+ * fresh verifier. A *second* renewal awaiting the same in-flight promise then
220
+ * resolves and clears again — now deleting the verifier the probe just wrote.
221
+ * The code comes back to a tab with nothing to redeem it, `completeSignIn`
222
+ * refuses it before `/token` is ever called, and the identity service has no
223
+ * record of a failure at all because nothing was asked of it.
224
+ *
225
+ * Anything that wants both says so, and only `beginSignOut` does.
226
+ */
227
+ const clearSession = (): void => {
228
+ const s = config.storage()
229
+ if (!s) return
230
+ for (const k of [TOKEN_KEY, REASON_KEY]) s.removeItem(k)
231
+ }
232
+
233
+ /**
234
+ * Abandon a sign-in that has not come back.
235
+ *
236
+ * Deliberately not called on any failure path. A verifier outliving its
237
+ * attempt is harmless — `completeSignIn` clears all three before it does
238
+ * anything else, and a stale one cannot redeem a code issued against a
239
+ * different challenge. Destroying a live one is the expensive direction.
240
+ */
241
+ const clearPendingSignIn = (): void => {
242
+ const s = config.storage()
243
+ if (!s) return
244
+ for (const k of [VERIFIER_KEY, STATE_KEY, RETURN_KEY]) s.removeItem(k)
245
+ }
246
+
247
+ const stashShellReason = (reason: ShellReason): void => {
248
+ config.storage()?.setItem(REASON_KEY, reason)
249
+ }
250
+
251
+ /** Read once. Single-use: a stale reason outliving its cause is a lie. */
252
+ const takeShellReason = (): ShellReason | null => {
253
+ const s = config.storage()
254
+ const raw = s?.getItem(REASON_KEY)
255
+ if (raw) s?.removeItem(REASON_KEY)
256
+ return (raw as ShellReason | null) ?? null
257
+ }
258
+
259
+ /**
260
+ * Where to go back to, as stashed by `beginSignIn`. Single-use.
261
+ *
262
+ * The refusal path needs this as much as the success path: a silent probe that
263
+ * comes back `login_required` was still started from somewhere, and dropping it
264
+ * puts people on a default screen instead of the link they opened (PEEK-123).
265
+ */
266
+ const takeReturnTo = (): string | null => {
267
+ const s = config.storage()
268
+ const raw = s?.getItem(RETURN_KEY)
269
+ if (raw) s?.removeItem(RETURN_KEY)
270
+ return raw ?? null
271
+ }
272
+
273
+ return {
274
+ redirectUri: config.redirectUri,
275
+ codeChallenge,
276
+ storedToken,
277
+ validToken,
278
+ clearSession,
279
+ clearPendingSignIn,
280
+ stashShellReason,
281
+ takeShellReason,
282
+ takeReturnTo,
283
+
284
+ hasSession: () => validToken() !== null,
285
+
286
+ /**
287
+ * The access token to hand anything that talks to the relay.
288
+ *
289
+ * Every such call needs it, because Buzz answers a non-member with
290
+ * `403 relay_membership_required` and a backend is a member of nothing. One
291
+ * call site forgetting it is not a visible failure — the read comes back
292
+ * empty and the widget renders nothing — so it is worth one name for "the
293
+ * current token" rather than copies of `validToken()?.accessToken`.
294
+ */
295
+ currentAccessToken: () => validToken()?.accessToken,
296
+
297
+ /**
298
+ * Whether this tab has a sign-in of its own in the air.
299
+ *
300
+ * `beginSignIn` writes the verifier and state together and `completeSignIn`
301
+ * spends them together, so either key answers this. Both are read because
302
+ * the interesting question is not which key survived — it is whether the
303
+ * code that just arrived belongs to anything this tab did.
304
+ */
305
+ hasPendingSignIn: () => {
306
+ const s = config.storage()
307
+ return Boolean(s?.getItem(VERIFIER_KEY) && s.getItem(STATE_KEY))
308
+ },
309
+
310
+ /**
311
+ * Leave for the identity service. Never returns — the browser navigates away.
312
+ *
313
+ * `returnTo` is kept locally rather than smuggled through `state`: `state` is
314
+ * a CSRF token, and widening it into a general-purpose payload invites
315
+ * treating whatever comes back as trustworthy. What comes back is only ever
316
+ * compared.
317
+ */
318
+ async beginSignIn(returnTo: string, { silent = false }: { silent?: boolean } = {}): Promise<void> {
319
+ const s = config.storage()
320
+ if (!s) throw new Error('sign-in requires a browser')
321
+
322
+ const verifier = randomUrlSafe()
323
+ const state = randomUrlSafe(16)
324
+ s.setItem(VERIFIER_KEY, verifier)
325
+ s.setItem(STATE_KEY, state)
326
+ s.setItem(RETURN_KEY, returnTo)
327
+
328
+ /*
329
+ The guard is marked HERE, synchronously with the writes above, and not
330
+ further down next to the navigation (PEEK-167).
331
+
332
+ It used to sit just before the navigation, which reads as the same thing
333
+ and is not: `codeChallenge` awaits a `crypto.subtle` digest in between.
334
+ For that whole window the verifier and state have already been overwritten
335
+ while `readGuard` still answers null, so a second boot decision landing in
336
+ it decides to probe as well.
337
+
338
+ Two probes then write two verifier/state pairs into one tab's storage, and
339
+ only one of the two navigations commits. When the one that commits is not
340
+ the one that wrote last, the code comes back matched to a state that is no
341
+ longer there and `completeSignIn` refuses it — an `exchange_failed` for a
342
+ code that was never even presented to `/token`.
343
+
344
+ Marking it with the other single-use writes is what makes the guard cover
345
+ the window it exists for: they are the things this attempt has already
346
+ spent, and they become true together or not at all.
347
+ */
348
+ if (silent) markGuard(s, now())
349
+
350
+ const url = new URL('/authorize', config.base)
351
+ url.searchParams.set('client_id', config.clientId)
352
+ url.searchParams.set('redirect_uri', config.redirectUri())
353
+ url.searchParams.set('response_type', 'code')
354
+ url.searchParams.set('code_challenge', await codeChallenge(verifier))
355
+ url.searchParams.set('code_challenge_method', 'S256')
356
+ url.searchParams.set('state', state)
357
+
358
+ /*
359
+ A silent attempt asks not to be prompted (PEEK-117), so a browser with no
360
+ session comes straight back with `error=login_required` instead of landing
361
+ on the identity origin's sign-in page.
362
+ */
363
+ if (silent) url.searchParams.set('prompt', 'none')
364
+
365
+ go(url.toString())
366
+ },
367
+
368
+ /**
369
+ * Finish the round trip: verify `state`, redeem the code, keep the token.
370
+ *
371
+ * Throws `SignInError` with something a human can read — every refusal from
372
+ * `/token` is a deliberately uniform `invalid_grant`, so the useful detail is
373
+ * on this side of the exchange.
374
+ */
375
+ 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')
378
+
379
+ const params = new URLSearchParams(search)
380
+ const error = params.get('error')
381
+ if (error) throw new SignInError(`Estiva ID refused the sign-in (${error}).`)
382
+
383
+ const code = params.get('code')
384
+ const state = params.get('state')
385
+ const expectedState = s.getItem(STATE_KEY)
386
+ const verifier = s.getItem(VERIFIER_KEY)
387
+ const returnTo = s.getItem(RETURN_KEY) || '/'
388
+
389
+ // Clear first: these are single-use, and a failed attempt must not leave a
390
+ // verifier lying around for a second code to be redeemed against.
391
+ s.removeItem(STATE_KEY)
392
+ s.removeItem(VERIFIER_KEY)
393
+ s.removeItem(RETURN_KEY)
394
+
395
+ if (!code) throw new SignInError('Estiva ID sent no authorization code back.')
396
+ if (!verifier || !expectedState) {
397
+ throw new SignInError('This sign-in did not start in this tab. Try signing in again.')
398
+ }
399
+ if (state !== expectedState) {
400
+ // The one check that is not about ergonomics: a code arriving with
401
+ // someone else's state is the shape of a login-CSRF, refused outright.
402
+ throw new SignInError('Sign-in state did not match. Try signing in again.')
403
+ }
404
+
405
+ const response = await send()(new URL('/token', config.base).toString(), {
406
+ method: 'POST',
407
+ headers: { 'content-type': 'application/json' },
408
+ // camelCase — see the file header.
409
+ body: JSON.stringify({
410
+ grantType: 'authorization_code',
411
+ code,
412
+ clientId: config.clientId,
413
+ redirectUri: config.redirectUri(),
414
+ codeVerifier: verifier,
415
+ }),
416
+ })
417
+
418
+ if (!response.ok) {
419
+ throw new SignInError(
420
+ response.status === 400
421
+ ? 'Estiva ID rejected this sign-in code. It may have expired — codes last 60 seconds.'
422
+ : `Estiva ID returned ${response.status} exchanging the code.`,
423
+ )
424
+ }
425
+
426
+ const body = (await response.json()) as TokenResponse
427
+ if (!body.access_token || !body.pubkey) throw new SignInError('Estiva ID returned no token.')
428
+
429
+ const token = tokenFrom(body, body.pubkey)
430
+ s.setItem(TOKEN_KEY, JSON.stringify(token))
431
+ // The attempt worked, so the next boot is entitled to a fresh silent one.
432
+ clearGuard(s)
433
+ return { token, returnTo }
434
+ },
435
+
436
+ /**
437
+ * Renew the access token without leaving the page (PEEK-108).
438
+ *
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.
442
+ *
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.
448
+ */
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
470
+ }
471
+
472
+ if (!response.ok) {
473
+ clearSession()
474
+ return null
475
+ }
476
+
477
+ const body = (await response.json()) as TokenResponse
478
+ if (!body.access_token) {
479
+ clearSession()
480
+ return null
481
+ }
482
+
483
+ const next = tokenFrom(body, body.pubkey ?? current.pubkey)
484
+ s.setItem(TOKEN_KEY, JSON.stringify(next))
485
+ return next
486
+ },
487
+
488
+ /**
489
+ * Leave for Estiva ID to end the session *there* (PEEK-122).
490
+ *
491
+ * Clearing the local token is not signing out. The `estiva_id_session` cookie
492
+ * on the identity origin is untouched by it, so the shell's silent probe
493
+ * succeeds on the next boot and puts the person straight back in — which is
494
+ * exactly what "sign out doesn't sign me out" was.
495
+ *
496
+ * The cookie can only be reached by going there, for the same reason entry
497
+ * works the way it does. The reason is stashed first so the shell offers a
498
+ * passkey on return rather than silently probing for a session just ended.
499
+ */
500
+ beginSignOut(): void {
501
+ clearSession()
502
+ // Going to /logout makes any half-started sign-in moot, and this is the
503
+ // one caller that genuinely means both.
504
+ clearPendingSignIn()
505
+ stashShellReason('no_session')
506
+ const url = new URL('/logout', config.base)
507
+ url.searchParams.set('client_id', config.clientId)
508
+ url.searchParams.set('post_logout_redirect_uri', config.redirectUri())
509
+ go(url.toString())
510
+ },
511
+ }
512
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `@estiva-app/identity` — signing in with Estiva ID, once.
3
+ *
4
+ * ## What is in here, and what is deliberately not
5
+ *
6
+ * **In:** the PKCE flow, token storage and validity, silent renewal, sign-out,
7
+ * the returnTo and shell-reason vocabulary, and the auth-shell state machine.
8
+ * Everything that decides *whether somebody is signed in* and *what to say when
9
+ * they are not*.
10
+ *
11
+ * **Not in: any app's data layer.** `peek-app/src/auth/useEstivaIdAuth.ts` is the
12
+ * adapter Convex's `ConvexProviderWithAuth` reads, and it stays in Peek. If it
13
+ * were here, Ship and every future app would inherit a Convex dependency to work
14
+ * around — which is precisely the failure this package's sequencing exists to
15
+ * avoid. Ship needs no adapter at all: it hands the token straight to its relay
16
+ * client, and that is the test that the line held.
17
+ *
18
+ * **Not in: the shell's rendering.** It is a full-page surface and each app will
19
+ * want its own. What is shared is the state machine and the reason vocabulary,
20
+ * which is where the expensive mistakes live. This also keeps the package clear
21
+ * of `@estiva-app/ui` — ADR 0002 §2 names identity depending on ui as the trigger
22
+ * that folds ui back into the foundation repo, and that should be a decision
23
+ * rather than a discovery.
24
+ *
25
+ * ## Every difference between the two consumers is a parameter
26
+ *
27
+ * `clientId`, `redirectUri`, `storage`, `keyPrefix` and `navigate` are all
28
+ * injected, because Peek and Ship genuinely differ on every one of them and
29
+ * hardcoding any would have made the package come out Peek-shaped. See
30
+ * `client.ts` for what each difference actually is.
31
+ *
32
+ * ## The globals prediction this package falsified
33
+ *
34
+ * Before starting, the expectation on record was that identity could not follow
35
+ * ADR 0002 §4a as cleanly as `@estiva-app/protocol` did — that being
36
+ * browser-shaped throughout, it would need `lib: dom` and a looser rule.
37
+ *
38
+ * It did not. `crypto`, `btoa`, `TextEncoder`, `URL`, `URLSearchParams`, `fetch`
39
+ * and `console` are declared inside the modules that use them and read inside
40
+ * function bodies; the DOM's `Storage` became a local `KeyValueStore` interface
41
+ * that `sessionStorage` and `localStorage` both satisfy structurally; and
42
+ * navigation is a required `navigate` callback rather than a `location` reach.
43
+ * The published declarations name no ambient global, and the package builds with
44
+ * `types: []` and no `lib: dom` exactly as `protocol` does.
45
+ *
46
+ * The thing that made it possible was not cleverness — it was that a browser API
47
+ * an app must inject is also a browser API a test can observe.
48
+ */
49
+ export const IDENTITY_VERSION = '0.1.0'
50
+
51
+ export {
52
+ type KeyValueStore,
53
+ type ShellPhase,
54
+ type ShellReason,
55
+ type ShellState,
56
+ type BootObservation,
57
+ type BootAction,
58
+ type ShellEvent,
59
+ isRecoverable,
60
+ SHELL_COPY,
61
+ CONTINUE_LABEL,
62
+ RETRY_LABEL,
63
+ PROBE_TIMEOUT_MS,
64
+ GUARD_TTL_MS,
65
+ GUARD_KEY,
66
+ decideBoot,
67
+ reasonForCallbackError,
68
+ readGuard,
69
+ markGuard,
70
+ clearGuard,
71
+ logShellEvent,
72
+ } from './shell.js'
73
+
74
+ export {
75
+ type EstivaIdConfig,
76
+ type EstivaIdClient,
77
+ type StoredToken,
78
+ type FetchLike,
79
+ SignInError,
80
+ createEstivaId,
81
+ } from './client.js'