@estiva-app/identity 0.1.3 → 0.2.1

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,89 @@
1
1
  # @estiva-app/identity
2
2
 
3
+ ## 0.2.1 — 2026-09-08
4
+
5
+ **Additive.** `SignRefused` is new; nothing that catches `Error` or reads a
6
+ message changes.
7
+
8
+ - **A `/sign` refusal that is not an expiry now throws `SignRefused`, carrying
9
+ the HTTP `status`.** A `401` still throws `SignerTokenExpired`, which is the
10
+ one refusal a new token fixes.
11
+
12
+ The status belongs on the error because **consumers classify on it and a
13
+ message is not an API.** Found while wiring Peek as the third consumer:
14
+ `reasonForSignFailure` maps `/sign` failures into the auth shell's reason
15
+ vocabulary by regexing `POST /sign failed: (\d{3})` out of the message Peek's
16
+ own copy threw. `403` is `identity_inactive` — an administrator has to restore
17
+ the identity — while a `422` policy refusal is not, and neither is a `502`.
18
+
19
+ Swapping in this package's differently worded message would have collapsed
20
+ every one of those into a single reason. **Nothing would have failed**: Peek's
21
+ tests pass literal strings rather than real errors, so the regex kept matching
22
+ in the test suite while matching nothing in production. That is the shape worth
23
+ recording — the test and the code agreed with each other and both stopped
24
+ describing the app.
25
+
26
+ ## 0.2.0 — 2026-09-07
27
+
28
+ **MINOR because it adds a peer dependency**, which is a break for a consumer
29
+ that does not already have `@estiva-app/protocol` — ADR 0002 §4b puts the break
30
+ on MINOR within `0.x`. Both consumers already have it, and per §5 both had an
31
+ open upgrade PR before this published.
32
+
33
+ **`POST /sign` lives here now.** SHA-3 left `estivaIdSigner` behind in the apps
34
+ deliberately — "it knows about Estiva ID, which is this ticket" — and SHA-4's
35
+ handoff comment called it "the one thing left duplicated between ship and
36
+ estiva-agent, byte-identical, and it is yours to remove". This is that.
37
+
38
+ - **New: `signViaEstivaId(unsigned, { base, token, expectedPubkey })`** — one
39
+ round trip. What Peek uses, because Peek signs per request with a token it
40
+ reads fresh each time.
41
+
42
+ - **New: `estivaIdSigner({ base, pubkey, token, renew })`** — a `Signer` over the
43
+ same round trip, holding a live bearer and renewing once on a `401`. What Ship
44
+ uses, because Ship builds one signer at module scope for the life of the page.
45
+ Neither shape can be expressed as the other without one of them getting worse,
46
+ so both are here over one implementation.
47
+
48
+ - **`SignerTokenExpired`** travels with them: "obtain a new token" and "this
49
+ event was refused" call for different things from the caller, and the status
50
+ code is the only thing that separates them.
51
+
52
+ ### The guard the three copies had already drifted on
53
+
54
+ `/sign` **signs as the token's subject regardless of what it is handed.** So a
55
+ token that belongs to somebody else does not produce an error — it produces
56
+ **HTTP 200 carrying a valid event authored by that other person**, and the
57
+ symptom arrives much later as a comment attributed to a colleague, with nothing
58
+ in any log tying it back.
59
+
60
+ SHA-4's Traps section says both apps must keep the `expectedPubkey` guard. **Only
61
+ Peek's did.** Ship's `estivaIdSigner` — the signer every issue, comment, status
62
+ change and assignment in Ship goes through — never had a pubkey comparison at
63
+ all. That is precisely the drift three copies of one call produce, and the app
64
+ that could least afford it was the one missing it.
65
+
66
+ Here the guard is not optional on the `estivaIdSigner` path: it is supplied from
67
+ the `pubkey` the signer was built with, whether or not the caller thought about
68
+ it. Two tests cover it and a control asserts a wrong-author refusal does **not**
69
+ trigger a renewal — spending a single-use refresh token on an error a new token
70
+ cannot fix is how one bad signature becomes a revoked chain.
71
+
72
+ ### What stayed in the apps, and why
73
+
74
+ `localSigner` (a per-browser anonymous key) and `nip07Signer` (a browser
75
+ extension) are still duplicated between `ship` and `estiva-agent`, held by
76
+ `scripts/conformance.test.ts`. Neither knows anything about Estiva ID, which is
77
+ the line this package draws, so neither moved. Worth its own ticket rather than
78
+ a quiet widening of this one.
79
+
80
+ ### The seam property still holds
81
+
82
+ `@estiva-app/protocol` is a **peer** dependency, following `@estiva-app/interop`.
83
+ Nothing here calls a protocol function — only its types are used — so the
84
+ complete list of imports in the shipped JavaScript is still `./client.js`,
85
+ `./shell.js` and now `./signer.js`. Only the `.d.ts` names protocol.
86
+
3
87
  ## 0.1.3 — 2026-09-07
4
88
 
5
89
  **Fixes a dead end reported from production.** No API change; `expiresAt` is now
@@ -39,6 +123,7 @@ still held: a token this cannot read is still a token.
39
123
  5 new tests, 59 to 64, including two controls that the `expires_in` fallback is
40
124
  untouched. Reverting the clamp fails exactly the two tests that depend on it.
41
125
 
126
+
42
127
  ## 0.1.2 — 2026-09-07
43
128
 
44
129
  **No wire behaviour, no break.** `scheduleRenewal` is new and the guard on
package/README.md CHANGED
@@ -69,6 +69,36 @@ const client = createEstivaId({
69
69
  export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client
70
70
  ```
71
71
 
72
+ ## Signing is asking, and the answer must be checked
73
+
74
+ ```ts
75
+ // Peek: one round trip, a token read fresh each time
76
+ const event = await signViaEstivaId(unsigned, { base, token, expectedPubkey: myPubkey })
77
+
78
+ // Ship: a protocol Signer, built once, renewing itself
79
+ const signer = estivaIdSigner({ base, pubkey, token, renew: client.refreshAccessToken })
80
+ ```
81
+
82
+ An app publishes as a person without ever seeing their secret. `@estiva-app/protocol`
83
+ owns how an event gets its signature; this package owns *who is allowed to ask*.
84
+
85
+ **`/sign` signs as the token's subject regardless of what it is handed.** So a
86
+ token belonging to somebody else is not an error — it is **HTTP 200 with a valid
87
+ event authored by that other person**. `expectedPubkey` is a check, not a
88
+ request, and `estivaIdSigner` supplies it unconditionally from the pubkey it was
89
+ built with. There were three copies of this round trip before SHA-4 and only one
90
+ of them checked; the one that did not was Ship's, through which every write in
91
+ Ship passes.
92
+
93
+ A `401` throws `SignerTokenExpired`; any other refusal throws `SignRefused`
94
+ carrying the HTTP `status`, because consumers classify on it and a message is not
95
+ an API — `403` means an administrator has to restore the identity, while a `422`
96
+ policy refusal means something else entirely. And
97
+ `estivaIdSigner` renews **once** and retries. Never twice: a second `401` after
98
+ a successful renewal is a token the service will not accept, and retrying that
99
+ is how one refused signature becomes a hot loop. Anything that is not an expiry
100
+ is rethrown untouched, so a wrong-author refusal never spends a refresh token.
101
+
72
102
  ## Renewal is scheduled, not reactive
73
103
 
74
104
  ```ts
package/dist/index.d.ts CHANGED
@@ -46,7 +46,8 @@
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.3";
49
+ export declare const IDENTITY_VERSION = "0.2.1";
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
51
  export { type EstivaIdConfig, type EstivaIdClient, type StoredToken, type FetchLike, type RenewalHandlers, type RenewalSchedule, SignInError, createEstivaId, } from './client.js';
52
+ export { type SignViaEstivaIdOptions, type EstivaIdSignerOptions, type SignFetchLike, SignerTokenExpired, SignRefused, signViaEstivaId, estivaIdSigner, } from './signer.js';
52
53
  //# 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,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,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;AAEpB,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,aAAa,EAClB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -46,7 +46,8 @@
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.3';
49
+ export const IDENTITY_VERSION = '0.2.1';
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
+ export { SignerTokenExpired, SignRefused, signViaEstivaId, estivaIdSigner, } from './signer.js';
52
53
  //# 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,EAOL,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;AAEpB,OAAO,EAIL,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Signing by asking Estiva ID, holding no key at all.
3
+ *
4
+ * The point of the whole seam: an app publishes as a person without ever seeing
5
+ * their secret. `POST /sign` takes a bearer token and an unsigned event and
6
+ * returns a signed one.
7
+ *
8
+ * ## Why this is identity's and not protocol's
9
+ *
10
+ * `@estiva-app/protocol` owns how an event gets its signature — the id
11
+ * computation, the serialization, the bytes. It owns the {@link Signer} seam and
12
+ * the raw-secret-key implementation of it. What it deliberately does not own is
13
+ * *who is allowed to ask*: the token, its renewal, and the round trip to an
14
+ * identity service. That is this package, and `ship/lib/nostr/signer.ts` has
15
+ * said so in a header comment since SHA-3 — "it is `@estiva-app/identity`'s
16
+ * remit — SHA-4. Until then this file is the app's copy."
17
+ *
18
+ * There were three copies until this file existed:
19
+ *
20
+ * | where | what |
21
+ * | --- | --- |
22
+ * | `ship/lib/nostr/signer.ts` | `estivaIdSigner` |
23
+ * | `estiva-agent/lib/nostr/signer.ts` | byte-identical to it |
24
+ * | `peek/src/nostr/bridge.ts` | `signViaEstivaId`, a third |
25
+ *
26
+ * Three implementations of the one call that decides who authored an event, and
27
+ * they had already drifted on the thing that matters most — see the guard below.
28
+ *
29
+ * ## Two exports over one round trip
30
+ *
31
+ * The consumers genuinely want different shapes, and this is not the same kind
32
+ * of difference as `storage` or `clientId`. Peek signs *per request* with a token
33
+ * it reads fresh each time (`signViaEstivaId`); Ship builds a `Signer` **once**,
34
+ * at module scope, which then holds a live bearer for the life of the page and
35
+ * renews it on a `401`. Neither can be expressed as the other without one of
36
+ * them getting worse, so both are here, over one implementation of the round
37
+ * trip and one copy of the guard.
38
+ *
39
+ * `@estiva-app/protocol` is a **peer** dependency, following `@estiva-app/interop`.
40
+ * Nothing here calls a protocol function — only its types are used — so the
41
+ * emitted `.js` imports nothing, and `dist/` keeps the property SHA-4's seam was
42
+ * verified by: the complete list of imports in the shipped JavaScript is
43
+ * `./client.js` and `./shell.js`.
44
+ */
45
+ import type { SignedEvent, Signer, UnsignedEvent } from '@estiva-app/protocol';
46
+ /**
47
+ * `fetch`, again, and for the same reason as in `client.ts` — `lib.es2022` has
48
+ * no such global, and this package compiles with `types: []` and no `lib: dom`
49
+ * so one published `.d.ts` works in every consumer (ADR 0002 §4a).
50
+ *
51
+ * Note this module needs a *slightly* wider `fetch` than `client.ts` does: it
52
+ * reads `text()` rather than `json()`, because `/sign`'s error bodies are not
53
+ * reliably JSON and the raw text is the only thing worth showing a person.
54
+ */
55
+ declare const fetch: (url: string, init: {
56
+ method: string;
57
+ headers: Record<string, string>;
58
+ body: string;
59
+ }) => Promise<{
60
+ ok: boolean;
61
+ status: number;
62
+ text(): Promise<string>;
63
+ }>;
64
+ /** Just the part of `fetch` this module uses. A parameter as well as a global. */
65
+ export type SignFetchLike = typeof fetch;
66
+ /** Distinguishes "sign in again" from "this event was refused". */
67
+ export declare class SignerTokenExpired extends Error {
68
+ constructor();
69
+ }
70
+ /**
71
+ * A refusal that is not an expiry, **carrying the status that says which**.
72
+ *
73
+ * The status is on the error rather than only in its text because consumers
74
+ * classify on it and a message is not an API. Peek maps `/sign` failures into
75
+ * the auth shell's reason vocabulary — `403` is `identity_inactive`, an
76
+ * administrator's problem, while a `422` policy refusal is not — and it did that
77
+ * by regexing its own thrown string. A package that threw a differently worded
78
+ * message would have quietly collapsed every one of those into the same reason,
79
+ * and Peek's tests pass literal strings, so nothing would have failed.
80
+ */
81
+ export declare class SignRefused extends Error {
82
+ readonly status: number;
83
+ constructor(status: number, message: string);
84
+ }
85
+ export interface SignViaEstivaIdOptions {
86
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
87
+ base: string;
88
+ /** A bearer token from `POST /token`, audience-bound to this app. */
89
+ token: string;
90
+ /**
91
+ * The pubkey the returned event **must** be authored by.
92
+ *
93
+ * Not a request. `/sign` ignores any pubkey it is handed and signs as the
94
+ * token's subject, always — so this is a *check*, and the reason one is needed
95
+ * at all is that a mismatch comes back as **HTTP 200 with a perfectly valid
96
+ * event authored by somebody else**. Without it the failure is invisible at
97
+ * the call site and surfaces later as a message attributed to the wrong
98
+ * person, which is not a bug anybody can debug from the symptom.
99
+ *
100
+ * Optional only because `estivaIdSigner` always supplies it and a caller with
101
+ * genuinely no expectation should be able to say so out loud. Supply it.
102
+ */
103
+ expectedPubkey?: string;
104
+ fetch?: SignFetchLike;
105
+ }
106
+ /**
107
+ * One `POST /sign`.
108
+ *
109
+ * **`/sign` refuses `kind:0` for every app, always.** Profiles belong to the
110
+ * identity service (PEEK-31); a refusal here is the policy working rather than
111
+ * something to route around.
112
+ *
113
+ * A `401` is thrown as {@link SignerTokenExpired} rather than a generic failure,
114
+ * because "obtain a new token" and "this event was refused" call for completely
115
+ * different responses from the caller and the status code is the only thing that
116
+ * distinguishes them.
117
+ */
118
+ export declare function signViaEstivaId(unsigned: UnsignedEvent, options: SignViaEstivaIdOptions): Promise<SignedEvent>;
119
+ export interface EstivaIdSignerOptions {
120
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
121
+ base: string;
122
+ /** The pubkey the token was issued for — `sub` in the JWT. */
123
+ pubkey: string;
124
+ /** A bearer token from `POST /token`, audience-bound to this app. */
125
+ token: string;
126
+ /**
127
+ * Renew the token, once, when `/sign` says it has expired (PEEK-108).
128
+ *
129
+ * Optional: without it a `401` surfaces as {@link SignerTokenExpired} for the
130
+ * caller to deal with, exactly as before. With it, an expiry is invisible —
131
+ * which matters more than it used to, because access tokens live ten minutes
132
+ * rather than an hour, so a session of any length crosses one.
133
+ *
134
+ * Returning `null` means renewal failed and the expiry is real.
135
+ *
136
+ * Pass `client.refreshAccessToken` and nothing else: it coalesces concurrent
137
+ * callers, and a refresh token is single-use with a replay read as theft.
138
+ */
139
+ renew?: () => Promise<string | null>;
140
+ fetch?: SignFetchLike;
141
+ }
142
+ /**
143
+ * A {@link Signer} that signs by asking Estiva ID.
144
+ *
145
+ * Holds the live bearer, so a renewal replaces it for every later signature
146
+ * rather than being rediscovered on each one.
147
+ */
148
+ export declare function estivaIdSigner({ base, pubkey, token, renew, fetch: send }: EstivaIdSignerOptions): Signer;
149
+ export {};
150
+ //# sourceMappingURL=signer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAE9E;;;;;;;;GAQG;AACH,OAAO,CAAC,MAAM,KAAK,EAAE,CACnB,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,MAAM,CAAC,CAAA;CAAE,CAAC,CAAA;AAEtE,kFAAkF;AAClF,MAAM,MAAM,aAAa,GAAG,OAAO,KAAK,CAAA;AAExC,mEAAmE;AACnE,qBAAa,kBAAmB,SAAQ,KAAK;;CAK5C;AAED;;;;;;;;;;GAUG;AACH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM;CAKlB;AAED,MAAM,WAAW,sBAAsB;IACrC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,WAAW,CAAC,CAkDtB;AAED,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACpC,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,qBAAqB,GAAG,MAAM,CAwCzG"}
package/dist/signer.js ADDED
@@ -0,0 +1,133 @@
1
+ /** Distinguishes "sign in again" from "this event was refused". */
2
+ export class SignerTokenExpired extends Error {
3
+ constructor() {
4
+ super('The signing token has expired');
5
+ this.name = 'SignerTokenExpired';
6
+ }
7
+ }
8
+ /**
9
+ * A refusal that is not an expiry, **carrying the status that says which**.
10
+ *
11
+ * The status is on the error rather than only in its text because consumers
12
+ * classify on it and a message is not an API. Peek maps `/sign` failures into
13
+ * the auth shell's reason vocabulary — `403` is `identity_inactive`, an
14
+ * administrator's problem, while a `422` policy refusal is not — and it did that
15
+ * by regexing its own thrown string. A package that threw a differently worded
16
+ * message would have quietly collapsed every one of those into the same reason,
17
+ * and Peek's tests pass literal strings, so nothing would have failed.
18
+ */
19
+ export class SignRefused extends Error {
20
+ status;
21
+ constructor(status, message) {
22
+ super(message);
23
+ this.status = status;
24
+ this.name = 'SignRefused';
25
+ }
26
+ }
27
+ /**
28
+ * One `POST /sign`.
29
+ *
30
+ * **`/sign` refuses `kind:0` for every app, always.** Profiles belong to the
31
+ * identity service (PEEK-31); a refusal here is the policy working rather than
32
+ * something to route around.
33
+ *
34
+ * A `401` is thrown as {@link SignerTokenExpired} rather than a generic failure,
35
+ * because "obtain a new token" and "this event was refused" call for completely
36
+ * different responses from the caller and the status code is the only thing that
37
+ * distinguishes them.
38
+ */
39
+ export async function signViaEstivaId(unsigned, options) {
40
+ const send = options.fetch ?? fetch;
41
+ const origin = options.base.replace(/\/+$/, '');
42
+ const response = await send(`${origin}/sign`, {
43
+ method: 'POST',
44
+ headers: {
45
+ 'content-type': 'application/json',
46
+ authorization: `Bearer ${options.token}`,
47
+ },
48
+ /*
49
+ Only the four fields that go into the signature input. The unsigned event
50
+ may carry a `pubkey` — Peek's NIP-98 path passes an empty one on purpose —
51
+ and sending it would imply `/sign` honours it, which it does not.
52
+ */
53
+ body: JSON.stringify({
54
+ event: {
55
+ kind: unsigned.kind,
56
+ created_at: unsigned.created_at,
57
+ tags: unsigned.tags,
58
+ content: unsigned.content,
59
+ },
60
+ }),
61
+ });
62
+ const text = await response.text();
63
+ if (response.status === 401)
64
+ throw new SignerTokenExpired();
65
+ if (!response.ok) {
66
+ let reason = text.slice(0, 200);
67
+ try {
68
+ const body = JSON.parse(text);
69
+ reason = body.error?.message ?? reason;
70
+ }
71
+ catch {
72
+ // Keep the raw text. `/sign`'s error bodies are not reliably JSON, and the
73
+ // raw text is more use to a person than "unparseable response".
74
+ }
75
+ throw new SignRefused(response.status, `Estiva ID refused to sign kind:${unsigned.kind} — ${reason}`);
76
+ }
77
+ const event = JSON.parse(text).event;
78
+ if (!event)
79
+ throw new Error('POST /sign returned no event');
80
+ if (options.expectedPubkey && event.pubkey !== options.expectedPubkey) {
81
+ throw new Error(`POST /sign returned an event authored by ${event.pubkey}, expected ${options.expectedPubkey}. ` +
82
+ 'That token belongs to a different identity — refusing to publish under the wrong author.');
83
+ }
84
+ return event;
85
+ }
86
+ /**
87
+ * A {@link Signer} that signs by asking Estiva ID.
88
+ *
89
+ * Holds the live bearer, so a renewal replaces it for every later signature
90
+ * rather than being rediscovered on each one.
91
+ */
92
+ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }) {
93
+ let bearer = token;
94
+ return {
95
+ pubkey,
96
+ kind: 'estiva-id',
97
+ async sign(unsigned) {
98
+ const once = (auth) => signViaEstivaId(unsigned, {
99
+ base,
100
+ token: auth,
101
+ // The guard, unconditionally. Ship's copy of this signer never had it
102
+ // and Peek's `signViaEstivaId` did, which is exactly the drift three
103
+ // copies produce: the app that publishes every issue, comment and
104
+ // status change was the one that could not tell it had signed as
105
+ // somebody else. SHA-4's Traps section says both must keep it.
106
+ expectedPubkey: pubkey,
107
+ ...(send ? { fetch: send } : {}),
108
+ });
109
+ try {
110
+ return await once(bearer);
111
+ }
112
+ catch (error) {
113
+ /*
114
+ Renew once and retry, never twice.
115
+
116
+ A second 401 after a successful renewal is not an expiry — it is a
117
+ token the service will not accept, and retrying that in a loop is how
118
+ one refused signature becomes a hot loop against the identity service.
119
+ Anything that is not an expiry is rethrown untouched, so a wrong-author
120
+ refusal is never mistaken for something a new token would fix.
121
+ */
122
+ if (!(error instanceof SignerTokenExpired) || !renew)
123
+ throw error;
124
+ const renewed = await renew();
125
+ if (!renewed)
126
+ throw error;
127
+ bearer = renewed;
128
+ return await once(bearer);
129
+ }
130
+ },
131
+ };
132
+ }
133
+ //# sourceMappingURL=signer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signer.js","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estiva-app/identity",
3
- "version": "0.1.3",
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.",
3
+ "version": "0.2.1",
4
+ "description": "Estiva ID sign-in: authorization code + PKCE, token storage, silent renewal, sign-out, signing via POST /sign, and the auth-shell state machine. Knows nothing about any app's data layer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -35,11 +35,13 @@
35
35
  "keywords": [
36
36
  "estiva",
37
37
  "estiva-id",
38
+ "nostr",
38
39
  "oauth",
39
40
  "pkce",
40
- "nostr"
41
+ "signer"
41
42
  ],
42
43
  "scripts": {
44
+ "prebuild": "npm run build --workspace @estiva-app/protocol",
43
45
  "build": "tsc -p tsconfig.json",
44
46
  "test": "npm run build && node --test test/*.test.mjs test/*.test.ts",
45
47
  "typecheck": "npm run build && tsc -p tsconfig.test.json",
@@ -47,7 +49,11 @@
47
49
  },
48
50
  "dependencies": {},
49
51
  "devDependencies": {
52
+ "@estiva-app/protocol": ">=0.14.0",
50
53
  "@types/node": "^24.13.3",
51
54
  "typescript": "~5.9.3"
55
+ },
56
+ "peerDependencies": {
57
+ "@estiva-app/protocol": ">=0.14.0"
52
58
  }
53
59
  }
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.3'
49
+ export const IDENTITY_VERSION = '0.2.1'
50
50
 
51
51
  export {
52
52
  type KeyValueStore,
@@ -81,3 +81,13 @@ export {
81
81
  SignInError,
82
82
  createEstivaId,
83
83
  } from './client.js'
84
+
85
+ export {
86
+ type SignViaEstivaIdOptions,
87
+ type EstivaIdSignerOptions,
88
+ type SignFetchLike,
89
+ SignerTokenExpired,
90
+ SignRefused,
91
+ signViaEstivaId,
92
+ estivaIdSigner,
93
+ } from './signer.js'
package/src/signer.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Signing by asking Estiva ID, holding no key at all.
3
+ *
4
+ * The point of the whole seam: an app publishes as a person without ever seeing
5
+ * their secret. `POST /sign` takes a bearer token and an unsigned event and
6
+ * returns a signed one.
7
+ *
8
+ * ## Why this is identity's and not protocol's
9
+ *
10
+ * `@estiva-app/protocol` owns how an event gets its signature — the id
11
+ * computation, the serialization, the bytes. It owns the {@link Signer} seam and
12
+ * the raw-secret-key implementation of it. What it deliberately does not own is
13
+ * *who is allowed to ask*: the token, its renewal, and the round trip to an
14
+ * identity service. That is this package, and `ship/lib/nostr/signer.ts` has
15
+ * said so in a header comment since SHA-3 — "it is `@estiva-app/identity`'s
16
+ * remit — SHA-4. Until then this file is the app's copy."
17
+ *
18
+ * There were three copies until this file existed:
19
+ *
20
+ * | where | what |
21
+ * | --- | --- |
22
+ * | `ship/lib/nostr/signer.ts` | `estivaIdSigner` |
23
+ * | `estiva-agent/lib/nostr/signer.ts` | byte-identical to it |
24
+ * | `peek/src/nostr/bridge.ts` | `signViaEstivaId`, a third |
25
+ *
26
+ * Three implementations of the one call that decides who authored an event, and
27
+ * they had already drifted on the thing that matters most — see the guard below.
28
+ *
29
+ * ## Two exports over one round trip
30
+ *
31
+ * The consumers genuinely want different shapes, and this is not the same kind
32
+ * of difference as `storage` or `clientId`. Peek signs *per request* with a token
33
+ * it reads fresh each time (`signViaEstivaId`); Ship builds a `Signer` **once**,
34
+ * at module scope, which then holds a live bearer for the life of the page and
35
+ * renews it on a `401`. Neither can be expressed as the other without one of
36
+ * them getting worse, so both are here, over one implementation of the round
37
+ * trip and one copy of the guard.
38
+ *
39
+ * `@estiva-app/protocol` is a **peer** dependency, following `@estiva-app/interop`.
40
+ * Nothing here calls a protocol function — only its types are used — so the
41
+ * emitted `.js` imports nothing, and `dist/` keeps the property SHA-4's seam was
42
+ * verified by: the complete list of imports in the shipped JavaScript is
43
+ * `./client.js` and `./shell.js`.
44
+ */
45
+ import type { SignedEvent, Signer, UnsignedEvent } from '@estiva-app/protocol'
46
+
47
+ /**
48
+ * `fetch`, again, and for the same reason as in `client.ts` — `lib.es2022` has
49
+ * no such global, and this package compiles with `types: []` and no `lib: dom`
50
+ * so one published `.d.ts` works in every consumer (ADR 0002 §4a).
51
+ *
52
+ * Note this module needs a *slightly* wider `fetch` than `client.ts` does: it
53
+ * reads `text()` rather than `json()`, because `/sign`'s error bodies are not
54
+ * reliably JSON and the raw text is the only thing worth showing a person.
55
+ */
56
+ declare const fetch: (
57
+ url: string,
58
+ init: { method: string; headers: Record<string, string>; body: string },
59
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>
60
+
61
+ /** Just the part of `fetch` this module uses. A parameter as well as a global. */
62
+ export type SignFetchLike = typeof fetch
63
+
64
+ /** Distinguishes "sign in again" from "this event was refused". */
65
+ export class SignerTokenExpired extends Error {
66
+ constructor() {
67
+ super('The signing token has expired')
68
+ this.name = 'SignerTokenExpired'
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A refusal that is not an expiry, **carrying the status that says which**.
74
+ *
75
+ * The status is on the error rather than only in its text because consumers
76
+ * classify on it and a message is not an API. Peek maps `/sign` failures into
77
+ * the auth shell's reason vocabulary — `403` is `identity_inactive`, an
78
+ * administrator's problem, while a `422` policy refusal is not — and it did that
79
+ * by regexing its own thrown string. A package that threw a differently worded
80
+ * message would have quietly collapsed every one of those into the same reason,
81
+ * and Peek's tests pass literal strings, so nothing would have failed.
82
+ */
83
+ export class SignRefused extends Error {
84
+ constructor(
85
+ readonly status: number,
86
+ message: string,
87
+ ) {
88
+ super(message)
89
+ this.name = 'SignRefused'
90
+ }
91
+ }
92
+
93
+ export interface SignViaEstivaIdOptions {
94
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
95
+ base: string
96
+ /** A bearer token from `POST /token`, audience-bound to this app. */
97
+ token: string
98
+ /**
99
+ * The pubkey the returned event **must** be authored by.
100
+ *
101
+ * Not a request. `/sign` ignores any pubkey it is handed and signs as the
102
+ * token's subject, always — so this is a *check*, and the reason one is needed
103
+ * at all is that a mismatch comes back as **HTTP 200 with a perfectly valid
104
+ * event authored by somebody else**. Without it the failure is invisible at
105
+ * the call site and surfaces later as a message attributed to the wrong
106
+ * person, which is not a bug anybody can debug from the symptom.
107
+ *
108
+ * Optional only because `estivaIdSigner` always supplies it and a caller with
109
+ * genuinely no expectation should be able to say so out loud. Supply it.
110
+ */
111
+ expectedPubkey?: string
112
+ fetch?: SignFetchLike
113
+ }
114
+
115
+ /**
116
+ * One `POST /sign`.
117
+ *
118
+ * **`/sign` refuses `kind:0` for every app, always.** Profiles belong to the
119
+ * identity service (PEEK-31); a refusal here is the policy working rather than
120
+ * something to route around.
121
+ *
122
+ * A `401` is thrown as {@link SignerTokenExpired} rather than a generic failure,
123
+ * because "obtain a new token" and "this event was refused" call for completely
124
+ * different responses from the caller and the status code is the only thing that
125
+ * distinguishes them.
126
+ */
127
+ export async function signViaEstivaId(
128
+ unsigned: UnsignedEvent,
129
+ options: SignViaEstivaIdOptions,
130
+ ): Promise<SignedEvent> {
131
+ const send = options.fetch ?? fetch
132
+ const origin = options.base.replace(/\/+$/, '')
133
+
134
+ const response = await send(`${origin}/sign`, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'content-type': 'application/json',
138
+ authorization: `Bearer ${options.token}`,
139
+ },
140
+ /*
141
+ Only the four fields that go into the signature input. The unsigned event
142
+ may carry a `pubkey` — Peek's NIP-98 path passes an empty one on purpose —
143
+ and sending it would imply `/sign` honours it, which it does not.
144
+ */
145
+ body: JSON.stringify({
146
+ event: {
147
+ kind: unsigned.kind,
148
+ created_at: unsigned.created_at,
149
+ tags: unsigned.tags,
150
+ content: unsigned.content,
151
+ },
152
+ }),
153
+ })
154
+
155
+ const text = await response.text()
156
+ if (response.status === 401) throw new SignerTokenExpired()
157
+ if (!response.ok) {
158
+ let reason = text.slice(0, 200)
159
+ try {
160
+ const body = JSON.parse(text) as { error?: { message?: string } }
161
+ reason = body.error?.message ?? reason
162
+ } catch {
163
+ // Keep the raw text. `/sign`'s error bodies are not reliably JSON, and the
164
+ // raw text is more use to a person than "unparseable response".
165
+ }
166
+ throw new SignRefused(response.status, `Estiva ID refused to sign kind:${unsigned.kind} — ${reason}`)
167
+ }
168
+
169
+ const event = (JSON.parse(text) as { event?: SignedEvent }).event
170
+ if (!event) throw new Error('POST /sign returned no event')
171
+
172
+ if (options.expectedPubkey && event.pubkey !== options.expectedPubkey) {
173
+ throw new Error(
174
+ `POST /sign returned an event authored by ${event.pubkey}, expected ${options.expectedPubkey}. ` +
175
+ 'That token belongs to a different identity — refusing to publish under the wrong author.',
176
+ )
177
+ }
178
+
179
+ return event
180
+ }
181
+
182
+ export interface EstivaIdSignerOptions {
183
+ /** Origin of the identity service, e.g. `https://id.estiva.app`. */
184
+ base: string
185
+ /** The pubkey the token was issued for — `sub` in the JWT. */
186
+ pubkey: string
187
+ /** A bearer token from `POST /token`, audience-bound to this app. */
188
+ token: string
189
+ /**
190
+ * Renew the token, once, when `/sign` says it has expired (PEEK-108).
191
+ *
192
+ * Optional: without it a `401` surfaces as {@link SignerTokenExpired} for the
193
+ * caller to deal with, exactly as before. With it, an expiry is invisible —
194
+ * which matters more than it used to, because access tokens live ten minutes
195
+ * rather than an hour, so a session of any length crosses one.
196
+ *
197
+ * Returning `null` means renewal failed and the expiry is real.
198
+ *
199
+ * Pass `client.refreshAccessToken` and nothing else: it coalesces concurrent
200
+ * callers, and a refresh token is single-use with a replay read as theft.
201
+ */
202
+ renew?: () => Promise<string | null>
203
+ fetch?: SignFetchLike
204
+ }
205
+
206
+ /**
207
+ * A {@link Signer} that signs by asking Estiva ID.
208
+ *
209
+ * Holds the live bearer, so a renewal replaces it for every later signature
210
+ * rather than being rediscovered on each one.
211
+ */
212
+ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }: EstivaIdSignerOptions): Signer {
213
+ let bearer = token
214
+
215
+ return {
216
+ pubkey,
217
+ kind: 'estiva-id',
218
+ async sign(unsigned) {
219
+ const once = (auth: string) =>
220
+ signViaEstivaId(unsigned, {
221
+ base,
222
+ token: auth,
223
+ // The guard, unconditionally. Ship's copy of this signer never had it
224
+ // and Peek's `signViaEstivaId` did, which is exactly the drift three
225
+ // copies produce: the app that publishes every issue, comment and
226
+ // status change was the one that could not tell it had signed as
227
+ // somebody else. SHA-4's Traps section says both must keep it.
228
+ expectedPubkey: pubkey,
229
+ ...(send ? { fetch: send } : {}),
230
+ })
231
+
232
+ try {
233
+ return await once(bearer)
234
+ } catch (error) {
235
+ /*
236
+ Renew once and retry, never twice.
237
+
238
+ A second 401 after a successful renewal is not an expiry — it is a
239
+ token the service will not accept, and retrying that in a loop is how
240
+ one refused signature becomes a hot loop against the identity service.
241
+ Anything that is not an expiry is rethrown untouched, so a wrong-author
242
+ refusal is never mistaken for something a new token would fix.
243
+ */
244
+ if (!(error instanceof SignerTokenExpired) || !renew) throw error
245
+ const renewed = await renew()
246
+ if (!renewed) throw error
247
+ bearer = renewed
248
+ return await once(bearer)
249
+ }
250
+ },
251
+ }
252
+ }