@oxyhq/core 3.14.0 → 3.16.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/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.applications.js +43 -0
- package/dist/cjs/mixins/OxyServices.assets.js +14 -35
- package/dist/cjs/mixins/OxyServices.auth.js +12 -1
- package/dist/cjs/mixins/OxyServices.links.js +68 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/index.js +8 -0
- package/dist/cjs/utils/ssoBounce.js +48 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.applications.js +43 -0
- package/dist/esm/mixins/OxyServices.assets.js +14 -35
- package/dist/esm/mixins/OxyServices.auth.js +12 -1
- package/dist/esm/mixins/OxyServices.links.js +65 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/index.js +8 -0
- package/dist/esm/utils/ssoBounce.js +46 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.applications.d.ts +51 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +8 -29
- package/dist/types/mixins/OxyServices.auth.d.ts +8 -0
- package/dist/types/mixins/OxyServices.links.d.ts +102 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +242 -0
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/utils/ssoBounce.d.ts +61 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mixins/OxyServices.applications.ts +79 -0
- package/src/mixins/OxyServices.assets.ts +14 -44
- package/src/mixins/OxyServices.auth.ts +35 -1
- package/src/mixins/OxyServices.links.ts +103 -0
- package/src/mixins/OxyServices.nodes.ts +348 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +41 -16
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +10 -24
- package/src/mixins/index.ts +10 -0
- package/src/utils/__tests__/ssoBounce.test.ts +28 -0
- package/src/utils/ssoBounce.ts +69 -0
package/dist/esm/index.js
CHANGED
|
@@ -128,7 +128,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
|
|
|
128
128
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
|
|
129
129
|
export { generateSsoState } from './mixins/OxyServices.sso.js';
|
|
130
130
|
// SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
|
|
131
|
-
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce.js';
|
|
131
|
+
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, allowSsoBounce, } from './utils/ssoBounce.js';
|
|
132
132
|
export { runColdBoot } from './utils/coldBoot.js';
|
|
133
133
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
134
134
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
@@ -23,6 +23,49 @@ export function OxyServicesApplicationsMixin(Base) {
|
|
|
23
23
|
throw this.handleError(error);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* List the OAuth-authorized applications the current user has connected —
|
|
28
|
+
* the third-party apps the user granted access to via the consent flow.
|
|
29
|
+
* Each entry is a {@link ConnectedApp} carrying the application's display
|
|
30
|
+
* identity, the granted scopes, and when the grant was first made and last
|
|
31
|
+
* exercised. Requires an authenticated session.
|
|
32
|
+
*
|
|
33
|
+
* Backed by `GET /auth/grants`. The response is briefly cached
|
|
34
|
+
* (identity-scoped); {@link revokeAppGrant} busts that cache so a revoke is
|
|
35
|
+
* reflected on the next read.
|
|
36
|
+
*/
|
|
37
|
+
async listConnectedApps() {
|
|
38
|
+
try {
|
|
39
|
+
return await this.makeRequest('GET', '/auth/grants', undefined, { cache: true, cacheTTL: CACHE_TIMES.SHORT });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw this.handleError(error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Revoke the current user's grant for a connected application, identified by
|
|
47
|
+
* its application `_id` (a {@link ConnectedApp.applicationId}, NOT a
|
|
48
|
+
* credential/client id — keyed by application so the revocation survives
|
|
49
|
+
* credential rotation). After this the application can no longer act on the
|
|
50
|
+
* user's behalf until it is re-authorized.
|
|
51
|
+
*
|
|
52
|
+
* Backed by `DELETE /auth/grants/:applicationId`. On success the cached
|
|
53
|
+
* connected-apps list (`GET:/auth/grants`) is invalidated so the next
|
|
54
|
+
* {@link listConnectedApps} read reflects the removal.
|
|
55
|
+
*
|
|
56
|
+
* @param applicationId - The connected application's Mongo `_id`.
|
|
57
|
+
*/
|
|
58
|
+
async revokeAppGrant(applicationId) {
|
|
59
|
+
try {
|
|
60
|
+
await this.makeRequest('DELETE', `/auth/grants/${applicationId}`, undefined, { cache: false });
|
|
61
|
+
// A revoke removes an entry from the user's connected-apps list; bust
|
|
62
|
+
// the cached `GET /auth/grants` so the next read re-fetches.
|
|
63
|
+
this.clearCacheEntry('GET:/auth/grants');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw this.handleError(error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
26
69
|
/**
|
|
27
70
|
* List applications the current user is an active member of.
|
|
28
71
|
*
|
|
@@ -16,51 +16,30 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Build a synchronous file URL from an Oxy asset id.
|
|
19
|
+
* Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
|
|
20
20
|
*
|
|
21
|
-
* This
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
|
|
28
|
-
* CloudFront resolves the id against the public media origin. No token,
|
|
29
|
-
* `fallback`, or origin query params are emitted — these URLs are cacheable
|
|
30
|
-
* and shareable.
|
|
31
|
-
* - **Signed / private asset** — an access token is present on the client OR
|
|
32
|
-
* `expiresIn` was passed (the caller explicitly wants an expiring/authorized
|
|
33
|
-
* URL) → keeps the authenticated origin form
|
|
34
|
-
* `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
|
|
35
|
-
* the public CDN, so they must go through the API origin that can authorize
|
|
36
|
-
* the request.
|
|
37
|
-
*
|
|
38
|
-
* `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
|
|
39
|
-
* `OxyServices` constructor and read via `getCloudURL()`; the API origin is
|
|
40
|
-
* `getBaseURL()` (e.g. `https://api.oxy.so`).
|
|
41
|
-
*
|
|
42
|
-
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
21
|
+
* This method must never embed the caller's general access token in the
|
|
22
|
+
* returned URL. The URL is commonly rendered into DOM attributes, browser
|
|
23
|
+
* network panels, caches, and logs. Public asset URLs use the clean CDN
|
|
24
|
+
* origin; callers that need authorized/private access should use
|
|
25
|
+
* {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
|
|
26
|
+
* URL instead of exposing the in-memory bearer token in a query string.
|
|
43
27
|
*/
|
|
44
|
-
getFileDownloadUrl(fileId, variant, expiresIn
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
28
|
+
getFileDownloadUrl(fileId, variant, expiresIn) {
|
|
29
|
+
// Never embed the in-memory bearer token: this URL is rendered into DOM
|
|
30
|
+
// attributes, browser network panels, caches, and logs. Public assets get
|
|
31
|
+
// the clean CDN origin; private/authorized access goes through
|
|
32
|
+
// `getFileDownloadUrlAsync`.
|
|
33
|
+
if (!expiresIn) {
|
|
49
34
|
const variantQs = variant ? `?variant=${encodeURIComponent(variant)}` : '';
|
|
50
35
|
return `${this.getCloudURL()}/${encodeURIComponent(fileId)}${variantQs}`;
|
|
51
36
|
}
|
|
52
|
-
// Signed / private case: route through the authenticated API origin's
|
|
53
|
-
// stream endpoint so the request can be authorized (private assets are not
|
|
54
|
-
// exposed on the public CDN).
|
|
55
37
|
const base = this.getBaseURL();
|
|
56
38
|
const params = new URLSearchParams();
|
|
57
39
|
if (variant)
|
|
58
40
|
params.set('variant', variant);
|
|
59
|
-
|
|
60
|
-
params.set('expiresIn', String(expiresIn));
|
|
41
|
+
params.set('expiresIn', String(expiresIn));
|
|
61
42
|
params.set('fallback', 'placeholderVisible');
|
|
62
|
-
if (token)
|
|
63
|
-
params.set('token', token);
|
|
64
43
|
const qs = params.toString();
|
|
65
44
|
return `${base}/assets/${encodeURIComponent(fileId)}/stream${qs ? `?${qs}` : ''}`;
|
|
66
45
|
}
|
|
@@ -546,7 +546,18 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
546
546
|
*/
|
|
547
547
|
async getCommonsApprovalInfo(authorizeCode) {
|
|
548
548
|
try {
|
|
549
|
-
|
|
549
|
+
const raw = await this.makeRequest('GET', `/auth/session/approve-info/${encodeURIComponent(authorizeCode)}`, undefined, { cache: false });
|
|
550
|
+
return {
|
|
551
|
+
application: raw.application,
|
|
552
|
+
scopes: raw.scopes,
|
|
553
|
+
boundOrigin: raw.boundOrigin,
|
|
554
|
+
// Fail-safe: only a literal boolean `true` counts as verified. A
|
|
555
|
+
// missing or non-boolean value (older server, malformed response)
|
|
556
|
+
// coerces to `false` so a stale server can never imply trust.
|
|
557
|
+
originVerified: raw.originVerified === true,
|
|
558
|
+
expiresAt: raw.expiresAt,
|
|
559
|
+
status: raw.status,
|
|
560
|
+
};
|
|
550
561
|
}
|
|
551
562
|
catch (error) {
|
|
552
563
|
throw this.handleError(error);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { buildUrl } from '../utils/apiUtils.js';
|
|
2
|
+
/**
|
|
3
|
+
* Maximum number of URLs sent per `POST /links/previews` request. Matches the
|
|
4
|
+
* server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
|
|
5
|
+
* inputs are split into multiple chunked calls and the result maps merged,
|
|
6
|
+
* mirroring how `getUsersByIds` chunks at 100.
|
|
7
|
+
*/
|
|
8
|
+
const LINK_PREVIEWS_CHUNK_SIZE = 50;
|
|
9
|
+
export function OxyServicesLinksMixin(Base) {
|
|
10
|
+
return class extends Base {
|
|
11
|
+
constructor(...args) {
|
|
12
|
+
super(...args);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
16
|
+
*
|
|
17
|
+
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
18
|
+
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
19
|
+
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
20
|
+
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
21
|
+
*
|
|
22
|
+
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
23
|
+
* on a later read, so caching here would serve the stale placeholder.
|
|
24
|
+
*/
|
|
25
|
+
async getLinkPreview(url, opts) {
|
|
26
|
+
try {
|
|
27
|
+
const path = buildUrl('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
|
|
28
|
+
return await this.makeRequest('GET', path, undefined, { cache: false });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw this.handleError(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
36
|
+
*
|
|
37
|
+
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
38
|
+
* (the server-side cap). Chunks run concurrently and their `data` maps are
|
|
39
|
+
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
40
|
+
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
41
|
+
* look its own input back up.
|
|
42
|
+
*
|
|
43
|
+
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
44
|
+
* performs no network call. A failure in any chunk surfaces (via
|
|
45
|
+
* `handleError`) rather than being swallowed.
|
|
46
|
+
*/
|
|
47
|
+
async getLinkPreviews(urls) {
|
|
48
|
+
const uniqueUrls = Array.from(new Set(urls.filter((u) => typeof u === 'string' && u.trim().length > 0)));
|
|
49
|
+
if (uniqueUrls.length === 0) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
|
|
54
|
+
chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const responses = await Promise.all(chunks.map((chunk) => this.makeRequest('POST', '/links/previews', { urls: chunk }, { cache: false })));
|
|
58
|
+
return responses.reduce((merged, response) => Object.assign(merged, response?.data ?? {}), {});
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw this.handleError(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { SignatureService } from '../crypto/signatureService.js';
|
|
2
|
+
import { buildUserDid } from './OxyServices.identity.js';
|
|
3
|
+
import { CACHE_TIMES } from './mixinHelpers.js';
|
|
4
|
+
/**
|
|
5
|
+
* AtProto-style collection (NSID) for a user-node registration record — matches
|
|
6
|
+
* the server's `NODE_COLLECTION`. A user has exactly one node, so the record is
|
|
7
|
+
* keyed by the constant {@link NODE_RKEY} (last-writer-wins): re-registering
|
|
8
|
+
* over-writes the single `self` record rather than appending a second node.
|
|
9
|
+
*/
|
|
10
|
+
const NODE_COLLECTION = 'app.oxy.node';
|
|
11
|
+
/**
|
|
12
|
+
* The AtProto-style record key for the single node registration — matches the
|
|
13
|
+
* server's `NODE_RKEY`. Constant (`'self'`) because a user has one node.
|
|
14
|
+
*/
|
|
15
|
+
const NODE_RKEY = 'self';
|
|
16
|
+
/**
|
|
17
|
+
* Cache-key prefix of every node read (`GET /nodes/me`). Swept after a
|
|
18
|
+
* register / revoke / managed-provision so a re-read reflects the new node
|
|
19
|
+
* (or its absence) instead of a stale cached one. The identity tag is a key
|
|
20
|
+
* SUFFIX, so this prefix invalidates the resource for every cached identity.
|
|
21
|
+
*/
|
|
22
|
+
const NODES_CACHE_PREFIX = 'GET:/nodes/';
|
|
23
|
+
/**
|
|
24
|
+
* Cache-key prefix of the current user's `GET /users/me`. Swept alongside the
|
|
25
|
+
* node caches because the user's derived DID document embeds an `#oxy-node`
|
|
26
|
+
* service entry derived from the node row, so registering / revoking / managing
|
|
27
|
+
* a node changes user-facing identity state.
|
|
28
|
+
*/
|
|
29
|
+
const USERS_ME_CACHE_PREFIX = 'GET:/users/me';
|
|
30
|
+
export function OxyServicesNodesMixin(Base) {
|
|
31
|
+
return class extends Base {
|
|
32
|
+
constructor(...args) {
|
|
33
|
+
super(...args);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Register (or re-register) the caller's SELF-HOSTED personal data node.
|
|
37
|
+
*
|
|
38
|
+
* Builds the `{ endpoint, nodePublicKey, mode }` node record, signs a v2
|
|
39
|
+
* envelope on the caller's own per-subject hash chain (fetching the current
|
|
40
|
+
* chain head first so `seq`/`prev` are never stale), and publishes it through
|
|
41
|
+
* the EXISTING `POST /identity/records` path — which verifies the signature
|
|
42
|
+
* and materializes the operational node cache as a side effect. The signed
|
|
43
|
+
* record (not this call) is the authority; re-registering over-writes the
|
|
44
|
+
* single `self` record (last-writer-wins).
|
|
45
|
+
*
|
|
46
|
+
* NATIVE-ONLY: signs with the on-device identity key (throws on web / when no
|
|
47
|
+
* identity or no authenticated user — the guard fires before any network).
|
|
48
|
+
* `mode` defaults to `'pull'`. After a successful publish the node + `/users/me`
|
|
49
|
+
* GET caches are swept, then the freshly-materialized status is returned.
|
|
50
|
+
*
|
|
51
|
+
* Throws if the chain record stored but the server skipped materialization
|
|
52
|
+
* (e.g. a malformed endpoint the server rejected) — an unexpected state rather
|
|
53
|
+
* than a silent `null`.
|
|
54
|
+
*
|
|
55
|
+
* @param input - The node's endpoint, public key, and optional transport mode.
|
|
56
|
+
*/
|
|
57
|
+
async registerNode(input) {
|
|
58
|
+
try {
|
|
59
|
+
const userId = this.getCurrentUserId();
|
|
60
|
+
if (!userId) {
|
|
61
|
+
throw new Error('No authenticated user — sign in before registering a node.');
|
|
62
|
+
}
|
|
63
|
+
const subject = buildUserDid(userId);
|
|
64
|
+
const record = {
|
|
65
|
+
endpoint: input.endpoint,
|
|
66
|
+
nodePublicKey: input.nodePublicKey,
|
|
67
|
+
mode: input.mode ?? 'pull',
|
|
68
|
+
};
|
|
69
|
+
// Fetch the caller's chain head fresh (uncached) so seq/prev are correct
|
|
70
|
+
// → no bad_seq / chain_fork — exactly as the identity/civic signers do.
|
|
71
|
+
const head = await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/chain/head`, undefined, { cache: false });
|
|
72
|
+
const envelope = await SignatureService.signRecordV2('node', subject, record, {
|
|
73
|
+
seq: head.seq + 1,
|
|
74
|
+
prev: head.headRecordId,
|
|
75
|
+
collection: NODE_COLLECTION,
|
|
76
|
+
rkey: NODE_RKEY,
|
|
77
|
+
});
|
|
78
|
+
await this.makeRequest('POST', '/identity/records', envelope, { cache: false });
|
|
79
|
+
this._sweepNodeCaches();
|
|
80
|
+
const node = await this.getMyNode();
|
|
81
|
+
if (!node) {
|
|
82
|
+
throw new Error('Node registration stored but the node could not be materialized.');
|
|
83
|
+
}
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw this.handleError(error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read the caller's registered node status (`GET /nodes/me`), or `null` when
|
|
92
|
+
* the caller has no node. Auth required; short-TTL cached (the liveness badge
|
|
93
|
+
* is background-maintained) and swept after the caller's own
|
|
94
|
+
* register / revoke / managed-provision.
|
|
95
|
+
*/
|
|
96
|
+
async getMyNode() {
|
|
97
|
+
try {
|
|
98
|
+
const res = await this.makeRequest('GET', '/nodes/me', undefined, { cache: true, cacheTTL: CACHE_TIMES.SHORT });
|
|
99
|
+
return res.node ?? null;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw this.handleError(error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Revoke the caller's node registration (`DELETE /nodes/me`). The node flips
|
|
107
|
+
* to `revoked` server-side (leaving the DID document and liveness sweeps).
|
|
108
|
+
* Auth required; the node + `/users/me` GET caches are swept on success.
|
|
109
|
+
*
|
|
110
|
+
* Maps the server's `{ success }` to the SDK's `{ revoked }` semantic.
|
|
111
|
+
*/
|
|
112
|
+
async removeMyNode() {
|
|
113
|
+
try {
|
|
114
|
+
const res = await this.makeRequest('DELETE', '/nodes/me', undefined, { cache: false });
|
|
115
|
+
this._sweepNodeCaches();
|
|
116
|
+
return { revoked: res.success === true };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw this.handleError(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Provision (or refresh) an Oxy-operated MANAGED vault for the caller
|
|
124
|
+
* (`POST /nodes/managed`) — the "Create your vault" convenience for
|
|
125
|
+
* non-technical users. Oxy custodial-signs the node registration onto the
|
|
126
|
+
* caller's chain and returns the materialized node (`managed:true,
|
|
127
|
+
* controller:'oxy'`). Idempotent server-side. Auth required; the owner id is
|
|
128
|
+
* resolved from the session, never the body. The node + `/users/me` GET caches
|
|
129
|
+
* are swept on success.
|
|
130
|
+
*/
|
|
131
|
+
async provisionManagedVault() {
|
|
132
|
+
try {
|
|
133
|
+
const res = await this.makeRequest('POST', '/nodes/managed', undefined, { cache: false });
|
|
134
|
+
this._sweepNodeCaches();
|
|
135
|
+
return res.node;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
throw this.handleError(error);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Send an ingest HINT that a user's node has new records
|
|
143
|
+
* (`POST /nodes/ingest/notify/:userId`). Unauthenticated by design and
|
|
144
|
+
* fire-and-forget on the server (it only schedules a background re-pull of the
|
|
145
|
+
* named user's OWN node, then fully re-verifies — a notify can never inject
|
|
146
|
+
* data), so this resolves once the 202 hint is accepted and returns nothing.
|
|
147
|
+
*
|
|
148
|
+
* @param userId - The user whose node may have new records. URL-encoded.
|
|
149
|
+
*/
|
|
150
|
+
async notifyNodeIngest(userId) {
|
|
151
|
+
try {
|
|
152
|
+
await this.makeRequest('POST', `/nodes/ingest/notify/${encodeURIComponent(userId)}`, undefined, { cache: false });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw this.handleError(error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Sweep the GET caches a node mutation invalidates: every node read
|
|
160
|
+
* (`GET:/nodes/`) so a re-read reflects the new node / its absence, and
|
|
161
|
+
* `/users/me` because the user's derived DID document embeds an `#oxy-node`
|
|
162
|
+
* service entry that changes on register / revoke / manage. Public rather
|
|
163
|
+
* than `private` because mixins compose into an exported anonymous class
|
|
164
|
+
* where TypeScript cannot represent a private member in the emitted
|
|
165
|
+
* declaration file (TS4094) — mirrors the civic / identity cache sweepers.
|
|
166
|
+
*/
|
|
167
|
+
_sweepNodeCaches() {
|
|
168
|
+
this.clearCacheByPrefix(NODES_CACHE_PREFIX);
|
|
169
|
+
this.clearCacheByPrefix(USERS_ME_CACHE_PREFIX);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -30,6 +30,8 @@ import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts.j
|
|
|
30
30
|
import { OxyServicesContactsMixin } from './OxyServices.contacts.js';
|
|
31
31
|
import { OxyServicesAppDataMixin } from './OxyServices.appData.js';
|
|
32
32
|
import { OxyServicesCivicMixin } from './OxyServices.civic.js';
|
|
33
|
+
import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
|
|
34
|
+
import { OxyServicesLinksMixin } from './OxyServices.links.js';
|
|
33
35
|
/**
|
|
34
36
|
* Mixin pipeline - applied in order from first to last.
|
|
35
37
|
*
|
|
@@ -77,6 +79,12 @@ const MIXIN_PIPELINE = [
|
|
|
77
79
|
OxyServicesAppDataMixin,
|
|
78
80
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
79
81
|
OxyServicesCivicMixin,
|
|
82
|
+
// User nodes / decentralization (Fase 5): register/read/revoke/manage the
|
|
83
|
+
// caller's personal data node + ingest hint.
|
|
84
|
+
OxyServicesNodesMixin,
|
|
85
|
+
// Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
|
|
86
|
+
// so apps stop scraping link metadata locally.
|
|
87
|
+
OxyServicesLinksMixin,
|
|
80
88
|
// Utility (last, can use all above)
|
|
81
89
|
OxyServicesUtilityMixin,
|
|
82
90
|
];
|
|
@@ -64,6 +64,7 @@ const DEST_KEY_PREFIX = 'oxy_sso_dest:';
|
|
|
64
64
|
const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
|
|
65
65
|
const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
|
|
66
66
|
const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
|
|
67
|
+
const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
|
|
67
68
|
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
68
69
|
export function ssoStateKey(origin) {
|
|
69
70
|
return `${STATE_KEY_PREFIX}${origin}`;
|
|
@@ -96,6 +97,23 @@ export function ssoNoSessionKey(origin) {
|
|
|
96
97
|
export function ssoAttemptedKey(origin) {
|
|
97
98
|
return `${ATTEMPTED_KEY_PREFIX}${origin}`;
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Per-origin DURABLE "this device/origin has had a signed-in Oxy session
|
|
102
|
+
* before" hint.
|
|
103
|
+
*
|
|
104
|
+
* Unlike every other key in this module — which lives in per-tab
|
|
105
|
+
* `sessionStorage` — this hint is written to DURABLE storage (web
|
|
106
|
+
* `localStorage`; the services provider uses its own `storageKeyPrefix`-scoped
|
|
107
|
+
* key in `@oxyhq/services`). It is set whenever a session is established or
|
|
108
|
+
* restored and survives a session expiring; it is cleared ONLY on an explicit
|
|
109
|
+
* full sign-out. It exists purely to drive {@link allowSsoBounce}: a returning
|
|
110
|
+
* visitor (hint present) whose local session has lapsed still gets ONE terminal
|
|
111
|
+
* `/sso` establish bounce to recover a session that lives only at the central
|
|
112
|
+
* IdP, while a truly first-time anonymous visitor is never force-bounced.
|
|
113
|
+
*/
|
|
114
|
+
export function ssoPriorSessionKey(origin) {
|
|
115
|
+
return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
|
|
116
|
+
}
|
|
99
117
|
/**
|
|
100
118
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
101
119
|
*
|
|
@@ -226,3 +244,31 @@ export function guardActive(storage, origin, now = Date.now()) {
|
|
|
226
244
|
}
|
|
227
245
|
return now - ts < SSO_GUARD_TTL_MS;
|
|
228
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
|
|
249
|
+
* visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).
|
|
250
|
+
*
|
|
251
|
+
* The terminal bounce is the ONLY cold-boot step that can recover a session
|
|
252
|
+
* that lives SOLELY at the central IdP — the cross-apex Relying-Party case
|
|
253
|
+
* (e.g. `mention.earth`, a different apex from `oxy.so`) whose device-local
|
|
254
|
+
* session has expired and whose `Domain=oxy.so` refresh cookie never reaches
|
|
255
|
+
* `api.<apex>`. It is also what plants the first-party per-apex `fedcm_session`
|
|
256
|
+
* cookie that the EARLIER `silent-iframe` step later relies on. So it must fire
|
|
257
|
+
* for a RETURNING user, yet it must NOT force a truly first-time anonymous
|
|
258
|
+
* visitor off to the IdP.
|
|
259
|
+
*
|
|
260
|
+
* - ALLOW when there is a prior-signed-in hint OR a local session was
|
|
261
|
+
* recovered this boot (a returning user) — so a central-only cross-domain
|
|
262
|
+
* session recovers via ONE bounce, after which the per-apex cookie is
|
|
263
|
+
* planted and subsequent loads restore silently with no bounce.
|
|
264
|
+
* - else (no hint, no local session) SUPPRESS — a first-time anonymous
|
|
265
|
+
* visitor browses without a forced redirect.
|
|
266
|
+
*
|
|
267
|
+
* This is the smart DEFAULT and the ONLY behaviour: apps never configure it.
|
|
268
|
+
* It is also the GATE DECISION ONLY — callers still apply the per-tab loop
|
|
269
|
+
* guards (`ssoAttemptedKey`, `ssoNoSessionKey`, {@link guardActive}) so an
|
|
270
|
+
* allowed bounce still fires at most once per cold boot.
|
|
271
|
+
*/
|
|
272
|
+
export function allowSsoBounce(gate) {
|
|
273
|
+
return gate.hasPriorSession || gate.hasLocalSession;
|
|
274
|
+
}
|