@me.jango/signa 0.0.0-stage → 0.4.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/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # @me.jango/signa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sign in to your app with a Signa passkey account or an external wallet, then request exact payment
4
+ reviews. This is the browser-only slice of the Signa client. Bot access and the CLI ship separately as
5
+ the full client archive linked from https://api.signa.center/api/docs/client.
6
+
7
+ ```sh
8
+ npm install @me.jango/signa
9
+ ```
10
+
11
+ | Import | What it is | Needs |
12
+ | --- | --- | --- |
13
+ | `@me.jango/signa` | The wallet client: connect an account, review and submit payments | viem |
14
+ | `@me.jango/signa/core` | The connect step, headless: sign-in options, popup or frame, callback page. Also re-exports the wallet client | viem |
15
+ | `@me.jango/signa/wagmi` | A read-only wagmi connector for the passkey account | wagmi |
16
+ | `@me.jango/signa/react` | The sign-in modal | React |
17
+
18
+ React and wagmi are optional peers: an app that imports only the root entry or `/core` never loads them.
19
+ These subpaths replace `@bananapus/nana-sdk-connect`; the export names are the same, so moving over only
20
+ changes the import specifier.
21
+
22
+ ## Wallet client
23
+
24
+ ```js
25
+ import { createCenterWalletClient } from '@me.jango/signa';
26
+ const wallet = createCenterWalletClient({
27
+ issuer: 'https://signa.center', audience: 'https://api.signa.center',
28
+ callbackUri: window.location.origin + '/center/callback',
29
+ });
30
+ const prepared = await wallet.prepareConnection();
31
+ prepared.launch(); // full-page redirect to the account
32
+ // prepared.launch({ target: name }) submits into a window or iframe the app opened under that
33
+ // name; Signa serves the sign-in inside an iframe only for apps its operator admits to frame.
34
+ // On /center/callback:
35
+ const connection = await wallet.completeConnection(window.location.href);
36
+ ```
37
+
38
+ Your app origin and exact callback URI must be allowlisted by Signa. Allow the issuer in your
39
+ `form-action` CSP directive and send `Referrer-Policy: strict-origin`. `restoreConnection()` reads the
40
+ saved connection, `disconnect()` clears it, and `payments()` prepares, completes and submits reviewed
41
+ payments. Protocol details: https://api.signa.center/api/docs/client
42
+ `refreshPayment({ waitSeconds })` holds a submitted payment's read on Signa (up to 20 s) until the
43
+ operation moves, so an app can wait on it instead of polling. A record whose app grant is gone and whose
44
+ signed validity has passed can be cleared; it is archived as unknown and Signa keeps the outcome.
45
+ When Signa refuses to send an approved payment, `submitPayment()` throws the refusal (with
46
+ `payFailure` when the calls say why) after asking Signa to retire the approval. Read
47
+ `pendingPayment()` next: `refused` means Signa retired it and never sends it, so `clearPayment()` and a
48
+ fresh `preparePayment()` can follow at once; the fresh payment takes the refused one's nonce, so at most
49
+ one of them executes. `pending` or `confirming` means a send won before the retirement: follow it.
50
+ `unknown` means the retirement or the answer was lost: `retirePayment()` retires an approved payment
51
+ Signa has not sent, or returns the submission that already claimed it.
52
+ A reverted payment's status and a refused submission's `RestClientError` carry `payFailure`: the SDK's own
53
+ `PayFailure`, as `decodePayFailure` reads the same revert (bigint `received` and `minimum`, plus `zeroFill`
54
+ and `adjustable` for a shortfall), so it goes straight to the SDK's `payRetrySlippageBps` and to the same
55
+ display as an external-wallet payment. `observation.payFailure` keeps the reason as Signa served it. Because the
56
+ amounts are bigints, `JSON.stringify` of a status or error needs a replacer such as
57
+ `(_, value) => typeof value === 'bigint' ? value.toString() : value`. Without `adjustable`, no setting the
58
+ payer controls would help, so never relax a minimum then.
59
+
60
+ ## Connect
61
+
62
+ - `/core`: `createConnectController(options)` (headless, no React or wagmi) takes the passkey option and
63
+ any external-wallet options (each a `ConnectOption`: `id`, `name`, optional `icon`, and `connect`, which
64
+ may publish a pairing URI through `handoff` for a QR code). `passkeyOption({ wallet, connected })` is the
65
+ Signa account. For the app's `/center/callback` page, call `deliverCenterCallback(url, { window })`, then
66
+ `completeCenterCallback(wallet, url)` when it returns false. The passkey account opens Signa in a popup
67
+ and the page stays; the callback page hands its URL back to the page, which finishes the exchange and runs
68
+ `connected`. A blocked popup falls back to a full-page redirect, where the callback page completes the
69
+ exchange itself. With `passkeyOption({ frame: true })` Signa opens in a frame the modal shows instead;
70
+ the callback page hands its URL up to the framing page through the same `deliverCenterCallback`. Signa
71
+ serves the sign-in framed only for apps its operator admits to frame (`WALLET_FRAMEABLE_APP_ORIGINS`),
72
+ and the page inside offers "Open as a page" when it cannot continue there. The frame delegates passkey
73
+ prompts and creation to Signa's origin by name, so a new user can sign up inside it.
74
+ - `/wagmi`: `centerAccountConnector({ wallet, read })`, a read-only Base connector for the passkey account.
75
+ It answers reads through `read` and refuses to sign: payments go through Signa's review.
76
+ - `/react`: `<JBConnectModal open controller onClose />` shows the passkey account as the primary button
77
+ (named after the platform prompt: Touch ID, Face ID, Windows Hello, unless `passkeyLabel` overrides it)
78
+ and external wallets as tiles. Theme it with the `--jb-connect-*` custom properties (`--jb-connect-pad`
79
+ is the dialog's inset, which the Signa frame bleeds through). The modal answers the frame's size reports
80
+ with its resolved theme, so the Signa page inside takes the app's colours, font and corners.
81
+
82
+ The passkey account signs nothing in the app. Payments go through Signa's review flow.
@@ -0,0 +1,43 @@
1
+ /** One way in. The passkey account and every external wallet share this shape, so the modal and
2
+ * the apps never special-case Signa. `handoff` publishes a pairing URI (WalletConnect, a mobile
3
+ * deep link) for the app to render as a QR code or a link. */
4
+ type ConnectOption = {
5
+ id: string;
6
+ name: string;
7
+ /** A data: image URL. Apps render their own mark when absent. */
8
+ icon?: string;
9
+ disabled?: boolean;
10
+ connect(context: {
11
+ signal: AbortSignal;
12
+ handoff(uri: string): void;
13
+ /** Asks the app to show a frame under this name for the option to continue in (the passkey
14
+ * sign-in at Signa, when Signa admits the app to frame it). `origin` is the page that will
15
+ * load in it, named in the frame's `allow` so the browser delegates passkeys to it: a frame
16
+ * without a `src` (Signa's launch form posts into it by name) delegates nothing otherwise. */
17
+ frame(name: string, origin: string): void;
18
+ }): Promise<void>;
19
+ };
20
+ type ConnectState = {
21
+ /** The option being connected, or null when idle. A passkey connection leaves the page, so it
22
+ * stays pending until the browser returns. */
23
+ pending: string | null;
24
+ error: string | null;
25
+ handoffUri: string | null;
26
+ /** The name of the frame the pending option continues in, once it asks for one. */
27
+ frameName: string | null;
28
+ /** The origin the frame delegates passkeys to. */
29
+ frameOrigin: string | null;
30
+ };
31
+ type ConnectController = {
32
+ readonly options: readonly ConnectOption[];
33
+ getState(): ConnectState;
34
+ subscribe(listener: () => void): () => void;
35
+ /** Starts an option; rejections become `state.error`, never a thrown promise. */
36
+ choose(id: string): Promise<void>;
37
+ /** Aborts the in-flight option and clears the state. */
38
+ cancel(): void;
39
+ };
40
+ declare function createConnectController(options: readonly ConnectOption[]): ConnectController;
41
+
42
+ export { createConnectController as c };
43
+ export type { ConnectOption as C, ConnectController as a, ConnectState as b };
@@ -0,0 +1,79 @@
1
+ import { C as ConnectOption } from './controller.js';
2
+ export { a as ConnectController, b as ConnectState, c as createConnectController } from './controller.js';
3
+ export * from '../index.js';
4
+
5
+ /** The wallet client surface the passkey option needs (`createCenterWalletClient` satisfies it). */
6
+ type PasskeyWallet = {
7
+ prepareConnection(): Promise<{
8
+ /** Where the launch lands: its origin is what a frame delegates passkeys to. */
9
+ authorizationUrl: string;
10
+ launch(options?: {
11
+ target?: string;
12
+ }): void;
13
+ }>;
14
+ completeConnection(url: string): Promise<unknown>;
15
+ retryConnection(): Promise<unknown>;
16
+ /** The current connection, when one is already held in this tab. */
17
+ restoreConnection?(): unknown;
18
+ disconnect(): void;
19
+ payments(): {
20
+ pendingPayment(): {
21
+ status: string;
22
+ } | null;
23
+ };
24
+ };
25
+ /** The built-in way in: a passkey account at Signa. By default Signa opens in a popup
26
+ * and the page stays; the callback comes back by message and the exchange finishes here, then
27
+ * `connected` runs. With `frame: true` Signa opens in a frame the app shows instead (the modal
28
+ * renders it under the name the option asks for); Signa serves the sign-in framed only for apps
29
+ * its operator admits, and the page inside offers "Open as a page" when it cannot continue there.
30
+ * When the popup is blocked, or with `popup: false`, connecting is a full-page redirect, so
31
+ * `beforeLaunch` is where the app saves what it needs to come back to. */
32
+ declare function passkeyOption(input: {
33
+ wallet(): Promise<PasskeyWallet> | PasskeyWallet;
34
+ beforeLaunch?(): void;
35
+ popup?: boolean;
36
+ frame?: boolean;
37
+ /** After a popup sign-in completes in this page: connect the app's wallet layer. */
38
+ connected?(connection: unknown): Promise<void> | void;
39
+ /** Defaults to the global window. */
40
+ window?: Window;
41
+ }): ConnectOption;
42
+
43
+ /** For the app's callback page. Hands the callback URL to the same-origin page that opened this
44
+ * window (a popup sign-in) or frames it (a framed sign-in or payment review), and closes a popup
45
+ * once that page acknowledges. The page may be slow to answer (a backgrounded
46
+ * tab on a phone), so the offer repeats until it is acknowledged or the opener closes. The cap
47
+ * (default fifteen minutes, the life of a Signa request) covers an opener that navigated away;
48
+ * after it the callback page completes the exchange itself, as it does after a full-page
49
+ * redirect, while still closing on a late acknowledgement. False when there is no same-origin
50
+ * opener. */
51
+ declare function deliverCenterCallback(url: string, options?: {
52
+ window?: Window | undefined;
53
+ timeoutMs?: number;
54
+ retryMs?: number;
55
+ }): Promise<boolean>;
56
+
57
+ /** The wallet client surface the callback page needs. */
58
+ type CenterCallbackWallet = {
59
+ completeConnection(url: string): Promise<unknown>;
60
+ retryConnection(): Promise<unknown>;
61
+ payments(): {
62
+ completePayment(url: string): Promise<unknown>;
63
+ refreshPayment(): Promise<unknown>;
64
+ };
65
+ };
66
+ type CenterCallbackResult = {
67
+ kind: "connection";
68
+ connection: unknown;
69
+ } | {
70
+ kind: "payment";
71
+ status: unknown;
72
+ };
73
+ /** Finishes whatever Signa sent the browser back for: a connection (`code`) or a payment
74
+ * review (`review`). Without parameters it retries the pending exchange, so a reload of the
75
+ * callback page after a failed exchange resumes instead of starting over. */
76
+ declare function completeCenterCallback(wallet: CenterCallbackWallet, url: string): Promise<CenterCallbackResult>;
77
+
78
+ export { ConnectOption, completeCenterCallback, deliverCenterCallback, passkeyOption };
79
+ export type { CenterCallbackResult, CenterCallbackWallet, PasskeyWallet };
package/core/index.js ADDED
@@ -0,0 +1,347 @@
1
+ "use client";
2
+
3
+ // wallet-client/src/core/controller.ts
4
+ var idle = {
5
+ pending: null,
6
+ error: null,
7
+ handoffUri: null,
8
+ frameName: null,
9
+ frameOrigin: null
10
+ };
11
+ function createConnectController(options) {
12
+ let state = idle;
13
+ let current = null;
14
+ const listeners = /* @__PURE__ */ new Set();
15
+ const set = (next) => {
16
+ state = { ...state, ...next };
17
+ for (const listener of listeners) listener();
18
+ };
19
+ return {
20
+ options,
21
+ getState: () => state,
22
+ subscribe(listener) {
23
+ listeners.add(listener);
24
+ return () => listeners.delete(listener);
25
+ },
26
+ async choose(id) {
27
+ const option = options.find((candidate) => candidate.id === id);
28
+ if (!option || option.disabled || current) return;
29
+ const controller = current = new AbortController();
30
+ set({
31
+ pending: id,
32
+ error: null,
33
+ handoffUri: null,
34
+ frameName: null,
35
+ frameOrigin: null
36
+ });
37
+ try {
38
+ await option.connect({
39
+ signal: controller.signal,
40
+ handoff: (uri) => {
41
+ if (current === controller) set({ handoffUri: uri });
42
+ },
43
+ frame: (name, origin) => {
44
+ if (current === controller)
45
+ set({ frameName: name, frameOrigin: origin });
46
+ }
47
+ });
48
+ } catch (error) {
49
+ if (current === controller)
50
+ set({
51
+ pending: null,
52
+ handoffUri: null,
53
+ frameName: null,
54
+ frameOrigin: null,
55
+ error: messageOf(error)
56
+ });
57
+ } finally {
58
+ if (current === controller) current = null;
59
+ }
60
+ },
61
+ cancel() {
62
+ current?.abort();
63
+ current = null;
64
+ set(idle);
65
+ }
66
+ };
67
+ }
68
+ function messageOf(error) {
69
+ const value = error;
70
+ if (value?.code === 4001 || value?.name === "AbortError") return null;
71
+ return value?.shortMessage || error instanceof Error && error.message || "The connection did not complete.";
72
+ }
73
+
74
+ // wallet-client/src/core/popup.ts
75
+ var popupName = "juicebox-center";
76
+ var frameName = "juicebox-center-frame";
77
+ var callbackType = "juicebox-center:callback";
78
+ var receivedType = "juicebox-center:received";
79
+ function openCenterPopup(win) {
80
+ if (!win) return null;
81
+ const width = 460, height = 720;
82
+ const left = Math.max(
83
+ 0,
84
+ Math.round((win.outerWidth ?? width) / 2 - width / 2 + (win.screenX ?? 0))
85
+ );
86
+ const top = Math.max(
87
+ 0,
88
+ Math.round(
89
+ (win.outerHeight ?? height) / 2 - height / 2 + (win.screenY ?? 0)
90
+ )
91
+ );
92
+ try {
93
+ return win.open(
94
+ "",
95
+ popupName,
96
+ `popup,width=${width},height=${height},left=${left},top=${top}`
97
+ );
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ var abort = () => new DOMException("The sign-in window was closed.", "AbortError");
103
+ function awaitPopupCallback(win, popup, signal) {
104
+ return new Promise((resolve, reject) => {
105
+ const origin = win.location.origin;
106
+ const done = () => {
107
+ win.removeEventListener("message", onMessage);
108
+ signal.removeEventListener("abort", onAbort);
109
+ clearInterval(timer);
110
+ };
111
+ const onMessage = (event) => {
112
+ const data = event.data;
113
+ if (event.source !== popup || event.origin !== origin || data?.type !== callbackType || typeof data.url !== "string")
114
+ return;
115
+ done();
116
+ popup.postMessage({ type: receivedType }, origin);
117
+ resolve(data.url);
118
+ };
119
+ const onAbort = () => {
120
+ done();
121
+ popup.close();
122
+ reject(abort());
123
+ };
124
+ const timer = setInterval(() => {
125
+ if (!popup.closed) return;
126
+ done();
127
+ reject(abort());
128
+ }, 250);
129
+ win.addEventListener("message", onMessage);
130
+ signal.addEventListener("abort", onAbort);
131
+ });
132
+ }
133
+ function awaitFrameCallback(win, frame, signal) {
134
+ return new Promise((resolve, reject) => {
135
+ const origin = win.location.origin;
136
+ const done = () => {
137
+ win.removeEventListener("message", onMessage);
138
+ signal.removeEventListener("abort", onAbort);
139
+ };
140
+ const onMessage = (event) => {
141
+ const data = event.data;
142
+ if (!frame.contentWindow || event.source !== frame.contentWindow || event.origin !== origin || data?.type !== callbackType || typeof data.url !== "string")
143
+ return;
144
+ done();
145
+ frame.contentWindow.postMessage({ type: receivedType }, origin);
146
+ resolve(data.url);
147
+ };
148
+ const onAbort = () => {
149
+ done();
150
+ reject(new DOMException("The sign-in was closed.", "AbortError"));
151
+ };
152
+ if (signal.aborted) return onAbort();
153
+ win.addEventListener("message", onMessage);
154
+ signal.addEventListener("abort", onAbort);
155
+ });
156
+ }
157
+ function deliverCenterCallback(url, options = {}) {
158
+ const win = options.window;
159
+ if (!win) return Promise.resolve(false);
160
+ const origin = win.location.origin;
161
+ if (win.parent && win.parent !== win) {
162
+ try {
163
+ if (win.parent.location.origin === origin) {
164
+ win.parent.postMessage({ type: callbackType, url }, origin);
165
+ return Promise.resolve(true);
166
+ }
167
+ } catch {
168
+ }
169
+ }
170
+ const opener = win.opener;
171
+ if (!opener) return Promise.resolve(false);
172
+ try {
173
+ if (opener.closed || opener.location.origin !== origin)
174
+ return Promise.resolve(false);
175
+ } catch {
176
+ return Promise.resolve(false);
177
+ }
178
+ return new Promise((resolve) => {
179
+ let settled = false;
180
+ const finish = (delivered) => {
181
+ clearInterval(again);
182
+ clearTimeout(cap);
183
+ if (delivered) {
184
+ win.removeEventListener("message", onMessage);
185
+ win.close();
186
+ }
187
+ if (!settled) {
188
+ settled = true;
189
+ resolve(delivered);
190
+ }
191
+ };
192
+ const onMessage = (event) => {
193
+ const data = event.data;
194
+ if (event.source !== opener || event.origin !== origin || data?.type !== receivedType)
195
+ return;
196
+ finish(true);
197
+ };
198
+ const offer = () => {
199
+ if (opener.closed) return finish(false);
200
+ opener.postMessage({ type: callbackType, url }, origin);
201
+ };
202
+ const again = setInterval(offer, options.retryMs ?? 1500);
203
+ const cap = setTimeout(() => finish(false), options.timeoutMs ?? 9e5);
204
+ win.addEventListener("message", onMessage);
205
+ offer();
206
+ });
207
+ }
208
+
209
+ // src/rest/client/paymentStates.ts
210
+ var settledPaymentStates = ["paid", "reverted", "cancelled", "expired", "refused"];
211
+ var isSettledPayment = (status) => settledPaymentStates.includes(status);
212
+
213
+ // wallet-client/src/core/passkey.ts
214
+ function passkeyOption(input) {
215
+ return {
216
+ id: "juicebox-center",
217
+ name: "Signa account",
218
+ async connect({ signal, frame }) {
219
+ signal.throwIfAborted();
220
+ const connected = async (connection) => {
221
+ signal.throwIfAborted();
222
+ await input.connected?.(connection);
223
+ };
224
+ const win = input.window ?? globalThis.window;
225
+ const popup = input.frame || input.popup === false ? null : openCenterPopup(win);
226
+ try {
227
+ const wallet = await input.wallet();
228
+ const payment = wallet.payments().pendingPayment();
229
+ if (payment && !isSettledPayment(payment.status))
230
+ throw new Error(
231
+ "Finish your pending Signa payment before signing in again."
232
+ );
233
+ input.beforeLaunch?.();
234
+ let prepared = await wallet.prepareConnection().catch(async (error) => {
235
+ const code = error?.code;
236
+ if (code === "WALLET_HANDOFF_PENDING" || code === "WALLET_ALREADY_CONNECTED" && wallet.restoreConnection?.())
237
+ return null;
238
+ if (code === "WALLET_HANDOFF_CHANGED")
239
+ return wallet.prepareConnection();
240
+ if (code !== "WALLET_HANDOFF_EXPIRED" && code !== "WALLET_ALREADY_CONNECTED" && code !== "WALLET_STORAGE_INVALID")
241
+ throw error;
242
+ wallet.disconnect();
243
+ return wallet.prepareConnection();
244
+ });
245
+ if (!prepared) {
246
+ const restored = wallet.restoreConnection?.();
247
+ if (restored) {
248
+ await connected(restored);
249
+ return;
250
+ }
251
+ let connection2;
252
+ try {
253
+ connection2 = await wallet.retryConnection();
254
+ } catch (error) {
255
+ const code = error?.code;
256
+ const raced = code === "WALLET_HANDOFF_CHANGED" ? wallet.restoreConnection?.() : null;
257
+ if (raced) {
258
+ await connected(raced);
259
+ return;
260
+ }
261
+ if (code !== "WALLET_REQUEST_REJECTED") throw error;
262
+ wallet.disconnect();
263
+ prepared = await wallet.prepareConnection();
264
+ }
265
+ if (!prepared) {
266
+ await connected(connection2);
267
+ return;
268
+ }
269
+ }
270
+ signal.throwIfAborted();
271
+ if (input.frame) {
272
+ frame(frameName, new URL(prepared.authorizationUrl).origin);
273
+ const element = await frameElement(win, signal);
274
+ prepared.launch({ target: frameName });
275
+ const url2 = await awaitFrameCallback(win, element, signal);
276
+ const connection2 = await wallet.completeConnection(url2);
277
+ await connected(connection2);
278
+ return;
279
+ }
280
+ if (!popup) {
281
+ prepared.launch();
282
+ return;
283
+ }
284
+ if (popup.closed)
285
+ throw new DOMException(
286
+ "The sign-in window was closed.",
287
+ "AbortError"
288
+ );
289
+ prepared.launch({ target: popupName });
290
+ const url = await awaitPopupCallback(win, popup, signal);
291
+ const connection = await wallet.completeConnection(url);
292
+ await connected(connection);
293
+ } finally {
294
+ popup?.close();
295
+ }
296
+ }
297
+ };
298
+ }
299
+ function frameElement(win, signal) {
300
+ return new Promise((resolve, reject) => {
301
+ const started = Date.now();
302
+ const look = () => {
303
+ if (signal.aborted)
304
+ return reject(
305
+ new DOMException("The sign-in was closed.", "AbortError")
306
+ );
307
+ const element = win.document?.querySelector(
308
+ `iframe[name="${frameName}"]`
309
+ );
310
+ if (element) return resolve(element);
311
+ if (Date.now() - started > 5e3)
312
+ return reject(new Error("The sign-in frame did not appear."));
313
+ setTimeout(look, 20);
314
+ };
315
+ look();
316
+ });
317
+ }
318
+
319
+ // wallet-client/src/core/callback.ts
320
+ async function completeCenterCallback(wallet, url) {
321
+ const parameters = new URL(url).searchParams;
322
+ if (parameters.has("review"))
323
+ return {
324
+ kind: "payment",
325
+ status: await wallet.payments().completePayment(url)
326
+ };
327
+ if (parameters.has("code"))
328
+ return {
329
+ kind: "connection",
330
+ connection: await wallet.completeConnection(url)
331
+ };
332
+ if (parameters.has("state"))
333
+ return {
334
+ kind: "payment",
335
+ status: await wallet.payments().refreshPayment()
336
+ };
337
+ return { kind: "connection", connection: await wallet.retryConnection() };
338
+ }
339
+
340
+ // wallet-client/src/core/index.ts
341
+ export * from "../index.js";
342
+ export {
343
+ completeCenterCallback,
344
+ createConnectController,
345
+ deliverCenterCallback,
346
+ passkeyOption
347
+ };