@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/shell.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The auth shell's contract: what a boot may conclude, and what to say about it.
3
+ *
4
+ * Pure decision logic and vocabulary. No I/O, no globals, no rendering — the
5
+ * shell's *appearance* stays in each app, because it is a full-page surface and
6
+ * each app will want its own. What is shared is the state machine and the
7
+ * reason vocabulary, which is where the expensive mistakes live.
8
+ *
9
+ * ## This file was duplicated verbatim, and said so
10
+ *
11
+ * Until SHA-4 it existed twice — `peek-app/src/auth/shell/contract.ts` and
12
+ * `estiva-ship/lib/auth/shell/contract.ts` — and each copy's header pointed at
13
+ * the other. They were byte-identical apart from that sentence, which is a file
14
+ * asking to be a package. Now it is one, and the claim is true by construction
15
+ * rather than by discipline.
16
+ *
17
+ * ## Why `token_rejected` is not "session expired"
18
+ *
19
+ * The distinction below is load-bearing and asserted by a test that travelled
20
+ * here with the code. Telling somebody their session expired when their token
21
+ * was refused for a *configuration* fault sends them to re-authenticate against
22
+ * a wall: they will succeed at signing in and fail in exactly the same way, and
23
+ * nothing in the message points at the real cause.
24
+ *
25
+ * ## `Storage` is not named here
26
+ *
27
+ * The guard helpers take a local {@link KeyValueStore} rather than the DOM's
28
+ * `Storage`. Both `sessionStorage` and `localStorage` satisfy it structurally,
29
+ * and it keeps the published declarations free of an ambient DOM type — a `.d.ts`
30
+ * that names one compiles in a browser app and fails in a Node consumer
31
+ * (ADR 0002 §4a).
32
+ */
33
+
34
+ /**
35
+ * The subset of a web Storage this package uses.
36
+ *
37
+ * Structural, so `sessionStorage`, `localStorage` and a plain object all satisfy
38
+ * it. Which one an app passes is an app decision with real consequences — see
39
+ * the note on storage in `client.ts`.
40
+ */
41
+ export interface KeyValueStore {
42
+ getItem(key: string): string | null
43
+ setItem(key: string, value: string): void
44
+ removeItem(key: string): void
45
+ }
46
+
47
+ /**
48
+ * Five states, and no more.
49
+ *
50
+ * `checking` and `entering` deliberately render the same thing: the difference
51
+ * is not information a person entering their own app needs, and distinguishing
52
+ * them visually produces two loading screens for one boot.
53
+ */
54
+ export type ShellPhase = 'checking' | 'entering' | 'authenticating' | 'ready' | 'failed'
55
+
56
+ /**
57
+ * Why the shell is not simply entering.
58
+ *
59
+ * These are not interchangeable. The whole reason the list is this long is that
60
+ * "your session expired" told to somebody whose token was refused for a
61
+ * configuration fault sends them to re-authenticate against a wall.
62
+ */
63
+ export type ShellReason =
64
+ | 'no_session'
65
+ | 'session_expired'
66
+ | 'identity_inactive'
67
+ | 'token_rejected'
68
+ | 'exchange_failed'
69
+ | 'network'
70
+ | 'timeout'
71
+ | 'loop_guard'
72
+
73
+ export type ShellState =
74
+ | { phase: 'checking' }
75
+ | { phase: 'entering' }
76
+ | { phase: 'authenticating'; reason: ShellReason }
77
+ | { phase: 'ready' }
78
+ | { phase: 'failed'; reason: ShellReason }
79
+
80
+ /** Whether a reason can be cleared by authenticating, or needs somebody else. */
81
+ export function isRecoverable(reason: ShellReason): boolean {
82
+ return reason !== 'identity_inactive' && reason !== 'token_rejected'
83
+ }
84
+
85
+ /**
86
+ * What the shell says, per reason.
87
+ *
88
+ * Deliberately plain and deliberately short. This is a bootstrap surface, not a
89
+ * login page, and a paragraph of reassurance here reads as a product asking to
90
+ * be trusted rather than an app starting up.
91
+ *
92
+ * `token_rejected` is the one that must never read as "signed out" — it is a
93
+ * configuration fault that signing in again cannot fix, and saying otherwise
94
+ * cost a real debugging detour the first time it appeared.
95
+ */
96
+ export const SHELL_COPY: Record<ShellReason, string> = {
97
+ no_session: 'Continue with your passkey to enter.',
98
+ session_expired: 'Your Estiva ID session ended. Continue with your passkey.',
99
+ identity_inactive: 'This identity is not active. An administrator needs to restore it.',
100
+ token_rejected:
101
+ 'Signed in to Estiva ID, but this app could not verify the token. That is a configuration problem rather than an expired session — signing in again will not fix it.',
102
+ exchange_failed: 'That sign-in did not complete. Try again.',
103
+ network: 'Could not reach Estiva ID. Check your connection and try again.',
104
+ timeout: 'Estiva ID did not answer in time. Try again.',
105
+ loop_guard: 'Continue with your passkey to enter.',
106
+ }
107
+
108
+ /** The label on the one affordance the shell ever shows. */
109
+ export const CONTINUE_LABEL = 'Continue with passkey'
110
+ export const RETRY_LABEL = 'Try again'
111
+
112
+ // --- The boot decision -------------------------------------------------------
113
+
114
+ /** What the app observed before deciding anything. */
115
+ export interface BootObservation {
116
+ /** Parsed from the address bar, if this load is a return trip. */
117
+ callback: { code?: string | undefined; error?: string | undefined } | null
118
+ /** A stored token that is present *and* still usable. */
119
+ hasValidToken: boolean
120
+ /** The silent-attempt marker, if one is set. */
121
+ guardAttemptedAt: number | null
122
+ /**
123
+ * Whether this page load has already been inside the app.
124
+ *
125
+ * A cold boot and a token dying under somebody's hands are the same
126
+ * observation — no valid token — and they must not get the same treatment.
127
+ * Silent entry is right on boot, where there is nothing on screen to lose.
128
+ * Mid-session it navigates away and takes whatever they were doing with it,
129
+ * which is how "enter automatically" turned into "the page left on its own".
130
+ */
131
+ enteredThisPageLoad: boolean
132
+ now: number
133
+ }
134
+
135
+ export type BootAction =
136
+ /** Redeem the code in the address bar. */
137
+ | { do: 'complete_callback' }
138
+ /** Enter with what is already held. */
139
+ | { do: 'enter' }
140
+ /** Navigate to `/authorize?prompt=none`. Sets the marker first. */
141
+ | { do: 'probe_silently' }
142
+ /** Show the one affordance and wait for the tap. */
143
+ | { do: 'prompt_passkey'; reason: ShellReason }
144
+ /** Stop, with something honest on screen. */
145
+ | { do: 'fail'; reason: ShellReason }
146
+
147
+ /**
148
+ * How long a silent attempt may take before the return trip counts as a timeout.
149
+ *
150
+ * A navigation cannot be timed out from the page being unloaded, so this is
151
+ * measured across the round trip instead: the marker carries when we left, and
152
+ * a return that took this long means a slow or half-failing identity service.
153
+ * Generous, because it is competing with a cold DNS lookup and a redirect, and
154
+ * a false timeout is worse than a slow entry.
155
+ */
156
+ export const PROBE_TIMEOUT_MS = 15_000
157
+
158
+ /**
159
+ * How long the guard remembers a silent attempt.
160
+ *
161
+ * Long enough that an immediate bounce is caught, short enough that a person
162
+ * who left the tab open and came back gets a real attempt rather than an
163
+ * affordance explaining a failure from an hour ago.
164
+ */
165
+ export const GUARD_TTL_MS = 60_000
166
+
167
+ /**
168
+ * The whole entry decision, as a pure function.
169
+ *
170
+ * Ordering is the contract. A callback is handled before a stored token because
171
+ * the code in the address bar is fresher and must be spent or discarded either
172
+ * way; the guard is consulted before a new probe because that is what stops the
173
+ * bounce.
174
+ */
175
+ export function decideBoot(o: BootObservation): BootAction {
176
+ if (o.callback) {
177
+ const { code, error } = o.callback
178
+ if (error) return { do: 'prompt_passkey', reason: reasonForCallbackError(error) }
179
+ if (code) return { do: 'complete_callback' }
180
+ }
181
+
182
+ if (o.hasValidToken) return { do: 'enter' }
183
+
184
+ /**
185
+ * The token died while they were working. Offer the passkey and let them
186
+ * choose when to lose the page, rather than deciding it for them.
187
+ *
188
+ * The old signed-out screen was a button partly for this reason. Re-entry is
189
+ * usually silent, so the cost here is not a prompt — it is a full navigation
190
+ * out of whatever they had open, and that is not the shell's call to make.
191
+ * PEEK-108 is what removes the interruption rather than relocating it.
192
+ */
193
+ if (o.enteredThisPageLoad) return { do: 'prompt_passkey', reason: 'session_expired' }
194
+
195
+ // A silent attempt already ran and we are back here without a token. Never
196
+ // navigate again — this is the infinite bounce the guard exists to prevent.
197
+ if (o.guardAttemptedAt !== null && o.now - o.guardAttemptedAt < GUARD_TTL_MS) {
198
+ const elapsed = o.now - o.guardAttemptedAt
199
+ return { do: 'prompt_passkey', reason: elapsed >= PROBE_TIMEOUT_MS ? 'timeout' : 'loop_guard' }
200
+ }
201
+
202
+ return { do: 'probe_silently' }
203
+ }
204
+
205
+ /**
206
+ * Map what `/authorize` sent back onto a reason.
207
+ *
208
+ * `login_required` is the expected negative answer from `prompt=none`, not a
209
+ * fault. `access_denied` is a suspended or offboarded identity, which is the
210
+ * one refusal a person genuinely cannot clear themselves.
211
+ */
212
+ export function reasonForCallbackError(error: string): ShellReason {
213
+ switch (error) {
214
+ case 'login_required':
215
+ return 'no_session'
216
+ case 'access_denied':
217
+ return 'identity_inactive'
218
+ default:
219
+ return 'exchange_failed'
220
+ }
221
+ }
222
+
223
+ // --- The loop guard ----------------------------------------------------------
224
+
225
+ export const GUARD_KEY = 'estiva.authShell.silentAttempt'
226
+
227
+ /**
228
+ * Per-tab, on purpose. `sessionStorage` dies with the tab, so a genuinely new
229
+ * session gets a genuinely fresh silent attempt rather than inheriting a
230
+ * failure somebody hit yesterday.
231
+ */
232
+ export function readGuard(store: Pick<KeyValueStore, 'getItem'>): number | null {
233
+ const raw = store.getItem(GUARD_KEY)
234
+ if (!raw) return null
235
+ const at = Number(raw)
236
+ return Number.isFinite(at) ? at : null
237
+ }
238
+
239
+ export function markGuard(store: Pick<KeyValueStore, 'setItem'>, now: number): void {
240
+ store.setItem(GUARD_KEY, String(now))
241
+ }
242
+
243
+ export function clearGuard(store: Pick<KeyValueStore, 'removeItem'>): void {
244
+ store.removeItem(GUARD_KEY)
245
+ }
246
+
247
+ // --- Instrumentation ---------------------------------------------------------
248
+
249
+ /**
250
+ * One event vocabulary across both apps (PEEK-120).
251
+ *
252
+ * Shared so Peek and Ship can be compared rather than read as two unrelated
253
+ * auth systems. `app` is the client id, which is already the name Estiva ID
254
+ * knows each of them by.
255
+ */
256
+ /**
257
+ * `console` is not in `lib.es2022`. Declared module-locally and read inside
258
+ * `logShellEvent`, so importing this module touches no global (ADR 0002 §4a).
259
+ */
260
+ declare const console: { info(...data: unknown[]): void; error(...data: unknown[]): void }
261
+
262
+ export interface ShellEvent {
263
+ app: string
264
+ phase: ShellPhase
265
+ reason?: ShellReason
266
+ /** Milliseconds since the shell started, for the drop-off view. */
267
+ elapsedMs: number
268
+ }
269
+
270
+ /**
271
+ * Failures are logged at `error`, not `warn`.
272
+ *
273
+ * The point of PEEK-120 is that a broken entry is loud. A `warn` in a browser
274
+ * console is indistinguishable from the noise every app emits at boot.
275
+ */
276
+ export function logShellEvent(e: ShellEvent): void {
277
+ const line = `[auth-shell] ${e.app} ${e.phase}${e.reason ? ` (${e.reason})` : ''} +${e.elapsedMs}ms`
278
+ if (e.phase === 'failed') console.error(line)
279
+ else console.info(line)
280
+ }