@oxy.so/core 7.17.0 → 7.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/HttpService.js +2 -0
- package/dist/cjs/OxyServices.errors.js +21 -0
- package/dist/cjs/OxyServices.js +86 -75
- package/dist/cjs/client/callOptions.js +66 -0
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/server/OxyServer.js +10 -27
- package/dist/cjs/server/callerAccess.js +20 -1
- package/dist/cjs/server/namespaces.js +85 -26
- package/dist/cjs/server/requesterClient.js +1 -3
- package/dist/cjs/server/safeFetch.js +43 -12
- package/dist/esm/HttpService.js +2 -1
- package/dist/esm/OxyServices.errors.js +20 -0
- package/dist/esm/OxyServices.js +86 -75
- package/dist/esm/client/callOptions.js +61 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server/OxyServer.js +10 -27
- package/dist/esm/server/callerAccess.js +20 -1
- package/dist/esm/server/namespaces.js +86 -27
- package/dist/esm/server/requesterClient.js +1 -3
- package/dist/esm/server/safeFetch.js +43 -12
- package/dist/types/HttpService.d.ts +2 -0
- package/dist/types/OxyServices.d.ts +38 -25
- package/dist/types/OxyServices.errors.d.ts +6 -0
- package/dist/types/client/callOptions.d.ts +45 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/server/OxyServer.d.ts +0 -9
- package/dist/types/server/callerAccess.d.ts +14 -1
- package/dist/types/server/namespaces.d.ts +50 -13
- package/dist/types/server/requesterClient.d.ts +0 -1
- package/dist/types/server/safeFetch.d.ts +16 -0
- package/package.json +1 -1
- package/src/HttpService.ts +2 -1
- package/src/OxyServices.errors.ts +19 -0
- package/src/OxyServices.ts +129 -75
- package/src/client/callOptions.ts +88 -0
- package/src/index.ts +12 -1
- package/src/models/interfaces.ts +25 -0
- package/src/server/OxyServer.ts +15 -33
- package/src/server/callerAccess.ts +34 -2
- package/src/server/namespaces.ts +128 -43
- package/src/server/requesterClient.ts +1 -3
- package/src/server/safeFetch.ts +69 -11
|
@@ -9,6 +9,7 @@ const externalMediaRequester_1 = require("./externalMediaRequester");
|
|
|
9
9
|
* `oxy.assets`.
|
|
10
10
|
*/
|
|
11
11
|
const contracts_1 = require("@oxy.so/contracts");
|
|
12
|
+
const callOptions_1 = require("../client/callOptions");
|
|
12
13
|
const requesterContext_1 = require("./requesterContext");
|
|
13
14
|
const assets_1 = require("../api/assets");
|
|
14
15
|
const largeUpload_1 = require("./largeUpload");
|
|
@@ -33,6 +34,14 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
|
33
34
|
const SERVICE_LINKED_DOWNLOAD_URL_CHUNK_SIZE = 25;
|
|
34
35
|
/** Server-side cap of `POST`/`DELETE /assets/service/references`. */
|
|
35
36
|
const SERVICE_REFERENCES_CHUNK_SIZE = 100;
|
|
37
|
+
/** Server-side cap of `POST /assets/service/federation/delete`. */
|
|
38
|
+
const SERVICE_FEDERATED_DELETE_CHUNK_SIZE = 20;
|
|
39
|
+
const FEDERATED_DELETE_RESULTS = new Set([
|
|
40
|
+
'deleted',
|
|
41
|
+
'not_found',
|
|
42
|
+
'in_use',
|
|
43
|
+
'forbidden',
|
|
44
|
+
]);
|
|
36
45
|
/** Server-side cap of `POST /assets/service/app/import/batch`. */
|
|
37
46
|
const SERVICE_IMPORT_BATCH_SIZE = 50;
|
|
38
47
|
/** An import fetches a remote source (up to 60 s server-side): a longer per-attempt timeout. */
|
|
@@ -61,14 +70,6 @@ const SERVICE_RECONCILE_RATE_LIMIT_MAX_WAIT_MS = 60000;
|
|
|
61
70
|
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
62
71
|
/** A header value fetch accepts on every runtime: printable ASCII only. */
|
|
63
72
|
const HEADER_SAFE_PATTERN = /^[\x20-\x7e]*$/;
|
|
64
|
-
/** The `RequestOptions` half of {@link ServiceCallOptions}, only the fields that were set. */
|
|
65
|
-
function serviceCallRequestOptions(options) {
|
|
66
|
-
return {
|
|
67
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
68
|
-
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
|
|
69
|
-
...(options.retry !== undefined ? { retry: options.retry } : {}),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
73
|
/** The headers both streamed service uploads share. */
|
|
73
74
|
function uploadHeaders(mime, description) {
|
|
74
75
|
return {
|
|
@@ -185,6 +186,80 @@ class ServerAssetsApi extends assets_1.AssetsApi {
|
|
|
185
186
|
*/
|
|
186
187
|
reconcile: (entityType, items, options = {}) => this.reconcileReferences(entityType, items, options),
|
|
187
188
|
};
|
|
189
|
+
/**
|
|
190
|
+
* The federation media cache (`/assets/service/cache`): copies of remote
|
|
191
|
+
* media this application serves from Oxy's CDN instead of the remote host,
|
|
192
|
+
* in a reserved namespace that only holds what may be evicted. Every file
|
|
193
|
+
* is public by construction (`https://cloud.oxy.so/<id>`, `publicUrl`).
|
|
194
|
+
*
|
|
195
|
+
* - `upload` (`files:write`): the bytes as the whole body — a Blob from
|
|
196
|
+
* `fs.openAsBlob(path)` streams a file from disk; image, video or audio
|
|
197
|
+
* only (415), at most Oxy's cache size (413). Sent once.
|
|
198
|
+
* - `delete` (`federation:write`): evict one cache object. 404 when it is
|
|
199
|
+
* gone, 403 for anything outside the cache namespace — a service token can
|
|
200
|
+
* never delete media a person owns.
|
|
201
|
+
*
|
|
202
|
+
* A refusal for this application's media-write budget is a 429 whose
|
|
203
|
+
* `retryAfterSeconds` ({@link retryAfterSecondsOf}) says when to write again.
|
|
204
|
+
*/
|
|
205
|
+
this.mediaCache = {
|
|
206
|
+
upload: (bytes, mime, options = {}) => lane(this.ctx).request('POST', '/assets/service/cache', bytes, {
|
|
207
|
+
headers: uploadHeaders(mime, options),
|
|
208
|
+
cache: false,
|
|
209
|
+
retry: false,
|
|
210
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
211
|
+
...(options.timeout ? { timeout: options.timeout } : {}),
|
|
212
|
+
}),
|
|
213
|
+
delete: async (fileId) => {
|
|
214
|
+
await lane(this.ctx).request('DELETE', `/assets/service/cache/${encodeURIComponent(fileId)}`, undefined, { cache: false });
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Durable federated media (`/assets/service/federation`): a remote post's
|
|
219
|
+
* media re-hosted as a normal public file owned by the federated Oxy user
|
|
220
|
+
* it came from — outside the cache namespace, so a stored post may
|
|
221
|
+
* reference it for good.
|
|
222
|
+
*
|
|
223
|
+
* - `upload` (`files:write`): `ownerUserId` must be an existing federated
|
|
224
|
+
* user (403). Idempotent per (owner, application, bytes): the existing
|
|
225
|
+
* file with `deduplicated: true`, which other posts may share — check
|
|
226
|
+
* your own references before deleting it. Bytes another owner or
|
|
227
|
+
* application holds are a 409 `FEDERATED_MEDIA_OWNED_ELSEWHERE`. Sent once.
|
|
228
|
+
* - `delete` (`files:write` + `federation:write`): one answer per distinct
|
|
229
|
+
* id, in order, sent 20 at a time — only federated media this application
|
|
230
|
+
* uploaded is ever deleted (`forbidden` otherwise), and bytes another
|
|
231
|
+
* account holds are kept (`in_use`). Every step is idempotent, so a failed
|
|
232
|
+
* call is retried whole.
|
|
233
|
+
*
|
|
234
|
+
* Budget refusals are 429s, as for {@link mediaCache}.
|
|
235
|
+
*/
|
|
236
|
+
this.federatedMedia = {
|
|
237
|
+
upload: async (ownerUserId, bytes, mime, options = {}) => {
|
|
238
|
+
const res = await lane(this.ctx).request('POST', '/assets/service/federation', bytes, {
|
|
239
|
+
headers: { ...uploadHeaders(mime, options), 'x-owner-user-id': ownerUserId },
|
|
240
|
+
cache: false,
|
|
241
|
+
retry: false,
|
|
242
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
243
|
+
...(options.timeout ? { timeout: options.timeout } : {}),
|
|
244
|
+
});
|
|
245
|
+
return { file: res.file, deduplicated: res.deduplicated === true };
|
|
246
|
+
},
|
|
247
|
+
delete: async (fileIds) => {
|
|
248
|
+
const results = [];
|
|
249
|
+
for (const part of chunk(uniqueIds(fileIds), SERVICE_FEDERATED_DELETE_CHUNK_SIZE)) {
|
|
250
|
+
const res = await lane(this.ctx).request('POST', '/assets/service/federation/delete', { ids: part }, { cache: false });
|
|
251
|
+
const asked = new Set(part);
|
|
252
|
+
for (const entry of Array.isArray(res?.results) ? res.results : []) {
|
|
253
|
+
// An id not asked, or a result this build does not know, is no
|
|
254
|
+
// answer: the caller sees the id unanswered and retries it.
|
|
255
|
+
if (asked.has(entry?.id) && FEDERATED_DELETE_RESULTS.has(entry?.result)) {
|
|
256
|
+
results.push({ id: entry.id, result: entry.result });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return results;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
188
263
|
/**
|
|
189
264
|
* Per-play, revocable streaming access (ADR 0043 stage 4c). A play session
|
|
190
265
|
* is ONE file's HLS ladder (a video's, or an audio file's audio-only one)
|
|
@@ -330,7 +405,7 @@ class ServerAssetsApi extends assets_1.AssetsApi {
|
|
|
330
405
|
const chunk = references
|
|
331
406
|
.slice(start, start + SERVICE_REFERENCES_CHUNK_SIZE)
|
|
332
407
|
.map(({ fileId, entityType, entityId }) => ({ fileId, entityType, entityId }));
|
|
333
|
-
const res = await lane(this.ctx).request(method, '/assets/service/references', { references: chunk },
|
|
408
|
+
const res = await (0, callOptions_1.sendWithCallOptions)({ cache: false }, options, (requestOptions) => lane(this.ctx).request(method, '/assets/service/references', { references: chunk }, requestOptions));
|
|
334
409
|
results.push(...res.results);
|
|
335
410
|
}
|
|
336
411
|
return results;
|
|
@@ -1037,26 +1112,10 @@ async function waitingOutRateLimits(send) {
|
|
|
1037
1112
|
}
|
|
1038
1113
|
/** How long to wait after a refusal: what Oxy said, else the doubling fallback; capped. */
|
|
1039
1114
|
function rateLimitWaitMs(error, refusals) {
|
|
1040
|
-
const said =
|
|
1115
|
+
const said = (0, OxyServices_errors_1.retryAfterSecondsOf)(error);
|
|
1041
1116
|
const wait = said !== undefined ? said * 1000 : SERVICE_RECONCILE_RATE_LIMIT_FALLBACK_MS * 2 ** refusals;
|
|
1042
1117
|
return Math.min(wait, SERVICE_RECONCILE_RATE_LIMIT_MAX_WAIT_MS);
|
|
1043
1118
|
}
|
|
1044
|
-
/** `details.retryAfterSeconds` of a refusal (`RateLimitedError` on the server), if any. */
|
|
1045
|
-
function retryAfterSeconds(error) {
|
|
1046
|
-
if (!error || typeof error !== 'object')
|
|
1047
|
-
return undefined;
|
|
1048
|
-
const candidates = [
|
|
1049
|
-
error.details,
|
|
1050
|
-
error.response
|
|
1051
|
-
?.data?.details,
|
|
1052
|
-
];
|
|
1053
|
-
for (const details of candidates) {
|
|
1054
|
-
const seconds = details?.retryAfterSeconds;
|
|
1055
|
-
if (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0)
|
|
1056
|
-
return seconds;
|
|
1057
|
-
}
|
|
1058
|
-
return undefined;
|
|
1059
|
-
}
|
|
1060
1119
|
async function waitForCommit(service, path, sessionId) {
|
|
1061
1120
|
const deadline = Date.now() + SERVICE_RECONCILE_COMMIT_WAIT_MS;
|
|
1062
1121
|
let interval = assetJobs_1.ASSET_JOB_POLL_INITIAL_MS;
|
|
@@ -15,9 +15,7 @@ class OxyRequesterClient extends OxyServices_1.OxyServices {
|
|
|
15
15
|
return this.requesterLane.request(method, url, data, options);
|
|
16
16
|
}
|
|
17
17
|
get assets() {
|
|
18
|
-
|
|
19
|
-
this.requesterAssets = new namespaces_1.ServerAssetsApi(this.context);
|
|
20
|
-
return this.requesterAssets;
|
|
18
|
+
return this.namespace('assets', (context) => new namespaces_1.ServerAssetsApi(context));
|
|
21
19
|
}
|
|
22
20
|
}
|
|
23
21
|
exports.OxyRequesterClient = OxyRequesterClient;
|
|
@@ -138,6 +138,28 @@ const BLOCKED_IPV6_CIDRS = [
|
|
|
138
138
|
['2001:db8::', 32], // documentation
|
|
139
139
|
['64:ff9b::', 96], // NAT64 (maps to IPv4 — IPv4 denylist still applies after unwrap)
|
|
140
140
|
];
|
|
141
|
+
/**
|
|
142
|
+
* Every successful check {@link assertSafePublicUrl} returned, and the exact
|
|
143
|
+
* URL it checked. `safeFetch({ validated })` accepts only these, for that URL:
|
|
144
|
+
* a hand-built `{ ok: true, ip }`, or a check of another URL, never pins a
|
|
145
|
+
* connection.
|
|
146
|
+
*/
|
|
147
|
+
const issuedChecks = new WeakMap();
|
|
148
|
+
/** `rawUrl`'s normalized form, or `null` when it is not a URL. */
|
|
149
|
+
function normalizedUrl(rawUrl) {
|
|
150
|
+
try {
|
|
151
|
+
return new node_url_1.URL(rawUrl).href;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A successful check of `href`, registered as one this module issued. */
|
|
158
|
+
function issueCheck(href, ip, family) {
|
|
159
|
+
const check = { ok: true, ip, family };
|
|
160
|
+
issuedChecks.set(check, href);
|
|
161
|
+
return check;
|
|
162
|
+
}
|
|
141
163
|
/**
|
|
142
164
|
* Return true if a literal IP address is private/loopback/link-local/reserved/
|
|
143
165
|
* multicast/metadata and therefore must NOT be contacted.
|
|
@@ -219,7 +241,7 @@ async function assertSafePublicUrl(rawUrl, options = {}) {
|
|
|
219
241
|
if (isBlockedIp(literalHost)) {
|
|
220
242
|
return { ok: false, reason: 'literal ip in blocked range' };
|
|
221
243
|
}
|
|
222
|
-
return
|
|
244
|
+
return issueCheck(parsed.href, literalHost, literalFamily === 4 ? 4 : 6);
|
|
223
245
|
}
|
|
224
246
|
// Reject ambiguous numeric host forms BEFORE touching the resolver. `isIP`
|
|
225
247
|
// returned 0 (not a canonical IP literal), yet the host is made entirely of
|
|
@@ -260,11 +282,7 @@ async function assertSafePublicUrl(rawUrl, options = {}) {
|
|
|
260
282
|
}
|
|
261
283
|
// All records are public. Pin the connection to the first one.
|
|
262
284
|
const chosen = records[0];
|
|
263
|
-
return
|
|
264
|
-
ok: true,
|
|
265
|
-
ip: chosen.address,
|
|
266
|
-
family: chosen.family === 4 ? 4 : 6,
|
|
267
|
-
};
|
|
285
|
+
return issueCheck(parsed.href, chosen.address, chosen.family === 4 ? 4 : 6);
|
|
268
286
|
}
|
|
269
287
|
/** What a refusal of a first-party Oxy host adds to its reason. */
|
|
270
288
|
const FIRST_PARTY_HOST_HINT = 'first-party Oxy host: inside the VPC it resolves to a private address, which safeFetch refuses by design; call it through OxyServer (its namespaces, serviceRequest, or createServiceClient().fetch), not safeFetch';
|
|
@@ -356,7 +374,13 @@ function fetchOnce(options, isHttps, headersTimeoutMs, body) {
|
|
|
356
374
|
* @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
|
|
357
375
|
*/
|
|
358
376
|
async function safeFetch(rawUrl, options = {}) {
|
|
359
|
-
const { method = 'GET', headers: callerHeaders, body, maxRedirects = exports.MAX_REDIRECTS, redirect = 'follow', headersTimeoutMs = exports.UPSTREAM_HEADERS_TIMEOUT_MS, signal, } = options;
|
|
377
|
+
const { method = 'GET', headers: callerHeaders, body, maxRedirects = exports.MAX_REDIRECTS, redirect = 'follow', headersTimeoutMs = exports.UPSTREAM_HEADERS_TIMEOUT_MS, signal, validated, requireHttps = false, } = options;
|
|
378
|
+
if (validated !== undefined) {
|
|
379
|
+
const checked = issuedChecks.get(validated);
|
|
380
|
+
if (checked === undefined || checked !== normalizedUrl(rawUrl)) {
|
|
381
|
+
throw new TypeError('safeFetch: `validated` must be the check assertSafePublicUrl returned for this exact URL');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
360
384
|
// Normalize a case-insensitive header map and ensure a User-Agent default.
|
|
361
385
|
const baseHeaders = {};
|
|
362
386
|
if (callerHeaders) {
|
|
@@ -373,11 +397,18 @@ async function safeFetch(rawUrl, options = {}) {
|
|
|
373
397
|
if (signal?.aborted) {
|
|
374
398
|
throw new UpstreamError('request aborted');
|
|
375
399
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
400
|
+
if (requireHttps && !normalizedUrl(currentUrl)?.startsWith('https:')) {
|
|
401
|
+
throw new SsrfRejection(hop === 0 ? 'https required' : 'https required: redirect to a non-https url');
|
|
402
|
+
}
|
|
403
|
+
// Hop 0 may reuse the caller's check of this exact URL; a redirect hop
|
|
404
|
+
// targets a new URL and is always checked here. The lookup shares the
|
|
405
|
+
// headers deadline and the caller's signal.
|
|
406
|
+
const guard = hop === 0 && validated
|
|
407
|
+
? validated
|
|
408
|
+
: await assertSafePublicUrl(currentUrl, {
|
|
409
|
+
lookupTimeoutMs: headersTimeoutMs,
|
|
410
|
+
signal,
|
|
411
|
+
});
|
|
381
412
|
if (!guard.ok) {
|
|
382
413
|
throw guard.incomplete
|
|
383
414
|
? new UpstreamError(guard.reason)
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -298,7 +298,8 @@ function isAbortLike(error) {
|
|
|
298
298
|
error !== null &&
|
|
299
299
|
error.name === 'AbortError');
|
|
300
300
|
}
|
|
301
|
-
|
|
301
|
+
/** @internal Shared with `client/callOptions.ts`; see the note above `isAbortLike`. */
|
|
302
|
+
export function linkAbort(parent, child) {
|
|
302
303
|
if (parent.aborted) {
|
|
303
304
|
child.abort(parent.reason);
|
|
304
305
|
return () => {
|
|
@@ -136,6 +136,26 @@ export class OxyApiError extends Error {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* How long Oxy asked the caller to wait before trying again, in seconds — the
|
|
141
|
+
* `Retry-After` of a 429 (`details.retryAfterSeconds` of its body) — or
|
|
142
|
+
* `undefined` when the error is no such refusal or names no wait.
|
|
143
|
+
*/
|
|
144
|
+
export function retryAfterSecondsOf(error) {
|
|
145
|
+
if (!error || typeof error !== 'object')
|
|
146
|
+
return undefined;
|
|
147
|
+
const candidates = [
|
|
148
|
+
error.details,
|
|
149
|
+
error.response
|
|
150
|
+
?.data?.details,
|
|
151
|
+
];
|
|
152
|
+
for (const details of candidates) {
|
|
153
|
+
const seconds = details?.retryAfterSeconds;
|
|
154
|
+
if (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0)
|
|
155
|
+
return seconds;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
139
159
|
/**
|
|
140
160
|
* Normalise anything a request rejected with into an `OxyApiError`. An error
|
|
141
161
|
* that already is one (or a typed SDK error such as `OxyAuthenticationError`)
|
package/dist/esm/OxyServices.js
CHANGED
|
@@ -25,6 +25,7 @@ import { ContentClaimsApi } from './api/contentClaims.js';
|
|
|
25
25
|
*/
|
|
26
26
|
import { HttpService, } from './HttpService.js';
|
|
27
27
|
import { toOxyApiError } from './OxyServices.errors.js';
|
|
28
|
+
import { assertCallOptions, sendWithCallOptions } from './client/callOptions.js';
|
|
28
29
|
import { SessionApi } from './api/session.js';
|
|
29
30
|
import { AuthApi } from './api/auth.js';
|
|
30
31
|
import { UsersApi } from './api/users.js';
|
|
@@ -64,6 +65,9 @@ export class OxyServices {
|
|
|
64
65
|
deletePrefix: (prefix) => this.http.clearCacheByPrefix(prefix),
|
|
65
66
|
stats: () => this.http.getCacheStats(),
|
|
66
67
|
};
|
|
68
|
+
// ── Namespaces (created on first access) ─────────────────────────────────
|
|
69
|
+
/** Each namespace, created on first access and kept for the client's life. */
|
|
70
|
+
this.namespaceInstances = new Map();
|
|
67
71
|
if (!config || typeof config !== 'object') {
|
|
68
72
|
throw new Error('OxyConfig is required');
|
|
69
73
|
}
|
|
@@ -166,150 +170,157 @@ export class OxyServices {
|
|
|
166
170
|
dispose() {
|
|
167
171
|
this.http.dispose();
|
|
168
172
|
}
|
|
173
|
+
/** The namespace `name`, created with this client's context on first access. */
|
|
174
|
+
namespace(name, create) {
|
|
175
|
+
let instance = this.namespaceInstances.get(name);
|
|
176
|
+
if (instance === undefined) {
|
|
177
|
+
instance = create(this.context);
|
|
178
|
+
this.namespaceInstances.set(name, instance);
|
|
179
|
+
}
|
|
180
|
+
return instance;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* This client's namespaces with per-call transport options: every request
|
|
184
|
+
* made through them carries `options`, on top of what each method sets.
|
|
185
|
+
*
|
|
186
|
+
* ```ts
|
|
187
|
+
* // One slow write, one tight read — one client.
|
|
188
|
+
* await oxy.withCallOptions({ timeout: 60_000 }).assets.setVisibility(id, 'public');
|
|
189
|
+
* const page = await oxy.withCallOptions({ deadline: 2_500 }).users.byUsername(name);
|
|
190
|
+
* // A server's own namespaces too:
|
|
191
|
+
* await server.withCallOptions({ retry: false }).assets.references.add(refs);
|
|
192
|
+
* ```
|
|
193
|
+
*
|
|
194
|
+
* - `timeout` / `deadline` replace the method's own and the client's.
|
|
195
|
+
* - `retry: false` sends each request once; `retry: true` never re-enables
|
|
196
|
+
* a request the method sends once on purpose.
|
|
197
|
+
* - `signal` aborts the call; a method's own signal still applies too.
|
|
198
|
+
*
|
|
199
|
+
* The scope is a view, not a client: it shares this client's session,
|
|
200
|
+
* cache, credentials and service lane, holds no resource and needs no
|
|
201
|
+
* disposal. A namespace one call makes into another (`privacy` reading
|
|
202
|
+
* `users.me()`) keeps the options. `session` is not part of it.
|
|
203
|
+
*/
|
|
204
|
+
withCallOptions(options) {
|
|
205
|
+
assertCallOptions(options);
|
|
206
|
+
const scope = { ...options };
|
|
207
|
+
const base = this.context;
|
|
208
|
+
const lane = base.service;
|
|
209
|
+
const view = Object.create(this);
|
|
210
|
+
const context = {
|
|
211
|
+
oxy: view,
|
|
212
|
+
http: base.http,
|
|
213
|
+
request: (method, url, data, requestOptions) => sendWithCallOptions(requestOptions, scope, (merged) => base.request(method, url, data, merged)),
|
|
214
|
+
service: lane && {
|
|
215
|
+
get available() {
|
|
216
|
+
return lane.available;
|
|
217
|
+
},
|
|
218
|
+
request: (method, url, data, requestOptions) => sendWithCallOptions(requestOptions, scope, (merged) => lane.request(method, url, data, merged)),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
// The view's own namespaces are built on the scoped context; everything
|
|
222
|
+
// else (http, cache, config, credentials) is this client's, by prototype.
|
|
223
|
+
Object.defineProperties(view, {
|
|
224
|
+
context: { value: context },
|
|
225
|
+
namespaceInstances: { value: new Map() },
|
|
226
|
+
session: { get: () => this.session },
|
|
227
|
+
});
|
|
228
|
+
return view;
|
|
229
|
+
}
|
|
169
230
|
/** This client's auth state (token, user id) and its server session. */
|
|
170
231
|
get session() {
|
|
171
|
-
|
|
172
|
-
this._session = new SessionApi(this.context);
|
|
173
|
-
return this._session;
|
|
232
|
+
return this.namespace('session', (context) => new SessionApi(context));
|
|
174
233
|
}
|
|
175
234
|
/** Signing in, signing up, and the account's credentials. */
|
|
176
235
|
get auth() {
|
|
177
|
-
|
|
178
|
-
this._auth = new AuthApi(this.context);
|
|
179
|
-
return this._auth;
|
|
236
|
+
return this.namespace('auth', (context) => new AuthApi(context));
|
|
180
237
|
}
|
|
181
238
|
/** People and their profiles. */
|
|
182
239
|
get users() {
|
|
183
|
-
|
|
184
|
-
this._users = new UsersApi(this.context);
|
|
185
|
-
return this._users;
|
|
240
|
+
return this.namespace('users', (context) => new UsersApi(context));
|
|
186
241
|
}
|
|
187
242
|
/** The follow graph: people and every other followable target. */
|
|
188
243
|
get follows() {
|
|
189
|
-
|
|
190
|
-
this._follows = new FollowsApi(this.context);
|
|
191
|
-
return this._follows;
|
|
244
|
+
return this.namespace('follows', (context) => new FollowsApi(context));
|
|
192
245
|
}
|
|
193
246
|
/** Privacy settings, blocks and restrictions. */
|
|
194
247
|
get privacy() {
|
|
195
|
-
|
|
196
|
-
this._privacy = new PrivacyApi(this.context);
|
|
197
|
-
return this._privacy;
|
|
248
|
+
return this.namespace('privacy', (context) => new PrivacyApi(context));
|
|
198
249
|
}
|
|
199
250
|
/** The in-app inbox and push tokens. */
|
|
200
251
|
get notifications() {
|
|
201
|
-
|
|
202
|
-
this._notifications = new NotificationsApi(this.context);
|
|
203
|
-
return this._notifications;
|
|
252
|
+
return this.namespace('notifications', (context) => new NotificationsApi(context));
|
|
204
253
|
}
|
|
205
254
|
/** Files: upload, link, URLs, content. */
|
|
206
255
|
get assets() {
|
|
207
|
-
|
|
208
|
-
this._assets = new AssetsApi(this.context);
|
|
209
|
-
return this._assets;
|
|
256
|
+
return this.namespace('assets', (context) => new AssetsApi(context));
|
|
210
257
|
}
|
|
211
258
|
/** DID, keys, identity links, domains, backup and the signed export. */
|
|
212
259
|
get identity() {
|
|
213
|
-
|
|
214
|
-
this._identity = new IdentityApi(this.context);
|
|
215
|
-
return this._identity;
|
|
260
|
+
return this.namespace('identity', (context) => new IdentityApi(context));
|
|
216
261
|
}
|
|
217
262
|
/** The account graph: accounts and their members. */
|
|
218
263
|
get safety() {
|
|
219
|
-
|
|
220
|
-
this._safety = new SafetyApi(this.context);
|
|
221
|
-
return this._safety;
|
|
264
|
+
return this.namespace('safety', (context) => new SafetyApi(context));
|
|
222
265
|
}
|
|
223
266
|
/** Content claims: an owner's counter-notice to a takedown (ADR 0043 D10). */
|
|
224
267
|
get contentClaims() {
|
|
225
|
-
|
|
226
|
-
this._contentClaims = new ContentClaimsApi(this.context);
|
|
227
|
-
return this._contentClaims;
|
|
268
|
+
return this.namespace('contentClaims', (context) => new ContentClaimsApi(context));
|
|
228
269
|
}
|
|
229
270
|
get organizations() {
|
|
230
|
-
|
|
231
|
-
this._organizations = new OrganizationsApi(this.context);
|
|
232
|
-
return this._organizations;
|
|
271
|
+
return this.namespace('organizations', (context) => new OrganizationsApi(context));
|
|
233
272
|
}
|
|
234
273
|
get accounts() {
|
|
235
|
-
|
|
236
|
-
this._accounts = new AccountsApi(this.context);
|
|
237
|
-
return this._accounts;
|
|
274
|
+
return this.namespace('accounts', (context) => new AccountsApi(context));
|
|
238
275
|
}
|
|
239
276
|
/** Applications an account owns, and the ones this user granted access. */
|
|
240
277
|
get apps() {
|
|
241
|
-
|
|
242
|
-
this._apps = new AppsApi(this.context);
|
|
243
|
-
return this._apps;
|
|
278
|
+
return this.namespace('apps', (context) => new AppsApi(context));
|
|
244
279
|
}
|
|
245
280
|
/** External accounts (Mastodon, Bluesky) linked by OAuth. */
|
|
246
281
|
get linkedAccounts() {
|
|
247
|
-
|
|
248
|
-
this._linkedAccounts = new LinkedAccountsApi(this.context);
|
|
249
|
-
return this._linkedAccounts;
|
|
282
|
+
return this.namespace('linkedAccounts', (context) => new LinkedAccountsApi(context));
|
|
250
283
|
}
|
|
251
284
|
/** Delegated capabilities for agents. */
|
|
252
285
|
get agency() {
|
|
253
|
-
|
|
254
|
-
this._agency = new AgencyApi(this.context);
|
|
255
|
-
return this._agency;
|
|
286
|
+
return this.namespace('agency', (context) => new AgencyApi(context));
|
|
256
287
|
}
|
|
257
288
|
/** The app store: storefront, reviews, and a publisher's listing. */
|
|
258
289
|
get store() {
|
|
259
|
-
|
|
260
|
-
this._store = new StoreApi(this.context);
|
|
261
|
-
return this._store;
|
|
290
|
+
return this.namespace('store', (context) => new StoreApi(context));
|
|
262
291
|
}
|
|
263
292
|
/** Subscription, wallet and payments. */
|
|
264
293
|
get billing() {
|
|
265
|
-
|
|
266
|
-
this._billing = new BillingApi(this.context);
|
|
267
|
-
return this._billing;
|
|
294
|
+
return this.namespace('billing', (context) => new BillingApi(context));
|
|
268
295
|
}
|
|
269
296
|
/** Oxy Trust reputation (read-only for people; apps award it). */
|
|
270
297
|
get reputation() {
|
|
271
|
-
|
|
272
|
-
this._reputation = new ReputationApi(this.context);
|
|
273
|
-
return this._reputation;
|
|
298
|
+
return this.namespace('reputation', (context) => new ReputationApi(context));
|
|
274
299
|
}
|
|
275
300
|
/** Oxy ID: cards, attestations, vouching, personhood, credentials. */
|
|
276
301
|
get civic() {
|
|
277
|
-
|
|
278
|
-
this._civic = new CivicApi(this.context);
|
|
279
|
-
return this._civic;
|
|
302
|
+
return this.namespace('civic', (context) => new CivicApi(context));
|
|
280
303
|
}
|
|
281
304
|
/** The user's personal data node. */
|
|
282
305
|
get nodes() {
|
|
283
|
-
|
|
284
|
-
this._nodes = new NodesApi(this.context);
|
|
285
|
-
return this._nodes;
|
|
306
|
+
return this.namespace('nodes', (context) => new NodesApi(context));
|
|
286
307
|
}
|
|
287
308
|
/** This user's devices, their sessions, and device sign-in. */
|
|
288
309
|
get devices() {
|
|
289
|
-
|
|
290
|
-
this._devices = new DevicesApi(this.context);
|
|
291
|
-
return this._devices;
|
|
310
|
+
return this.namespace('devices', (context) => new DevicesApi(context));
|
|
292
311
|
}
|
|
293
312
|
get topics() {
|
|
294
|
-
|
|
295
|
-
this._topics = new TopicsApi(this.context);
|
|
296
|
-
return this._topics;
|
|
313
|
+
return this.namespace('topics', (context) => new TopicsApi(context));
|
|
297
314
|
}
|
|
298
315
|
/** Per-app key/value data stored on the user's account. */
|
|
299
316
|
get appData() {
|
|
300
|
-
|
|
301
|
-
this._appData = new AppDataApi(this.context);
|
|
302
|
-
return this._appData;
|
|
317
|
+
return this.namespace('appData', (context) => new AppDataApi(context));
|
|
303
318
|
}
|
|
304
319
|
/** Oxy Console: first-run onboarding (terms and use cases) for the signed-in person. */
|
|
305
320
|
get console() {
|
|
306
|
-
|
|
307
|
-
this._console = new ConsoleApi(this.context);
|
|
308
|
-
return this._console;
|
|
321
|
+
return this.namespace('console', (context) => new ConsoleApi(context));
|
|
309
322
|
}
|
|
310
323
|
get contacts() {
|
|
311
|
-
|
|
312
|
-
this._contacts = new ContactsApi(this.context);
|
|
313
|
-
return this._contacts;
|
|
324
|
+
return this.namespace('contacts', (context) => new ContactsApi(context));
|
|
314
325
|
}
|
|
315
326
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-call transport options, for any namespace call.
|
|
3
|
+
*
|
|
4
|
+
* `oxy.withCallOptions({ timeout: 60_000 }).assets.setVisibility(id, 'public')`
|
|
5
|
+
* sends that one call with its own budget, without a second client and without
|
|
6
|
+
* every method growing an options parameter of its own. The scope is a view of
|
|
7
|
+
* the same client: same session, cache, service lane and namespaces' state.
|
|
8
|
+
*/
|
|
9
|
+
import { linkAbort } from '../HttpService.js';
|
|
10
|
+
/** Throws for a budget the transport cannot honour (0, negative, NaN, Infinity). */
|
|
11
|
+
export function assertCallOptions(options) {
|
|
12
|
+
for (const key of ['timeout', 'deadline']) {
|
|
13
|
+
const value = options[key];
|
|
14
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
|
|
15
|
+
throw new RangeError(`${key} must be a positive number of milliseconds`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The request options a method chose, with the scope's applied on top. The
|
|
21
|
+
* signal is NOT merged here: two signals need a linked controller that lives
|
|
22
|
+
* exactly as long as the request ({@link sendWithCallOptions}).
|
|
23
|
+
*/
|
|
24
|
+
export function applyCallOptions(method, scope) {
|
|
25
|
+
const merged = { ...method };
|
|
26
|
+
if (scope.timeout !== undefined)
|
|
27
|
+
merged.timeout = scope.timeout;
|
|
28
|
+
if (scope.deadline !== undefined)
|
|
29
|
+
merged.deadline = scope.deadline;
|
|
30
|
+
if (scope.retry === false)
|
|
31
|
+
merged.retry = false;
|
|
32
|
+
else if (scope.retry === true && merged.retry === undefined)
|
|
33
|
+
merged.retry = true;
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Send one request with the scope applied: `send` receives the merged
|
|
38
|
+
* options, with a signal that aborts when either the method's or the scope's
|
|
39
|
+
* does. The link between them is released when the request settles.
|
|
40
|
+
*/
|
|
41
|
+
export async function sendWithCallOptions(method, scope, send) {
|
|
42
|
+
const merged = applyCallOptions(method, scope);
|
|
43
|
+
const own = method?.signal;
|
|
44
|
+
if (!scope.signal || !own || own === scope.signal) {
|
|
45
|
+
const signal = scope.signal ?? own;
|
|
46
|
+
if (signal)
|
|
47
|
+
merged.signal = signal;
|
|
48
|
+
return send(merged);
|
|
49
|
+
}
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const unlinkOwn = linkAbort(own, controller);
|
|
52
|
+
const unlinkScope = linkAbort(scope.signal, controller);
|
|
53
|
+
merged.signal = controller.signal;
|
|
54
|
+
try {
|
|
55
|
+
return await send(merged);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
unlinkOwn();
|
|
59
|
+
unlinkScope();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import './crypto/polyfill.js';
|
|
|
29
29
|
// API client
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
export { OxyServices, OXY_API_URL, OXY_CLOUD_URL } from './OxyServices.js';
|
|
32
|
-
export { OxyApiError, OxyAuthenticationError, OxyAuthenticationTimeoutError, AssetUrlResolutionError, ServiceAssetMetadataError, } from './OxyServices.errors.js';
|
|
32
|
+
export { OxyApiError, OxyAuthenticationError, OxyAuthenticationTimeoutError, AssetUrlResolutionError, ServiceAssetMetadataError, retryAfterSecondsOf, } from './OxyServices.errors.js';
|
|
33
33
|
export { sameHolderIdentity } from './api/holderEvidence.js';
|
|
34
34
|
export { ASSET_MULTIPART_UPLOAD_MAX_BYTES, assetUrlCacheTTL, MAX_UPLOAD_BYTES } from './api/assets.js';
|
|
35
35
|
export { DirectUploadInterruptedError } from './api/directUpload.js';
|