@cortexkit/common-auth 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/quota/codec.d.ts +11 -0
- package/dist/quota/codec.js +12 -0
- package/dist/quota/index.d.ts +8 -0
- package/dist/quota/index.js +4 -0
- package/dist/quota/map.d.ts +63 -0
- package/dist/quota/map.js +105 -0
- package/dist/quota/merge.d.ts +48 -0
- package/dist/quota/merge.js +162 -0
- package/dist/quota/projection.d.ts +57 -0
- package/dist/quota/projection.js +127 -0
- package/dist/routing/admission.d.ts +93 -0
- package/dist/routing/admission.js +140 -0
- package/dist/routing/index.d.ts +8 -0
- package/dist/routing/index.js +4 -0
- package/dist/routing/ordered.d.ts +47 -0
- package/dist/routing/ordered.js +58 -0
- package/dist/routing/pins.d.ts +22 -0
- package/dist/routing/pins.js +34 -0
- package/dist/routing/sticky.d.ts +118 -0
- package/dist/routing/sticky.js +310 -0
- package/dist/sidebar-file/sidebar-file.d.ts +4 -3
- package/dist/sidebar-file/sidebar-file.js +1 -4
- package/dist/store/attribution.d.ts +17 -0
- package/dist/store/attribution.js +46 -0
- package/dist/store/errors.d.ts +52 -0
- package/dist/store/errors.js +38 -0
- package/dist/store/hooks.d.ts +12 -0
- package/dist/store/hooks.js +34 -0
- package/dist/store/identity.d.ts +23 -0
- package/dist/store/identity.js +53 -0
- package/dist/store/index.d.ts +16 -0
- package/dist/store/index.js +5 -0
- package/dist/store/mutate.d.ts +111 -0
- package/dist/store/mutate.js +294 -0
- package/dist/store/pool.d.ts +86 -0
- package/dist/store/pool.js +98 -0
- package/dist/store/pull.d.ts +36 -0
- package/dist/store/pull.js +129 -0
- package/dist/store/refresh-lock.d.ts +63 -0
- package/dist/store/refresh-lock.js +125 -0
- package/dist/store/refresh.d.ts +48 -0
- package/dist/store/refresh.js +169 -0
- package/dist/store/rows.d.ts +53 -0
- package/dist/store/rows.js +250 -0
- package/dist/store/runtime.d.ts +28 -0
- package/dist/store/runtime.js +45 -0
- package/dist/store/schema.d.ts +133 -0
- package/dist/store/schema.js +323 -0
- package/package.json +13 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// `sticky-balanced` routing: openai-auth's quota-weighted session placement
|
|
2
|
+
// and pin-break classification, judged over the quota projection rather than
|
|
3
|
+
// openai-auth's fixed primary/secondary snapshot.
|
|
4
|
+
import { budgetExhaustedResetAt, projectQuota, } from '../quota/projection.js';
|
|
5
|
+
import { admit, } from './admission.js';
|
|
6
|
+
import { isPinValid } from './pins.js';
|
|
7
|
+
export const QUOTA_STALENESS_MS = 15 * 60_000;
|
|
8
|
+
export const MIN_RESET_HOURS = 1 / 60;
|
|
9
|
+
export const MIN_WEIGHT = 1e-6;
|
|
10
|
+
/**
|
|
11
|
+
* How many window readings the selection primitives judge, as openai-auth's
|
|
12
|
+
* primary and secondary slots did. The projection's first readings in its
|
|
13
|
+
* order fill the slots; any further window is judged by admission only.
|
|
14
|
+
*/
|
|
15
|
+
export const STICKY_WINDOW_SLOTS = 2;
|
|
16
|
+
/** The projection's time, else the caller's cache-entry time. */
|
|
17
|
+
export function snapshotCheckedAt(quota, entryCheckedAt) {
|
|
18
|
+
for (const checkedAt of [quota?.checkedAt, entryCheckedAt]) {
|
|
19
|
+
if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
|
|
20
|
+
return checkedAt;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function slotReadings(quota) {
|
|
26
|
+
// Longest known window first, unknown lengths last, as openai-auth sorts.
|
|
27
|
+
// Tombstones and absence records carry no capacity figure, so they occupy
|
|
28
|
+
// no slot.
|
|
29
|
+
return quota.limits
|
|
30
|
+
.filter((limit) => limit.kind === 'reading')
|
|
31
|
+
.sort((left, right) => {
|
|
32
|
+
const leftKnown = left.windowMinutes !== undefined;
|
|
33
|
+
const rightKnown = right.windowMinutes !== undefined;
|
|
34
|
+
if (leftKnown !== rightKnown)
|
|
35
|
+
return leftKnown ? -1 : 1;
|
|
36
|
+
if (leftKnown && rightKnown) {
|
|
37
|
+
return (right.windowMinutes ?? 0) - (left.windowMinutes ?? 0);
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
})
|
|
41
|
+
.slice(0, STICKY_WINDOW_SLOTS);
|
|
42
|
+
}
|
|
43
|
+
/** Classifies whether a pinned session should leave its row after a failure. */
|
|
44
|
+
export function decideStickyBreak(input) {
|
|
45
|
+
if (input.status === 401 || input.status === 403) {
|
|
46
|
+
return { action: 'migrate', reason: 'permanent' };
|
|
47
|
+
}
|
|
48
|
+
if (!input.quota)
|
|
49
|
+
return { action: 'retain', reason: 'unknown' };
|
|
50
|
+
const checkedAt = snapshotCheckedAt(input.quota, input.quotaCheckedAt);
|
|
51
|
+
if (checkedAt === undefined ||
|
|
52
|
+
!Number.isFinite(checkedAt) ||
|
|
53
|
+
input.now - checkedAt > QUOTA_STALENESS_MS) {
|
|
54
|
+
return { action: 'retain', reason: 'stale' };
|
|
55
|
+
}
|
|
56
|
+
// After the stale check, so a stale snapshot never judges the account on a
|
|
57
|
+
// reading the killswitch would consider below its floor.
|
|
58
|
+
if (input.killswitchPasses === false) {
|
|
59
|
+
return { action: 'migrate', reason: 'killswitch' };
|
|
60
|
+
}
|
|
61
|
+
for (const limit of slotReadings(input.quota)) {
|
|
62
|
+
const remaining = limit.remainingPercent;
|
|
63
|
+
if (typeof remaining === 'number' &&
|
|
64
|
+
Number.isFinite(remaining) &&
|
|
65
|
+
remaining <= 0) {
|
|
66
|
+
return {
|
|
67
|
+
action: 'migrate',
|
|
68
|
+
reason: 'exhausted',
|
|
69
|
+
window: { scope: limit.scope, label: limit.label },
|
|
70
|
+
...(typeof limit.resetsAt === 'string'
|
|
71
|
+
? { resetsAt: limit.resetsAt }
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// A reached credit budget is exhaustion on its own axis, judged by the same
|
|
77
|
+
// signal admission uses so the two never disagree on what "spent" means.
|
|
78
|
+
const budgetReset = budgetExhaustedResetAt(input.quota, input.now);
|
|
79
|
+
if (budgetReset) {
|
|
80
|
+
return {
|
|
81
|
+
action: 'migrate',
|
|
82
|
+
reason: 'exhausted',
|
|
83
|
+
resetsAt: budgetReset.resetsAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (input.status === undefined ||
|
|
87
|
+
input.status === 0 ||
|
|
88
|
+
!Number.isFinite(input.status) ||
|
|
89
|
+
(input.status >= 500 && input.status <= 599) ||
|
|
90
|
+
input.status === 429) {
|
|
91
|
+
return { action: 'retain', reason: 'transient' };
|
|
92
|
+
}
|
|
93
|
+
return { action: 'retain', reason: 'healthy' };
|
|
94
|
+
}
|
|
95
|
+
export function sustainableWindowWeight(window, reservePercent, now) {
|
|
96
|
+
const spendable = Math.max(0, window.remainingPercent - reservePercent);
|
|
97
|
+
if (spendable <= 0)
|
|
98
|
+
return 0;
|
|
99
|
+
if (!window.resetsAt)
|
|
100
|
+
return spendable;
|
|
101
|
+
const resetMs = Date.parse(window.resetsAt);
|
|
102
|
+
// A lapsed reset cannot yield a spend rate: the divisor would clamp to
|
|
103
|
+
// MIN_RESET_HOURS and inflate the weight about sixty-fold on stale
|
|
104
|
+
// information, so the un-rate-adjusted spendable capacity is used instead.
|
|
105
|
+
if (!Number.isFinite(resetMs) || resetMs <= now)
|
|
106
|
+
return spendable;
|
|
107
|
+
const hours = Math.max((resetMs - now) / 3_600_000, MIN_RESET_HOURS);
|
|
108
|
+
return spendable / hours;
|
|
109
|
+
}
|
|
110
|
+
function compareAccountIds(left, right) {
|
|
111
|
+
if (left < right)
|
|
112
|
+
return -1;
|
|
113
|
+
if (left > right)
|
|
114
|
+
return 1;
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
function candidateWeight(candidate, now) {
|
|
118
|
+
if (!candidate.quota)
|
|
119
|
+
return undefined;
|
|
120
|
+
const quotaCheckedAt = snapshotCheckedAt(candidate.quota, candidate.quotaCheckedAt);
|
|
121
|
+
if (quotaCheckedAt === undefined ||
|
|
122
|
+
now - quotaCheckedAt > QUOTA_STALENESS_MS) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
// Missing reserve data must leave a window usable rather than silently
|
|
126
|
+
// excluding its account.
|
|
127
|
+
const weights = slotReadings(candidate.quota).map((limit) => sustainableWindowWeight({
|
|
128
|
+
remainingPercent: limit.remainingPercent ?? Number.NaN,
|
|
129
|
+
...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
|
|
130
|
+
}, candidate.reservePercent[limit.label] ?? 0, now));
|
|
131
|
+
// The credit budget is a third pressure axis on its own reset clock. It has
|
|
132
|
+
// no configured reserve, and a malformed reading is ignored rather than
|
|
133
|
+
// allowed to zero the account's weight.
|
|
134
|
+
const budget = candidate.quota.budget;
|
|
135
|
+
if (budget &&
|
|
136
|
+
typeof budget.remainingPercent === 'number' &&
|
|
137
|
+
Number.isFinite(budget.remainingPercent)) {
|
|
138
|
+
weights.push(sustainableWindowWeight({
|
|
139
|
+
remainingPercent: budget.remainingPercent,
|
|
140
|
+
...(budget.resetsAt === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { resetsAt: budget.resetsAt }),
|
|
143
|
+
}, 0, now));
|
|
144
|
+
}
|
|
145
|
+
const weight = weights.length > 0 ? Math.min(...weights) : 0;
|
|
146
|
+
return weight > 0 ? { candidate, quotaCheckedAt, weight } : undefined;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Places a session: the lowest projected pressure among candidates with a
|
|
150
|
+
* fresh positive weight, else (`mode-fallback`) the first candidate in
|
|
151
|
+
* configured order, preferring one with an applicable reset credit.
|
|
152
|
+
*/
|
|
153
|
+
export function selectStickyCandidate(input) {
|
|
154
|
+
// A candidate killed by the killswitch is excluded from BOTH weighted
|
|
155
|
+
// placement and the fallback branch, which must never become a way to
|
|
156
|
+
// spend on a killed account.
|
|
157
|
+
const eligibleCandidates = input.candidates.filter((candidate) => candidate.killswitchPasses !== false);
|
|
158
|
+
if (input.candidates.length === 0) {
|
|
159
|
+
throw new Error('Cannot select a sticky candidate: input.candidates is empty');
|
|
160
|
+
}
|
|
161
|
+
if (eligibleCandidates.length === 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
const weighted = eligibleCandidates
|
|
164
|
+
.map((candidate) => candidateWeight(candidate, input.now))
|
|
165
|
+
.filter((candidate) => candidate !== undefined);
|
|
166
|
+
if (weighted.length > 0) {
|
|
167
|
+
weighted.sort((left, right) => {
|
|
168
|
+
// MIN_WEIGHT only guards the division; every weight here is positive.
|
|
169
|
+
const leftScore = ((input.pendingBytes.get(left.candidate.accountId) ?? 0) +
|
|
170
|
+
input.requestBytes) /
|
|
171
|
+
Math.max(left.weight, MIN_WEIGHT);
|
|
172
|
+
const rightScore = ((input.pendingBytes.get(right.candidate.accountId) ?? 0) +
|
|
173
|
+
input.requestBytes) /
|
|
174
|
+
Math.max(right.weight, MIN_WEIGHT);
|
|
175
|
+
return (leftScore - rightScore ||
|
|
176
|
+
left.candidate.configuredOrder - right.candidate.configuredOrder ||
|
|
177
|
+
compareAccountIds(left.candidate.accountId, right.candidate.accountId));
|
|
178
|
+
});
|
|
179
|
+
const selected = weighted[0];
|
|
180
|
+
if (selected) {
|
|
181
|
+
return {
|
|
182
|
+
accountId: selected.candidate.accountId,
|
|
183
|
+
quotaCheckedAt: selected.quotaCheckedAt,
|
|
184
|
+
source: 'weighted',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
input.onEmptyWeightedSet?.();
|
|
189
|
+
const fallback = [...eligibleCandidates].sort((left, right) => {
|
|
190
|
+
const leftHasCredits = (left.resetCreditsApplicable ?? 0) > 0 ? 1 : 0;
|
|
191
|
+
const rightHasCredits = (right.resetCreditsApplicable ?? 0) > 0 ? 1 : 0;
|
|
192
|
+
return (rightHasCredits - leftHasCredits ||
|
|
193
|
+
left.configuredOrder - right.configuredOrder ||
|
|
194
|
+
compareAccountIds(left.accountId, right.accountId));
|
|
195
|
+
})[0];
|
|
196
|
+
if (!fallback) {
|
|
197
|
+
throw new Error('Cannot select a sticky candidate: input.candidates is empty');
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
accountId: fallback.accountId,
|
|
201
|
+
quotaCheckedAt: snapshotCheckedAt(fallback.quota, fallback.quotaCheckedAt),
|
|
202
|
+
source: 'mode-fallback',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Routes one request in `sticky-balanced` mode. A valid pin whose row is
|
|
207
|
+
* admitted, not excluded and not killed is dispatched as is. Otherwise
|
|
208
|
+
* selection runs over the non-excluded rows; each selected row admission
|
|
209
|
+
* refused is removed and selection re-runs, so the loop ends either on an
|
|
210
|
+
* admitted row or with no admissible account.
|
|
211
|
+
*/
|
|
212
|
+
export function routeSticky(input) {
|
|
213
|
+
const admission = admit(input);
|
|
214
|
+
const admitted = new Map(admission.admitted.map((row) => [row.id, row]));
|
|
215
|
+
const refusals = new Map(admission.refused.map((r) => [r.id, r]));
|
|
216
|
+
const excluded = new Set(admission.excluded.map((row) => row.id));
|
|
217
|
+
const validIds = new Set(input.rows.map((row) => row.id));
|
|
218
|
+
const pinValid = input.pin !== undefined &&
|
|
219
|
+
isPinValid(input.pin, validIds, input.identities?.get(input.pin.accountId));
|
|
220
|
+
if (pinValid && input.pin) {
|
|
221
|
+
const pinned = admitted.get(input.pin.accountId);
|
|
222
|
+
if (pinned && input.killswitch?.get(pinned.id) !== false) {
|
|
223
|
+
return {
|
|
224
|
+
outcome: 'dispatch',
|
|
225
|
+
accountId: pinned.id,
|
|
226
|
+
source: 'pin',
|
|
227
|
+
...(pinned.projection?.checkedAt === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: { quotaCheckedAt: pinned.projection.checkedAt }),
|
|
230
|
+
pin: { action: 'retain' },
|
|
231
|
+
refusedSelections: [],
|
|
232
|
+
admission,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const scope = input.scope;
|
|
237
|
+
let candidates = input.rows
|
|
238
|
+
.map((row, configuredOrder) => ({ row, configuredOrder }))
|
|
239
|
+
.filter(({ row }) => !excluded.has(row.id))
|
|
240
|
+
.map(({ row, configuredOrder }) => {
|
|
241
|
+
const killswitchPasses = input.killswitch?.get(row.id);
|
|
242
|
+
const credits = input.resetCreditsApplicable?.get(row.id);
|
|
243
|
+
return {
|
|
244
|
+
accountId: row.id,
|
|
245
|
+
quota: row.kind === 'api-key'
|
|
246
|
+
? undefined
|
|
247
|
+
: (admitted.get(row.id)?.projection ??
|
|
248
|
+
projectQuota(row.quota, scope)),
|
|
249
|
+
reservePercent: input.reservePercent ?? {},
|
|
250
|
+
configuredOrder,
|
|
251
|
+
...(credits === undefined ? {} : { resetCreditsApplicable: credits }),
|
|
252
|
+
...(killswitchPasses === undefined ? {} : { killswitchPasses }),
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
const refusedSelections = [];
|
|
256
|
+
const unplaced = pinValid
|
|
257
|
+
? { action: 'retain' }
|
|
258
|
+
: input.pin
|
|
259
|
+
? { action: 'clear' }
|
|
260
|
+
: { action: 'none' };
|
|
261
|
+
while (candidates.length > 0) {
|
|
262
|
+
const selection = selectStickyCandidate({
|
|
263
|
+
candidates,
|
|
264
|
+
pendingBytes: input.pendingBytes ?? new Map(),
|
|
265
|
+
requestBytes: input.requestBytes,
|
|
266
|
+
now: input.now,
|
|
267
|
+
...(input.onEmptyWeightedSet
|
|
268
|
+
? { onEmptyWeightedSet: input.onEmptyWeightedSet }
|
|
269
|
+
: {}),
|
|
270
|
+
});
|
|
271
|
+
if (!selection)
|
|
272
|
+
break;
|
|
273
|
+
const refusal = refusals.get(selection.accountId);
|
|
274
|
+
if (refusal) {
|
|
275
|
+
refusedSelections.push(refusal);
|
|
276
|
+
candidates = candidates.filter((candidate) => candidate.accountId !== selection.accountId);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const identity = input.identities?.get(selection.accountId);
|
|
280
|
+
return {
|
|
281
|
+
outcome: 'dispatch',
|
|
282
|
+
accountId: selection.accountId,
|
|
283
|
+
source: selection.source,
|
|
284
|
+
...(selection.quotaCheckedAt === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: { quotaCheckedAt: selection.quotaCheckedAt }),
|
|
287
|
+
pin: pinValid
|
|
288
|
+
? { action: 'retain' }
|
|
289
|
+
: {
|
|
290
|
+
action: 'assign',
|
|
291
|
+
pin: {
|
|
292
|
+
accountId: selection.accountId,
|
|
293
|
+
inputBytes: input.requestBytes,
|
|
294
|
+
...(identity === undefined ? {} : { wireIdentity: identity }),
|
|
295
|
+
...(selection.quotaCheckedAt === undefined
|
|
296
|
+
? {}
|
|
297
|
+
: { quotaCheckedAt: selection.quotaCheckedAt }),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
refusedSelections,
|
|
301
|
+
admission,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
outcome: 'no-admissible-account',
|
|
306
|
+
pin: unplaced,
|
|
307
|
+
refusedSelections,
|
|
308
|
+
admission,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -10,9 +10,10 @@ export interface SidebarFileOptions<T> {
|
|
|
10
10
|
normalize: (parsed: unknown) => T;
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Tighten an existing parent directory to 0o700 before each write. Defaults
|
|
14
|
+
* to true. Pass false for a directory the user chose (an override path),
|
|
15
|
+
* whose permissions are theirs to set. A parent this library has to create
|
|
16
|
+
* is always created private, either way.
|
|
16
17
|
*/
|
|
17
18
|
secureDir?: boolean;
|
|
18
19
|
logger?: {
|
|
@@ -42,10 +42,7 @@ export function createSidebarFile(options) {
|
|
|
42
42
|
const persist = async (merge, hooks) => {
|
|
43
43
|
const parent = dirname(path);
|
|
44
44
|
const secureDir = options.secureDir ?? true;
|
|
45
|
-
await mkdir(parent, {
|
|
46
|
-
recursive: true,
|
|
47
|
-
mode: secureDir ? 0o700 : undefined,
|
|
48
|
-
});
|
|
45
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
49
46
|
if (secureDir) {
|
|
50
47
|
await chmod(parent, 0o700).catch((error) => {
|
|
51
48
|
options.logger?.warn('sidebar directory permission remediation failed', { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type StoreRuntime } from './runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a pull or refresh captured about its row (named by id alongside) when
|
|
4
|
+
* it was issued. A result applies only while the row with that id still has
|
|
5
|
+
* this credential epoch and this recorded identity; a replaced credential bumps the epoch, so work issued
|
|
6
|
+
* for the old one is discarded.
|
|
7
|
+
*/
|
|
8
|
+
export interface Attribution {
|
|
9
|
+
credentialEpoch: number;
|
|
10
|
+
identity?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Merges a quota observation into a row's stored map under the store locks,
|
|
14
|
+
* after attribution passes. Needs no row lock, so it is permitted from
|
|
15
|
+
* inside hooks. Clears needs-first-reading on success.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recordQuota(rt: StoreRuntime, id: string, attribution: Attribution, observation: unknown): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { PoolOperationError } from './errors.js';
|
|
2
|
+
import { toFailure, withTransaction } from './mutate.js';
|
|
3
|
+
import { LockStack } from './refresh-lock.js';
|
|
4
|
+
import { refusal, unknownRow } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* Merges a quota observation into a row's stored map under the store locks,
|
|
7
|
+
* after attribution passes. Needs no row lock, so it is permitted from
|
|
8
|
+
* inside hooks. Clears needs-first-reading on success.
|
|
9
|
+
*/
|
|
10
|
+
export async function recordQuota(rt, id, attribution, observation) {
|
|
11
|
+
const { ctx } = rt;
|
|
12
|
+
const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
|
|
13
|
+
const progress = { writes: 0 };
|
|
14
|
+
try {
|
|
15
|
+
await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
|
|
16
|
+
const row = tx.row(id);
|
|
17
|
+
if (!row)
|
|
18
|
+
throw unknownRow('pull', id);
|
|
19
|
+
if (row.invalid)
|
|
20
|
+
throw refusal('pull', id, 'invalid-row', `row ${id} is invalid`);
|
|
21
|
+
const entry = tx.entry(id);
|
|
22
|
+
if (!entry ||
|
|
23
|
+
entry.credentialEpoch !== attribution.credentialEpoch ||
|
|
24
|
+
row.identity !== attribution.identity)
|
|
25
|
+
throw new PoolOperationError({
|
|
26
|
+
operation: 'pull',
|
|
27
|
+
rowId: id,
|
|
28
|
+
phase: 'pull',
|
|
29
|
+
retryable: true,
|
|
30
|
+
kind: 'attribution',
|
|
31
|
+
message: `quota for ${id} was issued for a credential the row no longer holds`,
|
|
32
|
+
});
|
|
33
|
+
const merged = ctx.codec.merge(entry.quota, observation);
|
|
34
|
+
if (!ctx.codec.validate(merged))
|
|
35
|
+
throw refusal('pull', id, 'invalid-quota', 'the quota codec rejected the merged map');
|
|
36
|
+
tx.setEntry(id, { ...entry, quota: merged, needsFirstReading: false });
|
|
37
|
+
await tx.commitConfig();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw toFailure(error, 'pull', id, progress);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await locks.releaseAll();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { StoredCredential } from './schema.js';
|
|
2
|
+
/** Every library operation that can fail, as named in the failure value. */
|
|
3
|
+
export type PoolOperation = 'add' | 'replace' | 'rotate' | 'disable' | 'recordIdentity' | 'refresh' | 'pull';
|
|
4
|
+
/**
|
|
5
|
+
* How far an operation got before it failed.
|
|
6
|
+
*
|
|
7
|
+
* `before-first-write`: nothing was written; both files are as they were.
|
|
8
|
+
* `after-first-write`: the operation's first file write landed and a later one
|
|
9
|
+
* did not; what that first write left is on disk and is never rolled back
|
|
10
|
+
* (add: a row with no credential; replace: the bumped epoch beside the prior
|
|
11
|
+
* credential; rotate: the rotated credential beside the old per-row entry). `pull`: a quota pull, or the recording of its result, failed.
|
|
12
|
+
*/
|
|
13
|
+
export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pull';
|
|
14
|
+
/**
|
|
15
|
+
* Why an operation failed. `lock-contention` and `lock-ownership` are the two
|
|
16
|
+
* lock outcomes (a wait that ran out, and a lease found lost); the rest are
|
|
17
|
+
* refusals and failures of the operation itself.
|
|
18
|
+
*/
|
|
19
|
+
export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-key-changed' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
|
|
20
|
+
/**
|
|
21
|
+
* The single failure value of every store operation. `committed` is present
|
|
22
|
+
* only when the operation had already written a credential to the state file
|
|
23
|
+
* before it failed (a partial rotation or refresh): it is the credential now
|
|
24
|
+
* on disk, so a caller never has to re-read the files to learn it.
|
|
25
|
+
*/
|
|
26
|
+
export declare class PoolOperationError extends Error {
|
|
27
|
+
readonly operation: PoolOperation;
|
|
28
|
+
readonly rowId: string | undefined;
|
|
29
|
+
readonly phase: PoolFailurePhase;
|
|
30
|
+
readonly retryable: boolean;
|
|
31
|
+
readonly kind: PoolFailureKind;
|
|
32
|
+
readonly committed: StoredCredential | undefined;
|
|
33
|
+
constructor(details: {
|
|
34
|
+
operation: PoolOperation;
|
|
35
|
+
rowId?: string;
|
|
36
|
+
phase: PoolFailurePhase;
|
|
37
|
+
retryable: boolean;
|
|
38
|
+
kind: PoolFailureKind;
|
|
39
|
+
committed?: StoredCredential;
|
|
40
|
+
message?: string;
|
|
41
|
+
cause?: unknown;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when a row operation or a refresh is called from inside a hook of a
|
|
46
|
+
* lock-holding operation (or from any continuation created inside one). It is
|
|
47
|
+
* thrown before any lock is taken or waited for.
|
|
48
|
+
*/
|
|
49
|
+
export declare class PoolReentryError extends Error {
|
|
50
|
+
readonly operation: PoolOperation;
|
|
51
|
+
constructor(operation: PoolOperation);
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single failure value of every store operation. `committed` is present
|
|
3
|
+
* only when the operation had already written a credential to the state file
|
|
4
|
+
* before it failed (a partial rotation or refresh): it is the credential now
|
|
5
|
+
* on disk, so a caller never has to re-read the files to learn it.
|
|
6
|
+
*/
|
|
7
|
+
export class PoolOperationError extends Error {
|
|
8
|
+
operation;
|
|
9
|
+
rowId;
|
|
10
|
+
phase;
|
|
11
|
+
retryable;
|
|
12
|
+
kind;
|
|
13
|
+
committed;
|
|
14
|
+
constructor(details) {
|
|
15
|
+
super(details.message ??
|
|
16
|
+
`${details.operation} failed (${details.kind}, ${details.phase})`, details.cause === undefined ? undefined : { cause: details.cause });
|
|
17
|
+
this.name = 'PoolOperationError';
|
|
18
|
+
this.operation = details.operation;
|
|
19
|
+
this.rowId = details.rowId;
|
|
20
|
+
this.phase = details.phase;
|
|
21
|
+
this.retryable = details.retryable;
|
|
22
|
+
this.kind = details.kind;
|
|
23
|
+
this.committed = details.committed;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Thrown when a row operation or a refresh is called from inside a hook of a
|
|
28
|
+
* lock-holding operation (or from any continuation created inside one). It is
|
|
29
|
+
* thrown before any lock is taken or waited for.
|
|
30
|
+
*/
|
|
31
|
+
export class PoolReentryError extends Error {
|
|
32
|
+
operation;
|
|
33
|
+
constructor(operation) {
|
|
34
|
+
super(`${operation} was called from inside a store hook; hand it to the caller's continuation instead`);
|
|
35
|
+
this.name = 'PoolReentryError';
|
|
36
|
+
this.operation = operation;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type PoolOperation } from './errors.js';
|
|
2
|
+
/** Refuses a row operation or refresh called from inside a hook. */
|
|
3
|
+
export declare function assertNotInsideHook(operation: PoolOperation): void;
|
|
4
|
+
export declare function runInsideHook<T>(operation: PoolOperation, fn: () => Promise<T> | T): Promise<T>;
|
|
5
|
+
export interface PoolLogger {
|
|
6
|
+
warn(message: string, data?: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs a failure hook. A hook that throws never replaces the failure it was
|
|
10
|
+
* handed: its exception is logged and discarded.
|
|
11
|
+
*/
|
|
12
|
+
export declare function callFailureHook<E>(operation: PoolOperation, hook: ((rowId: string, error: E) => void | Promise<void>) | undefined, rowId: string, error: E, logger: PoolLogger | undefined): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { PoolReentryError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Set while a hook of a lock-holding operation runs. Async context is
|
|
5
|
+
* inherited by every timer, promise and continuation created inside the hook,
|
|
6
|
+
* so work a hook schedules is inside the guarded region too.
|
|
7
|
+
*/
|
|
8
|
+
const insideHook = new AsyncLocalStorage();
|
|
9
|
+
/** Refuses a row operation or refresh called from inside a hook. */
|
|
10
|
+
export function assertNotInsideHook(operation) {
|
|
11
|
+
if (insideHook.getStore() !== undefined)
|
|
12
|
+
throw new PoolReentryError(operation);
|
|
13
|
+
}
|
|
14
|
+
export function runInsideHook(operation, fn) {
|
|
15
|
+
return insideHook.run(operation, async () => await fn());
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Runs a failure hook. A hook that throws never replaces the failure it was
|
|
19
|
+
* handed: its exception is logged and discarded.
|
|
20
|
+
*/
|
|
21
|
+
export async function callFailureHook(operation, hook, rowId, error, logger) {
|
|
22
|
+
if (!hook)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
await runInsideHook(operation, () => hook(rowId, error));
|
|
26
|
+
}
|
|
27
|
+
catch (hookError) {
|
|
28
|
+
logger?.warn('store failure hook threw; the original failure stands', {
|
|
29
|
+
operation,
|
|
30
|
+
rowId,
|
|
31
|
+
error: hookError instanceof Error ? hookError.message : String(hookError),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Transaction } from './mutate.js';
|
|
2
|
+
import type { PoolRow } from './schema.js';
|
|
3
|
+
/** The reason recorded on a row disabled because an earlier row is the same account. */
|
|
4
|
+
export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
|
|
5
|
+
/**
|
|
6
|
+
* Enabled OAuth rows holding a credential whose wire identity is not yet
|
|
7
|
+
* known. API-key rows and disabled rows never count.
|
|
8
|
+
*/
|
|
9
|
+
export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): number;
|
|
10
|
+
/**
|
|
11
|
+
* Marks a row disabled with a reason: `enabled: false` in the roster row,
|
|
12
|
+
* which older readers honour, and the reason in the per-row entry. A row
|
|
13
|
+
* without an entry gets one at epoch 1. Nothing is ever deleted.
|
|
14
|
+
*/
|
|
15
|
+
export declare function disableIn(tx: Transaction, id: string, reason: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Two enabled OAuth rows with one wire identity are the same account: the
|
|
18
|
+
* earlier row in roster order stays enabled and every later one is disabled
|
|
19
|
+
* with a reason. Returns the ids it disabled.
|
|
20
|
+
*/
|
|
21
|
+
export declare function disableIdentityDuplicates(tx: Transaction, identity: string): string[];
|
|
22
|
+
/** Records a row's wire identity in its roster row, then applies dedupe. */
|
|
23
|
+
export declare function recordIdentityIn(tx: Transaction, id: string, identity: string): string[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The reason recorded on a row disabled because an earlier row is the same account. */
|
|
2
|
+
export const DUPLICATE_IDENTITY_REASON = 'duplicate-identity';
|
|
3
|
+
/**
|
|
4
|
+
* Enabled OAuth rows holding a credential whose wire identity is not yet
|
|
5
|
+
* known. API-key rows and disabled rows never count.
|
|
6
|
+
*/
|
|
7
|
+
export function countUnknownIdentityRows(rows) {
|
|
8
|
+
return rows.filter((row) => row.invalid === undefined &&
|
|
9
|
+
row.type === 'oauth' &&
|
|
10
|
+
row.enabled &&
|
|
11
|
+
row.credential !== undefined &&
|
|
12
|
+
row.identity === undefined).length;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Marks a row disabled with a reason: `enabled: false` in the roster row,
|
|
16
|
+
* which older readers honour, and the reason in the per-row entry. A row
|
|
17
|
+
* without an entry gets one at epoch 1. Nothing is ever deleted.
|
|
18
|
+
*/
|
|
19
|
+
export function disableIn(tx, id, reason) {
|
|
20
|
+
const raw = tx.rosterRow(id);
|
|
21
|
+
if (!raw)
|
|
22
|
+
return;
|
|
23
|
+
raw.enabled = false;
|
|
24
|
+
const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
|
|
25
|
+
tx.setEntry(id, { ...entry, disabledReason: reason });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Two enabled OAuth rows with one wire identity are the same account: the
|
|
29
|
+
* earlier row in roster order stays enabled and every later one is disabled
|
|
30
|
+
* with a reason. Returns the ids it disabled.
|
|
31
|
+
*/
|
|
32
|
+
export function disableIdentityDuplicates(tx, identity) {
|
|
33
|
+
const holders = tx
|
|
34
|
+
.rows()
|
|
35
|
+
.filter((row) => row.invalid === undefined &&
|
|
36
|
+
row.type === 'oauth' &&
|
|
37
|
+
row.enabled &&
|
|
38
|
+
row.identity === identity);
|
|
39
|
+
const disabled = [];
|
|
40
|
+
for (const row of holders.slice(1)) {
|
|
41
|
+
disableIn(tx, row.id, DUPLICATE_IDENTITY_REASON);
|
|
42
|
+
disabled.push(row.id);
|
|
43
|
+
}
|
|
44
|
+
return disabled;
|
|
45
|
+
}
|
|
46
|
+
/** Records a row's wire identity in its roster row, then applies dedupe. */
|
|
47
|
+
export function recordIdentityIn(tx, id, identity) {
|
|
48
|
+
const raw = tx.rosterRow(id);
|
|
49
|
+
if (!raw)
|
|
50
|
+
return [];
|
|
51
|
+
raw.accountId = identity;
|
|
52
|
+
return disableIdentityDuplicates(tx, identity);
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { Attribution } from './attribution.js';
|
|
2
|
+
export type { PoolFailureKind, PoolFailurePhase, PoolOperation, } from './errors.js';
|
|
3
|
+
export { PoolOperationError, PoolReentryError } from './errors.js';
|
|
4
|
+
export type { PoolLogger } from './hooks.js';
|
|
5
|
+
export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
|
|
6
|
+
export type { HoldPoint, WriteStep } from './mutate.js';
|
|
7
|
+
export type { OpenPoolStoreOptions, PoolLoad, PoolStore, } from './pool.js';
|
|
8
|
+
export { openPoolStore } from './pool.js';
|
|
9
|
+
export type { PullHook, PullRequest } from './pull.js';
|
|
10
|
+
export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
|
|
11
|
+
export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
|
|
12
|
+
export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
|
|
13
|
+
export type { AddInput, AddResult, FailureHook, RowOperationOptions, } from './rows.js';
|
|
14
|
+
export type { PullReason } from './runtime.js';
|
|
15
|
+
export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
|
|
16
|
+
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PoolOperationError, PoolReentryError } from './errors.js';
|
|
2
|
+
export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
|
|
3
|
+
export { openPoolStore } from './pool.js';
|
|
4
|
+
export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
|
|
5
|
+
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|