@witnium-tech/witniumchain 0.3.0 → 0.6.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.
@@ -0,0 +1,265 @@
1
+ # WitniumChain — Frontend Integration Guide
2
+
3
+ Audience: the team building the WitniumChain dashboard frontend. This is the
4
+ contract between the backend (accounts + chain-api) and your UI. It covers the
5
+ end-to-end user flows, where keys live, the recovery-file format, and the
6
+ known sharp edges discovered during backend validation.
7
+
8
+ The SDK that backs all of this is **`@witnium-tech/witniumchain`** (npm). Every
9
+ flow below has a typed method on it.
10
+
11
+ ---
12
+
13
+ ## 1. The custody model — read this first
14
+
15
+ Two ed25519 keys are generated **client-side** at signup and never leave the
16
+ user's device in plaintext:
17
+
18
+ | Key | Controls | Used for |
19
+ |---|---|---|
20
+ | **Owner key** | The contract's signing-key set (add/revoke), pause/unpause | Authorizing changes. Never signs witnesses. |
21
+ | **Signing key** | — | Signing witnesses day-to-day |
22
+
23
+ Witnium only ever receives the **public** halves. The backend enforces
24
+ `ownerPublicKey !== initialSigningPublicKey`.
25
+
26
+ **Your job on the frontend:** generate these keys, store them encrypted, let
27
+ the user export/import them, and produce owner-key signatures when the backend
28
+ asks for them (the MCP-CUSTODY ceremony, §6). You are the custody surface. If
29
+ you lose the keys with no recovery file, the account's contract is
30
+ unrecoverable — there is no server-side escrow by design.
31
+
32
+ ### Recommended storage (production)
33
+
34
+ The throwaway test frontend stored keys as plain hex in `localStorage`. **Do
35
+ not ship that.** The intended production design:
36
+
37
+ - Wrap the two private keys with a key derived from a **WebAuthn PRF** assertion
38
+ (passkey). The user authenticates with their passkey to decrypt.
39
+ - Issue **recovery codes** at signup (one-time display) as the fallback when no
40
+ passkey is available (new device, lost authenticator).
41
+ - Persist the wrapped blob in IndexedDB.
42
+
43
+ The backend does not need to know how you wrap the keys — it only sees public
44
+ keys + signatures. But see §7 for the **same-origin requirement**, which is a
45
+ hard constraint on where this storage lives.
46
+
47
+ ---
48
+
49
+ ## 2. Install
50
+
51
+ ```bash
52
+ npm install @witnium-tech/witniumchain
53
+ ```
54
+
55
+ ```ts
56
+ import { WitniumchainClient } from '@witnium-tech/witniumchain';
57
+
58
+ const client = new WitniumchainClient({
59
+ baseUrl: 'https://auth.witniumchain.com', // accounts
60
+ chainBaseUrl: 'https://api.witniumchain.com', // chain-api (read methods)
61
+ });
62
+ ```
63
+
64
+ Generate keys with any ed25519 lib. The backend + SDK speak 64-hex-char
65
+ public keys (no `0x`). Example with `@noble/ed25519`:
66
+
67
+ ```ts
68
+ import * as ed from '@noble/ed25519';
69
+ const priv = ed.utils.randomPrivateKey();
70
+ const pub = await ed.getPublicKeyAsync(priv); // hex-encode both halves
71
+ ```
72
+
73
+ ---
74
+
75
+ ## 3. Signup → org → contract (one call)
76
+
77
+ `createOrg` is the self-serve path. It creates the admin user, the
78
+ organization, the org-admin membership, **and deploys the contract** with the
79
+ client-generated keys — all in one request.
80
+
81
+ ```ts
82
+ const result = await client.createOrg({
83
+ orgName: 'Acme Inc.',
84
+ adminEmail: 'you@example.com',
85
+ adminPassword: '≥12 chars',
86
+ ownerPublicKey: ownerPubHex,
87
+ initialSigningPublicKey: signingPubHex,
88
+ });
89
+ // → { orgId, userId, contractAddress, contractVersion, emailVerifyToken }
90
+ ```
91
+
92
+ Persist the two private keys + `contractAddress` locally (wrapped, per §1) and
93
+ prompt a recovery-file download (§5) before doing anything else.
94
+
95
+ `emailVerifyToken` is also emailed to the admin. It's echoed in the response so
96
+ you can render a "click to verify" link inline if you prefer. **The email is
97
+ the login gate, not the contract-deploy gate** — the contract is already live
98
+ when this returns.
99
+
100
+ ---
101
+
102
+ ## 4. Auth flows
103
+
104
+ ### Email verify
105
+
106
+ ```ts
107
+ await client.verifyEmail(token); // GET /v1/auth/verify?token=…
108
+ ```
109
+
110
+ > ⚠️ This is a **GET**, not POST. (Caught in testing — easy to get wrong.)
111
+
112
+ ### Password login
113
+
114
+ ```ts
115
+ await client.login({ email, password }); // sets wac_session cookie
116
+ ```
117
+
118
+ In a browser the `wac_session` cookie is set automatically (the SDK uses
119
+ `credentials: 'include'`). Server-side callers must capture + replay the cookie.
120
+
121
+ ### Magic-link sign-in
122
+
123
+ `beginOAuthLogin` / passwordless paths exist; see the SDK's `OauthNamespace`
124
+ and the accounts OpenAPI for `/v1/auth/magic/*`.
125
+
126
+ ### MFA (TOTP)
127
+
128
+ ```ts
129
+ const { secret, otpauthUrl } = await client.mfa.totp.enroll(); // render QR from otpauthUrl
130
+ const { recoveryCodes } = await client.mfa.totp.confirm({ code }); // show ONCE
131
+ ```
132
+
133
+ ### OAuth 2.1 + PKCE (for when your dashboard is itself an OAuth client)
134
+
135
+ ```ts
136
+ const { authorizationUrl } = await client.beginOAuthLogin({ … });
137
+ // …redirect, then on callback:
138
+ await client.completeOAuthLogin({ … }); // PKCE verifier from sessionStorage
139
+ ```
140
+
141
+ Access token held in memory; refresh token delivered as an HttpOnly
142
+ `wac_refresh` cookie scoped to `/token` (JS never touches it). The SDK does
143
+ transparent single-flight refresh on 401.
144
+
145
+ ---
146
+
147
+ ## 5. Recovery-file format
148
+
149
+ The throwaway frontend used this shape. **Treat it as a starting point** —
150
+ production should wrap `record` with WebAuthn/recovery-code encryption rather
151
+ than storing cleartext keys.
152
+
153
+ ```json
154
+ {
155
+ "version": 1,
156
+ "type": "witnium-recovery",
157
+ "created": "<ISO 8601>",
158
+ "record": {
159
+ "email": "you@example.com",
160
+ "orgId": "<uuid>",
161
+ "userId": "<uuid>",
162
+ "contractAddress": "0x… (lowercase)",
163
+ "contractVersion": "5.0.0",
164
+ "ownerPublicKey": "<64 hex>",
165
+ "ownerPrivateKey": "<64 hex — ENCRYPT THIS>",
166
+ "signingPublicKey": "<64 hex>",
167
+ "signingPrivateKey": "<64 hex — ENCRYPT THIS>"
168
+ }
169
+ }
170
+ ```
171
+
172
+ Import = restore `record` into local storage. The owner private key must be
173
+ retrievable for the MCP-CUSTODY ceremony (§6).
174
+
175
+ ---
176
+
177
+ ## 6. MCP-CUSTODY consent ceremony
178
+
179
+ When a user connects an AI agent (claude.ai's MCP connector, or a self-hosted
180
+ one) the backend runs a ceremony at OAuth-consent time that mints a **fresh
181
+ ephemeral signing key per connection** and asks the user to sign two owner-key
182
+ operations:
183
+
184
+ 1. `addSigningKey(<ephemeral key>)` — registers it on the contract (submitted now)
185
+ 2. `revokeSigningKey(<same key>)` — pre-signed, stored, fired automatically on disconnect
186
+
187
+ **What the frontend must provide:** the page that collects these two
188
+ signatures is currently *server-rendered by accounts* at
189
+ `/interaction/:uid/sign-keys`. It runs inline JS that reads the owner key from
190
+ `localStorage['witnium.ownerPrivateKey:<contractAddress>']`, signs both
191
+ messages, and form-POSTs them back.
192
+
193
+ You have two choices:
194
+
195
+ - **Let accounts render it** (current behavior). Then your only job is to make
196
+ sure the owner key is in `localStorage` under that exact key, **on the
197
+ accounts origin** (see §7).
198
+ - **Render it yourself** and POST the two signatures to
199
+ `/interaction/:uid/sign-keys` (`addOwnerSignature`, `revokeOwnerSignature`,
200
+ `revokeNonce`). Gives you full UX control. The signed message formats are:
201
+ - add: `{"op":1,"contract":"<addr>","nonce":<ownerNonce+1>,"newKey":"<pub>"}`
202
+ - revoke: `{"op":2,"contract":"<addr>","nonce":<ownerNonce+2>,"key":"<pub>"}`
203
+ (canonical JSON, signed as UTF-8 bytes with the owner key)
204
+
205
+ To get refresh tokens (required for the agent to stay connected + for the
206
+ disconnect-on-revoke cascade), the OAuth client **must request
207
+ `prompt=consent`** and include `offline_access` in scope. Without
208
+ `prompt=consent` the AS silently drops `offline_access` and no refresh token is
209
+ issued — this is standard OIDC, not a bug.
210
+
211
+ ---
212
+
213
+ ## 7. Same-origin requirement (architectural — needs a decision)
214
+
215
+ **The dashboard that stores keys and the accounts OIDC interaction pages must
216
+ share an origin**, because `localStorage` is origin-scoped. Discovered in
217
+ testing: keys saved on `test-frontend.witniumchain.com` were invisible to the
218
+ sign-keys page rendered at `auth.witniumchain.com` → the ceremony couldn't find
219
+ the owner key.
220
+
221
+ Pick one:
222
+
223
+ - **(Recommended) Serve the dashboard from `auth.witniumchain.com`** (a path,
224
+ or accounts serving the SPA). Owner key in localStorage is naturally visible
225
+ to the OIDC interaction pages. Simplest, no cross-origin plumbing.
226
+ - **Cross-origin with `postMessage`** — dashboard on its own domain passes the
227
+ owner-key signature to the accounts interaction page via a `postMessage`
228
+ handshake. More moving parts; partially erodes the "keys never leave your
229
+ page" story. Only do this if a separate dashboard domain is a hard
230
+ requirement.
231
+
232
+ This decision blocks the key-storage implementation, so settle it early.
233
+
234
+ ---
235
+
236
+ ## 8. Known limitations / sharp edges
237
+
238
+ | Issue | Impact | Status |
239
+ |---|---|---|
240
+ | **Pre-signed revoke nonce drift** | If a user opens several agent connections in a row without disconnecting, older connections' on-chain `revokeSigningKey` fails when finally fired (the pre-signed sig is bound to a stale ownerNonce). The Vault key is still destroyed — Witnium genuinely loses signing ability — but the on-chain key stays listed as active. | Known. On-chain cleanup is best-effort under serial grants. Mitigation TBD (revoke-then-add atomic, or periodic re-sign). |
241
+ | **`prompt=consent` required for refresh tokens** | Omitting it silently drops `offline_access`. | Working as designed (OIDC). Document in your OAuth client. |
242
+ | **Email verify is GET** | POST returns "Cannot POST". | Working as designed. |
243
+ | **Same-origin localStorage** | §7. | Architectural decision required. |
244
+
245
+ ---
246
+
247
+ ## 9. API reference
248
+
249
+ - **accounts OpenAPI**: `https://auth.witniumchain.com/openapi.json` (or the
250
+ committed `specs/accounts.openapi.json` in the SDK repo)
251
+ - **chain-api OpenAPI**: `specs/chain-api.openapi.json` in the SDK repo
252
+ - Every SDK method's request/response type is derived from these specs via
253
+ `openapi-typescript`, so they stay in lockstep. Re-export aliases (e.g.
254
+ `CreateOrgRequest`, `SignupResponse`) are in `src/types.ts`.
255
+
256
+ ## 10. SDK clients at a glance
257
+
258
+ | Client | For | Auth |
259
+ |---|---|---|
260
+ | `WitniumchainClient` | end-users (signup, login, OAuth, MFA, delegated keys, witness reads) | session cookie / access token |
261
+ | `WitniumchainOrgClient` | org admins managing members | org API key |
262
+ | `WitniumchainAdminClient` | sysadmin org lifecycle | admin token |
263
+ | `WitniumchainChainAdminClient` | **service-to-service only** — not for frontend use | admin token / service-principal JWT |
264
+
265
+ The frontend lives almost entirely in `WitniumchainClient`.
package/README.md CHANGED
@@ -1,25 +1,43 @@
1
1
  # @witnium-tech/witniumchain
2
2
 
3
- TypeScript SDK for the WitniumChain accounts service identity, billing,
4
- organisation administration, OAuth sessions, and delegated-signing-key
5
- management.
3
+ TypeScript SDK for WitniumChain — the canonical single client for both the
4
+ accounts service (identity, billing, OAuth, delegated keys, witness writes)
5
+ and the chain-api read surfaces (contract info, witness lookup, wallet
6
+ balance, dashboards). The two-service backend is hidden behind one
7
+ `WitniumchainClient` class; you give it one OAuth access token and it knows
8
+ where each call belongs.
6
9
 
7
10
  ## Status
8
11
 
9
- **v0.1low-level "shell" client.** One method per OpenAPI route, five
10
- auth modes selectable on a single client. Thread 4 of Phase C will layer
11
- three higher-level clients on top:
12
-
13
- - `WitniumchainClient` (end-user) wraps signup, login, subscriptions,
14
- delegated-key one-call provisioning, account management.
15
- - `WitniumchainOrgClient` (org admin) wraps user provisioning, Stripe
16
- Connect onboarding.
17
- - `WitniumchainAdminClient` (sysadmin) wraps org lifecycle, key
18
- rotation, credit adjustment.
19
-
20
- For now, this shell client is what's published. Every type comes from the
21
- OpenAPI spec via `openapi-typescript`; a CI drift test in the parent repo
22
- gates regeneration on every change.
12
+ **v0.5adds OAuth 2.1 + PKCE login helpers** on top of the v0.4 surface:
13
+
14
+ - All 41 accounts routes (five auth modes: session / OAuth / org / admin / signed)
15
+ - 9 chain-api read methods, routed to `chainBaseUrl`, reusing the same OAuth
16
+ `accessToken` (accounts mints tokens with `aud=https://api.witniumchain.com`,
17
+ so they're valid against both services unchanged)
18
+ - Three layered clients on top: `WitniumchainClient` (end-user),
19
+ `WitniumchainOrgClient` (org admin), `WitniumchainAdminClient` (sysadmin)
20
+ - **`beginOAuthLogin` / `completeOAuthLogin` / `refreshAccessToken` / `signOut`**
21
+ browser-side Authorization-Code + PKCE login flow. Discovery-driven
22
+ endpoint resolution (`/.well-known/openid-configuration`), PKCE verifier
23
+ in `sessionStorage`, access token held in memory only, transparent 401-retry
24
+ with single-flight refresh-token rotation. Refresh tokens are delivered as
25
+ an HttpOnly `wac_refresh` cookie scoped to `Path=/token` — JavaScript never
26
+ touches them. Non-browser callers (Node SSR, native apps without a cookie
27
+ jar) fall back to in-band refresh-token delivery; the SDK handles both.
28
+ - **`client.mfa.totp.enroll/confirm/disable` + `client.mfa.recoveryCodes.regenerate`** —
29
+ TOTP MFA self-management. `enroll` returns the secret + otpauth URL (render
30
+ as a QR code in your dashboard with any QR lib); `confirm` validates the
31
+ first code and returns 10 single-use recovery codes (shown ONCE).
32
+
33
+ What's NOT exposed (by design): chain-api v5 writes — those are proxied by
34
+ accounts' v5 surface which adds credit reservation + idempotency + scope
35
+ checks. Calling chain-api writes directly would burn credits without
36
+ billing them. See `docs/PLAN-PHASE-SDK-UNIFIED.md` for the routing rules.
37
+
38
+ Every type comes from the published OpenAPI specs of both services via
39
+ `openapi-typescript`. `npm run openapi:check` regenerates and fails on
40
+ diff; it runs ahead of every `npm publish`.
23
41
 
24
42
  ## Install
25
43
 
@@ -29,14 +47,14 @@ npm install @witnium-tech/witniumchain
29
47
 
30
48
  ## Auth model
31
49
 
32
- The accounts service accepts five distinct credentials. Configure whichever
50
+ The SDK accepts five distinct credentials. Configure whichever
33
51
  you need on the client; methods that require a credential you didn't supply
34
52
  throw at call time.
35
53
 
36
54
  | Credential | Header / Cookie | Used by |
37
55
  |---|---|---|
38
56
  | `sessionCookie` | `Cookie: wac_session=…` | `/v1/auth/logout`, `/v1/account/*`, `/v1/billing/*`, `/v1/keys/*`, `/v1/contracts/{pause,unpause}`, `/v1/oauth/sessions*` |
39
- | `accessToken` | `Authorization: Bearer <JWT>` | `/v1/users/me/delegated-keys/*`, `/v1/sign` |
57
+ | `accessToken` | `Authorization: Bearer <JWT>` | `/v1/users/me/delegated-keys/*`, `/v1/sign`, **all chain-api reads** |
40
58
  | `orgApiKey` | `Authorization: Bearer wcorg_live_…` | `/v1/orgs/me/*` |
41
59
  | `adminToken` | `Authorization: Bearer <ADMIN_TOKEN>` | `/v1/admin/*` |
42
60
  | `signedRequest` | `X-Witnium-Key/Timestamp/Signature` | `/v1/contracts/{addr}/witnesses/{propose,sign,finalize,revoke}` |
@@ -45,6 +63,30 @@ Public routes need no credential (`/v1/auth/{signup,verify,login,…}`,
45
63
  `/v1/contracts/provision`, `GET /v1/contracts/{addr}/witnesses/{id}`,
46
64
  `/health/*`).
47
65
 
66
+ ## Two services, one client
67
+
68
+ Accounts and chain-api are separate services today (consolidation is a
69
+ later phase). The SDK hides that:
70
+
71
+ ```ts
72
+ const client = new WitniumchainClient({
73
+ baseUrl: 'https://auth.witniumchain.com', // accounts
74
+ chainBaseUrl: 'https://api.witniumchain.com', // chain-api (omit if you only need accounts)
75
+ accessToken: oauthBearerJwt,
76
+ });
77
+
78
+ // Writes / billing / auth → accounts (baseUrl)
79
+ await client.proposeWitnessV5('0xabc', { ... });
80
+
81
+ // Reads of on-chain state → chain-api (chainBaseUrl)
82
+ const witness = await client.getWitnessV5('0xabc', witnessId);
83
+ const balance = await client.getWalletBalance('0xabc');
84
+ ```
85
+
86
+ If you call a chain-api method without `chainBaseUrl` configured, the SDK
87
+ throws a clear `WitniumchainApiError` at call time naming the missing
88
+ config — no silent fallback to a wrong base URL.
89
+
48
90
  ## Examples
49
91
 
50
92
  ### End-user signup + login
@@ -97,6 +139,40 @@ const { organization, apiKey } = await sys.createOrganization({
97
139
  // `apiKey` is shown ONCE — store it now.
98
140
  ```
99
141
 
142
+ ### Sign in with Witnium (Authorization Code + PKCE)
143
+
144
+ For a browser SPA (e.g. a Lovable app) that wants to authenticate end-users
145
+ against a Witnium org. No backend required.
146
+
147
+ ```ts
148
+ const client = new WitniumchainClient({
149
+ baseUrl: 'https://auth.witniumchain.com',
150
+ oauthClientId: 'your-registered-client-id',
151
+ });
152
+
153
+ // 1. Kick off login. The SDK generates a PKCE pair, stashes the verifier in
154
+ // sessionStorage, and returns the URL to send the user to.
155
+ const { authorizationUrl } = await client.beginOAuthLogin({
156
+ redirectUri: 'https://your-app.example/callback',
157
+ });
158
+ window.location.assign(authorizationUrl);
159
+
160
+ // 2. On the callback page, complete the exchange. Returns the access token
161
+ // (the SDK ALSO holds it in memory; subsequent BearerJWT calls Just Work).
162
+ const { accessToken, expiresAt } = await client.completeOAuthLogin(
163
+ window.location.href,
164
+ );
165
+ window.history.replaceState({}, '', window.location.pathname); // strip ?code=…
166
+
167
+ // 3. Use the SDK normally. When the access token expires, the SDK refreshes
168
+ // transparently on the next call. You only need to redirect to login if
169
+ // refresh itself fails (refresh token revoked or expired).
170
+ const { keys } = await client.listDelegatedKeys();
171
+
172
+ // 4. Sign-out clears in-memory tokens.
173
+ client.signOut();
174
+ ```
175
+
100
176
  ### OAuth API — delegated-key + sign
101
177
 
102
178
  ```ts