@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/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # @estiva-app/identity
2
+
3
+ ## 0.1.0 — 2026-08-27
4
+
5
+ First release. SHA-4, extracted during REW-2.
6
+
7
+ **No wire behaviour.** This package publishes no events. It obtains and holds a
8
+ token; `@estiva-app/protocol` is what turns intent into bytes.
9
+
10
+ The PKCE flow, token storage and validity, silent renewal, sign-out, the returnTo
11
+ and shell-reason vocabulary, and the auth-shell state machine. Extracted from
12
+ Peek, which was ahead of Ship: Ship had no shell-reason vocabulary at all, so
13
+ this is an upgrade for Ship rather than a port of it.
14
+
15
+ ### What is deliberately not here
16
+
17
+ - **Any app's data layer.** Peek's `useEstivaIdAuth.ts` — the
18
+ `ConvexProviderWithAuth` adapter — stays in Peek. If it were here, Ship and
19
+ every future app would inherit a Convex dependency to work around. Ship needing
20
+ no adapter at all is the test that the line held, and CI enforces it.
21
+ - **The shell's rendering.** A full-page surface each app will want its own of.
22
+ Shared is the state machine and the reason vocabulary, which is where the
23
+ expensive mistakes live — and keeping components out also keeps this package
24
+ clear of `@estiva-app/ui`, whose entry would trigger ADR 0002 §2's rule about
25
+ folding `ui` back into the foundation repo.
26
+
27
+ ### Five behaviours that are each an incident, not a nicety
28
+
29
+ Every one is covered by a test that travelled with the code:
30
+
31
+ - **`clearSession` must not touch a sign-in in flight (PEEK-167).** The verifier,
32
+ state and returnTo belong to an authorization request currently in the air.
33
+ Clearing them on somebody else's schedule destroys a sign-in that is still
34
+ happening, and the failure surfaces as an `exchange_failed` for a code that was
35
+ never presented to `/token` — so the identity service has no record of it.
36
+ - **The silent guard is marked synchronously with those writes**, not next to the
37
+ navigation. `codeChallenge` awaits a digest in between, and that window is long
38
+ enough for a second boot to decide to probe as well.
39
+ - **`token_rejected` never reads as "session expired".** Telling somebody their
40
+ session expired when their token was refused for a configuration fault sends
41
+ them to re-authenticate against a wall.
42
+ - **Refresh tokens are single-use and a replay is treated as theft.** The stored
43
+ token is cleared when the grant is *refused*, and deliberately not when the
44
+ network is merely unreachable — a flaky connection must not sign somebody out.
45
+ - **Clearing the local token is not signing out (PEEK-122).** The
46
+ `estiva_id_session` cookie on the identity origin survives it, so the next
47
+ silent probe puts the person straight back in. Real sign-out navigates to
48
+ `/logout`.
49
+
50
+ ### A prediction this package falsified
51
+
52
+ The expectation on record, from having just done `@estiva-app/protocol`, was that
53
+ identity could not follow ADR 0002 §4a as cleanly — that being browser-shaped
54
+ throughout, it would need `lib: dom` and a looser rule.
55
+
56
+ It did not need either. Details in the README; the short version is that a browser
57
+ API an app must inject is also a browser API a test can observe.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Estiva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @estiva-app/identity
2
+
3
+ Signing in with Estiva ID: authorization code + PKCE, token storage, silent
4
+ renewal, sign-out, and the auth-shell state machine.
5
+
6
+ ```bash
7
+ npm install @estiva-app/identity
8
+ ```
9
+
10
+ No runtime dependencies. CI enforces that.
11
+
12
+ ## The seam this package exists for
13
+
14
+ **In:** everything that decides *whether somebody is signed in* and *what to say
15
+ when they are not*.
16
+
17
+ **Not in: any app's data layer.** `peek-app/src/auth/useEstivaIdAuth.ts` is the
18
+ adapter `ConvexProviderWithAuth` reads, and it stays in Peek. If it were here,
19
+ Ship and every future app would inherit a Convex dependency to work around —
20
+ which is precisely the failure SHA-4's sequencing exists to avoid.
21
+
22
+ **Ship needing no adapter at all is the test that the line held.** It hands the
23
+ token straight to its relay client.
24
+
25
+ **Not in: the shell's rendering.** A full-page surface each app wants its own of.
26
+ Shared is the state machine and the reason vocabulary. That also keeps this
27
+ package clear of `@estiva-app/ui` — ADR 0002 §2 names identity depending on `ui`
28
+ as the trigger that folds `ui` back into the foundation repo, and that should be
29
+ a decision somebody makes rather than one a sign-in screen makes by accident.
30
+
31
+ ## Every difference between the consumers is a parameter
32
+
33
+ The package is a factory, not a module of free functions, because Peek and Ship
34
+ genuinely differ on every one of these — and hardcoding any would have made the
35
+ package come out Peek-shaped, which is the whole risk SHA-4 names.
36
+
37
+ | injected | Peek | Ship |
38
+ | --- | --- | --- |
39
+ | `clientId` | `estiva-peek` | `estiva-ship` |
40
+ | `redirectUri` | a fixed `/auth/callback` | its current pathname — Ship routes on `location.hash` |
41
+ | `storage` | `sessionStorage`, which dies with the tab | `localStorage` |
42
+ | `keyPrefix` | `peek.estivaId` | `ship.estiva-id` |
43
+ | `navigate` | the one genuinely untestable act, so it is observable | |
44
+
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.
48
+
49
+ An app instantiates once and re-exports its own names, so call sites do not churn:
50
+
51
+ ```ts
52
+ const client = createEstivaId({
53
+ base: ESTIVA_ID_ORIGIN,
54
+ clientId: 'estiva-peek',
55
+ redirectUri: () => `${window.location.origin}/auth/callback`,
56
+ storage: () => (typeof window === 'undefined' ? null : window.sessionStorage),
57
+ keyPrefix: 'peek.estivaId',
58
+ navigate: (url) => window.location.assign(url),
59
+ })
60
+ export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client
61
+ ```
62
+
63
+ ## The prediction this package falsified
64
+
65
+ Before starting, the expectation on the ticket was that identity **could not**
66
+ follow ADR 0002 §4a as cleanly as `@estiva-app/protocol` did — that being
67
+ browser-shaped throughout, it would need `lib: dom`, and that the ADR would need
68
+ amending to say so.
69
+
70
+ That was wrong, and it is worth saying why rather than quietly not mentioning it:
71
+
72
+ - `crypto`, `btoa`, `TextEncoder`, `URL`, `URLSearchParams`, `fetch` and `console`
73
+ are declared inside the modules that use them and read inside function bodies.
74
+ - The DOM's `Storage` became a local `KeyValueStore` interface. `sessionStorage`,
75
+ `localStorage` and a plain object all satisfy it structurally, and the published
76
+ declarations name no ambient type.
77
+ - Navigation is a **required** `navigate` callback rather than a `location` reach.
78
+
79
+ So the package builds with `types: []` and no `lib: dom`, exactly as `protocol`
80
+ does, and a test can import it with every browser global deleted. CI asserts both.
81
+
82
+ The thing that made it possible was not cleverness. It was that **a browser API an
83
+ app must inject is also a browser API a test can observe** — the seams §4a forces
84
+ and the seams a test wants are the same seams. Worth remembering before assuming
85
+ the next browser-shaped package needs an exemption.
86
+
87
+ `@estiva-app/ui` is still the counter-example: it ships `.d.ts` files naming
88
+ `HTMLButtonElement`, and gets away with it only because both its consumers are
89
+ browser apps.
90
+
91
+ ## Releasing
92
+
93
+ ```bash
94
+ git tag identity@0.1.1 && git push origin identity@0.1.1
95
+ ```
96
+
97
+ The **first** publish is manual and once-ever, because a trusted publisher is
98
+ configured on a package that already exists — ADR 0002 §4c, §7. Every release
99
+ after it is a tag.
@@ -0,0 +1,124 @@
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 } from './shell.js';
51
+ /** Just enough of `fetch` to redeem a code. A parameter as well as a global. */
52
+ export type FetchLike = (url: string, init: {
53
+ method: string;
54
+ headers: Record<string, string>;
55
+ body: string;
56
+ }) => Promise<{
57
+ ok: boolean;
58
+ status: number;
59
+ json(): Promise<unknown>;
60
+ }>;
61
+ export interface EstivaIdConfig {
62
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
63
+ base: string;
64
+ /** This app's `client_id` row in `app_credentials`. Never defaulted. */
65
+ clientId: string;
66
+ /**
67
+ * Where Estiva ID sends people back to. Must match a registered entry
68
+ * **exactly** — a trailing slash is a refused sign-in.
69
+ */
70
+ redirectUri: () => string;
71
+ /** See the note on storage above. Returns null outside a browser. */
72
+ storage: () => KeyValueStore | null;
73
+ /** Namespaces this app's keys, e.g. `peek.estivaId`. */
74
+ keyPrefix: string;
75
+ /** Defaults to the runtime's `location.assign`-equivalent via `navigate`. */
76
+ navigate?: (url: string) => void;
77
+ now?: () => number;
78
+ fetch?: FetchLike;
79
+ }
80
+ export interface StoredToken {
81
+ accessToken: string;
82
+ /** Epoch ms. Derived from `expires_in` at receipt, not sent by the server. */
83
+ expiresAt: number;
84
+ pubkey: string;
85
+ /**
86
+ * The credential that renews the one above without a redirect (PEEK-108).
87
+ *
88
+ * Absent when Estiva ID issued none — a deployment predating the grant. The
89
+ * app then behaves exactly as it did before, which is what lets the two sides
90
+ * ship independently.
91
+ *
92
+ * **Single-use.** Estiva ID rotates it on every redemption and treats a replay
93
+ * as evidence of theft, revoking the whole chain. So it is replaced, never
94
+ * kept alongside its successor.
95
+ */
96
+ refreshToken?: string;
97
+ }
98
+ export declare class SignInError extends Error {
99
+ }
100
+ export interface EstivaIdClient {
101
+ redirectUri: () => string;
102
+ codeChallenge: (verifier: string) => Promise<string>;
103
+ beginSignIn: (returnTo: string, options?: {
104
+ silent?: boolean;
105
+ }) => Promise<void>;
106
+ completeSignIn: (search: string) => Promise<{
107
+ token: StoredToken;
108
+ returnTo: string;
109
+ }>;
110
+ refreshAccessToken: () => Promise<StoredToken | null>;
111
+ storedToken: () => StoredToken | null;
112
+ validToken: (now?: number) => StoredToken | null;
113
+ hasSession: () => boolean;
114
+ currentAccessToken: () => string | undefined;
115
+ beginSignOut: () => void;
116
+ clearSession: () => void;
117
+ hasPendingSignIn: () => boolean;
118
+ clearPendingSignIn: () => void;
119
+ takeReturnTo: () => string | null;
120
+ stashShellReason: (reason: ShellReason) => void;
121
+ takeShellReason: () => ShellReason | null;
122
+ }
123
+ export declare function createEstivaId(config: EstivaIdConfig): EstivaIdClient;
124
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +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"}