@oxyhq/core 2.4.0 → 3.0.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/OxyServices.js +1 -1
- package/dist/cjs/mixins/OxyServices.applications.js +212 -0
- package/dist/cjs/mixins/OxyServices.auth.js +4 -4
- package/dist/cjs/mixins/OxyServices.fedcm.js +52 -2
- package/dist/cjs/mixins/index.js +2 -2
- package/dist/cjs/utils/coldBoot.js +66 -17
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/mixins/OxyServices.applications.js +209 -0
- package/dist/esm/mixins/OxyServices.auth.js +4 -4
- package/dist/esm/mixins/OxyServices.fedcm.js +52 -2
- package/dist/esm/mixins/index.js +2 -2
- package/dist/esm/utils/coldBoot.js +66 -17
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.applications.d.ts +317 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -1
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/utils/coldBoot.d.ts +26 -0
- package/package.json +1 -1
- package/src/OxyServices.ts +1 -1
- package/src/index.ts +29 -0
- package/src/mixins/OxyServices.applications.ts +511 -0
- package/src/mixins/OxyServices.auth.ts +4 -4
- package/src/mixins/OxyServices.fedcm.ts +56 -2
- package/src/mixins/OxyServices.utility.ts +1 -1
- package/src/mixins/__tests__/fedcm.test.ts +52 -0
- package/src/mixins/index.ts +3 -3
- package/src/utils/__tests__/coldBoot.test.ts +150 -0
- package/src/utils/coldBoot.ts +96 -17
- package/src/mixins/OxyServices.developer.ts +0 -114
package/dist/cjs/OxyServices.js
CHANGED
|
@@ -25,7 +25,7 @@ const mixins_1 = require("./mixins");
|
|
|
25
25
|
* - **Payment**: Payment processing
|
|
26
26
|
* - **Karma**: Karma system
|
|
27
27
|
* - **Assets**: File upload and asset management
|
|
28
|
-
* - **
|
|
28
|
+
* - **Applications**: Application, membership, and credential management
|
|
29
29
|
* - **Location**: Location-based features
|
|
30
30
|
* - **Analytics**: Analytics tracking
|
|
31
31
|
* - **Devices**: Device management
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OxyServicesApplicationsMixin = OxyServicesApplicationsMixin;
|
|
4
|
+
const mixinHelpers_1 = require("./mixinHelpers");
|
|
5
|
+
function OxyServicesApplicationsMixin(Base) {
|
|
6
|
+
return class extends Base {
|
|
7
|
+
constructor(...args) {
|
|
8
|
+
super(...args);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* List applications the current user is an active member of.
|
|
12
|
+
*/
|
|
13
|
+
async getApplications() {
|
|
14
|
+
try {
|
|
15
|
+
const res = await this.makeRequest('GET', '/applications', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.MEDIUM });
|
|
16
|
+
return res.applications ?? [];
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw this.handleError(error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a new application. The caller becomes its `owner`.
|
|
24
|
+
* @param data - Application configuration. Staff-only fields are ignored.
|
|
25
|
+
*/
|
|
26
|
+
async createApplication(data) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await this.makeRequest('POST', '/applications', data, { cache: false });
|
|
29
|
+
return res.application;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw this.handleError(error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fetch a single application by id.
|
|
37
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
38
|
+
*/
|
|
39
|
+
async getApplication(applicationId) {
|
|
40
|
+
try {
|
|
41
|
+
const res = await this.makeRequest('GET', `/applications/${applicationId}`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.LONG });
|
|
42
|
+
return res.application;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw this.handleError(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Update an application's mutable fields.
|
|
50
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
51
|
+
* @param data - Subset of updatable fields. Staff-only fields are ignored.
|
|
52
|
+
*/
|
|
53
|
+
async updateApplication(applicationId, data) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await this.makeRequest('PATCH', `/applications/${applicationId}`, data, { cache: false });
|
|
56
|
+
return res.application;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw this.handleError(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Soft-delete an application (owner only).
|
|
64
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
65
|
+
*/
|
|
66
|
+
async deleteApplication(applicationId) {
|
|
67
|
+
try {
|
|
68
|
+
return await this.makeRequest('DELETE', `/applications/${applicationId}`, undefined, { cache: false });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw this.handleError(error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* List members of an application.
|
|
76
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
77
|
+
*/
|
|
78
|
+
async getApplicationMembers(applicationId) {
|
|
79
|
+
try {
|
|
80
|
+
const res = await this.makeRequest('GET', `/applications/${applicationId}/members`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.MEDIUM });
|
|
81
|
+
return res.members ?? [];
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw this.handleError(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Add a member to an application.
|
|
89
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
90
|
+
* @param data - Target user id and role (never `owner`).
|
|
91
|
+
*/
|
|
92
|
+
async inviteApplicationMember(applicationId, data) {
|
|
93
|
+
try {
|
|
94
|
+
const res = await this.makeRequest('POST', `/applications/${applicationId}/members`, data, { cache: false });
|
|
95
|
+
return res.member;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw this.handleError(error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Change a member's role.
|
|
103
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
104
|
+
* @param memberId - The member's Mongo `_id`.
|
|
105
|
+
* @param data - New role.
|
|
106
|
+
*/
|
|
107
|
+
async updateApplicationMember(applicationId, memberId, data) {
|
|
108
|
+
try {
|
|
109
|
+
const res = await this.makeRequest('PATCH', `/applications/${applicationId}/members/${memberId}`, data, { cache: false });
|
|
110
|
+
return res.member;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw this.handleError(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Remove a member from an application.
|
|
118
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
119
|
+
* @param memberId - The member's Mongo `_id`.
|
|
120
|
+
*/
|
|
121
|
+
async removeApplicationMember(applicationId, memberId) {
|
|
122
|
+
try {
|
|
123
|
+
return await this.makeRequest('DELETE', `/applications/${applicationId}/members/${memberId}`, undefined, { cache: false });
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw this.handleError(error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Transfer ownership of an application to another member (owner only).
|
|
131
|
+
* Demotes the current owner to `admin` and promotes the target to `owner`.
|
|
132
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
133
|
+
* @param data - Target user id.
|
|
134
|
+
*/
|
|
135
|
+
async transferApplicationOwnership(applicationId, data) {
|
|
136
|
+
try {
|
|
137
|
+
return await this.makeRequest('POST', `/applications/${applicationId}/transfer-ownership`, data, { cache: false });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw this.handleError(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* List an application's credentials. The response NEVER includes secrets.
|
|
145
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
146
|
+
*/
|
|
147
|
+
async getApplicationCredentials(applicationId) {
|
|
148
|
+
try {
|
|
149
|
+
const res = await this.makeRequest('GET', `/applications/${applicationId}/credentials`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.MEDIUM });
|
|
150
|
+
return res.credentials ?? [];
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
throw this.handleError(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Create a credential. The plaintext `secret` is returned exactly ONCE;
|
|
158
|
+
* the server stores only a hash and will never return it again.
|
|
159
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
160
|
+
* @param data - Credential configuration.
|
|
161
|
+
*/
|
|
162
|
+
async createApplicationCredential(applicationId, data) {
|
|
163
|
+
try {
|
|
164
|
+
return await this.makeRequest('POST', `/applications/${applicationId}/credentials`, data, { cache: false });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw this.handleError(error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Rotate a credential's secret. The new plaintext `secret` is returned
|
|
172
|
+
* exactly ONCE.
|
|
173
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
174
|
+
* @param credentialId - The credential's Mongo `_id`.
|
|
175
|
+
*/
|
|
176
|
+
async rotateApplicationCredential(applicationId, credentialId) {
|
|
177
|
+
try {
|
|
178
|
+
return await this.makeRequest('POST', `/applications/${applicationId}/credentials/${credentialId}/rotate`, undefined, { cache: false });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw this.handleError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Revoke a credential (`status='revoked'`). Revoked credentials can no
|
|
186
|
+
* longer authenticate.
|
|
187
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
188
|
+
* @param credentialId - The credential's Mongo `_id`.
|
|
189
|
+
*/
|
|
190
|
+
async revokeApplicationCredential(applicationId, credentialId) {
|
|
191
|
+
try {
|
|
192
|
+
return await this.makeRequest('DELETE', `/applications/${applicationId}/credentials/${credentialId}`, undefined, { cache: false });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw this.handleError(error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Fetch usage statistics for an application.
|
|
200
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
201
|
+
* @param period - Time window (defaults to the server default).
|
|
202
|
+
*/
|
|
203
|
+
async getApplicationUsage(applicationId, period) {
|
|
204
|
+
try {
|
|
205
|
+
return await this.makeRequest('GET', `/applications/${applicationId}/usage`, period ? { period } : undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
throw this.handleError(error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -65,8 +65,8 @@ function OxyServicesAuthMixin(Base) {
|
|
|
65
65
|
* legitimate multi-tenant hosts that need to switch credentials cannot leak
|
|
66
66
|
* one tenant's token to another tenant on the same instance.
|
|
67
67
|
*
|
|
68
|
-
* @param apiKey -
|
|
69
|
-
* @param apiSecret -
|
|
68
|
+
* @param apiKey - Application credential public key (oxy_dk_*)
|
|
69
|
+
* @param apiSecret - Application credential secret
|
|
70
70
|
*/
|
|
71
71
|
configureServiceAuth(apiKey, apiSecret) {
|
|
72
72
|
this._serviceApiKey = apiKey;
|
|
@@ -88,8 +88,8 @@ function OxyServicesAuthMixin(Base) {
|
|
|
88
88
|
* This prevents an attacker who learned a peer's apiKey from extracting
|
|
89
89
|
* their service token by polling with a wrong secret.
|
|
90
90
|
*
|
|
91
|
-
* @param apiKey -
|
|
92
|
-
* @param apiSecret -
|
|
91
|
+
* @param apiKey - Application credential public key (optional if configureServiceAuth was called)
|
|
92
|
+
* @param apiSecret - Application credential secret (optional if configureServiceAuth was called)
|
|
93
93
|
*/
|
|
94
94
|
async getServiceToken(apiKey, apiSecret) {
|
|
95
95
|
const key = apiKey || this._serviceApiKey;
|
|
@@ -445,6 +445,34 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
445
445
|
debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
|
|
446
446
|
controller.abort();
|
|
447
447
|
}, timeoutMs);
|
|
448
|
+
// Hard settle guarantee for the timeout path.
|
|
449
|
+
//
|
|
450
|
+
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
451
|
+
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
452
|
+
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
453
|
+
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
454
|
+
// to settle the awaited promise in every Chrome version / internal state
|
|
455
|
+
// (the credential request can sit "pending" while the browser-side flow is
|
|
456
|
+
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
457
|
+
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
458
|
+
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
459
|
+
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
460
|
+
//
|
|
461
|
+
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
462
|
+
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
463
|
+
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
464
|
+
// never settles, the race resolves and the step falls through cleanly to the
|
|
465
|
+
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
466
|
+
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
467
|
+
// the existing error path) before we force a clean `null`.
|
|
468
|
+
let settleTimer;
|
|
469
|
+
const settlePromise = new Promise((resolve) => {
|
|
470
|
+
const ctor = this.constructor;
|
|
471
|
+
settleTimer = setTimeout(() => {
|
|
472
|
+
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
473
|
+
resolve(null);
|
|
474
|
+
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
475
|
+
});
|
|
448
476
|
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
449
477
|
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
450
478
|
// synchronous TypeError, in which case we retry with the legacy value.
|
|
@@ -481,7 +509,13 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
481
509
|
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
482
510
|
let credential;
|
|
483
511
|
try {
|
|
484
|
-
|
|
512
|
+
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
513
|
+
// a `credentials.get` that ignores the abort signal can never hang
|
|
514
|
+
// the cold boot (see `settlePromise`).
|
|
515
|
+
credential = await Promise.race([
|
|
516
|
+
credentials.get(buildCredentialOptions(modernMode)),
|
|
517
|
+
settlePromise,
|
|
518
|
+
]);
|
|
485
519
|
}
|
|
486
520
|
catch (modeError) {
|
|
487
521
|
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
@@ -490,7 +524,10 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
490
524
|
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
491
525
|
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
492
526
|
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
493
|
-
credential = await
|
|
527
|
+
credential = await Promise.race([
|
|
528
|
+
credentials.get(buildCredentialOptions(legacyMode)),
|
|
529
|
+
settlePromise,
|
|
530
|
+
]);
|
|
494
531
|
}
|
|
495
532
|
else {
|
|
496
533
|
throw modeError;
|
|
@@ -517,6 +554,9 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
517
554
|
}
|
|
518
555
|
finally {
|
|
519
556
|
clearTimeout(timeout);
|
|
557
|
+
if (settleTimer !== undefined) {
|
|
558
|
+
clearTimeout(settleTimer);
|
|
559
|
+
}
|
|
520
560
|
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
521
561
|
// interactive request aborts a slow silent one, the silent settles (and
|
|
522
562
|
// runs this `finally`) AFTER the interactive has already taken over the
|
|
@@ -767,5 +807,15 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
767
807
|
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
768
808
|
_a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
|
|
769
809
|
,
|
|
810
|
+
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
811
|
+
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
812
|
+
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
813
|
+
// within this window (keeping the existing error path intact). If — as seen
|
|
814
|
+
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
815
|
+
// awaited promise never settles, the hard settle resolves the request to
|
|
816
|
+
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
817
|
+
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
818
|
+
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
819
|
+
_a.FEDCM_ABORT_SETTLE_GRACE_MS = 500,
|
|
770
820
|
_a;
|
|
771
821
|
}
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -20,7 +20,7 @@ const OxyServices_language_1 = require("./OxyServices.language");
|
|
|
20
20
|
const OxyServices_payment_1 = require("./OxyServices.payment");
|
|
21
21
|
const OxyServices_karma_1 = require("./OxyServices.karma");
|
|
22
22
|
const OxyServices_assets_1 = require("./OxyServices.assets");
|
|
23
|
-
const
|
|
23
|
+
const OxyServices_applications_1 = require("./OxyServices.applications");
|
|
24
24
|
const OxyServices_location_1 = require("./OxyServices.location");
|
|
25
25
|
const OxyServices_analytics_1 = require("./OxyServices.analytics");
|
|
26
26
|
const OxyServices_devices_1 = require("./OxyServices.devices");
|
|
@@ -64,7 +64,7 @@ const MIXIN_PIPELINE = [
|
|
|
64
64
|
OxyServices_payment_1.OxyServicesPaymentMixin,
|
|
65
65
|
OxyServices_karma_1.OxyServicesKarmaMixin,
|
|
66
66
|
OxyServices_assets_1.OxyServicesAssetsMixin,
|
|
67
|
-
|
|
67
|
+
OxyServices_applications_1.OxyServicesApplicationsMixin,
|
|
68
68
|
OxyServices_location_1.OxyServicesLocationMixin,
|
|
69
69
|
OxyServices_analytics_1.OxyServicesAnalyticsMixin,
|
|
70
70
|
OxyServices_devices_1.OxyServicesDevicesMixin,
|
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
exports.runColdBoot = runColdBoot;
|
|
28
|
+
/**
|
|
29
|
+
* The unique sentinel a step's `run()` resolves to (via the internal race)
|
|
30
|
+
* when the overall cold-boot deadline expires before that step settled. It is
|
|
31
|
+
* NOT a {@link ColdBootStepResult} — the runner detects it by identity and
|
|
32
|
+
* treats it as "this step did not settle in time; move on".
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
const DEADLINE_EXPIRED = Symbol('coldBoot.deadlineExpired');
|
|
28
37
|
/**
|
|
29
38
|
* Run the ordered cold-boot steps and resolve to the first recovered session,
|
|
30
39
|
* or `unauthenticated` if none recovers one.
|
|
@@ -41,31 +50,71 @@ exports.runColdBoot = runColdBoot;
|
|
|
41
50
|
* 4. After the loop with no winner → `{ kind: 'unauthenticated' }`.
|
|
42
51
|
*/
|
|
43
52
|
async function runColdBoot(options) {
|
|
44
|
-
const { steps, onStepError } = options;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
|
|
54
|
+
// Arm the optional overall deadline. The budget is SHARED across the whole
|
|
55
|
+
// loop (not reset per step): a single timer resolves a reusable
|
|
56
|
+
// `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
|
|
57
|
+
// fires, later steps race against an already-resolved promise and so never
|
|
58
|
+
// block, yet the loop keeps iterating so the terminal step still fires.
|
|
59
|
+
const deadlineMs = typeof overallDeadlineMs === 'number' &&
|
|
60
|
+
Number.isFinite(overallDeadlineMs) &&
|
|
61
|
+
overallDeadlineMs > 0
|
|
62
|
+
? overallDeadlineMs
|
|
63
|
+
: null;
|
|
64
|
+
let deadlineTimer;
|
|
65
|
+
let deadlinePromise;
|
|
66
|
+
if (deadlineMs !== null) {
|
|
67
|
+
deadlinePromise = new Promise((resolve) => {
|
|
68
|
+
deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
for (const step of steps) {
|
|
73
|
+
if (step.enabled) {
|
|
74
|
+
let isEnabled;
|
|
75
|
+
try {
|
|
76
|
+
isEnabled = step.enabled();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
onStepError?.(step.id, error);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!isEnabled)
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let result;
|
|
48
86
|
try {
|
|
49
|
-
|
|
87
|
+
// Without a deadline: legacy behaviour — await the step directly.
|
|
88
|
+
// With a deadline: race the step against the shared deadline. The
|
|
89
|
+
// step's `run()` still STARTS synchronously up to its first `await`
|
|
90
|
+
// (so a terminal step's synchronous navigation side effect always
|
|
91
|
+
// executes), but a non-settling step can no longer block the loop —
|
|
92
|
+
// the race resolves with the sentinel and we move on.
|
|
93
|
+
result = deadlinePromise
|
|
94
|
+
? await Promise.race([step.run(), deadlinePromise])
|
|
95
|
+
: await step.run();
|
|
50
96
|
}
|
|
51
97
|
catch (error) {
|
|
52
98
|
onStepError?.(step.id, error);
|
|
53
99
|
continue;
|
|
54
100
|
}
|
|
55
|
-
if (
|
|
101
|
+
if (result === DEADLINE_EXPIRED) {
|
|
102
|
+
// The deadline tripped before this step settled. Abandon the await and
|
|
103
|
+
// continue: subsequent steps race against the already-resolved deadline
|
|
104
|
+
// (so they cannot block), which lets a terminal side-effect step still
|
|
105
|
+
// run while guaranteeing the loop terminates promptly.
|
|
106
|
+
onStepDeadline?.(step.id);
|
|
56
107
|
continue;
|
|
108
|
+
}
|
|
109
|
+
if (result.kind === 'session') {
|
|
110
|
+
return { kind: 'session', via: step.id, session: result.session };
|
|
111
|
+
}
|
|
57
112
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
onStepError?.(step.id, error);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (result.kind === 'session') {
|
|
67
|
-
return { kind: 'session', via: step.id, session: result.session };
|
|
113
|
+
return { kind: 'unauthenticated' };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (deadlineTimer !== undefined) {
|
|
117
|
+
clearTimeout(deadlineTimer);
|
|
68
118
|
}
|
|
69
119
|
}
|
|
70
|
-
return { kind: 'unauthenticated' };
|
|
71
120
|
}
|