@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,207 @@
1
+ import { type RequestSigningKey } from "./requestGrant.ts";
2
+ import type { DeviceSharePackage } from "./walletOperations.ts";
3
+ export { ModeCServerClock, MaxModeCProofLifetimeSeconds } from "./modeC.ts";
4
+ export { generateRequestSigningKey, type RequestSigningKey } from "./requestGrant.ts";
5
+ /** ModeCLinkedProofVersion is the proof of a transfer from a linked wallet. */
6
+ export declare const ModeCLinkedProofVersion = "2.0";
7
+ /** The assets a linked grant may name. */
8
+ export declare const LinkedModeCAssets: readonly ["native", "usdt", "usdc"];
9
+ export type LinkedModeCAsset = (typeof LinkedModeCAssets)[number];
10
+ export interface LinkedAssetCeiling {
11
+ readonly asset: LinkedModeCAsset;
12
+ /** The most one transfer of the asset may move, in its minimal units. */
13
+ readonly maxAmountRaw: string;
14
+ }
15
+ /** LinkedRequestGrantEnrollment is what the person sets and confirms with the passkey. */
16
+ export interface LinkedRequestGrantEnrollment {
17
+ readonly ownerId: string;
18
+ readonly servicePrincipalId: string;
19
+ readonly linkedWalletId: string;
20
+ /** The network the transfers are made on: the wallet's own, or for an EVM wallet another EVM network. */
21
+ readonly network: string;
22
+ readonly assets: readonly LinkedAssetCeiling[];
23
+ /** Zero to ten recipients; none means any recipient. */
24
+ readonly recipientAllowlist: readonly string[];
25
+ /** The most the network fee may be, in minimal units of the network's own coin. */
26
+ readonly maxFeeRaw: string;
27
+ }
28
+ /** The closed session.authorize_requests OperationCreate of a linked wallet. */
29
+ export interface LinkedRequestGrantEnrollmentCreate {
30
+ readonly kind: "session.authorize_requests";
31
+ readonly owner_type: "account";
32
+ readonly owner_id: string;
33
+ /** Who may read the grant; absent, the account itself. */
34
+ readonly result_recipient?: {
35
+ readonly type: "account" | "service";
36
+ readonly id: string;
37
+ };
38
+ readonly payload: {
39
+ readonly public_jwk: {
40
+ readonly kty: "EC";
41
+ readonly crv: "P-256";
42
+ readonly x: string;
43
+ readonly y: string;
44
+ };
45
+ readonly service_principal_id: string;
46
+ readonly linked_wallet_id: string;
47
+ readonly network: string;
48
+ readonly assets: readonly {
49
+ readonly asset: LinkedModeCAsset;
50
+ readonly max_amount_raw: string;
51
+ }[];
52
+ readonly recipient_allowlist: readonly string[];
53
+ readonly max_fee_raw: string;
54
+ };
55
+ }
56
+ /** The enrollment as the person confirms it, read back with the operation. */
57
+ export interface LinkedEnrollmentDisplay {
58
+ kind: "session.authorize_requests";
59
+ ownerId: string;
60
+ requesterId: string;
61
+ servicePrincipalId: string;
62
+ linkedWalletId: string;
63
+ address: string;
64
+ network: string;
65
+ assets: readonly LinkedAssetCeiling[];
66
+ recipientAllowlist: readonly string[];
67
+ maxFeeRaw: string;
68
+ publicKeyThumbprint: string;
69
+ }
70
+ /** The grant the enrollment issued: its identity, binding and bounds. Times are NumericDate seconds. */
71
+ export interface LinkedRequestGrantResult {
72
+ readonly requestGrantId: string;
73
+ readonly publicKeyThumbprint: string;
74
+ readonly serverTime: number;
75
+ readonly expiresAt: number;
76
+ readonly binding: {
77
+ readonly tenantId: string;
78
+ readonly applicationId: string;
79
+ readonly environmentId: string;
80
+ readonly clientId: string;
81
+ readonly accountId: string;
82
+ readonly sessionId: string;
83
+ readonly servicePrincipalId: string;
84
+ readonly linkedWalletId: string;
85
+ readonly bindingRevision: string;
86
+ };
87
+ readonly bounds: {
88
+ readonly network: string;
89
+ readonly recipientAllowlist: readonly string[];
90
+ readonly assets: readonly LinkedAssetCeiling[];
91
+ readonly maxFeeRaw: string;
92
+ };
93
+ }
94
+ /** One linked grant beside the exact key that signs under it. */
95
+ export interface PairedLinkedRequestGrant {
96
+ readonly grant: LinkedRequestGrantResult;
97
+ readonly key: RequestSigningKey;
98
+ }
99
+ /**
100
+ * canonicalAddress is a recipient in the form the network compares addresses
101
+ * by, as the provider keeps it: an EVM address in lower case, a TON address
102
+ * raw (workchain:hash in lower-case hex, from the user-friendly form when its
103
+ * checksum holds), anything else as given. An address that is not well formed
104
+ * for its network is returned unchanged, as the provider does.
105
+ */
106
+ export declare function canonicalAddress(network: string, address: string): string;
107
+ /** sameAddress compares two recipients as the network compares addresses. */
108
+ export declare function sameAddress(network: string, a: string, b: string): boolean;
109
+ /** buildLinkedRequestGrantEnrollment checks every bound locally and returns the closed create body. */
110
+ export declare function buildLinkedRequestGrantEnrollment(key: RequestSigningKey, enrollment: LinkedRequestGrantEnrollment): LinkedRequestGrantEnrollmentCreate;
111
+ /** validateLinkedEnrollmentCreate is the client's createOperation check of the body above. */
112
+ export declare function validateLinkedEnrollmentCreate(value: unknown, expectedOwnerId: string): void;
113
+ /** parseLinkedEnrollmentDisplay reads the enrollment display of a linked wallet. */
114
+ export declare function parseLinkedEnrollmentDisplay(value: unknown): LinkedEnrollmentDisplay;
115
+ /** parseLinkedRequestGrantResult accepts exactly the protected result of a linked grant. */
116
+ export declare function parseLinkedRequestGrantResult(payload: unknown): LinkedRequestGrantResult;
117
+ /**
118
+ * pairLinkedRequestGrant keeps a grant beside the key that enrolled it, and
119
+ * refuses one that is not this key's grant on these bounds: another key, owner,
120
+ * service, wallet, network, asset ceiling, fee ceiling or recipient list.
121
+ */
122
+ export declare function pairLinkedRequestGrant(key: RequestSigningKey, grant: LinkedRequestGrantResult, enrollment: LinkedRequestGrantEnrollment): PairedLinkedRequestGrant;
123
+ /** What the person chooses for one transfer. A comment is carried on TON only. */
124
+ export interface LinkedTransferIntent {
125
+ readonly asset: LinkedModeCAsset;
126
+ readonly recipient: string;
127
+ readonly amountRaw: string;
128
+ readonly comment?: string;
129
+ }
130
+ /** The closed OperationCreate a linked transfer's request hash covers. */
131
+ export interface LinkedMediatedOperationCreate {
132
+ readonly kind: "wallet.send";
133
+ readonly owner_type: "account";
134
+ readonly owner_id: string;
135
+ readonly linked_wallet_id: string;
136
+ readonly payload: {
137
+ readonly network: string;
138
+ readonly asset: LinkedModeCAsset;
139
+ readonly recipient: string;
140
+ readonly amount_raw: string;
141
+ readonly comment?: string;
142
+ };
143
+ }
144
+ /** The transfer exactly as the app shows it before signing. */
145
+ export interface LinkedTransferDisplay {
146
+ readonly ownerId: string;
147
+ readonly linkedWalletId: string;
148
+ readonly servicePrincipalId: string;
149
+ readonly network: string;
150
+ readonly asset: LinkedModeCAsset;
151
+ readonly recipient: string;
152
+ readonly amountRaw: string;
153
+ readonly comment?: string;
154
+ readonly maxFeeRaw: string;
155
+ }
156
+ export interface PreparedLinkedTransfer {
157
+ readonly display: LinkedTransferDisplay;
158
+ readonly operation: LinkedMediatedOperationCreate;
159
+ }
160
+ /**
161
+ * prepareLinkedMediatedTransfer derives the display and the closed body from
162
+ * the grant and the person's choice, and refuses before any signature what the
163
+ * grant does not cover: another asset, an amount over its ceiling, a recipient
164
+ * off a non-empty list, a comment off TON.
165
+ */
166
+ export declare function prepareLinkedMediatedTransfer(paired: PairedLinkedRequestGrant, intent: LinkedTransferIntent): PreparedLinkedTransfer;
167
+ /** canonicalLinkedMediatedRequest is the RFC 8785 serialization the request hash covers. */
168
+ export declare function canonicalLinkedMediatedRequest(operation: LinkedMediatedOperationCreate): string;
169
+ /** The device share as the request carries it, snake_case. */
170
+ export interface WireDeviceShare {
171
+ readonly ciphertext: string;
172
+ readonly nonce: string;
173
+ readonly ephemeral_public_key: string;
174
+ readonly encryption_algorithm: string;
175
+ readonly encryption_context: string;
176
+ }
177
+ export declare function wireDeviceShare(share: DeviceSharePackage): WireDeviceShare;
178
+ /** deviceShareDigest is the lowercase hex SHA-256 of the RFC 8785 JSON of the share as the request carries it. */
179
+ export declare function deviceShareDigest(crypto: Crypto, share: WireDeviceShare): Promise<string>;
180
+ /** The request the backend forwards, unchanged, with Idempotency-Key = idempotencyKey. */
181
+ export interface LinkedMediatedRequest {
182
+ readonly operation: LinkedMediatedOperationCreate;
183
+ readonly user_proof: string;
184
+ readonly device_share: WireDeviceShare;
185
+ }
186
+ export interface SignedLinkedMediatedRequest {
187
+ readonly display: LinkedTransferDisplay;
188
+ readonly request: LinkedMediatedRequest;
189
+ readonly idempotencyKey: string;
190
+ readonly requestHash: string;
191
+ readonly issuedAt: number;
192
+ readonly expiresAt: number;
193
+ }
194
+ /**
195
+ * signLinkedMediatedRequest signs the shown transfer and the device share with
196
+ * the enrolled key as the 2.0 proof. The share is the one sealed to the Wallet
197
+ * for this transfer (resealDeviceShare); its digest is signed, so the backend
198
+ * can neither swap it nor change the transfer. The request lives at most 60
199
+ * seconds and never past the grant.
200
+ */
201
+ export declare function signLinkedMediatedRequest(input: {
202
+ grant: PairedLinkedRequestGrant;
203
+ prepared: PreparedLinkedTransfer;
204
+ deviceShare: DeviceSharePackage;
205
+ crypto: Crypto;
206
+ serverNowSeconds: number;
207
+ }): Promise<SignedLinkedMediatedRequest>;
@@ -0,0 +1,270 @@
1
+ // Mode C on the person's own linked wallet (owner decision, 14 October 2026).
2
+ //
3
+ // The person enrolls, once and with a passkey, a request key bounded to one of
4
+ // their linked wallets: the network the transfers are made on, a ceiling for
5
+ // one transfer of each asset, a ceiling on the network fee and the recipients
6
+ // (none: any). Each later transfer goes with no passkey: the app shows the
7
+ // transfer, signs it and the device share it sealed for the Wallet with the
8
+ // enrolled key (proof 2.0), and hands the request to its own backend, which
9
+ // forwards it unchanged with its service key. The service key never reaches
10
+ // this code, and nothing here sends the request: forwarding belongs to the
11
+ // backend. Every locally detectable fault is refused before the signing key
12
+ // is touched.
13
+ import { ClientFlowError, base64url, canonicalDecimal, integer, opaqueId, record } from "./auth.js";
14
+ import { canonicalJSON, canonicalJSONBytes, sha256HexLower } from "./canonicalize.js";
15
+ import { MaxModeCProofLifetimeSeconds, ModeCJtiBytes, ModeCProofAudience, ModeCProofKind, modeCProtectedHeader } from "./modeC.js";
16
+ import { MaxRequestGrantLifetimeSeconds, MaxRequestGrantRecipients } from "./requestGrant.js";
17
+ export { ModeCServerClock, MaxModeCProofLifetimeSeconds } from "./modeC.js";
18
+ export { generateRequestSigningKey } from "./requestGrant.js";
19
+ /** ModeCLinkedProofVersion is the proof of a transfer from a linked wallet. */
20
+ export const ModeCLinkedProofVersion = "2.0";
21
+ /** The assets a linked grant may name. */
22
+ export const LinkedModeCAssets = ["native", "usdt", "usdc"];
23
+ const maxUint256 = (1n << 256n) - 1n;
24
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
25
+ const linkedNetwork = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
26
+ const printable = /^[\x21-\x7e]{1,256}$/;
27
+ const grantToken = /^[A-Za-z0-9_-]{43}$/;
28
+ const fault = (code) => { throw new ClientFlowError(code); };
29
+ function quantity(value, positive) {
30
+ return canonicalDecimal(value) && BigInt(value) <= maxUint256 && (!positive || BigInt(value) > 0n);
31
+ }
32
+ /**
33
+ * canonicalAddress is a recipient in the form the network compares addresses
34
+ * by, as the provider keeps it: an EVM address in lower case, a TON address
35
+ * raw (workchain:hash in lower-case hex, from the user-friendly form when its
36
+ * checksum holds), anything else as given. An address that is not well formed
37
+ * for its network is returned unchanged, as the provider does.
38
+ */
39
+ export function canonicalAddress(network, address) {
40
+ if (network.startsWith("ton:"))
41
+ return canonicalTONAddress(network, address);
42
+ if (!network.startsWith("eip155:") || !/^0[xX][0-9a-fA-F]{40}$/.test(address))
43
+ return address;
44
+ return "0x" + address.slice(2).toLowerCase();
45
+ }
46
+ function canonicalTONAddress(network, address) {
47
+ const raw = /^(0|-1):([0-9a-fA-F]{64})$/.exec(address);
48
+ if (raw)
49
+ return `${raw[1]}:${raw[2].toLowerCase()}`;
50
+ if (address.includes(":") || address.length !== 48 || !/^[A-Za-z0-9+/_-]{48}$/.test(address))
51
+ return address;
52
+ const bytes = Uint8Array.from(atob(address.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
53
+ if (bytes.length !== 36 || (network === "ton:mainnet" && (bytes[0] & 0x80) !== 0) || ((bytes[0] & 0x7f) !== 0x11 && (bytes[0] & 0x7f) !== 0x51))
54
+ return address;
55
+ let crc = 0;
56
+ for (const byte of bytes.subarray(0, 34)) {
57
+ crc ^= byte << 8;
58
+ for (let bit = 0; bit < 8; bit++)
59
+ crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
60
+ }
61
+ if (((bytes[34] << 8) | bytes[35]) !== crc)
62
+ return address;
63
+ const workchain = bytes[1] > 127 ? bytes[1] - 256 : bytes[1];
64
+ return `${workchain}:${Array.from(bytes.subarray(2, 34), (b) => b.toString(16).padStart(2, "0")).join("")}`;
65
+ }
66
+ /** sameAddress compares two recipients as the network compares addresses. */
67
+ export function sameAddress(network, a, b) {
68
+ return canonicalAddress(network, a) === canonicalAddress(network, b);
69
+ }
70
+ function assets(value, code) {
71
+ if (!Array.isArray(value) || value.length < 1 || value.length > LinkedModeCAssets.length)
72
+ fault(code);
73
+ const out = [];
74
+ for (const item of value) {
75
+ const r = record(item, ["asset", "max_amount_raw"]);
76
+ if (!LinkedModeCAssets.includes(r.asset) || !quantity(r.max_amount_raw, true) || out.some((a) => a.asset === r.asset))
77
+ fault(code);
78
+ out.push(Object.freeze({ asset: r.asset, maxAmountRaw: r.max_amount_raw }));
79
+ }
80
+ return out;
81
+ }
82
+ function recipients(network, value, code) {
83
+ if (!Array.isArray(value) || value.length > MaxRequestGrantRecipients)
84
+ fault(code);
85
+ const out = [];
86
+ for (const item of value) {
87
+ if (typeof item !== "string" || !printable.test(item) || out.some((seen) => sameAddress(network, seen, item)))
88
+ fault(code);
89
+ out.push(canonicalAddress(network, item));
90
+ }
91
+ return out;
92
+ }
93
+ /** buildLinkedRequestGrantEnrollment checks every bound locally and returns the closed create body. */
94
+ export function buildLinkedRequestGrantEnrollment(key, enrollment) {
95
+ const code = "invalid_request_grant_enrollment";
96
+ const e = record(enrollment, ["ownerId", "servicePrincipalId", "linkedWalletId", "network", "assets", "recipientAllowlist", "maxFeeRaw"]);
97
+ if (!opaqueId(e.ownerId) || !opaqueId(e.servicePrincipalId) || typeof e.linkedWalletId !== "string" || !uuid.test(e.linkedWalletId) ||
98
+ typeof e.network !== "string" || !linkedNetwork.test(e.network) || !quantity(e.maxFeeRaw, false) || !key?.publicJwk)
99
+ fault(code);
100
+ const network = e.network;
101
+ const ceilings = assets(e.assets?.map((a) => ({ asset: a?.asset, max_amount_raw: a?.maxAmountRaw })), code);
102
+ return {
103
+ kind: "session.authorize_requests", owner_type: "account", owner_id: e.ownerId,
104
+ payload: {
105
+ public_jwk: { kty: key.publicJwk.kty, crv: key.publicJwk.crv, x: key.publicJwk.x, y: key.publicJwk.y },
106
+ service_principal_id: e.servicePrincipalId, linked_wallet_id: e.linkedWalletId, network,
107
+ assets: ceilings.map((a) => ({ asset: a.asset, max_amount_raw: a.maxAmountRaw })),
108
+ recipient_allowlist: recipients(network, e.recipientAllowlist, code), max_fee_raw: e.maxFeeRaw,
109
+ },
110
+ };
111
+ }
112
+ /** validateLinkedEnrollmentCreate is the client's createOperation check of the body above. */
113
+ export function validateLinkedEnrollmentCreate(value, expectedOwnerId) {
114
+ const code = "invalid_operation_create";
115
+ const create = record(value, ["kind", "owner_type", "owner_id", "payload"], ["result_recipient"]);
116
+ const p = record(create.payload, ["public_jwk", "service_principal_id", "linked_wallet_id", "network", "assets", "recipient_allowlist", "max_fee_raw"]);
117
+ const jwk = record(p.public_jwk, ["kty", "crv", "x", "y"]);
118
+ if (create.kind !== "session.authorize_requests" || create.owner_type !== "account" || create.owner_id !== expectedOwnerId || !opaqueId(create.owner_id) ||
119
+ jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || !grantToken.test(jwk.x) || typeof jwk.y !== "string" || !grantToken.test(jwk.y) ||
120
+ !opaqueId(p.service_principal_id) || typeof p.linked_wallet_id !== "string" || !uuid.test(p.linked_wallet_id) || typeof p.network !== "string" ||
121
+ !linkedNetwork.test(p.network) || !quantity(p.max_fee_raw, false))
122
+ fault(code);
123
+ assets(p.assets, code);
124
+ recipients(p.network, p.recipient_allowlist, code);
125
+ }
126
+ /** parseLinkedEnrollmentDisplay reads the enrollment display of a linked wallet. */
127
+ export function parseLinkedEnrollmentDisplay(value) {
128
+ const code = "invalid_server_response";
129
+ const d = record(value, ["address", "assets", "kind", "linked_wallet_id", "max_fee_raw", "network", "owner_id", "public_key_thumbprint", "recipient_allowlist", "requester_id", "service_principal_id"]);
130
+ if (d.kind !== "session.authorize_requests" || !opaqueId(d.owner_id) || !opaqueId(d.requester_id) || !opaqueId(d.service_principal_id) ||
131
+ typeof d.linked_wallet_id !== "string" || !uuid.test(d.linked_wallet_id) || typeof d.address !== "string" || !printable.test(d.address) ||
132
+ typeof d.network !== "string" || !linkedNetwork.test(d.network) || !quantity(d.max_fee_raw, false) || typeof d.public_key_thumbprint !== "string" || !grantToken.test(d.public_key_thumbprint))
133
+ fault(code);
134
+ return {
135
+ kind: "session.authorize_requests", ownerId: d.owner_id, requesterId: d.requester_id, servicePrincipalId: d.service_principal_id,
136
+ linkedWalletId: d.linked_wallet_id, address: d.address, network: d.network, assets: assets(d.assets, code),
137
+ recipientAllowlist: recipients(d.network, d.recipient_allowlist, code), maxFeeRaw: d.max_fee_raw, publicKeyThumbprint: d.public_key_thumbprint,
138
+ };
139
+ }
140
+ /** parseLinkedRequestGrantResult accepts exactly the protected result of a linked grant. */
141
+ export function parseLinkedRequestGrantResult(payload) {
142
+ const code = "invalid_request_grant_result";
143
+ const root = record(payload, ["request_grant_id", "public_key_thumbprint", "server_time", "expires_at", "binding", "bounds"]);
144
+ if (typeof root.request_grant_id !== "string" || !grantToken.test(root.request_grant_id) || typeof root.public_key_thumbprint !== "string" ||
145
+ !grantToken.test(root.public_key_thumbprint) || !integer(root.server_time) || !integer(root.expires_at) || root.expires_at <= root.server_time ||
146
+ root.expires_at - root.server_time > MaxRequestGrantLifetimeSeconds)
147
+ fault(code);
148
+ const b = record(root.binding, ["tenant_id", "application_id", "environment_id", "client_id", "account_id", "session_id", "service_principal_id", "linked_wallet_id", "binding_revision"]);
149
+ if (![b.tenant_id, b.application_id, b.environment_id, b.client_id, b.account_id, b.session_id, b.service_principal_id].every(opaqueId) ||
150
+ typeof b.linked_wallet_id !== "string" || !uuid.test(b.linked_wallet_id) || !canonicalDecimal(b.binding_revision) || b.binding_revision === "0")
151
+ fault(code);
152
+ const bounds = record(root.bounds, ["network", "recipient_allowlist", "assets", "max_fee_raw"]);
153
+ if (typeof bounds.network !== "string" || !linkedNetwork.test(bounds.network) || !quantity(bounds.max_fee_raw, false))
154
+ fault(code);
155
+ return Object.freeze({
156
+ requestGrantId: root.request_grant_id, publicKeyThumbprint: root.public_key_thumbprint,
157
+ serverTime: root.server_time, expiresAt: root.expires_at,
158
+ binding: Object.freeze({ tenantId: b.tenant_id, applicationId: b.application_id, environmentId: b.environment_id,
159
+ clientId: b.client_id, accountId: b.account_id, sessionId: b.session_id, servicePrincipalId: b.service_principal_id,
160
+ linkedWalletId: b.linked_wallet_id, bindingRevision: b.binding_revision }),
161
+ bounds: Object.freeze({ network: bounds.network, recipientAllowlist: Object.freeze(recipients(bounds.network, bounds.recipient_allowlist, code)),
162
+ assets: Object.freeze(assets(bounds.assets, code)), maxFeeRaw: bounds.max_fee_raw }),
163
+ });
164
+ }
165
+ /**
166
+ * pairLinkedRequestGrant keeps a grant beside the key that enrolled it, and
167
+ * refuses one that is not this key's grant on these bounds: another key, owner,
168
+ * service, wallet, network, asset ceiling, fee ceiling or recipient list.
169
+ */
170
+ export function pairLinkedRequestGrant(key, grant, enrollment) {
171
+ const mismatch = () => fault("request_grant_mismatch");
172
+ const b = grant.binding, n = grant.bounds;
173
+ if (grant.publicKeyThumbprint !== key.thumbprint || b.accountId !== enrollment.ownerId || b.servicePrincipalId !== enrollment.servicePrincipalId ||
174
+ b.linkedWalletId !== enrollment.linkedWalletId || n.network !== enrollment.network || n.maxFeeRaw !== enrollment.maxFeeRaw ||
175
+ n.assets.length !== enrollment.assets.length || n.assets.some((a, i) => a.asset !== enrollment.assets[i].asset || a.maxAmountRaw !== enrollment.assets[i].maxAmountRaw) ||
176
+ n.recipientAllowlist.length !== enrollment.recipientAllowlist.length ||
177
+ n.recipientAllowlist.some((r, i) => !sameAddress(n.network, r, enrollment.recipientAllowlist[i])))
178
+ mismatch();
179
+ return Object.freeze({ grant, key });
180
+ }
181
+ /**
182
+ * prepareLinkedMediatedTransfer derives the display and the closed body from
183
+ * the grant and the person's choice, and refuses before any signature what the
184
+ * grant does not cover: another asset, an amount over its ceiling, a recipient
185
+ * off a non-empty list, a comment off TON.
186
+ */
187
+ export function prepareLinkedMediatedTransfer(paired, intent) {
188
+ const code = "invalid_transfer_intent";
189
+ const i = record(intent, ["asset", "recipient", "amountRaw"], ["comment"]);
190
+ const { grant } = paired;
191
+ const network = grant.bounds.network;
192
+ const ceiling = grant.bounds.assets.find((a) => a.asset === i.asset);
193
+ if (!ceiling || typeof i.recipient !== "string" || !printable.test(i.recipient) || !quantity(i.amountRaw, true) || BigInt(i.amountRaw) > BigInt(ceiling.maxAmountRaw))
194
+ fault(code);
195
+ const allowed = grant.bounds.recipientAllowlist;
196
+ if (allowed.length !== 0 && !allowed.some((r) => sameAddress(network, r, i.recipient)))
197
+ fault(code);
198
+ if (i.comment !== undefined && (typeof i.comment !== "string" || i.comment === "" || new TextEncoder().encode(i.comment).length > 120 || !network.startsWith("ton:")))
199
+ fault(code);
200
+ const payload = { network, asset: ceiling.asset, recipient: i.recipient, amount_raw: i.amountRaw, ...(i.comment !== undefined ? { comment: i.comment } : {}) };
201
+ const b = grant.binding;
202
+ return Object.freeze({
203
+ display: Object.freeze({ ownerId: b.accountId, linkedWalletId: b.linkedWalletId, servicePrincipalId: b.servicePrincipalId, network, asset: payload.asset,
204
+ recipient: payload.recipient, amountRaw: payload.amount_raw, ...(payload.comment !== undefined ? { comment: payload.comment } : {}), maxFeeRaw: grant.bounds.maxFeeRaw }),
205
+ operation: Object.freeze({ kind: "wallet.send", owner_type: "account", owner_id: b.accountId, linked_wallet_id: b.linkedWalletId, payload: Object.freeze(payload) }),
206
+ });
207
+ }
208
+ /** canonicalLinkedMediatedRequest is the RFC 8785 serialization the request hash covers. */
209
+ export function canonicalLinkedMediatedRequest(operation) {
210
+ const o = record(operation, ["kind", "owner_type", "owner_id", "linked_wallet_id", "payload"]);
211
+ const p = record(o.payload, ["network", "asset", "recipient", "amount_raw"], ["comment"]);
212
+ const payload = { network: p.network, asset: p.asset, recipient: p.recipient, amount_raw: p.amount_raw };
213
+ if (p.comment !== undefined)
214
+ payload.comment = p.comment;
215
+ return canonicalJSON({ kind: o.kind, owner_type: o.owner_type, owner_id: o.owner_id, linked_wallet_id: o.linked_wallet_id, payload });
216
+ }
217
+ export function wireDeviceShare(share) {
218
+ const s = record(share, ["ciphertext", "nonce", "ephemeralPublicKey", "encryptionAlgorithm", "encryptionContext"]);
219
+ if (![s.ciphertext, s.nonce, s.ephemeralPublicKey, s.encryptionAlgorithm, s.encryptionContext].every((v) => typeof v === "string" && v !== ""))
220
+ fault("device_share_unavailable");
221
+ return Object.freeze({ ciphertext: s.ciphertext, nonce: s.nonce, ephemeral_public_key: s.ephemeralPublicKey,
222
+ encryption_algorithm: s.encryptionAlgorithm, encryption_context: s.encryptionContext });
223
+ }
224
+ /** deviceShareDigest is the lowercase hex SHA-256 of the RFC 8785 JSON of the share as the request carries it. */
225
+ export function deviceShareDigest(crypto, share) {
226
+ return sha256HexLower(crypto, canonicalJSONBytes({ ...share }));
227
+ }
228
+ /**
229
+ * signLinkedMediatedRequest signs the shown transfer and the device share with
230
+ * the enrolled key as the 2.0 proof. The share is the one sealed to the Wallet
231
+ * for this transfer (resealDeviceShare); its digest is signed, so the backend
232
+ * can neither swap it nor change the transfer. The request lives at most 60
233
+ * seconds and never past the grant.
234
+ */
235
+ export async function signLinkedMediatedRequest(input) {
236
+ const { grant, prepared, crypto, serverNowSeconds } = record(input, ["grant", "prepared", "deviceShare", "crypto", "serverNowSeconds"]);
237
+ if (!grant?.grant || !grant.key?.privateKey || !prepared?.operation || !prepared.display)
238
+ fault("opaque_signing_input_refused");
239
+ if (!crypto?.subtle || typeof crypto.getRandomValues !== "function")
240
+ fault("runtime_capability_unavailable");
241
+ const again = prepareLinkedMediatedTransfer(grant, { asset: prepared.display.asset, recipient: prepared.display.recipient, amountRaw: prepared.display.amountRaw,
242
+ ...(prepared.display.comment !== undefined ? { comment: prepared.display.comment } : {}) });
243
+ // Everything the person was shown is what the grant names and what is signed.
244
+ const shown = record(prepared.display, ["ownerId", "linkedWalletId", "servicePrincipalId", "network", "asset", "recipient", "amountRaw", "maxFeeRaw"], ["comment"]);
245
+ const members = new Set([...Object.keys(shown), ...Object.keys(again.display)]);
246
+ if ([...members].some((m) => shown[m] !== again.display[m]) ||
247
+ canonicalLinkedMediatedRequest(again.operation) !== canonicalLinkedMediatedRequest(prepared.operation))
248
+ fault("display_intent_mismatch");
249
+ if (!integer(serverNowSeconds) || serverNowSeconds <= 0 || serverNowSeconds >= grant.grant.expiresAt)
250
+ fault("request_grant_reenrollment_required");
251
+ const share = wireDeviceShare(input.deviceShare);
252
+ const issuedAt = serverNowSeconds, expiresAt = Math.min(issuedAt + MaxModeCProofLifetimeSeconds, grant.grant.expiresAt);
253
+ const requestHash = await sha256HexLower(crypto, new TextEncoder().encode(canonicalLinkedMediatedRequest(prepared.operation)));
254
+ const jti = base64url(crypto.getRandomValues(new Uint8Array(ModeCJtiBytes)));
255
+ const b = grant.grant.binding;
256
+ const payload = canonicalJSONBytes({
257
+ version: ModeCLinkedProofVersion, aud: ModeCProofAudience, tenant_id: b.tenantId, application_id: b.applicationId, environment_id: b.environmentId,
258
+ client_id: b.clientId, account_id: b.accountId, session_id: b.sessionId, service_principal_id: b.servicePrincipalId,
259
+ linked_wallet_id: b.linkedWalletId, binding_revision: b.bindingRevision, device_share_digest: await deviceShareDigest(crypto, share),
260
+ kind: ModeCProofKind, request_hash: requestHash, jti, iat: issuedAt, exp: expiresAt,
261
+ });
262
+ const signingInput = `${base64url(new TextEncoder().encode(modeCProtectedHeader(grant.grant.requestGrantId)))}.${base64url(payload)}`;
263
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: { name: "SHA-256" } }, grant.key.privateKey, new TextEncoder().encode(signingInput)));
264
+ if (signature.length !== 64)
265
+ fault("invalid_request_signature");
266
+ return Object.freeze({
267
+ display: prepared.display, idempotencyKey: jti, requestHash, issuedAt, expiresAt,
268
+ request: Object.freeze({ operation: prepared.operation, user_proof: `${signingInput}.${base64url(signature)}`, device_share: share }),
269
+ });
270
+ }
@@ -0,0 +1,22 @@
1
+ export interface PageOptions {
2
+ limit?: number;
3
+ cursor?: string;
4
+ signal?: AbortSignal;
5
+ }
6
+ export type PageEnvelope = {
7
+ request_id: string;
8
+ items: unknown[];
9
+ has_more: boolean;
10
+ next_cursor?: string;
11
+ };
12
+ export declare function pageRequest(path: string, input?: AbortSignal | PageOptions): {
13
+ path: string;
14
+ signal?: AbortSignal;
15
+ };
16
+ export declare function pageEnvelope(payload: unknown): PageEnvelope;
17
+ export declare function pageProjection<T>(requestId: string, items: T[], hasMore: boolean, nextCursor?: string): {
18
+ requestId: string;
19
+ items: T[];
20
+ hasMore: boolean;
21
+ nextCursor?: string;
22
+ };
package/dist/pages.js ADDED
@@ -0,0 +1,45 @@
1
+ // Paging of the account lists: the closed request options and the envelope
2
+ // every page arrives in.
3
+ import { ClientFlowError, integer, opaqueId, record } from "./auth.js";
4
+ export function pageRequest(path, input) {
5
+ if (isAbortSignal(input))
6
+ return { path, signal: input };
7
+ const options = input ?? {};
8
+ if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => !["limit", "cursor", "signal"].includes(key))) {
9
+ throw new ClientFlowError("invalid_page_request");
10
+ }
11
+ if (options.signal !== undefined && !isAbortSignal(options.signal))
12
+ throw new ClientFlowError("invalid_page_request");
13
+ const query = new URLSearchParams();
14
+ if (options.limit !== undefined) {
15
+ if (!integer(options.limit) || options.limit < 1 || options.limit > 100)
16
+ throw new ClientFlowError("invalid_page_request");
17
+ query.set("limit", String(options.limit));
18
+ }
19
+ if (options.cursor !== undefined) {
20
+ if (typeof options.cursor !== "string" || options.cursor.length === 0)
21
+ throw new ClientFlowError("invalid_page_request");
22
+ query.set("cursor", options.cursor);
23
+ }
24
+ return { path: query.size === 0 ? path : `${path}?${query.toString()}`, signal: options.signal };
25
+ }
26
+ function isAbortSignal(value) {
27
+ return value !== null && typeof value === "object" && typeof value.aborted === "boolean" && typeof value.addEventListener === "function";
28
+ }
29
+ export function pageEnvelope(payload) {
30
+ const root = record(payload, ["request_id", "items", "has_more"], ["next_cursor"]);
31
+ if (!opaqueId(root.request_id) || typeof root.has_more !== "boolean" || !Array.isArray(root.items))
32
+ throw new ClientFlowError("invalid_server_response");
33
+ const hasCursor = Object.prototype.hasOwnProperty.call(root, "next_cursor");
34
+ if (root.has_more) {
35
+ if (!hasCursor || typeof root.next_cursor !== "string" || root.next_cursor.length === 0)
36
+ throw new ClientFlowError("invalid_server_response");
37
+ }
38
+ else if (hasCursor) {
39
+ throw new ClientFlowError("invalid_server_response");
40
+ }
41
+ return root;
42
+ }
43
+ export function pageProjection(requestId, items, hasMore, nextCursor) {
44
+ return hasMore ? { requestId, items, hasMore, nextCursor } : { requestId, items, hasMore };
45
+ }
package/dist/poll.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface PollOptions<T> {
2
+ read: (signal: AbortSignal) => Promise<T>;
3
+ terminal: (value: T) => boolean;
4
+ signal?: AbortSignal;
5
+ intervalMs?: number;
6
+ timeoutMs?: number;
7
+ now?: () => number;
8
+ sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
9
+ }
10
+ export type PollDisposition = "server_terminal" | "local_timeout" | "local_canceled";
11
+ export interface PollResult<T> {
12
+ lastKnown: T | undefined;
13
+ disposition: PollDisposition;
14
+ }
15
+ export declare function pollAuthoritative<T>(options: PollOptions<T>): Promise<PollResult<T>>;
package/dist/poll.js ADDED
@@ -0,0 +1,76 @@
1
+ import { ClientFlowError } from "./auth.js";
2
+ export async function pollAuthoritative(options) {
3
+ const intervalMs = options.intervalMs ?? 2_000;
4
+ const timeoutMs = options.timeoutMs ?? 60_000;
5
+ if (intervalMs < 2_000 || timeoutMs <= 0 || timeoutMs > 60_000)
6
+ throw new ClientFlowError("invalid_poll_bounds");
7
+ if (options.signal?.aborted)
8
+ return { lastKnown: undefined, disposition: "local_canceled" };
9
+ const now = options.now ?? Date.now;
10
+ const sleep = options.sleep ?? boundedSleep;
11
+ const deadline = now() + timeoutMs;
12
+ const boundary = new AbortController();
13
+ let timedOut = false;
14
+ const timeout = setTimeout(() => {
15
+ timedOut = true;
16
+ boundary.abort();
17
+ }, timeoutMs);
18
+ const cancel = () => boundary.abort();
19
+ options.signal?.addEventListener("abort", cancel, { once: true });
20
+ let lastKnown;
21
+ try {
22
+ while (true) {
23
+ if (options.signal?.aborted)
24
+ return { lastKnown, disposition: "local_canceled" };
25
+ if (timedOut || now() >= deadline)
26
+ return { lastKnown, disposition: "local_timeout" };
27
+ const observed = await raceBoundary(options.read(boundary.signal), boundary.signal);
28
+ if (!observed.completed)
29
+ return { lastKnown, disposition: timedOut ? "local_timeout" : "local_canceled" };
30
+ if (now() >= deadline)
31
+ return { lastKnown, disposition: "local_timeout" };
32
+ lastKnown = observed.value;
33
+ if (options.terminal(lastKnown))
34
+ return { lastKnown, disposition: "server_terminal" };
35
+ const remaining = deadline - now();
36
+ if (remaining <= 0)
37
+ return { lastKnown, disposition: "local_timeout" };
38
+ const waited = await raceBoundary(sleep(Math.min(intervalMs, remaining), boundary.signal), boundary.signal);
39
+ if (!waited.completed)
40
+ return { lastKnown, disposition: timedOut ? "local_timeout" : "local_canceled" };
41
+ }
42
+ }
43
+ finally {
44
+ clearTimeout(timeout);
45
+ options.signal?.removeEventListener("abort", cancel);
46
+ }
47
+ }
48
+ async function raceBoundary(operation, signal) {
49
+ if (signal.aborted)
50
+ return { completed: false };
51
+ return new Promise((resolve, reject) => {
52
+ const aborted = () => resolve({ completed: false });
53
+ signal.addEventListener("abort", aborted, { once: true });
54
+ operation.then((value) => {
55
+ signal.removeEventListener("abort", aborted);
56
+ resolve({ completed: true, value });
57
+ }, (error) => {
58
+ signal.removeEventListener("abort", aborted);
59
+ reject(error);
60
+ });
61
+ });
62
+ }
63
+ function boundedSleep(milliseconds, signal) {
64
+ return new Promise((resolve) => {
65
+ const timer = setTimeout(done, milliseconds);
66
+ const aborted = () => {
67
+ clearTimeout(timer);
68
+ done();
69
+ };
70
+ function done() {
71
+ signal.removeEventListener("abort", aborted);
72
+ resolve();
73
+ }
74
+ signal.addEventListener("abort", aborted, { once: true });
75
+ });
76
+ }