@shuttle.technology/authorization 0.0.0-stage → 0.2.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1124 -2
  3. package/dist/accountLists.d.ts +28 -0
  4. package/dist/accountLists.js +27 -0
  5. package/dist/aesGcmSiv.d.ts +14 -0
  6. package/dist/aesGcmSiv.js +160 -0
  7. package/dist/auth.d.ts +68 -0
  8. package/dist/auth.js +189 -0
  9. package/dist/balances.d.ts +31 -0
  10. package/dist/balances.js +44 -0
  11. package/dist/canonicalize.d.ts +19 -0
  12. package/dist/canonicalize.js +107 -0
  13. package/dist/client.d.ts +152 -0
  14. package/dist/client.js +781 -0
  15. package/dist/clientTypes.d.ts +56 -0
  16. package/dist/clientTypes.js +5 -0
  17. package/dist/configuration.d.ts +48 -0
  18. package/dist/configuration.js +72 -0
  19. package/dist/consentDebit.d.ts +36 -0
  20. package/dist/consentDebit.js +95 -0
  21. package/dist/credentials.d.ts +116 -0
  22. package/dist/credentials.js +315 -0
  23. package/dist/debitPermissionWire.d.ts +90 -0
  24. package/dist/debitPermissionWire.js +118 -0
  25. package/dist/debitPermissions.d.ts +39 -0
  26. package/dist/debitPermissions.js +117 -0
  27. package/dist/deviceKey.d.ts +13 -0
  28. package/dist/deviceKey.js +28 -0
  29. package/dist/deviceRecovery.d.ts +40 -0
  30. package/dist/deviceRecovery.js +143 -0
  31. package/dist/deviceShare.d.ts +9 -0
  32. package/dist/deviceShare.js +24 -0
  33. package/dist/deviceShareEnvelope.d.ts +13 -0
  34. package/dist/deviceShareEnvelope.js +139 -0
  35. package/dist/deviceShares.d.ts +91 -0
  36. package/dist/deviceShares.js +204 -0
  37. package/dist/errors.d.ts +280 -0
  38. package/dist/errors.js +85 -0
  39. package/dist/linkedChecks.d.ts +17 -0
  40. package/dist/linkedChecks.js +31 -0
  41. package/dist/linkedRecovery.d.ts +40 -0
  42. package/dist/linkedRecovery.js +49 -0
  43. package/dist/linkedRegistrations.d.ts +11 -0
  44. package/dist/linkedRegistrations.js +109 -0
  45. package/dist/linkedSendDisplay.d.ts +73 -0
  46. package/dist/linkedSendDisplay.js +84 -0
  47. package/dist/linkedWalletSignals.d.ts +22 -0
  48. package/dist/linkedWalletSignals.js +101 -0
  49. package/dist/linkedWallets.d.ts +401 -0
  50. package/dist/linkedWallets.js +436 -0
  51. package/dist/me.d.ts +27 -0
  52. package/dist/me.js +30 -0
  53. package/dist/mediatedOperation.d.ts +27 -0
  54. package/dist/mediatedOperation.js +68 -0
  55. package/dist/mobile.d.ts +64 -0
  56. package/dist/mobile.js +69 -0
  57. package/dist/modeC.d.ts +131 -0
  58. package/dist/modeC.js +248 -0
  59. package/dist/modeCLinked.d.ts +207 -0
  60. package/dist/modeCLinked.js +270 -0
  61. package/dist/pages.d.ts +22 -0
  62. package/dist/pages.js +45 -0
  63. package/dist/poll.d.ts +15 -0
  64. package/dist/poll.js +76 -0
  65. package/dist/redirects.d.ts +45 -0
  66. package/dist/redirects.js +108 -0
  67. package/dist/requestGrant.d.ts +169 -0
  68. package/dist/requestGrant.js +243 -0
  69. package/dist/sends.d.ts +89 -0
  70. package/dist/sends.js +127 -0
  71. package/dist/serviceGrants.d.ts +52 -0
  72. package/dist/serviceGrants.js +46 -0
  73. package/dist/slice4.d.ts +187 -0
  74. package/dist/slice4.js +299 -0
  75. package/dist/streams.d.ts +212 -0
  76. package/dist/streams.js +282 -0
  77. package/dist/trackedWallets.d.ts +53 -0
  78. package/dist/trackedWallets.js +104 -0
  79. package/dist/transferStatus.d.ts +45 -0
  80. package/dist/transferStatus.js +43 -0
  81. package/dist/walletCreateGrant.d.ts +62 -0
  82. package/dist/walletCreateGrant.js +89 -0
  83. package/dist/walletDisplayParts.d.ts +17 -0
  84. package/dist/walletDisplayParts.js +47 -0
  85. package/dist/walletEvents.d.ts +43 -0
  86. package/dist/walletEvents.js +97 -0
  87. package/dist/walletLimits.d.ts +65 -0
  88. package/dist/walletLimits.js +102 -0
  89. package/dist/walletOperations.d.ts +209 -0
  90. package/dist/walletOperations.js +317 -0
  91. package/dist/webRenewal.d.ts +92 -0
  92. package/dist/webRenewal.js +179 -0
  93. package/package.json +65 -3
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The session's credentials and every change to them, in one place.
3
+ *
4
+ * Every attempt takes a generation when it starts, before anything is
5
+ * awaited: a sign-in (code exchange), a renewal or restore, a logout. A
6
+ * session's lineage is the generation of the sign-in (or first restore) it
7
+ * comes from; its renewals carry it on, and one lineage is installed at a
8
+ * time. A sign-in installs while no newer sign-in or logout began. A renewal
9
+ * installs while the installed lineage did not change since it began (an
10
+ * epoch counts every change, to none included; a rotation within the lineage
11
+ * is no change) and it answers the same session of the same account: on the
12
+ * web the cookie is shared by every tab, and a sign-in in another tab puts its
13
+ * own session there. A renewal or restore of an installed session that
14
+ * answers another one installs nothing and ends the installed session
15
+ * (session_replaced); a client with no session takes up what the cookie holds.
16
+ * A definitive refusal ends the lineage of the token it refused, also after a
17
+ * rotation, and nothing else. A logout ends everything older.
18
+ *
19
+ * Every change of memory and of secure storage runs in one queue, one after
20
+ * another, failure cleanup included, and installs only while its attempt is
21
+ * current at that moment. A renewal reads the stored refresh token only in
22
+ * the queue and is checked again after the read and before it asks the
23
+ * provider. A superseded attempt undoes its own storage write and nothing
24
+ * else. A stored token whose cleanup failed stays marked ended, so no restore
25
+ * takes it up again. On the web nothing is stored: the refresh credential is
26
+ * the provider's cookie, and a web renewal or restore is checked against its
27
+ * attempt under the tabs' lock (webRenewal.ts).
28
+ */
29
+ import { ClientFlowError, integer, isAuthorizationError, opaqueId, record } from "./auth.js";
30
+ /** A renewal that changed hands before it asked the provider anything. */
31
+ export class Superseded extends Error {
32
+ constructor() { super("superseded"); this.name = "Superseded"; }
33
+ }
34
+ /** The lineage of a stored token of unknown or failed provenance: it is ended and never taken up. */
35
+ const unclaimed = -1;
36
+ export class Credentials {
37
+ #channel;
38
+ #storage;
39
+ #key;
40
+ #clock = 0;
41
+ #latestSignIn = 0;
42
+ #cut = 0;
43
+ /** The lineage installed now (0 for none), and the epoch that counts its changes. */
44
+ #lineage = 0;
45
+ #epoch = 0;
46
+ #ended = new Set([unclaimed]);
47
+ /** Every token of the installed lineage handed out, with its refusal by a read, so a refusal reaches its lineage also after a rotation. */
48
+ #issued = new Map();
49
+ #held;
50
+ #storedLineage;
51
+ #queue = Promise.resolve();
52
+ constructor(channel, storage, key) {
53
+ this.#channel = channel;
54
+ this.#storage = storage;
55
+ this.#key = key;
56
+ }
57
+ /** current is the credentials held, while their lineage is the one installed. */
58
+ current() {
59
+ const held = this.#held;
60
+ return held && held.lineage === this.#lineage && !this.#ended.has(held.lineage) ? held : undefined;
61
+ }
62
+ get hasSession() { return this.current() !== undefined; }
63
+ /** usable is the token to send now: none while there is no session or a read was refused with it. */
64
+ usable() {
65
+ const held = this.current();
66
+ if (!held || this.#refusedRead(held.token))
67
+ throw new ClientFlowError("session_unavailable");
68
+ return held.token;
69
+ }
70
+ /** dispatchable says a request may still go out with token: of the lineage installed, and not refused by a read. */
71
+ dispatchable(token) {
72
+ const issued = this.#issued.get(token);
73
+ return issued !== undefined && issued.lineage === this.#lineage && !this.#ended.has(issued.lineage) && !issued.refused;
74
+ }
75
+ #refusedRead(token) { return this.#issued.get(token)?.refused === true; }
76
+ /** due says the token is to be renewed first: it lives less than marginSeconds more, or a read was refused with it. */
77
+ due(nowSeconds, marginSeconds) {
78
+ const held = this.current();
79
+ return held !== undefined && (nowSeconds >= held.session.accessExpiresAt - marginSeconds || this.#refusedRead(held.token));
80
+ }
81
+ /**
82
+ * markRefused marks a token a read was refused with, held now or rotated
83
+ * away, so no call waiting with it sends it; it says whether it is the
84
+ * token held now, which only a renewal replaces.
85
+ */
86
+ markRefused(token) {
87
+ const issued = this.#issued.get(token);
88
+ if (!issued)
89
+ return false;
90
+ issued.refused = true;
91
+ return this.current()?.token === token;
92
+ }
93
+ /** signIn starts a sign-in: no older sign-in installs after it. */
94
+ signIn() {
95
+ const generation = ++this.#clock;
96
+ this.#latestSignIn = generation;
97
+ return { generation, kind: "sign_in", epoch: this.#epoch, founds: true, lineage: generation };
98
+ }
99
+ /** renewal starts a renewal of the lineage installed now, or a restore that founds one when none is. */
100
+ renewal() {
101
+ const generation = ++this.#clock;
102
+ const held = this.#held?.lineage === this.#lineage ? this.#held : undefined;
103
+ const renews = this.#lineage !== 0 && held ? { sessionId: held.session.sessionId, accountId: held.session.accountId } : undefined;
104
+ return { generation, kind: "renewal", epoch: this.#epoch, founds: this.#lineage === 0, lineage: this.#lineage || generation, renews };
105
+ }
106
+ #valid(attempt) {
107
+ if (attempt.generation <= this.#cut || this.#ended.has(attempt.lineage))
108
+ return false;
109
+ return attempt.kind === "sign_in" ? this.#latestSignIn === attempt.generation : this.#epoch === attempt.epoch;
110
+ }
111
+ /** #install makes lineage the installed one; a change of lineage starts a new epoch and drops the older lineages' tokens. */
112
+ #setLineage(lineage) {
113
+ if (lineage === this.#lineage)
114
+ return;
115
+ this.#lineage = lineage;
116
+ this.#epoch++;
117
+ for (const [token, issued] of this.#issued)
118
+ if (issued.lineage !== lineage)
119
+ this.#issued.delete(token);
120
+ }
121
+ /** check throws Superseded once the renewal is no longer current, right before it asks the provider. */
122
+ check(attempt) {
123
+ if (!this.#valid(attempt))
124
+ throw new Superseded();
125
+ }
126
+ /** superseded answers for an overtaken renewal: the session held now, or none. */
127
+ superseded() {
128
+ const held = this.current();
129
+ if (held && !this.#refusedRead(held.token))
130
+ return { ...held.session };
131
+ throw new ClientFlowError("session_unavailable");
132
+ }
133
+ #overtaken(attempt) {
134
+ if (attempt.kind === "renewal")
135
+ return this.superseded();
136
+ throw new ClientFlowError("session_unavailable");
137
+ }
138
+ /** refreshToken reads the stored refresh token for a mobile renewal, in the queue, and checks the renewal again after the read. */
139
+ refreshToken(attempt) {
140
+ return this.#serial(async () => {
141
+ this.check(attempt);
142
+ const storage = this.#storage;
143
+ const token = await storage.get(this.#key);
144
+ if (this.#storedLineage !== undefined && this.#ended.has(this.#storedLineage)) {
145
+ await this.#remove(this.#storedLineage);
146
+ throw new ClientFlowError("mobile_refresh_unavailable");
147
+ }
148
+ if (this.#storedLineage !== undefined) {
149
+ // A founding restore takes up the stored token's lineage; a renewal renews only its own.
150
+ if (attempt.founds)
151
+ attempt.lineage = this.#storedLineage;
152
+ else if (this.#storedLineage !== attempt.lineage)
153
+ throw new Superseded();
154
+ }
155
+ this.check(attempt);
156
+ if (!token)
157
+ throw new ClientFlowError("mobile_refresh_unavailable");
158
+ // A first restore owns the token it took up, so a refusal of it clears it.
159
+ if (this.#storedLineage === undefined)
160
+ this.#storedLineage = attempt.lineage;
161
+ return token;
162
+ });
163
+ }
164
+ /**
165
+ * install keeps what an exchange or a renewal answered, in the queue, only
166
+ * while its attempt is current: in secure storage first on mobile, then in
167
+ * memory. A current attempt whose write fails ends its lineage and clears
168
+ * the held and stored credentials; an attempt overtaken during its write
169
+ * puts back what was stored before and installs nothing.
170
+ */
171
+ install(attempt, payload, exchange) {
172
+ const grant = this.#parse(payload, exchange);
173
+ return this.#serial(async () => {
174
+ if (!this.#valid(attempt))
175
+ return this.#overtaken(attempt);
176
+ if (attempt.renews && (grant.session.sessionId !== attempt.renews.sessionId || grant.session.accountId !== attempt.renews.accountId)) {
177
+ // Another sign-in's session is in the cookie: the one installed can no longer be renewed, and the other is not taken up unasked.
178
+ this.#fail(attempt.lineage);
179
+ if (this.#held?.lineage === attempt.lineage)
180
+ this.#held = undefined;
181
+ await this.#remove(attempt.lineage);
182
+ throw new ClientFlowError("session_replaced");
183
+ }
184
+ if (this.#storage) {
185
+ const storage = this.#storage;
186
+ let previous;
187
+ try {
188
+ previous = { value: await storage.get(this.#key), lineage: this.#storedLineage };
189
+ await storage.set(this.#key, grant.refresh);
190
+ this.#storedLineage = attempt.lineage;
191
+ }
192
+ catch {
193
+ if (this.#valid(attempt)) {
194
+ // Fail closed: this lineage and the one installed end, and what may have been written goes.
195
+ this.#fail(attempt.lineage);
196
+ this.#fail(this.#lineage);
197
+ this.#held = undefined;
198
+ this.#storedLineage = attempt.lineage;
199
+ await this.#remove(attempt.lineage);
200
+ }
201
+ else if (previous) {
202
+ await this.#putBack(previous);
203
+ }
204
+ throw new ClientFlowError("secure_storage_unavailable");
205
+ }
206
+ if (!this.#valid(attempt)) {
207
+ await this.#putBack(previous);
208
+ return this.#overtaken(attempt);
209
+ }
210
+ }
211
+ const session = { ...grant.session, setupRequired: exchange ? grant.setupRequired : this.#held?.session.setupRequired };
212
+ if (session.setupRequired === undefined)
213
+ delete session.setupRequired;
214
+ this.#held = { token: grant.token, csrf: grant.csrf, session, lineage: attempt.lineage };
215
+ this.#setLineage(attempt.lineage);
216
+ // A token a read refused stays refused, also when a renewal answers its bytes again.
217
+ this.#issued.set(grant.token, { lineage: attempt.lineage, refused: this.#refusedRead(grant.token) });
218
+ return { ...session };
219
+ });
220
+ }
221
+ /** refuse ends the lineage of a token the provider refused for good, whichever of its tokens it was. */
222
+ refuse(token) {
223
+ const issued = this.#issued.get(token);
224
+ if (issued !== undefined)
225
+ this.end(issued.lineage);
226
+ }
227
+ /** renewalFailed answers for a failed renewal: overtaken, it is the session now; refused for good, its lineage ends. */
228
+ renewalFailed(attempt, error) {
229
+ if (error instanceof Superseded)
230
+ return this.superseded();
231
+ if (isAuthorizationError(error, "UNAUTHORIZED"))
232
+ this.end(attempt.lineage);
233
+ throw error;
234
+ }
235
+ /** end ends one lineage at once, and clears its held and stored credentials in the queue. */
236
+ end(lineage) {
237
+ this.#fail(lineage);
238
+ void this.#serial(async () => {
239
+ if (this.#held?.lineage === lineage)
240
+ this.#held = undefined;
241
+ await this.#remove(lineage);
242
+ });
243
+ }
244
+ /** logout ends the session held at once and every older attempt, clears memory and storage in the queue, and answers what it ended. */
245
+ logout() {
246
+ const held = this.current();
247
+ this.#cut = ++this.#clock;
248
+ this.#fail(this.#lineage);
249
+ const cleared = this.#serial(async () => {
250
+ this.#held = undefined;
251
+ if (!this.#storage)
252
+ return;
253
+ // Whatever is stored ends with the logout, of known lineage or not, and stays ended if the deletion fails.
254
+ if (this.#storedLineage === undefined)
255
+ this.#storedLineage = unclaimed;
256
+ this.#ended.add(this.#storedLineage);
257
+ await this.#storage.delete(this.#key);
258
+ this.#storedLineage = undefined;
259
+ });
260
+ return { token: held?.token, csrf: held?.csrf, sessionId: held?.session.sessionId, cleared };
261
+ }
262
+ /** #fail ends a lineage in memory: nothing of it installs or goes out any more. */
263
+ #fail(lineage) {
264
+ if (lineage === 0)
265
+ return;
266
+ this.#ended.add(lineage);
267
+ if (this.#lineage === lineage)
268
+ this.#setLineage(0);
269
+ }
270
+ /** #remove deletes the stored token of an ended lineage; when the deletion fails it stays marked ended. */
271
+ async #remove(lineage) {
272
+ if (!this.#storage || this.#storedLineage !== lineage)
273
+ return;
274
+ try {
275
+ await this.#storage.delete(this.#key);
276
+ this.#storedLineage = undefined;
277
+ }
278
+ catch { /* stays stored and ended: no restore takes it up */ }
279
+ }
280
+ /** #putBack restores what was stored before an overtaken attempt wrote. */
281
+ async #putBack(previous) {
282
+ const storage = this.#storage;
283
+ try {
284
+ if (previous.value === null)
285
+ await storage.delete(this.#key);
286
+ else
287
+ await storage.set(this.#key, previous.value);
288
+ this.#storedLineage = previous.lineage;
289
+ }
290
+ catch {
291
+ // What is stored now is the overtaken attempt's: it stays marked ended.
292
+ this.#storedLineage = unclaimed;
293
+ }
294
+ }
295
+ #serial(work) {
296
+ const run = this.#queue.then(work);
297
+ this.#queue = run.then(() => undefined, () => undefined);
298
+ return run;
299
+ }
300
+ #parse(payload, exchange) {
301
+ const channelField = this.#channel === "web" ? "csrf_token" : "refresh_token";
302
+ const required = ["request_id", "access_token", "expires_at", "account_id", "session_id", channelField];
303
+ if (exchange)
304
+ required.push("setup_required");
305
+ const root = record(payload, required);
306
+ if (!opaqueId(root.request_id) || typeof root.access_token !== "string" || root.access_token.length === 0 || !integer(root.expires_at) || !opaqueId(root.account_id) || !opaqueId(root.session_id) || (exchange && typeof root.setup_required !== "boolean") || typeof root[channelField] !== "string" || root[channelField].length === 0) {
307
+ throw new ClientFlowError("invalid_server_response");
308
+ }
309
+ return {
310
+ token: root.access_token, csrf: this.#channel === "web" ? root.csrf_token : undefined, refresh: this.#channel === "mobile" ? root.refresh_token : undefined,
311
+ setupRequired: exchange ? root.setup_required : undefined,
312
+ session: { requestId: root.request_id, accessExpiresAt: root.expires_at, accountId: root.account_id, sessionId: root.session_id },
313
+ };
314
+ }
315
+ }
@@ -0,0 +1,90 @@
1
+ import type { DeviceSharePackage } from './walletOperations.ts';
2
+ export type DebitAsset = 'native' | 'usdt' | 'usdc';
3
+ /** At most this many assets, and this many recipients, in one permission. */
4
+ export declare const MaxDebitAssets = 3;
5
+ export declare const MaxDebitRecipients = 10;
6
+ /** The bounds of one asset, in its minimal units on the wallet's network: one debit, and 24 hours of debits. */
7
+ export interface DebitAssetBound {
8
+ asset: DebitAsset;
9
+ perDebitRaw: string;
10
+ perDayRaw: string;
11
+ }
12
+ /**
13
+ * What the person grants. walletId is the linked wallet; the permission is on that wallet's own network.
14
+ * recipients absent or empty: any recipient. expiresAt (UTC RFC 3339, in the future) absent: until revoked.
15
+ */
16
+ export interface DebitPermissionInput {
17
+ walletId: string;
18
+ servicePrincipalId: string;
19
+ assets: readonly DebitAssetBound[];
20
+ recipients?: readonly string[];
21
+ expiresAt?: string;
22
+ }
23
+ /**
24
+ * What the person confirms. recipients are in the form the network compares them by. shareKeeperPublicKey is
25
+ * present when the keeper does not hold this wallet's device share yet: the execute then hands it over.
26
+ */
27
+ export interface DebitPermissionDisplay {
28
+ kind: 'debit_permission.create';
29
+ ownerId: string;
30
+ requesterId: string;
31
+ linkedWalletId: string;
32
+ servicePrincipalId: string;
33
+ network: string;
34
+ address: string;
35
+ assets: readonly DebitAssetBound[];
36
+ recipients: readonly string[];
37
+ expiresAt?: string;
38
+ shareKeeperPublicKey?: string;
39
+ }
40
+ /** What the person confirms to end one permission. */
41
+ export interface DebitPermissionRevokeDisplay {
42
+ kind: 'debit_permission.revoke';
43
+ ownerId: string;
44
+ requesterId: string;
45
+ debitPermissionId: string;
46
+ servicePrincipalId: string;
47
+ linkedWalletId: string;
48
+ network: string;
49
+ address: string;
50
+ }
51
+ /** expired: past its end. A revoked permission names revokedAt. */
52
+ export type DebitPermissionState = 'active' | 'revoked' | 'expired';
53
+ /** One permission the account gave. serviceName is absent when the service has no display name. */
54
+ export interface DebitPermission {
55
+ id: string;
56
+ servicePrincipalId: string;
57
+ serviceName?: string;
58
+ linkedWalletId: string;
59
+ network: string;
60
+ address: string;
61
+ assets: readonly DebitAssetBound[];
62
+ recipients: readonly string[];
63
+ expiresAt?: string;
64
+ state: DebitPermissionState;
65
+ version: string;
66
+ createdAt: string;
67
+ revokedAt?: string;
68
+ }
69
+ /** The account's permissions, newest first, at most 100. */
70
+ export interface DebitPermissionList {
71
+ requestId: string;
72
+ items: readonly DebitPermission[];
73
+ }
74
+ /** The device key and the wallet's device share as stored on the device, still sealed to that key. */
75
+ export interface DebitShareKeys {
76
+ devicePrivateKey: CryptoKey;
77
+ deviceShare: DeviceSharePackage;
78
+ }
79
+ /** The closed body of POST /v1/operations that asks for a new permission. */
80
+ export declare function debitPermissionCreate(ownerId: string, input: DebitPermissionInput): Record<string, unknown>;
81
+ /** The closed body of POST /v1/operations that asks to end one permission. */
82
+ export declare function debitPermissionRevocation(ownerId: string, debitPermissionId: string): Record<string, unknown>;
83
+ /** The display of a debit_permission.create operation. */
84
+ export declare function parseDebitPermissionDisplay(value: unknown): DebitPermissionDisplay;
85
+ /** The display of a debit_permission.revoke operation. */
86
+ export declare function parseDebitPermissionRevokeDisplay(value: unknown): DebitPermissionRevokeDisplay;
87
+ /** The answer of GET /v1/debit-permissions. */
88
+ export declare function parseDebitPermissionList(payload: unknown): DebitPermissionList;
89
+ export declare function wireBounds(value: unknown): readonly DebitAssetBound[];
90
+ export declare function recipients(v: unknown): v is string[];
@@ -0,0 +1,118 @@
1
+ // The wire of card-like debits (owner decision of 28 October 2026). A person
2
+ // lets one service debit one of their linked wallets without their device:
3
+ // for one to three assets the most one debit and the most 24 hours of debits
4
+ // may move, the recipients a debit may go to (none: any) and an optional end.
5
+ // The person confirms it with a passkey on the provider's page, and its
6
+ // execute hands the wallet's device share to the platform share keeper,
7
+ // sealed to a key the keeper made for that operation; Authorization never
8
+ // opens it. The person keeps their own copy. This module builds the closed
9
+ // request bodies and reads the closed answers; debitPermissions.ts sends them.
10
+ import { ClientFlowError, opaqueId, record, rfc3339UTC } from './auth.js';
11
+ import { fault, network, safeRevision, text, uuid } from './linkedChecks.js';
12
+ /** At most this many assets, and this many recipients, in one permission. */
13
+ export const MaxDebitAssets = 3;
14
+ export const MaxDebitRecipients = 10;
15
+ const invalid = () => { throw new ClientFlowError('invalid_operation_create'); };
16
+ /** The closed body of POST /v1/operations that asks for a new permission. */
17
+ export function debitPermissionCreate(ownerId, input) {
18
+ let declared;
19
+ try {
20
+ declared = record(input, ['walletId', 'servicePrincipalId', 'assets'], ['recipients', 'expiresAt']);
21
+ }
22
+ catch {
23
+ return invalid();
24
+ }
25
+ if (!opaqueId(ownerId) || !uuid(declared.walletId) || !opaqueId(declared.servicePrincipalId) ||
26
+ (declared.recipients !== undefined && !recipients(declared.recipients)) || (declared.expiresAt !== undefined && !rfc3339UTC(declared.expiresAt)))
27
+ invalid();
28
+ const assets = parseBounds(declared.assets, ['asset', 'perDebitRaw', 'perDayRaw'], invalid).map((a) => ({ asset: a.asset, per_debit_raw: a.perDebitRaw, per_day_raw: a.perDayRaw }));
29
+ const payload = { service_principal_id: declared.servicePrincipalId, assets };
30
+ if (declared.recipients !== undefined)
31
+ payload.recipients = [...declared.recipients];
32
+ if (declared.expiresAt !== undefined)
33
+ payload.expires_at = declared.expiresAt;
34
+ return { kind: 'debit_permission.create', owner_type: 'account', owner_id: ownerId, linked_wallet_id: declared.walletId, payload };
35
+ }
36
+ /** The closed body of POST /v1/operations that asks to end one permission. */
37
+ export function debitPermissionRevocation(ownerId, debitPermissionId) {
38
+ if (!opaqueId(ownerId) || !uuid(debitPermissionId))
39
+ invalid();
40
+ return { kind: 'debit_permission.revoke', owner_type: 'account', owner_id: ownerId, payload: { debit_permission_id: debitPermissionId } };
41
+ }
42
+ /** The display of a debit_permission.create operation. */
43
+ export function parseDebitPermissionDisplay(value) {
44
+ const d = record(value, ['kind', 'owner_id', 'requester_id', 'linked_wallet_id', 'service_principal_id', 'network', 'address', 'assets', 'recipients'], ['expires_at', 'share_keeper_public_key']);
45
+ if (d.kind !== 'debit_permission.create' || !opaqueId(d.owner_id) || !opaqueId(d.requester_id) || !uuid(d.linked_wallet_id) || !opaqueId(d.service_principal_id) ||
46
+ !network(d.network) || !text(d.address, 256) || !recipients(d.recipients) || (d.expires_at !== undefined && !rfc3339UTC(d.expires_at)) ||
47
+ (d.share_keeper_public_key !== undefined && (typeof d.share_keeper_public_key !== 'string' || !/^0[23][0-9a-f]{64}$/.test(d.share_keeper_public_key))))
48
+ fault();
49
+ const display = { kind: 'debit_permission.create', ownerId: d.owner_id, requesterId: d.requester_id, linkedWalletId: d.linked_wallet_id, servicePrincipalId: d.service_principal_id,
50
+ network: d.network, address: d.address, assets: wireBounds(d.assets), recipients: Object.freeze([...d.recipients]) };
51
+ if (d.expires_at !== undefined)
52
+ display.expiresAt = d.expires_at;
53
+ if (d.share_keeper_public_key !== undefined)
54
+ display.shareKeeperPublicKey = d.share_keeper_public_key;
55
+ return Object.freeze(display);
56
+ }
57
+ /** The display of a debit_permission.revoke operation. */
58
+ export function parseDebitPermissionRevokeDisplay(value) {
59
+ const d = record(value, ['kind', 'owner_id', 'requester_id', 'debit_permission_id', 'service_principal_id', 'linked_wallet_id', 'network', 'address']);
60
+ if (d.kind !== 'debit_permission.revoke' || !opaqueId(d.owner_id) || !opaqueId(d.requester_id) || !uuid(d.debit_permission_id) || !opaqueId(d.service_principal_id) ||
61
+ !uuid(d.linked_wallet_id) || !network(d.network) || !text(d.address, 256))
62
+ fault();
63
+ return Object.freeze({ kind: 'debit_permission.revoke', ownerId: d.owner_id, requesterId: d.requester_id, debitPermissionId: d.debit_permission_id,
64
+ servicePrincipalId: d.service_principal_id, linkedWalletId: d.linked_wallet_id, network: d.network, address: d.address });
65
+ }
66
+ /** The answer of GET /v1/debit-permissions. */
67
+ export function parseDebitPermissionList(payload) {
68
+ const root = record(payload, ['items', 'request_id']);
69
+ if (!opaqueId(root.request_id) || !Array.isArray(root.items) || root.items.length > 100)
70
+ fault();
71
+ const items = root.items.map((value) => {
72
+ const p = record(value, ['id', 'service_principal_id', 'linked_wallet_id', 'network', 'address', 'assets', 'recipients', 'state', 'version', 'created_at'], ['service_name', 'expires_at', 'revoked_at']);
73
+ if (!uuid(p.id) || !opaqueId(p.service_principal_id) || !uuid(p.linked_wallet_id) || !network(p.network) || !text(p.address, 256) || !recipients(p.recipients) ||
74
+ (p.state !== 'active' && p.state !== 'revoked' && p.state !== 'expired') || !safeRevision(p.version) || !rfc3339UTC(p.created_at) ||
75
+ (p.service_name !== undefined && !serviceName(p.service_name)) || (p.expires_at !== undefined && !rfc3339UTC(p.expires_at)) ||
76
+ (p.revoked_at !== undefined && !rfc3339UTC(p.revoked_at)) || (p.state === 'revoked') !== (p.revoked_at !== undefined) || (p.state === 'expired' && p.expires_at === undefined))
77
+ fault();
78
+ const item = { id: p.id, servicePrincipalId: p.service_principal_id, linkedWalletId: p.linked_wallet_id, network: p.network, address: p.address,
79
+ assets: wireBounds(p.assets), recipients: Object.freeze([...p.recipients]), state: p.state, version: p.version, createdAt: p.created_at };
80
+ if (p.service_name !== undefined)
81
+ item.serviceName = p.service_name;
82
+ if (p.expires_at !== undefined)
83
+ item.expiresAt = p.expires_at;
84
+ if (p.revoked_at !== undefined)
85
+ item.revokedAt = p.revoked_at;
86
+ return Object.freeze(item);
87
+ });
88
+ if (new Set(items.map((item) => item.id)).size !== items.length)
89
+ fault();
90
+ return Object.freeze({ requestId: root.request_id, items: Object.freeze(items) });
91
+ }
92
+ export function wireBounds(value) {
93
+ return Object.freeze(parseBounds(value, ['asset', 'per_debit_raw', 'per_day_raw'], fault).map((a) => Object.freeze(a)));
94
+ }
95
+ // parseBounds reads one to three assets, each named once, both amounts canonical and above zero, the one debit no more than the day.
96
+ function parseBounds(value, members, refuse) {
97
+ if (!Array.isArray(value) || value.length < 1 || value.length > MaxDebitAssets)
98
+ refuse();
99
+ const seen = new Set();
100
+ return value.map((item) => {
101
+ let a;
102
+ try {
103
+ a = record(item, members);
104
+ }
105
+ catch {
106
+ return refuse();
107
+ }
108
+ const [perDebit, perDay] = [a[members[1]], a[members[2]]];
109
+ if (!asset(a.asset) || seen.has(a.asset) || !amount(perDebit) || !amount(perDay) || BigInt(perDebit) > BigInt(perDay))
110
+ refuse();
111
+ seen.add(a.asset);
112
+ return { asset: a.asset, perDebitRaw: perDebit, perDayRaw: perDay };
113
+ });
114
+ }
115
+ function asset(v) { return v === 'native' || v === 'usdt' || v === 'usdc'; }
116
+ function amount(v) { return typeof v === 'string' && /^[1-9][0-9]{0,77}$/.test(v); }
117
+ export function recipients(v) { return Array.isArray(v) && v.length <= MaxDebitRecipients && v.every((r) => typeof r === 'string' && /^[\x21-\x7e]{1,128}$/.test(r)); }
118
+ function serviceName(v) { return typeof v === 'string' && v.length > 0 && new TextEncoder().encode(v).length <= 128 && !/[\x00-\x1f\x7f-\x9f]/.test(v); }
@@ -0,0 +1,39 @@
1
+ import { type DebitPermissionInput, type DebitPermissionList, type DebitShareKeys } from './debitPermissionWire.ts';
2
+ import type { LinkedWalletTransport } from './linkedWallets.ts';
3
+ import { type LocalOperation } from './slice4.ts';
4
+ import type { DeviceSharePackage } from './walletOperations.ts';
5
+ /** The device share the SDK keeps for a linked wallet, resealed to one public key (LinkedWallets); with recover false it never recovers one. */
6
+ export type KeptShareSealer = (linkedWalletId: string, publicKey: string, signal?: AbortSignal, recover?: boolean) => Promise<DeviceSharePackage>;
7
+ export { MaxDebitAssets, MaxDebitRecipients, debitPermissionCreate, debitPermissionRevocation, parseDebitPermissionDisplay, parseDebitPermissionList, parseDebitPermissionRevokeDisplay } from './debitPermissionWire.ts';
8
+ export type { DebitAsset, DebitAssetBound, DebitPermission, DebitPermissionDisplay, DebitPermissionInput, DebitPermissionList, DebitPermissionRevokeDisplay, DebitPermissionState, DebitShareKeys } from './debitPermissionWire.ts';
9
+ export declare class DebitPermissions {
10
+ #private;
11
+ constructor(transport: LinkedWalletTransport, kept?: KeptShareSealer);
12
+ /**
13
+ * Asks to let one service debit one linked wallet within the bounds given. The operation comes back
14
+ * awaiting the person's passkey; its display says the bounds as the provider keeps them and, when the
15
+ * keeper does not hold the wallet's device share yet, the key the execute seals it to.
16
+ */
17
+ createDebitPermission(input: DebitPermissionInput, idempotencyKey: string, signal?: AbortSignal): Promise<LocalOperation>;
18
+ /**
19
+ * Applies a confirmed permission. It reads the operation first: when it is confirmed (authorized, at the
20
+ * version given) and its display names the share keeper's key, the device share is resealed to that key and sent: without keys the
21
+ * share the SDK keeps for the wallet (recovered first with one passkey confirmation when this browser holds none), with keys the
22
+ * app's own (resealDeviceShare). The plaintext share exists only inside that call. Without that key the keeper holds the share already
23
+ * and nothing is sent. The person's own copy stays where it is, and their sends with it go on as before.
24
+ */
25
+ executeDebitPermission(operationId: string, version: string, idempotencyKey: string, keys?: DebitShareKeys, signal?: AbortSignal): Promise<LocalOperation>;
26
+ /**
27
+ * executeConsentDebitPermission is executeDebitPermission() with the share the SDK keeps, for the permission a
28
+ * grant's consent created (consentDebit.ts): it never recovers a share, so reading a result never asks for a
29
+ * passkey; without a current share kept here it refuses with device_share_unavailable and sends nothing.
30
+ */
31
+ executeConsentDebitPermission(operationId: string, version: string, idempotencyKey: string, signal?: AbortSignal): Promise<LocalOperation>;
32
+ /** The account's permissions in every state, newest first. */
33
+ listDebitPermissions(signal?: AbortSignal): Promise<DebitPermissionList>;
34
+ /**
35
+ * Asks to end one permission. The operation awaits the person's passkey; executeOperation() applies it,
36
+ * without a body. A debit not yet executed is refused from then on.
37
+ */
38
+ revokeDebitPermission(debitPermissionId: string, idempotencyKey: string, signal?: AbortSignal): Promise<LocalOperation>;
39
+ }