@defdone/annon 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CLI.md +378 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +7 -0
  5. package/README.md +591 -2
  6. package/dist/api/annon-types.d.ts +108 -0
  7. package/dist/api/annon-types.js +2 -0
  8. package/dist/api/annon.d.ts +117 -0
  9. package/dist/api/annon.js +1152 -0
  10. package/dist/api/hosted-record.d.ts +13 -0
  11. package/dist/api/hosted-record.js +149 -0
  12. package/dist/api/sharing-types.d.ts +109 -0
  13. package/dist/api/sharing-types.js +1 -0
  14. package/dist/api/sharing.d.ts +36 -0
  15. package/dist/api/sharing.js +879 -0
  16. package/dist/browser/download.d.ts +19 -0
  17. package/dist/browser/download.js +52 -0
  18. package/dist/browser/index.d.ts +23 -0
  19. package/dist/browser/index.js +22 -0
  20. package/dist/browser/login-bundle.js +151 -0
  21. package/dist/browser/login.d.ts +1 -0
  22. package/dist/browser/login.js +216 -0
  23. package/dist/browser/purchase-bundle.js +84 -0
  24. package/dist/browser/purchases.d.ts +98 -0
  25. package/dist/browser/purchases.js +735 -0
  26. package/dist/browser/record.d.ts +55 -0
  27. package/dist/browser/record.js +16 -0
  28. package/dist/browser/selling.d.ts +146 -0
  29. package/dist/browser/selling.js +418 -0
  30. package/dist/browser/wallets.d.ts +53 -0
  31. package/dist/browser/wallets.js +286 -0
  32. package/dist/bytes.d.ts +4 -0
  33. package/dist/bytes.js +16 -0
  34. package/dist/download/download.d.ts +59 -0
  35. package/dist/download/download.js +384 -0
  36. package/dist/download/host.d.ts +56 -0
  37. package/dist/download/host.js +161 -0
  38. package/dist/download/http.d.ts +23 -0
  39. package/dist/download/http.js +87 -0
  40. package/dist/download/proof.d.ts +20 -0
  41. package/dist/download/proof.js +79 -0
  42. package/dist/download/upload.d.ts +130 -0
  43. package/dist/download/upload.js +408 -0
  44. package/dist/envelope/evm.d.ts +4 -0
  45. package/dist/envelope/evm.js +32 -0
  46. package/dist/envelope/pay2.d.ts +176 -0
  47. package/dist/envelope/pay2.js +541 -0
  48. package/dist/envelope/replay.d.ts +45 -0
  49. package/dist/envelope/replay.js +186 -0
  50. package/dist/envelope/stream.d.ts +78 -0
  51. package/dist/envelope/stream.js +372 -0
  52. package/dist/errors.d.ts +21 -0
  53. package/dist/errors.js +300 -0
  54. package/dist/index.d.ts +25 -0
  55. package/dist/index.js +23 -0
  56. package/dist/keys/near-ckd.d.ts +47 -0
  57. package/dist/keys/near-ckd.js +96 -0
  58. package/dist/limits.d.ts +4 -0
  59. package/dist/limits.js +4 -0
  60. package/dist/near/nep413.d.ts +22 -0
  61. package/dist/near/nep413.js +39 -0
  62. package/dist/near/rpc.d.ts +170 -0
  63. package/dist/near/rpc.js +423 -0
  64. package/dist/near/texts.d.ts +39 -0
  65. package/dist/near/texts.js +60 -0
  66. package/dist/net/ip.d.ts +7 -0
  67. package/dist/net/ip.js +62 -0
  68. package/dist/net/retry.d.ts +3 -0
  69. package/dist/net/retry.js +31 -0
  70. package/dist/node/adapter-checks.d.ts +6 -0
  71. package/dist/node/adapter-checks.js +13 -0
  72. package/dist/node/adapter.d.ts +8 -0
  73. package/dist/node/adapter.js +98 -0
  74. package/dist/node/agent.d.ts +49 -0
  75. package/dist/node/agent.js +266 -0
  76. package/dist/node/amounts.d.ts +2 -0
  77. package/dist/node/amounts.js +20 -0
  78. package/dist/node/browser-login.d.ts +25 -0
  79. package/dist/node/browser-login.js +219 -0
  80. package/dist/node/budget.d.ts +24 -0
  81. package/dist/node/budget.js +59 -0
  82. package/dist/node/buy-commands.d.ts +24 -0
  83. package/dist/node/buy-commands.js +301 -0
  84. package/dist/node/cli.d.ts +60 -0
  85. package/dist/node/cli.js +201 -0
  86. package/dist/node/command-context.d.ts +82 -0
  87. package/dist/node/command-context.js +77 -0
  88. package/dist/node/config.d.ts +37 -0
  89. package/dist/node/config.js +189 -0
  90. package/dist/node/core-adapter.d.ts +43 -0
  91. package/dist/node/core-adapter.js +122 -0
  92. package/dist/node/errors.d.ts +8 -0
  93. package/dist/node/errors.js +12 -0
  94. package/dist/node/index.d.ts +7 -0
  95. package/dist/node/index.js +5 -0
  96. package/dist/node/io.d.ts +13 -0
  97. package/dist/node/io.js +112 -0
  98. package/dist/node/keychain.d.ts +10 -0
  99. package/dist/node/keychain.js +67 -0
  100. package/dist/node/main.d.ts +2 -0
  101. package/dist/node/main.js +21 -0
  102. package/dist/node/offers.d.ts +16 -0
  103. package/dist/node/offers.js +91 -0
  104. package/dist/node/origin.d.ts +11 -0
  105. package/dist/node/origin.js +80 -0
  106. package/dist/node/paths.d.ts +3 -0
  107. package/dist/node/paths.js +71 -0
  108. package/dist/node/payment-terms.d.ts +26 -0
  109. package/dist/node/payment-terms.js +127 -0
  110. package/dist/node/purchase-commands.d.ts +16 -0
  111. package/dist/node/purchase-commands.js +237 -0
  112. package/dist/node/purchase-guard.d.ts +19 -0
  113. package/dist/node/purchase-guard.js +53 -0
  114. package/dist/node/relay-transport.d.ts +6 -0
  115. package/dist/node/relay-transport.js +173 -0
  116. package/dist/node/resources.d.ts +2 -0
  117. package/dist/node/resources.js +26 -0
  118. package/dist/node/retry-gate.d.ts +15 -0
  119. package/dist/node/retry-gate.js +53 -0
  120. package/dist/node/seller-commands.d.ts +12 -0
  121. package/dist/node/seller-commands.js +207 -0
  122. package/dist/node/sharing.d.ts +3 -0
  123. package/dist/node/sharing.js +28 -0
  124. package/dist/node/shops.d.ts +17 -0
  125. package/dist/node/shops.js +74 -0
  126. package/dist/node/signer-mock.d.ts +11 -0
  127. package/dist/node/signer-mock.js +79 -0
  128. package/dist/node/signer-wallet.d.ts +8 -0
  129. package/dist/node/signer-wallet.js +149 -0
  130. package/dist/node/signing.d.ts +29 -0
  131. package/dist/node/signing.js +200 -0
  132. package/dist/node/storage-commands.d.ts +11 -0
  133. package/dist/node/storage-commands.js +50 -0
  134. package/dist/node/sync-dir.d.ts +1 -0
  135. package/dist/node/sync-dir.js +11 -0
  136. package/dist/node/wallets.d.ts +13 -0
  137. package/dist/node/wallets.js +146 -0
  138. package/dist/oneclick/oneclick.d.ts +132 -0
  139. package/dist/oneclick/oneclick.js +342 -0
  140. package/dist/pay/backup.d.ts +70 -0
  141. package/dist/pay/backup.js +402 -0
  142. package/dist/pay/base.d.ts +135 -0
  143. package/dist/pay/base.js +386 -0
  144. package/dist/pay/eip3009.d.ts +59 -0
  145. package/dist/pay/eip3009.js +72 -0
  146. package/dist/pay/pay.d.ts +86 -0
  147. package/dist/pay/pay.js +297 -0
  148. package/dist/pay/purchase.d.ts +103 -0
  149. package/dist/pay/purchase.js +20 -0
  150. package/dist/pay/recover.d.ts +31 -0
  151. package/dist/pay/recover.js +34 -0
  152. package/dist/pay/seller.d.ts +130 -0
  153. package/dist/pay/seller.js +444 -0
  154. package/dist/pay/settle.d.ts +96 -0
  155. package/dist/pay/settle.js +308 -0
  156. package/dist/pay/split.d.ts +115 -0
  157. package/dist/pay/split.js +256 -0
  158. package/dist/pay/store-node.d.ts +8 -0
  159. package/dist/pay/store-node.js +224 -0
  160. package/dist/pay/store.d.ts +54 -0
  161. package/dist/pay/store.js +135 -0
  162. package/dist/pay/x402.d.ts +95 -0
  163. package/dist/pay/x402.js +136 -0
  164. package/dist/relay/admission.d.ts +8 -0
  165. package/dist/relay/admission.js +58 -0
  166. package/dist/relay/capabilities.d.ts +36 -0
  167. package/dist/relay/capabilities.js +232 -0
  168. package/dist/relay/fees.d.ts +17 -0
  169. package/dist/relay/fees.js +105 -0
  170. package/dist/relay/index.d.ts +10 -0
  171. package/dist/relay/index.js +8 -0
  172. package/dist/relay/keeper-balance.d.ts +17 -0
  173. package/dist/relay/keeper-balance.js +46 -0
  174. package/dist/relay/keeper-runner.d.ts +114 -0
  175. package/dist/relay/keeper-runner.js +703 -0
  176. package/dist/relay/keeper.d.ts +38 -0
  177. package/dist/relay/keeper.js +73 -0
  178. package/dist/relay/ledger.d.ts +155 -0
  179. package/dist/relay/ledger.js +392 -0
  180. package/dist/relay/relay-checks.d.ts +3 -0
  181. package/dist/relay/relay-checks.js +244 -0
  182. package/dist/relay/relay-reconciliation.d.ts +15 -0
  183. package/dist/relay/relay-reconciliation.js +293 -0
  184. package/dist/relay/relay-request.d.ts +6 -0
  185. package/dist/relay/relay-request.js +139 -0
  186. package/dist/relay/relay-transactions.d.ts +23 -0
  187. package/dist/relay/relay-transactions.js +182 -0
  188. package/dist/relay/relay-types.d.ts +128 -0
  189. package/dist/relay/relay-types.js +17 -0
  190. package/dist/relay/relay.d.ts +102 -0
  191. package/dist/relay/relay.js +426 -0
  192. package/dist/relay/reservation.d.ts +29 -0
  193. package/dist/relay/reservation.js +132 -0
  194. package/dist/relay/sponsorship.d.ts +42 -0
  195. package/dist/relay/sponsorship.js +184 -0
  196. package/dist/routes.d.ts +2 -0
  197. package/dist/routes.js +1 -0
  198. package/dist/settle-near/codec.d.ts +17 -0
  199. package/dist/settle-near/codec.js +86 -0
  200. package/dist/settle-near/identity.d.ts +27 -0
  201. package/dist/settle-near/identity.js +80 -0
  202. package/dist/settle-near/purchase.d.ts +40 -0
  203. package/dist/settle-near/purchase.js +153 -0
  204. package/dist/settle-near/terms.d.ts +37 -0
  205. package/dist/settle-near/terms.js +89 -0
  206. package/dist/shops/discovery.d.ts +95 -0
  207. package/dist/shops/discovery.js +384 -0
  208. package/dist/wallet/account-only.d.ts +11 -0
  209. package/dist/wallet/account-only.js +6 -0
  210. package/dist/wallet/uncertain.d.ts +8 -0
  211. package/dist/wallet/uncertain.js +13 -0
  212. package/dist/wallet/wallet.d.ts +100 -0
  213. package/dist/wallet/wallet.js +204 -0
  214. package/package.json +136 -3
@@ -0,0 +1,384 @@
1
+ /** Download a final purchase grant with Range retries and checked chunks. */
2
+ import { copy, equal, parseHeader, sha256, TAG } from '../envelope/pay2.js';
3
+ import { AnnonError } from '../errors.js';
4
+ import { HTTP_RETRY_MAX_WAIT_SECONDS, MAX_PAY2_BYTES } from '../limits.js';
5
+ import { parseRetryAfterMs, sleep } from '../net/retry.js';
6
+ import { BUYER_PROOF_HEADER, purchaseProof, sameResource } from './proof.js';
7
+ export class DownloadError extends AnnonError {
8
+ code;
9
+ retryAfterSeconds;
10
+ purchaseId;
11
+ fileId;
12
+ constructor(code, message = code) {
13
+ super(code, message);
14
+ this.code = code;
15
+ this.name = 'DownloadError';
16
+ }
17
+ }
18
+ const fail = (code, message) => {
19
+ throw new DownloadError(code, message);
20
+ };
21
+ /**
22
+ * Gated downloads go over HTTPS only: on plain HTTP anyone on the path reads the body or races the
23
+ * buyer with the intercepted authorization. Plain HTTP only for this machine, in development.
24
+ */
25
+ export function secureUrl(url) {
26
+ const u = new URL(url);
27
+ const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
28
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) {
29
+ throw new TypeError(`${url} must be https`);
30
+ }
31
+ return u;
32
+ }
33
+ const b64json = (s) => {
34
+ try {
35
+ return s === null
36
+ ? null
37
+ : JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(s), (c) => c.charCodeAt(0))));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ /**
44
+ * The file as a stream: the header first, then each chunk once its hash matches. Throws
45
+ * `DownloadError`; a broken connection is retried from the last checked chunk.
46
+ */
47
+ export function download(o) {
48
+ secureUrl(o.url);
49
+ const header = copy(o.header);
50
+ const h = parseHeader(header);
51
+ const lengths = Array.from({ length: h.n - 1 }, (_, i) => i === 0
52
+ ? h.metaLen + TAG
53
+ : i === 1 && h.previewLen > 0
54
+ ? h.previewLen + TAG
55
+ : 2 ** h.chunkLog2 + TAG);
56
+ const limit = Math.min(o.maxBytes ?? MAX_PAY2_BYTES, MAX_PAY2_BYTES);
57
+ const sizeCheck = (size) => {
58
+ if (size > limit)
59
+ fail('too_large', 'File exceeds the 50 MB size limit.');
60
+ };
61
+ sizeCheck(header.length + lengths.reduce((sum, n) => sum + n, 0) + TAG);
62
+ const maxLast = 2 ** h.chunkLog2 + TAG;
63
+ const fetcher = o.fetch ?? globalThis.fetch;
64
+ // Cancelling aborts any request in flight and stops before the next signature or request.
65
+ const abort = new AbortController();
66
+ const cancelExternal = () => {
67
+ abort.abort();
68
+ void activeReader?.cancel().catch(() => { });
69
+ };
70
+ if (o.signal?.aborted)
71
+ fail('cancelled');
72
+ const cleanup = () => o.signal?.removeEventListener('abort', cancelExternal);
73
+ const live = () => {
74
+ if (abort.signal.aborted)
75
+ fail('cancelled');
76
+ };
77
+ async function untilAbort(pending) {
78
+ let stop = () => { };
79
+ const cancelled = new Promise((_resolve, reject) => {
80
+ stop = () => reject(new DownloadError('cancelled'));
81
+ abort.signal.addEventListener('abort', stop, { once: true });
82
+ if (abort.signal.aborted)
83
+ stop();
84
+ });
85
+ try {
86
+ return await Promise.race([pending, cancelled]);
87
+ }
88
+ finally {
89
+ abort.signal.removeEventListener('abort', stop);
90
+ }
91
+ }
92
+ async function wait(ms) {
93
+ await untilAbort(o.wait ? o.wait(ms) : sleep(ms, abort.signal));
94
+ }
95
+ let waitedMs = 0;
96
+ let chunk = 0; // next chunk to check
97
+ let offset = header.length; // its first byte in the file
98
+ let sentHeader = false;
99
+ let retries = o.retries ?? 3;
100
+ // The buyer proof only for the URL the payment names: never for another host's.
101
+ // It signs the advertised `resource.url` octets, which the host checks, not the request's spelling.
102
+ const payment = o.payment;
103
+ let named = '';
104
+ if (payment) {
105
+ const u = b64json(payment.header)?.resource?.url;
106
+ if (typeof u !== 'string' || !sameResource(u, o.url)) {
107
+ throw new TypeError('the payment names another resource than the file URL');
108
+ }
109
+ named = u;
110
+ }
111
+ o.signal?.addEventListener('abort', cancelExternal, { once: true });
112
+ // Where the current response ends, when the host says (Content-Range or Content-Length).
113
+ let pos = 0;
114
+ let end;
115
+ const get = (headers, start) => {
116
+ live();
117
+ // No redirects: one could move the request to plain HTTP or another host.
118
+ // No cookies either: the grant proof travels in `Annon-Buyer-Proof` (x402-annon.md section 9).
119
+ return untilAbort(fetcher(o.url, {
120
+ redirect: 'error',
121
+ credentials: 'omit',
122
+ signal: abort.signal,
123
+ headers: { ...headers, range: `bytes=${start}-` },
124
+ }).then(async (response) => {
125
+ if (abort.signal.aborted)
126
+ await response.body?.cancel().catch(() => { });
127
+ return response;
128
+ }));
129
+ };
130
+ const body = (r) => r.body && (r.status === 200 || r.status === 206);
131
+ /** Every retry proves the same purchase grant to this URL. */
132
+ async function authorized() {
133
+ let start = offset;
134
+ const proof = purchaseProof(o.proof.secret, named || o.url, o.proof.purchase_id, o.proof.grant);
135
+ for (let i = 0;; i++) {
136
+ const r = await get({
137
+ [BUYER_PROOF_HEADER]: proof,
138
+ ...(payment ? { 'PAYMENT-SIGNATURE': payment.header } : {}),
139
+ }, start);
140
+ if (abort.signal.aborted) {
141
+ await r.body?.cancel().catch(() => { });
142
+ live();
143
+ }
144
+ if (body(r))
145
+ return { response: r, start };
146
+ if (r.status === 416 && start > header.length) {
147
+ await r.body?.cancel().catch(() => { });
148
+ start = header.length;
149
+ continue;
150
+ }
151
+ const pending = r.status === 503 || r.status === 402 || r.status === 429;
152
+ let code = r.status === 429 ? 'rate_limited' : r.status === 503 ? 'unavailable' : 'settlement_pending';
153
+ let seconds;
154
+ if ([429, 403, 503].includes(r.status)) {
155
+ // Read only a bounded control response, never an unbounded error document.
156
+ const reader = r.body?.getReader();
157
+ try {
158
+ const parts = [];
159
+ let size = 0;
160
+ if (reader)
161
+ for (;;) {
162
+ const part = await untilAbort(reader.read());
163
+ if (part.done)
164
+ break;
165
+ size += part.value.length;
166
+ if (size > 8192)
167
+ break;
168
+ parts.push(part.value);
169
+ }
170
+ if (size <= 8192) {
171
+ const all = new Uint8Array(size);
172
+ let at = 0;
173
+ for (const part of parts) {
174
+ all.set(part, at);
175
+ at += part.length;
176
+ }
177
+ const value = JSON.parse(new TextDecoder().decode(all));
178
+ if ((r.status === 429 &&
179
+ (value.error === 'download_limited' || value.error === 'download_in_progress')) ||
180
+ (r.status === 403 && value.error === 'not_sponsored') ||
181
+ (r.status === 503 && value.error === 'relayer_unavailable'))
182
+ code = value.error;
183
+ if (typeof value.retryAfterSeconds === 'number' &&
184
+ Number.isFinite(value.retryAfterSeconds) &&
185
+ value.retryAfterSeconds >= 0 &&
186
+ Number.isSafeInteger(Math.ceil(value.retryAfterSeconds)))
187
+ seconds = Math.ceil(value.retryAfterSeconds);
188
+ }
189
+ }
190
+ catch {
191
+ /* Unknown control responses still follow the bounded overload policy. */
192
+ }
193
+ finally {
194
+ await reader?.cancel().catch(() => { });
195
+ }
196
+ }
197
+ else
198
+ await r.body?.cancel().catch(() => { });
199
+ const after = parseRetryAfterMs(r.headers.get('Retry-After'), (o.now?.() ?? Date.now() / 1000) * 1000);
200
+ if (after !== undefined)
201
+ seconds = Math.max(seconds ?? 0, Math.ceil(after / 1000));
202
+ const delayed = () => Object.assign(new DownloadError(code), {
203
+ fileId: o.fileId,
204
+ purchaseId: o.proof.purchase_id,
205
+ ...(seconds === undefined ? {} : { retryAfterSeconds: seconds }),
206
+ });
207
+ if (code === 'download_limited' || code === 'not_sponsored')
208
+ throw delayed();
209
+ if (!pending)
210
+ return fail('not_authorized', `the host answered ${r.status}`);
211
+ const delay = Math.max(1, seconds ?? (r.status === 402 ? 3 : 5)) * 1000;
212
+ if (i + 1 >= (o.settleTries ?? 40) ||
213
+ delay > HTTP_RETRY_MAX_WAIT_SECONDS * 1000 - waitedMs ||
214
+ delay > HTTP_RETRY_MAX_WAIT_SECONDS * 1000)
215
+ throw delayed();
216
+ waitedMs += delay;
217
+ await wait(delay);
218
+ }
219
+ }
220
+ async function request() {
221
+ const { response: r, start } = await authorized();
222
+ const length = Number(r.headers.get('content-length') ?? Number.NaN);
223
+ const total = Number((/\/(\d+)$/.exec(r.headers.get('content-range') ?? '') ?? [])[1]);
224
+ try {
225
+ sizeCheck(r.status === 200 ? length : start + length);
226
+ if (r.status === 206)
227
+ sizeCheck(total);
228
+ }
229
+ catch (error) {
230
+ await r.body?.cancel().catch(() => { });
231
+ throw error;
232
+ }
233
+ const source = r.body.getReader();
234
+ let received = r.status === 200 ? 0 : start;
235
+ const reader = {
236
+ cancel: () => source.cancel(),
237
+ async read() {
238
+ const result = await source.read();
239
+ live();
240
+ if (!result.done) {
241
+ received += result.value.length;
242
+ try {
243
+ sizeCheck(received);
244
+ }
245
+ catch (error) {
246
+ await source.cancel().catch(() => { });
247
+ throw error;
248
+ }
249
+ }
250
+ return result;
251
+ },
252
+ };
253
+ // Retain immediately, so external cancellation also interrupts header skipping.
254
+ activeReader = reader;
255
+ // A host without Range support sends everything: skip what was already checked.
256
+ if (r.status === 200) {
257
+ const length = Number(r.headers.get('content-length') ?? Number.NaN);
258
+ end = Number.isSafeInteger(length) ? length : undefined;
259
+ pos = offset;
260
+ return { reader, rest: await skip(reader, offset, header) };
261
+ }
262
+ const range = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(r.headers.get('content-range') ?? '');
263
+ // RFC 9110 section 14.4: first <= last < complete length, or the range is invalid.
264
+ if (!range ||
265
+ Number(range[1]) !== start ||
266
+ Number(range[2]) < start ||
267
+ (range[3] !== '*' && Number(range[2]) >= Number(range[3]))) {
268
+ await reader.cancel().catch(() => { });
269
+ return fail('bad_response', 'the host resumed from another offset');
270
+ }
271
+ end = Number(range[2]) + 1;
272
+ sizeCheck(end);
273
+ pos = offset;
274
+ return {
275
+ reader,
276
+ rest: start < offset ? await skip(reader, offset - start, new Uint8Array(0)) : new Uint8Array(0),
277
+ };
278
+ }
279
+ let reader;
280
+ let activeReader;
281
+ let pending = new Uint8Array(0);
282
+ async function read(n, eofOk) {
283
+ n = Math.min(n, Math.max(0, limit - offset) + 1);
284
+ const out = new Uint8Array(n);
285
+ let at = 0;
286
+ while (at < n) {
287
+ if (pending.length === 0 && !reader) {
288
+ const got = await request();
289
+ reader = got.reader;
290
+ pending = got.rest;
291
+ }
292
+ if (pending.length === 0) {
293
+ const r = await reader.read();
294
+ if (r.done) {
295
+ // Retried: a connection can end cleanly in the middle, also before the end it announced.
296
+ if (eofOk && (end === undefined || pos >= end))
297
+ break;
298
+ throw new Error('the connection ended early');
299
+ }
300
+ pending = r.value;
301
+ }
302
+ const take = Math.min(n - at, pending.length);
303
+ out.set(pending.subarray(0, take), at);
304
+ pending = pending.subarray(take);
305
+ at += take;
306
+ pos += take;
307
+ }
308
+ return out.subarray(0, at);
309
+ }
310
+ return new ReadableStream({
311
+ async pull(controller) {
312
+ live();
313
+ if (!sentHeader) {
314
+ sentHeader = true;
315
+ controller.enqueue(copy(header));
316
+ return;
317
+ }
318
+ for (;;) {
319
+ try {
320
+ // The last chunk is the rest of the file: one byte over its maximum means bytes follow.
321
+ const last = chunk === h.n - 1;
322
+ const wire = await read(last ? maxLast + 1 : lengths[chunk], last);
323
+ // Spec 6, step 4: a seller can sign a hash of a malformed last chunk, so its length is
324
+ // checked on its own; one byte over the maximum is also a byte after the file.
325
+ if (last && (wire.length < TAG || wire.length > maxLast)) {
326
+ fail('file_mismatch', 'the last chunk has a wrong length, or bytes follow it');
327
+ }
328
+ if (!equal(await sha256(wire), h.hashes[chunk])) {
329
+ fail('file_mismatch', `chunk ${chunk} is not the one the header names`);
330
+ }
331
+ controller.enqueue(wire);
332
+ chunk++;
333
+ offset += wire.length;
334
+ if (last) {
335
+ cleanup();
336
+ controller.close();
337
+ await reader?.cancel().catch(() => { });
338
+ }
339
+ return;
340
+ }
341
+ catch (e) {
342
+ // Only a broken connection is retried: fetch's network TypeError, a stream error, or
343
+ // an early end. A mismatch, a refusal or a cancellation ends the download.
344
+ await activeReader?.cancel().catch(() => { });
345
+ reader = undefined;
346
+ pending = new Uint8Array(0);
347
+ if (abort.signal.aborted) {
348
+ cleanup();
349
+ fail('cancelled');
350
+ }
351
+ if (e instanceof DownloadError || retries-- <= 0) {
352
+ // Final: abort every request of this download, including a response rejected
353
+ // before its reader was kept.
354
+ abort.abort();
355
+ cleanup();
356
+ throw e;
357
+ }
358
+ }
359
+ }
360
+ },
361
+ async cancel() {
362
+ abort.abort();
363
+ cleanup();
364
+ await activeReader?.cancel().catch(() => { });
365
+ },
366
+ }, { highWaterMark: 0 });
367
+ }
368
+ /** Drops the first `n` bytes; returns what came after them in the same read. */
369
+ async function skip(reader, n, header) {
370
+ // Note: a host without Range costs bandwidth on a resume, not memory.
371
+ let left = n;
372
+ for (;;) {
373
+ const r = await reader.read();
374
+ if (r.done)
375
+ throw new Error('the connection ended before the resume point');
376
+ const at = n - left;
377
+ const check = Math.min(r.value.length, left, Math.max(0, header.length - at));
378
+ if (!equal(r.value.subarray(0, check), header.subarray(at, at + check)))
379
+ fail('file_mismatch', 'the host sent another header');
380
+ if (r.value.length >= left)
381
+ return r.value.subarray(left);
382
+ left -= r.value.length;
383
+ }
384
+ }
@@ -0,0 +1,56 @@
1
+ import { PayError } from '../pay/base.js';
2
+ import { type SettleOptions } from '../pay/settle.js';
3
+ export type HostRecord = import('../pay/settle.js').SellerRecord;
4
+ export interface DownloadInput {
5
+ network: string;
6
+ contract: string;
7
+ purchaseId: string;
8
+ fileId: string;
9
+ version: string;
10
+ size: number;
11
+ headerLength: number;
12
+ start: number;
13
+ range: boolean;
14
+ }
15
+ export interface DownloadDelivery {
16
+ wrote(offset: number): Promise<void>;
17
+ prepare(): Promise<void>;
18
+ finish(): Promise<void>;
19
+ interrupt(): Promise<void>;
20
+ }
21
+ export type DownloadReservation = {
22
+ ok: true;
23
+ delivery: DownloadDelivery;
24
+ } | {
25
+ ok: false;
26
+ status: 416 | 429;
27
+ code: 'bad_request' | 'download_limited' | 'download_in_progress';
28
+ retryAfterSeconds?: number;
29
+ };
30
+ export interface DownloadAccounting {
31
+ reserve(input: DownloadInput): Promise<DownloadReservation>;
32
+ }
33
+ export interface FileHostOptions extends SettleOptions {
34
+ /** Host-only diagnostics; never included in the HTTP response. */
35
+ onPaymentError?(error: PayError): void | Promise<void>;
36
+ headerHash: string;
37
+ downloads?: DownloadAccounting;
38
+ settlementPollSeconds?: number;
39
+ file: {
40
+ size: number;
41
+ headerLength?: number;
42
+ read(start: number): ReadableStream<Uint8Array>;
43
+ };
44
+ }
45
+ export interface HostRequest {
46
+ headers: {
47
+ get(name: string): string | null;
48
+ };
49
+ }
50
+ export interface HostResponse {
51
+ status: number;
52
+ headers: Record<string, string>;
53
+ body?: ReadableStream<Uint8Array>;
54
+ delivery?: DownloadDelivery;
55
+ }
56
+ export declare function serveFile(req: HostRequest, o: FileHostOptions): Promise<HostResponse>;
@@ -0,0 +1,161 @@
1
+ /** Downloads are authorized only by a final purchase grant (§9). */
2
+ import { isTransient } from '../errors.js';
3
+ import { getFile, getGrant, getPurchase } from '../near/rpc.js';
4
+ import { PayError } from '../pay/base.js';
5
+ import { paymentRequired } from '../pay/seller.js';
6
+ import { paymentResponse, settle } from '../pay/settle.js';
7
+ import { BUYER_PROOF_HEADER, readPurchaseProof, verifyBuyerProof } from './proof.js';
8
+ const b64json = (v) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(v))));
9
+ export async function serveFile(req, o) {
10
+ const headers = { 'Cache-Control': 'no-store' };
11
+ const file = await getFile(o.nearRpc, o.contractId, o.fileId);
12
+ if (!file)
13
+ return { status: 404, headers };
14
+ if (file.takenDown !== null)
15
+ return { status: 410, headers };
16
+ const payload = req.headers.get('PAYMENT-SIGNATURE');
17
+ if (payload) {
18
+ try {
19
+ const settled = await settle(payload, o);
20
+ headers['PAYMENT-RESPONSE'] = b64json(paymentResponse(settled));
21
+ if (settled.state === 'pending')
22
+ headers['Retry-After'] = String(o.settlementPollSeconds ?? 10);
23
+ }
24
+ catch (e) {
25
+ if (!(e instanceof PayError))
26
+ throw e;
27
+ try {
28
+ void Promise.resolve(o.onPaymentError?.(e)).catch(() => { });
29
+ }
30
+ catch {
31
+ // Diagnostics must not change the payment response.
32
+ }
33
+ const code = e.code;
34
+ const retry = e.retryAfterSeconds ??
35
+ (code === 'settlement_pending' || code === 'facilitator_busy' || code === 'unavailable'
36
+ ? (o.settlementPollSeconds ?? 10)
37
+ : code === 'relayer_unavailable'
38
+ ? 30
39
+ : undefined);
40
+ const status = code === 'not_sponsored'
41
+ ? 403
42
+ : code === 'settlement_pending' ||
43
+ code === 'facilitator_busy' ||
44
+ code === 'unavailable' ||
45
+ code === 'relayer_unavailable'
46
+ ? 503
47
+ : isTransient(e)
48
+ ? 502
49
+ : code === 'payment_id_conflict'
50
+ ? 409
51
+ : 400;
52
+ const message = code === 'route_unavailable'
53
+ ? 'This file does not support the selected payment route.'
54
+ : code === 'settlement_pending'
55
+ ? 'Settlement is pending. Try again later.'
56
+ : code === 'not_sponsored'
57
+ ? 'This file is not sponsored.'
58
+ : code === 'relayer_unavailable'
59
+ ? 'Relayer is temporarily unavailable.'
60
+ : 'Payment could not be completed.';
61
+ return {
62
+ status,
63
+ headers: {
64
+ ...headers,
65
+ 'Content-Type': 'application/json',
66
+ ...(retry === undefined ? {} : { 'Retry-After': String(retry) }),
67
+ },
68
+ body: new Response(JSON.stringify({
69
+ error: code,
70
+ message,
71
+ ...(retry === undefined ? {} : { retryAfterSeconds: retry }),
72
+ })).body,
73
+ };
74
+ }
75
+ }
76
+ const proof = readPurchaseProof(req.headers.get(BUYER_PROOF_HEADER));
77
+ if (proof) {
78
+ const p = await getPurchase(o.nearRpc, o.contractId, proof.purchase_id);
79
+ if (p && p.file_id === o.fileId && proof.grant < p.grants) {
80
+ const g = await getGrant(o.nearRpc, o.contractId, proof.purchase_id, proof.grant);
81
+ if (g && verifyBuyerProof(g.buyer_key, o.url, proof.signature))
82
+ return serve(req, o, headers, proof.purchase_id, file.terms?.network ?? 'near:mainnet');
83
+ }
84
+ }
85
+ return {
86
+ status: 402,
87
+ headers: {
88
+ ...headers,
89
+ 'PAYMENT-REQUIRED': b64json(paymentRequired(o, file, file.terms.base?.usdc ?? '')),
90
+ },
91
+ };
92
+ }
93
+ /** `200` with the whole file, or `206` from a `Range: bytes=<start>-` offset. */
94
+ async function serve(req, o, headers, purchaseId, network) {
95
+ const size = o.file.size;
96
+ const rawRange = req.headers.get('Range');
97
+ const range = /^bytes=(\d+)-$/.exec(rawRange ?? '');
98
+ const start = range ? Number(range[1]) : 0;
99
+ const base = {
100
+ ...headers,
101
+ 'Cache-Control': 'private, no-store',
102
+ 'Content-Type': 'application/octet-stream',
103
+ 'Accept-Ranges': 'bytes',
104
+ };
105
+ if ((rawRange !== null && !range) || !Number.isSafeInteger(start) || start >= size) {
106
+ return { status: 416, headers: { ...base, 'Content-Range': `bytes */${size}` } };
107
+ }
108
+ const reservation = await o.downloads?.reserve({
109
+ network,
110
+ contract: o.contractId,
111
+ purchaseId,
112
+ fileId: o.fileId,
113
+ version: o.headerHash,
114
+ size,
115
+ headerLength: o.file.headerLength ?? 0,
116
+ start,
117
+ range: !!range,
118
+ });
119
+ if (reservation && !reservation.ok) {
120
+ if (reservation.status === 416)
121
+ return { status: 416, headers: { ...base, 'Content-Range': `bytes */${size}` } };
122
+ const retry = reservation.retryAfterSeconds;
123
+ const message = reservation.code === 'download_limited'
124
+ ? 'Download limit reached. Wait before downloading again.'
125
+ : 'A download is already in progress.';
126
+ return {
127
+ status: 429,
128
+ headers: {
129
+ ...headers,
130
+ 'Content-Type': 'application/json',
131
+ ...(retry === undefined ? {} : { 'Retry-After': String(retry) }),
132
+ },
133
+ body: new Response(JSON.stringify({
134
+ error: reservation.code,
135
+ message,
136
+ purchaseId,
137
+ fileId: o.fileId,
138
+ ...(retry === undefined ? {} : { retryAfterSeconds: retry }),
139
+ })).body,
140
+ };
141
+ }
142
+ let body;
143
+ try {
144
+ body = o.file.read(start);
145
+ }
146
+ catch (error) {
147
+ if (reservation?.ok)
148
+ await reservation.delivery.interrupt();
149
+ throw error;
150
+ }
151
+ return {
152
+ ...(reservation?.ok ? { delivery: reservation.delivery } : {}),
153
+ status: range ? 206 : 200,
154
+ headers: {
155
+ ...base,
156
+ 'Content-Length': String(size - start),
157
+ ...(range ? { 'Content-Range': `bytes ${start}-${size - 1}/${size}` } : {}),
158
+ },
159
+ body,
160
+ };
161
+ }
@@ -0,0 +1,23 @@
1
+ /** Shared exact-origin CORS policy for the file host and relayer. */
2
+ import { type FileHostOptions, type HostResponse } from './host.js';
3
+ export declare const DEFAULT_ORIGINS: readonly ["https://annon.dev", "https://www.annon.dev"];
4
+ export interface CorsPolicy {
5
+ origins: readonly string[];
6
+ methods: string;
7
+ headers: string;
8
+ expose: string;
9
+ maxAge: number;
10
+ }
11
+ export declare function corsPolicy(allowed?: readonly string[], previews?: readonly string[], service?: 'host' | 'relayer', maxAge?: number): CorsPolicy;
12
+ export declare function corsHeaders(origin: string | null | undefined, allowed?: readonly string[], expose?: string): Record<string, string>;
13
+ export declare function preflight(headers: {
14
+ get(name: string): string | null;
15
+ }, policy?: CorsPolicy): HostResponse;
16
+ export declare function hasContentType(value: string | null | undefined, expected: 'application/json' | 'application/octet-stream'): boolean;
17
+ export interface HttpHostOptions extends FileHostOptions {
18
+ allowedOrigins?: readonly string[];
19
+ previewOrigins?: readonly string[];
20
+ }
21
+ export declare function handle(method: string, headers: {
22
+ get(name: string): string | null;
23
+ }, o: HttpHostOptions): Promise<HostResponse>;