@openkey/sdk 0.9.0 → 0.10.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/README.md CHANGED
@@ -125,6 +125,32 @@ Link an external wallet to the user's OpenKey account.
125
125
  const { address, keyId } = await openkey.linkWallet();
126
126
  ```
127
127
 
128
+ ### `openkey.nostr`
129
+
130
+ Connect one managed Nostr identity and sign a bounded NIP-01 event. Nostr uses
131
+ BIP-340 Schnorr keys and does not reuse the Ethereum signing path.
132
+
133
+ ```typescript
134
+ const identity = await openkey.nostr.connect({
135
+ relayUrl: 'wss://relay.example',
136
+ });
137
+
138
+ const event = await openkey.nostr.signEvent(identity.keyId, {
139
+ pubkey: identity.pubkey,
140
+ created_at: Math.floor(Date.now() / 1000),
141
+ kind: 9,
142
+ tags: [['h', 'channel-id']],
143
+ content: 'Hello from OpenKey',
144
+ });
145
+ ```
146
+
147
+ `connect()` returns only public identity metadata. `signEvent()` can sign kind
148
+ `9` channel messages and relay-bound kind `22242` NIP-42 events; OpenKey shows
149
+ consent unless an active grant covers the exact origin, key, kind, and relay.
150
+ Verify the returned event independently before publishing it. See the
151
+ [managed Nostr signing guide](../../docs/nostr-signing.md) for limits and
152
+ rollout requirements.
153
+
128
154
  ### `OpenKeyEIP1193Provider`
129
155
 
130
156
  A drop-in EIP-1193 provider that routes signing through OpenKey. Handles both managed and external keys transparently.
package/dist/index.d.mts CHANGED
@@ -1,3 +1,54 @@
1
+ /** NIP-01 unsigned event template. */
2
+ interface UnsignedNostrEvent {
3
+ pubkey: string;
4
+ created_at: number;
5
+ kind: number;
6
+ tags: string[][];
7
+ content: string;
8
+ }
9
+ /** A fully signed NIP-01 event, as returned by OpenKey custody signing. */
10
+ interface SignedNostrEvent extends UnsignedNostrEvent {
11
+ id: string;
12
+ sig: string;
13
+ }
14
+ /** Public descriptor for the user's OpenKey-custodied Nostr identity. Never includes the secret key. */
15
+ interface NostrIdentity {
16
+ keyId: string;
17
+ pubkey: string;
18
+ npub: string;
19
+ }
20
+ interface NostrError {
21
+ code: 'USER_CANCELLED' | 'TIMEOUT' | 'INTERACTION_REQUIRED' | 'UNAUTHORIZED' | 'UNKNOWN';
22
+ message: string;
23
+ }
24
+ /**
25
+ * Browser client for the OpenKey Nostr custody + signing flow. Get an
26
+ * instance from `OpenKey#nostr` rather than constructing this directly.
27
+ */
28
+ declare class OpenKeyNostr {
29
+ private host;
30
+ constructor(host: string);
31
+ /**
32
+ * Get-or-create the user's OpenKey-custodied Nostr identity. Always shows
33
+ * a visible consent card (identity creation, or - when `relayUrl` is
34
+ * given - a relay-scoped kind:22242/NIP-42 grant for that exact relay).
35
+ * Never resolves with the secret key; only `keyId`/`pubkey`/`npub`.
36
+ */
37
+ connect(opts?: {
38
+ relayUrl?: string;
39
+ }): Promise<NostrIdentity>;
40
+ /**
41
+ * Sign a NIP-01 event template with the user's OpenKey Nostr key. Silent
42
+ * (no visible UI) when an active grant already covers this client origin
43
+ * + key + kind (+ exact relay, for kind 22242). Otherwise shows a visible
44
+ * consent card before signing - required for every first kind:9 message to
45
+ * a new origin, per NIP-29 channel-message policy.
46
+ */
47
+ signEvent(keyId: string, event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
48
+ private widgetUrl;
49
+ private run;
50
+ }
51
+
1
52
  interface EIP1193Provider {
2
53
  request(args: {
3
54
  method: string;
@@ -45,6 +96,20 @@ interface AuthResult {
45
96
  keyId: string;
46
97
  keyType: 'MANAGED' | 'EXTERNAL';
47
98
  }
99
+ /**
100
+ * Confirmation returned by {@link OpenKey.signOut}. `requestId` is generated
101
+ * by the SDK and echoed by the OpenKey-owned widget, so callers can associate
102
+ * the acknowledgement with the sign-out they initiated.
103
+ */
104
+ interface SignOutAcknowledgement {
105
+ requestId: string;
106
+ /**
107
+ * Whether the widget confirmed that the previous bearer no longer
108
+ * authenticates. `false` means the SDK is locally signed out, but remote
109
+ * revocation could not be verified.
110
+ */
111
+ revoked: boolean;
112
+ }
48
113
  interface OpenKeyError {
49
114
  code: 'USER_CANCELLED' | 'POPUP_BLOCKED' | 'TIMEOUT' | 'NO_KEY' | 'UNAUTHORIZED' | 'UNKNOWN' | 'STATE_MISMATCH';
50
115
  message: string;
@@ -74,6 +139,187 @@ interface OAuthTokenResponse {
74
139
  scope: string;
75
140
  refresh_token?: string;
76
141
  }
142
+ interface TinyCloudAuthorizationRequestV1 {
143
+ protocolVersion: 1;
144
+ /** Suggested SIWE (may be edited server-side before signing). */
145
+ siwe: string;
146
+ /** The keyId the user picked, if the caller has one in mind. */
147
+ keyId?: string;
148
+ /**
149
+ * The session-key JWK bound to `siwe`. Required so the server can
150
+ * regenerate a narrowed SIWE tied to the SAME session key when the
151
+ * user removes capabilities. Passing a mismatched jwk (or omitting it
152
+ * when the widget attempts to narrow) causes the /authorize-sign call
153
+ * to fail — never a silent fallback to the caller's exact bytes.
154
+ */
155
+ jwk?: Record<string, unknown>;
156
+ /**
157
+ * The TinyCloud host the resulting session will activate against.
158
+ * Bound into the /authorize-sign-prepare context so /authorize-sign
159
+ * cannot swap hosts server-side. Optional for callers that do not
160
+ * plan to activate a delegation (they should still forward it when
161
+ * known; the empty string is treated as "unknown").
162
+ */
163
+ host?: string;
164
+ /** Optional presentation envelope (name, reason, manifest). */
165
+ presentation?: CapabilityPresentationEnvelopeV1;
166
+ }
167
+ interface TinyCloudAuthorizationResultV1 {
168
+ protocolVersion: 1;
169
+ /** EIP-55 signer address. */
170
+ address: string;
171
+ /** Signature over `signedMessage`. */
172
+ signature: string;
173
+ /** The EXACT bytes the signature verifies against. Never the caller's original. */
174
+ signedMessage: string;
175
+ /** The action IDs the user (or default consent) selected. */
176
+ selectedActionKeys: string[];
177
+ /** Effective grant set after any narrowing. */
178
+ permissions: Array<{
179
+ service: string;
180
+ space: string;
181
+ path: string;
182
+ actions: string[];
183
+ }>;
184
+ }
185
+ interface CapabilityPresentationEnvelopeV1 {
186
+ protocolVersion: 1;
187
+ /** Human-readable requester name shown in the review. */
188
+ displayName?: string;
189
+ /**
190
+ * Reason string the requester supplied for the delegation. Rendered as
191
+ * caller-supplied (never as verified) unless a signed manifest carries
192
+ * the same reason string.
193
+ */
194
+ reason?: string;
195
+ /** Manifest ID + digest, when the app publishes a signed manifest. */
196
+ manifestId?: string;
197
+ manifestDigest?: string;
198
+ /**
199
+ * Full manifest payload(s) the caller wants surfaced in the review. The
200
+ * receiving route validates and size-bounds this before it is used. This
201
+ * is display-only: no metadata may expand authority beyond what the
202
+ * ReCap already grants. Bounded to a reasonable size at the transport
203
+ * boundary — oversized envelopes are dropped.
204
+ */
205
+ manifests?: Array<{
206
+ name?: string;
207
+ appId?: string;
208
+ payload?: Record<string, unknown>;
209
+ }>;
210
+ }
211
+ /**
212
+ * Sol final continuation contract requirement 5: pure validation of an
213
+ * incoming iframe resize message envelope. Exported so unit tests can
214
+ * exercise every rejection branch — wrong requestId, wrong version,
215
+ * malformed height — without booting the browser runtime that
216
+ * `IframeModal` requires.
217
+ *
218
+ * Two modes, chosen by the modal's bound correlation:
219
+ * - Versioned request bound (`requestId` AND `protocolVersion` set): the
220
+ * resize MUST carry that exact pair.
221
+ * - No versioned request (both null — `connect`, `signMessage`, and the
222
+ * other unversioned flows): the widget sends a plain
223
+ * `{ type, height }`. It is accepted only when it carries no
224
+ * `requestId`. The caller has already checked that the message came
225
+ * from this modal's iframe (`event.source`) on the OpenKey origin
226
+ * (TC-647).
227
+ * A half-bound correlation (only one of the two set) drops everything.
228
+ *
229
+ * Returns the height clamped to 85% of the viewport when acceptable, or
230
+ * `null` when the message must be dropped.
231
+ */
232
+ declare function validateIframeResize(incoming: unknown, expected: {
233
+ requestId: string | null;
234
+ protocolVersion: number | null;
235
+ viewportHeight: number;
236
+ }): number | null;
237
+ /**
238
+ * Sol MAJOR-4 (final): pure routing helper — decides whether an
239
+ * `authorizeTinyCloud` call should route through the external-wallet
240
+ * preview→sign→finalize flow or the managed-key widget-signs-server-side
241
+ * flow. Exported so unit tests can exercise every branch
242
+ * (explicit-external + external-session, explicit-external + external-
243
+ * session with different keyId, no-explicit + external-session,
244
+ * explicit-managed + managed-session, no-session) without booting the
245
+ * browser runtime.
246
+ *
247
+ * The decision is authoritative from the ACTIVE session's keyType when
248
+ * no explicit `requestKeyId` was supplied.
249
+ *
250
+ * Kept as a thin wrapper for backwards compatibility. New callers should
251
+ * prefer `resolveAuthorizeTinyCloudRouting` which also considers the
252
+ * request's explicit `keyId`.
253
+ */
254
+ declare function shouldRouteAuthorizeTinyCloudExternal(state: {
255
+ lastAuth: {
256
+ keyType?: 'MANAGED' | 'EXTERNAL' | null;
257
+ } | null;
258
+ }): boolean;
259
+ /**
260
+ * Sol MAJOR-4 (final continuation): resolve the target key's identity and
261
+ * route by that key's type — NOT by `lastAuth.keyType` alone.
262
+ *
263
+ * Sol's rejection: when the caller supplies an explicit `requestKeyId`
264
+ * that identifies a DIFFERENT key than `lastAuth.keyId` (address vs
265
+ * internal id, or a genuinely different key), the SDK must
266
+ * - resolve THAT key's type,
267
+ * - route by that resolved type (external ⇒ wallet path, managed ⇒
268
+ * server-signing path), and
269
+ * - reject an inconsistent explicit pin whose resolved type contradicts
270
+ * the caller's other implicit assumptions (e.g. `lastAuth` is
271
+ * external but the caller pinned a managed key).
272
+ *
273
+ * Inputs:
274
+ * - `lastAuth`: the connected-session key metadata (may be null on
275
+ * boot; the widget renders a connect flow first in that case).
276
+ * - `requestKeyId`: the caller's explicit pin (may be absent).
277
+ * - `knownKeys`: any keys we have metadata for; the SDK's higher-level
278
+ * `authorizeTinyCloud` populates this from the server before calling
279
+ * the resolver so the pure helper is fully synchronous and testable.
280
+ *
281
+ * Return shape:
282
+ * { route: 'external' | 'managed', resolvedKeyId: string | null,
283
+ * resolvedKeyType: 'EXTERNAL' | 'MANAGED' | null }
284
+ *
285
+ * `resolvedKeyId` is null only when neither `requestKeyId` nor
286
+ * `lastAuth.keyId` exists. When both exist and identify the same key
287
+ * (matching id OR matching known-key type), the resolver returns that
288
+ * shared identity. When both exist and identify different keys AND we
289
+ * know both types AND those types disagree, the resolver throws
290
+ * `KEY_ID_TYPE_MISMATCH` so no silent route coercion occurs.
291
+ */
292
+ interface AuthorizeTinyCloudRouting {
293
+ route: 'external' | 'managed';
294
+ resolvedKeyId: string | null;
295
+ resolvedKeyType: 'EXTERNAL' | 'MANAGED' | null;
296
+ }
297
+ declare class KeyIdTypeMismatchError extends Error {
298
+ code: 'KEY_ID_TYPE_MISMATCH';
299
+ requestKeyId: string;
300
+ lastAuthKeyId: string | null;
301
+ requestKeyType: 'EXTERNAL' | 'MANAGED' | null;
302
+ lastAuthKeyType: 'EXTERNAL' | 'MANAGED' | null;
303
+ constructor(fields: {
304
+ message: string;
305
+ requestKeyId: string;
306
+ lastAuthKeyId: string | null;
307
+ requestKeyType: 'EXTERNAL' | 'MANAGED' | null;
308
+ lastAuthKeyType: 'EXTERNAL' | 'MANAGED' | null;
309
+ });
310
+ }
311
+ declare function resolveAuthorizeTinyCloudRouting(state: {
312
+ lastAuth: {
313
+ keyId?: string | null;
314
+ keyType?: 'MANAGED' | 'EXTERNAL' | null;
315
+ } | null;
316
+ requestKeyId?: string | null;
317
+ knownKeys?: ReadonlyArray<{
318
+ id: string;
319
+ address?: string;
320
+ keyType: 'MANAGED' | 'EXTERNAL';
321
+ }>;
322
+ }): AuthorizeTinyCloudRouting;
77
323
  declare class OpenKey {
78
324
  private host;
79
325
  private oauthHost;
@@ -84,6 +330,16 @@ declare class OpenKey {
84
330
  private lastAuth;
85
331
  private discoveredProviders;
86
332
  private sessionToken;
333
+ /** Cancels widget flows that could outlive a sign-out on this instance. */
334
+ private activeFlowCancellations;
335
+ /**
336
+ * Nostr identity custody + signing (secp256k1 Schnorr / BIP-340). Kept
337
+ * fully separate from the Ethereum flows above: it never reuses
338
+ * `lastAuth`/`sessionToken`, and its widget messages never carry a
339
+ * sessionToken - the OpenKey session and any signing grants stay inside
340
+ * the OpenKey-origin iframe for the whole lifetime of this client.
341
+ */
342
+ readonly nostr: OpenKeyNostr;
87
343
  constructor(config?: OpenKeyConfig);
88
344
  /**
89
345
  * Derive the OAuth API host from the main host by prefixing 'api.'
@@ -91,6 +347,20 @@ declare class OpenKey {
91
347
  * @returns The derived OAuth API host URL
92
348
  */
93
349
  private deriveOAuthHost;
350
+ /**
351
+ * Sol MAJOR-4 (final continuation): fetch the caller's registered keys
352
+ * so `resolveAuthorizeTinyCloudRouting` can resolve an explicit pin
353
+ * whose keyId does not match the active session key. Called only when
354
+ * the caller passes a `requestKeyId` we do not already recognise; the
355
+ * common no-pin path skips the network round-trip.
356
+ *
357
+ * Failures do NOT block routing — we fall through with `knownKeys=[]`
358
+ * so the resolver's lastAuth-based fallback still runs. If the caller
359
+ * pinned a genuinely unknown key that turns out to be external, the
360
+ * widget rejects the server-signing path and the SDK surfaces that
361
+ * error from the finalize call rather than silently routing wrong.
362
+ */
363
+ private fetchKnownKeysIfNeeded;
94
364
  /**
95
365
  * Connect to OpenKey and get user's wallet address
96
366
  * Opens auth flow for user to select/create a key
@@ -98,6 +368,17 @@ declare class OpenKey {
98
368
  connect(opts?: {
99
369
  mode?: OpenKeyMode;
100
370
  }): Promise<AuthResult>;
371
+ /**
372
+ * Signs the active OpenKey account out through the OpenKey-owned widget.
373
+ *
374
+ * The SDK passes its Better Auth bearer through the OpenKey-owned widget
375
+ * protocol so applications do not have to implement revocation themselves.
376
+ * Local SDK session state is cleared before opening the widget, including
377
+ * when remote revocation is unavailable.
378
+ */
379
+ signOut(opts?: {
380
+ mode?: OpenKeyMode;
381
+ }): Promise<SignOutAcknowledgement>;
101
382
  /**
102
383
  * Better-auth session token relayed during connect(), or null if the
103
384
  * flow did not provide one. Use as a bearer token for API calls made
@@ -130,6 +411,47 @@ declare class OpenKey {
130
411
  signMessage(request: SignMessageRequest, opts?: {
131
412
  mode?: OpenKeyMode;
132
413
  }): Promise<SignResult>;
414
+ /**
415
+ * Versioned TinyCloud authorization protocol (v1).
416
+ *
417
+ * Unlike `signMessage()` (which is contractually byte-exact — the returned
418
+ * signature always verifies against the caller's exact bytes), this method
419
+ * permits the user to narrow the requested capabilities before signing.
420
+ * OpenKey may therefore regenerate the SIWE. The response carries
421
+ * `signedMessage` — the exact bytes the signature actually verifies against.
422
+ *
423
+ * TinyCloud completes the session with `signedMessage`, not the original
424
+ * `siwe` passed in. Consumers MUST use the returned bytes and MUST NOT
425
+ * assume the returned grants are a superset of what they requested — the
426
+ * user is allowed to remove any grant that is not required.
427
+ *
428
+ * Legacy `signMessage()` callers are unaffected: their exact-byte contract
429
+ * remains preserved and OpenKey never rewrites their bytes.
430
+ */
431
+ authorizeTinyCloud(request: TinyCloudAuthorizationRequestV1, opts?: {
432
+ mode?: OpenKeyMode;
433
+ }): Promise<TinyCloudAuthorizationResultV1>;
434
+ /**
435
+ * Sol MAJOR-3 (continuation): authorizeTinyCloud path for external keys.
436
+ * The wallet lives outside OpenKey, so the SDK opens the shared widget
437
+ * for review + preview, then invokes the user's wallet to sign the
438
+ * preview bytes, then finalizes server-side via /authorize-sign.
439
+ *
440
+ * Flow:
441
+ * 1. Open the widget with `externalSign: true` — the widget renders
442
+ * the shared SigningApproval UI, calls /authorize-sign-prepare,
443
+ * calls /authorize-sign-preview after the user approves a
444
+ * selection, and hands back `{ previewApprovalToken,
445
+ * signedMessage, selectedActionIds, address, authorizationContextToken }`.
446
+ * 2. SDK invokes the user's wallet on the returned signedMessage.
447
+ * 3. SDK POSTs /authorize-sign with `externalSignature`.
448
+ *
449
+ * Legacy exact-byte signMessage callers are unaffected — this path is
450
+ * only taken via authorizeTinyCloud (protocolVersion:1) when the target
451
+ * key is EXTERNAL. Sol's rejection required this path to go through
452
+ * the same review UI as managed keys.
453
+ */
454
+ private authorizeTinyCloudExternal;
133
455
  /**
134
456
  * Sign typed data (EIP-712) with the user's OpenKey wallet
135
457
  * For external keys, routes directly to the user's wallet provider
@@ -199,9 +521,11 @@ declare class OpenKey {
199
521
  private hasDetectedEoa;
200
522
  private openFlow;
201
523
  private openIframeModal;
524
+ private isResponseForAction;
202
525
  private handleWalletLinkDelegation;
203
526
  private handleExternalWalletConnect;
204
527
  private openPopup;
528
+ private cancelActiveFlows;
205
529
  /**
206
530
  * Disconnect and close any open flows
207
531
  */
@@ -227,4 +551,4 @@ declare class OpenKeyProvider implements EIP1193Provider {
227
551
 
228
552
  declare function getOpenKey(config?: OpenKeyConfig): OpenKey;
229
553
 
230
- export { type AuthResult, type EIP1193Provider, type OAuthConfig, type OAuthResult, type OAuthTokenResponse, OpenKey, type OpenKeyConfig, type OpenKeyError, type OpenKeyMode, OpenKeyProvider, type SignMessageRequest, type SignResult, type SignTypedDataRequest, OpenKey as default, getOpenKey };
554
+ export { type AuthResult, type AuthorizeTinyCloudRouting, type CapabilityPresentationEnvelopeV1, type EIP1193Provider, KeyIdTypeMismatchError, type NostrError, type NostrIdentity, type OAuthConfig, type OAuthResult, type OAuthTokenResponse, OpenKey, type OpenKeyConfig, type OpenKeyError, type OpenKeyMode, OpenKeyNostr, OpenKeyProvider, type SignMessageRequest, type SignOutAcknowledgement, type SignResult, type SignTypedDataRequest, type SignedNostrEvent, type TinyCloudAuthorizationRequestV1, type TinyCloudAuthorizationResultV1, type UnsignedNostrEvent, OpenKey as default, getOpenKey, resolveAuthorizeTinyCloudRouting, shouldRouteAuthorizeTinyCloudExternal, validateIframeResize };