@cortexkit/antigravity-auth-core 2.2.1 → 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/README.md +24 -2
- package/dist/abort-forwarding.d.ts +3 -0
- package/dist/abort-forwarding.d.ts.map +1 -0
- package/dist/abort-forwarding.js +19 -0
- package/dist/abort-forwarding.js.map +1 -0
- package/dist/account-identity.d.ts +52 -0
- package/dist/account-identity.d.ts.map +1 -0
- package/dist/account-identity.js +66 -0
- package/dist/account-identity.js.map +1 -0
- package/dist/account-manager.d.ts +228 -43
- package/dist/account-manager.d.ts.map +1 -1
- package/dist/account-manager.js +942 -903
- package/dist/account-manager.js.map +1 -1
- package/dist/account-migration.d.ts +309 -0
- package/dist/account-migration.d.ts.map +1 -0
- package/dist/account-migration.js +2282 -0
- package/dist/account-migration.js.map +1 -0
- package/dist/account-repository-codecs.d.ts +82 -0
- package/dist/account-repository-codecs.d.ts.map +1 -0
- package/dist/account-repository-codecs.js +609 -0
- package/dist/account-repository-codecs.js.map +1 -0
- package/dist/account-repository-types.d.ts +831 -0
- package/dist/account-repository-types.d.ts.map +1 -0
- package/dist/account-repository-types.js +137 -0
- package/dist/account-repository-types.js.map +1 -0
- package/dist/account-repository.d.ts +442 -0
- package/dist/account-repository.d.ts.map +1 -0
- package/dist/account-repository.js +2253 -0
- package/dist/account-repository.js.map +1 -0
- package/dist/account-selector.d.ts +470 -0
- package/dist/account-selector.d.ts.map +1 -0
- package/dist/account-selector.js +1383 -0
- package/dist/account-selector.js.map +1 -0
- package/dist/account-storage.d.ts.map +1 -1
- package/dist/account-storage.js +5 -0
- package/dist/account-storage.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +21 -35
- package/dist/agy-transport.js.map +1 -1
- package/dist/antigravity-command-menu.d.ts +171 -0
- package/dist/antigravity-command-menu.d.ts.map +1 -0
- package/dist/antigravity-command-menu.js +523 -0
- package/dist/antigravity-command-menu.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -1
- package/dist/atomic-write.d.ts.map +1 -1
- package/dist/atomic-write.js +15 -3
- package/dist/atomic-write.js.map +1 -1
- package/dist/common-auth-embedded/NOTICE.txt +21 -0
- package/dist/common-auth-embedded/auth-menu/accounts.d.ts +97 -0
- package/dist/common-auth-embedded/auth-menu/accounts.js +387 -0
- package/dist/common-auth-embedded/auth-menu/ansi.d.ts +26 -0
- package/dist/common-auth-embedded/auth-menu/ansi.js +98 -0
- package/dist/common-auth-embedded/auth-menu/confirm.d.ts +7 -0
- package/dist/common-auth-embedded/auth-menu/confirm.js +21 -0
- package/dist/common-auth-embedded/auth-menu/doctor.d.ts +52 -0
- package/dist/common-auth-embedded/auth-menu/doctor.js +81 -0
- package/dist/common-auth-embedded/auth-menu/index.d.ts +17 -0
- package/dist/common-auth-embedded/auth-menu/index.js +12 -0
- package/dist/common-auth-embedded/auth-menu/login.d.ts +55 -0
- package/dist/common-auth-embedded/auth-menu/login.js +83 -0
- package/dist/common-auth-embedded/auth-menu/menu.d.ts +59 -0
- package/dist/common-auth-embedded/auth-menu/menu.js +73 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.js +47 -0
- package/dist/common-auth-embedded/auth-menu/select.d.ts +21 -0
- package/dist/common-auth-embedded/auth-menu/select.js +159 -0
- package/dist/common-auth-embedded/auth-menu/terminal.d.ts +43 -0
- package/dist/common-auth-embedded/auth-menu/terminal.js +19 -0
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +102 -0
- package/dist/common-auth-embedded/claustrum/consumer.js +298 -0
- package/dist/common-auth-embedded/claustrum/custody.d.ts +175 -0
- package/dist/common-auth-embedded/claustrum/custody.js +354 -0
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +158 -0
- package/dist/common-auth-embedded/claustrum/enrollment.js +759 -0
- package/dist/common-auth-embedded/claustrum/errors.d.ts +16 -0
- package/dist/common-auth-embedded/claustrum/errors.js +8 -0
- package/dist/common-auth-embedded/claustrum/host-slot.d.ts +39 -0
- package/dist/common-auth-embedded/claustrum/host-slot.js +72 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +18 -0
- package/dist/common-auth-embedded/claustrum/index.js +22 -0
- package/dist/common-auth-embedded/claustrum/interlock.d.ts +27 -0
- package/dist/common-auth-embedded/claustrum/interlock.js +29 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +208 -0
- package/dist/common-auth-embedded/claustrum/roster.js +536 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +71 -0
- package/dist/common-auth-embedded/commands/builtins.js +534 -0
- package/dist/common-auth-embedded/commands/index.d.ts +10 -0
- package/dist/common-auth-embedded/commands/index.js +5 -0
- package/dist/common-auth-embedded/commands/menu.d.ts +61 -0
- package/dist/common-auth-embedded/commands/menu.js +300 -0
- package/dist/common-auth-embedded/commands/model.d.ts +238 -0
- package/dist/common-auth-embedded/commands/model.js +16 -0
- package/dist/common-auth-embedded/commands/pi.d.ts +23 -0
- package/dist/common-auth-embedded/commands/pi.js +206 -0
- package/dist/common-auth-embedded/commands/seam.d.ts +72 -0
- package/dist/common-auth-embedded/commands/seam.js +301 -0
- package/dist/common-auth-embedded/fs/atomic-write.d.ts +20 -0
- package/dist/common-auth-embedded/fs/atomic-write.js +80 -0
- package/dist/common-auth-embedded/fs/index.d.ts +7 -0
- package/dist/common-auth-embedded/fs/index.js +6 -0
- package/dist/common-auth-embedded/fs/lock-constants.d.ts +14 -0
- package/dist/common-auth-embedded/fs/lock-constants.js +14 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +501 -0
- package/dist/common-auth-embedded/fs/with-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/with-lock.js +48 -0
- package/dist/common-auth-embedded/logger/capture-sink.d.ts +14 -0
- package/dist/common-auth-embedded/logger/capture-sink.js +13 -0
- package/dist/common-auth-embedded/logger/engine.d.ts +100 -0
- package/dist/common-auth-embedded/logger/engine.js +246 -0
- package/dist/common-auth-embedded/logger/index.d.ts +6 -0
- package/dist/common-auth-embedded/logger/index.js +3 -0
- package/dist/common-auth-embedded/logger/redact.d.ts +11 -0
- package/dist/common-auth-embedded/logger/redact.js +83 -0
- package/dist/common-auth-embedded/pi-slot/index.d.ts +3 -0
- package/dist/common-auth-embedded/pi-slot/index.js +2 -0
- package/dist/common-auth-embedded/pi-slot/lock.js +212 -0
- package/dist/common-auth-embedded/pi-slot/slot.d.ts +49 -0
- package/dist/common-auth-embedded/pi-slot/slot.js +383 -0
- package/dist/common-auth-embedded/quota/codec.d.ts +11 -0
- package/dist/common-auth-embedded/quota/codec.js +12 -0
- package/dist/common-auth-embedded/quota/format.d.ts +46 -0
- package/dist/common-auth-embedded/quota/format.js +138 -0
- package/dist/common-auth-embedded/quota/index.d.ts +10 -0
- package/dist/common-auth-embedded/quota/index.js +5 -0
- package/dist/common-auth-embedded/quota/map.d.ts +63 -0
- package/dist/common-auth-embedded/quota/map.js +105 -0
- package/dist/common-auth-embedded/quota/merge.d.ts +48 -0
- package/dist/common-auth-embedded/quota/merge.js +162 -0
- package/dist/common-auth-embedded/quota/projection.d.ts +64 -0
- package/dist/common-auth-embedded/quota/projection.js +134 -0
- package/dist/common-auth-embedded/routing/admission.d.ts +93 -0
- package/dist/common-auth-embedded/routing/admission.js +142 -0
- package/dist/common-auth-embedded/routing/index.d.ts +8 -0
- package/dist/common-auth-embedded/routing/index.js +4 -0
- package/dist/common-auth-embedded/routing/ordered.d.ts +47 -0
- package/dist/common-auth-embedded/routing/ordered.js +58 -0
- package/dist/common-auth-embedded/routing/pins.d.ts +22 -0
- package/dist/common-auth-embedded/routing/pins.js +34 -0
- package/dist/common-auth-embedded/routing/sticky.d.ts +158 -0
- package/dist/common-auth-embedded/routing/sticky.js +339 -0
- package/dist/common-auth-embedded/source-output.json +1090 -0
- package/dist/common-auth-embedded/store/attribution.d.ts +28 -0
- package/dist/common-auth-embedded/store/attribution.js +60 -0
- package/dist/common-auth-embedded/store/errors.d.ts +55 -0
- package/dist/common-auth-embedded/store/errors.js +38 -0
- package/dist/common-auth-embedded/store/hooks.d.ts +12 -0
- package/dist/common-auth-embedded/store/hooks.js +34 -0
- package/dist/common-auth-embedded/store/identity.d.ts +39 -0
- package/dist/common-auth-embedded/store/identity.js +79 -0
- package/dist/common-auth-embedded/store/index.d.ts +21 -0
- package/dist/common-auth-embedded/store/index.js +7 -0
- package/dist/common-auth-embedded/store/mutate.d.ts +189 -0
- package/dist/common-auth-embedded/store/mutate.js +500 -0
- package/dist/common-auth-embedded/store/pool.d.ts +196 -0
- package/dist/common-auth-embedded/store/pool.js +120 -0
- package/dist/common-auth-embedded/store/provider-state.d.ts +131 -0
- package/dist/common-auth-embedded/store/provider-state.js +226 -0
- package/dist/common-auth-embedded/store/publication.d.ts +47 -0
- package/dist/common-auth-embedded/store/publication.js +304 -0
- package/dist/common-auth-embedded/store/pull.d.ts +36 -0
- package/dist/common-auth-embedded/store/pull.js +143 -0
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +63 -0
- package/dist/common-auth-embedded/store/refresh-lock.js +136 -0
- package/dist/common-auth-embedded/store/refresh.d.ts +72 -0
- package/dist/common-auth-embedded/store/refresh.js +225 -0
- package/dist/common-auth-embedded/store/reserved.js +13 -0
- package/dist/common-auth-embedded/store/rows.d.ts +313 -0
- package/dist/common-auth-embedded/store/rows.js +1258 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +36 -0
- package/dist/common-auth-embedded/store/runtime.js +68 -0
- package/dist/common-auth-embedded/store/schema.d.ts +443 -0
- package/dist/common-auth-embedded/store/schema.js +754 -0
- package/dist/common-auth-embedded/store/settings.d.ts +63 -0
- package/dist/common-auth-embedded/store/settings.js +120 -0
- package/dist/common-auth-embedded/store/torn.d.ts +78 -0
- package/dist/common-auth-embedded/store/torn.js +294 -0
- package/dist/common-auth-embedded/util/any-signal.js +42 -0
- package/dist/common-auth-runtime.d.ts +73 -0
- package/dist/common-auth-runtime.d.ts.map +1 -0
- package/dist/common-auth-runtime.js +65 -0
- package/dist/common-auth-runtime.js.map +1 -0
- package/dist/credential-redaction.d.ts +13 -0
- package/dist/credential-redaction.d.ts.map +1 -0
- package/dist/credential-redaction.js +230 -0
- package/dist/credential-redaction.js.map +1 -0
- package/dist/fetch-timeout.d.ts +4 -2
- package/dist/fetch-timeout.d.ts.map +1 -1
- package/dist/fetch-timeout.js +121 -14
- package/dist/fetch-timeout.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +35 -10
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts +10 -0
- package/dist/quota-presentation.d.ts.map +1 -0
- package/dist/quota-presentation.js +60 -0
- package/dist/quota-presentation.js.map +1 -0
- package/dist/quota-types.d.ts +2 -0
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/rotation.d.ts +8 -0
- package/dist/rotation.d.ts.map +1 -1
- package/dist/rotation.js +24 -0
- package/dist/rotation.js.map +1 -1
- package/dist/transform/claude.d.ts +7 -3
- package/dist/transform/claude.d.ts.map +1 -1
- package/dist/transform/claude.js +6 -2
- package/dist/transform/claude.js.map +1 -1
- package/dist/transform/types.d.ts +1 -1
- package/dist/transform/types.d.ts.map +1 -1
- package/dist/vault-account-source.d.ts +183 -0
- package/dist/vault-account-source.d.ts.map +1 -0
- package/dist/vault-account-source.js +296 -0
- package/dist/vault-account-source.js.map +1 -0
- package/dist/vault-enrollment-authority.d.ts +16 -0
- package/dist/vault-enrollment-authority.d.ts.map +1 -0
- package/dist/vault-enrollment-authority.js +19 -0
- package/dist/vault-enrollment-authority.js.map +1 -0
- package/dist/vault-family.d.ts +11 -0
- package/dist/vault-family.d.ts.map +1 -0
- package/dist/vault-family.js +24 -0
- package/dist/vault-family.js.map +1 -0
- package/dist/vault-provider-state.d.ts +162 -0
- package/dist/vault-provider-state.d.ts.map +1 -0
- package/dist/vault-provider-state.js +422 -0
- package/dist/vault-provider-state.js.map +1 -0
- package/package.json +39 -2
|
@@ -0,0 +1,2253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Antigravity account repository: typed, attributed operations on the
|
|
3
|
+
* account pool, persisted by common-auth's public lease-backed pool store
|
|
4
|
+
* (its `./store` entry).
|
|
5
|
+
*
|
|
6
|
+
* This module owns everything Antigravity-specific about the pool: how
|
|
7
|
+
* provider metadata is combined when two values meet (`ACCOUNT_STATE_POLICY`),
|
|
8
|
+
* how each observation (project, fingerprint, tier, cooldown, rate limits,
|
|
9
|
+
* usage, access verdicts) changes it, which credential a write is fenced on,
|
|
10
|
+
* the per-account refresh lock, the topology lease that orders changes to
|
|
11
|
+
* the set of rows, the management journal for multi-row destructive
|
|
12
|
+
* operations, and the bookkeeping that lets `flush` and `dispose` drain and
|
|
13
|
+
* report every write.
|
|
14
|
+
*
|
|
15
|
+
* The store and its lease helper are handed in
|
|
16
|
+
* (`createAccountRepositoryFactory`) rather than imported, because the core
|
|
17
|
+
* package does not ship a copy of them; the composition root passes the
|
|
18
|
+
* genuine public `./store` and `./fs` modules, which it has verified.
|
|
19
|
+
* `AccountStoreModule` and `AccountLockModule` declare only the public
|
|
20
|
+
* capabilities this file calls, with the shapes of the public declarations;
|
|
21
|
+
* as structural types they describe what is required, not which module
|
|
22
|
+
* supplies it.
|
|
23
|
+
*
|
|
24
|
+
* Every write about an existing credential is fenced by the store, under the
|
|
25
|
+
* row lock and the store locks, before anything is written: the repository
|
|
26
|
+
* hands the store the caller's `RowRef` as the store's attribution argument,
|
|
27
|
+
* or (for a refresh and a removal) checks the locked row the store shows its
|
|
28
|
+
* `refuse`/`protect` callback. Nothing here checks a ref only before calling
|
|
29
|
+
* the store.
|
|
30
|
+
*/
|
|
31
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
32
|
+
import { mkdir, open, readFile, realpath, stat, unlink } from 'node:fs/promises';
|
|
33
|
+
import { basename, dirname, join } from 'node:path';
|
|
34
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
35
|
+
import { credentialFenceOf, refMismatchReason, rowRefKey, rowRefOf, } from "./account-identity.js";
|
|
36
|
+
import { ACCOUNT_MIGRATION_MANAGEMENT_STEPS, ACCOUNT_STORE_GENERATION_SETTINGS_KEY, decodeAccountStoreGeneration, readAccountStoreBinding, } from "./account-migration.js";
|
|
37
|
+
import { AccountCodecError, createProviderStateCodec, decodeProviderMetadata, decodeProviderState, decodeQuotaState, decodeRoutingSettings, encodeProviderMetadata, encodeProviderState, encodeQuotaState, encodeRoutingSettings, QUOTA_CODEC, } from "./account-repository-codecs.js";
|
|
38
|
+
import { ACCOUNT_STORE_PROVIDER, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, PROVIDER_STATE_SCHEMA_VERSION, ROUTING_SETTINGS_KEY, ROUTING_SETTINGS_SCHEMA_VERSION, refreshProviderLock, } from "./account-repository-types.js";
|
|
39
|
+
/**
|
|
40
|
+
* Read config and state under the writer's save locks so generation
|
|
41
|
+
* validation cannot observe a partial store write.
|
|
42
|
+
*/
|
|
43
|
+
const STORE_SAVE_LOCK = {
|
|
44
|
+
name: 'save',
|
|
45
|
+
ttlMs: 10_000,
|
|
46
|
+
timeoutMs: 15_000,
|
|
47
|
+
renew: true,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The lease that orders the local operations that change which rows the
|
|
51
|
+
* pool holds: login, re-authentication, `clear` and `replacePool`, each
|
|
52
|
+
* from its first read to its last write. It is taken before any store lock
|
|
53
|
+
* and around nothing else. Refreshes, quota, identity and routing writes
|
|
54
|
+
* never wait for it; the store's own per-row credential-epoch checks fence
|
|
55
|
+
* them. Its name differs from every lock the store takes, including the
|
|
56
|
+
* inner management lock, so holding it never makes a store call wait on
|
|
57
|
+
* itself. The repository checks the lease before each write and the lease
|
|
58
|
+
* renews itself; a check comes before the act it guards, so a stall longer
|
|
59
|
+
* than the lease's lifetime can still lose it in between. It is a file
|
|
60
|
+
* lease, not a kernel lock.
|
|
61
|
+
*/
|
|
62
|
+
const TOPOLOGY_LEASE = {
|
|
63
|
+
name: 'antigravity-topology',
|
|
64
|
+
ttlMs: 10_000,
|
|
65
|
+
/** The store's own default bounded wait for a lock. */
|
|
66
|
+
timeoutMs: 15_000,
|
|
67
|
+
renew: true,
|
|
68
|
+
};
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Failures
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* Thrown by every repository operation that fails. `failure` says how; the
|
|
74
|
+
* message never contains a credential, an email or an identity.
|
|
75
|
+
*/
|
|
76
|
+
export class AccountRepositoryError extends Error {
|
|
77
|
+
failure;
|
|
78
|
+
constructor(failure, options) {
|
|
79
|
+
super(failure.message, options);
|
|
80
|
+
this.name = 'AccountRepositoryError';
|
|
81
|
+
this.failure = failure;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function refusal(operation, kind, message, details = {}) {
|
|
85
|
+
return new AccountRepositoryError({
|
|
86
|
+
operation,
|
|
87
|
+
kind,
|
|
88
|
+
retryable: details.retryable ?? false,
|
|
89
|
+
ambiguous: details.ambiguous ?? false,
|
|
90
|
+
...(details.rowId !== undefined ? { rowId: details.rowId } : {}),
|
|
91
|
+
message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Provider-metadata rules
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
/** The UTC day `at` falls on, as `dailyRequestCounts.date` records it. */
|
|
98
|
+
export function utcDay(at) {
|
|
99
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Combines two rate-limit maps key by key, keeping the later reset time of
|
|
103
|
+
* each key. A `null` (written by an older build) never replaces a time.
|
|
104
|
+
*/
|
|
105
|
+
export function mergeRateLimitResets(stored, incoming) {
|
|
106
|
+
if (incoming === null)
|
|
107
|
+
return stored === undefined ? null : stored;
|
|
108
|
+
const out = { ...(stored ?? {}) };
|
|
109
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
110
|
+
const prior = out[key];
|
|
111
|
+
if (value === null) {
|
|
112
|
+
if (prior === undefined)
|
|
113
|
+
out[key] = null;
|
|
114
|
+
}
|
|
115
|
+
else if (typeof prior !== 'number' || value > prior) {
|
|
116
|
+
out[key] = value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** The later of two evidence times, ignoring absent and `null` ones. */
|
|
122
|
+
function latest(...times) {
|
|
123
|
+
let result;
|
|
124
|
+
for (const time of times) {
|
|
125
|
+
if (typeof time === 'number' && (result === undefined || time > result)) {
|
|
126
|
+
result = time;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The latest of `verificationRequiredAt`, `accountIneligibleAt` and
|
|
133
|
+
* `eligibilityStateUpdatedAt`: when the newest access evidence was observed.
|
|
134
|
+
*/
|
|
135
|
+
export function accessEvidenceAt(metadata) {
|
|
136
|
+
return latest(metadata.verificationRequiredAt, metadata.accountIneligibleAt, metadata.eligibilityStateUpdatedAt);
|
|
137
|
+
}
|
|
138
|
+
function mergeExtensions(a, b) {
|
|
139
|
+
if (a === undefined && b === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
return { ...(a ?? {}), ...(b ?? {}) };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Combines stored metadata with metadata a same-credential write brings (a
|
|
145
|
+
* refresh, or a login of a secret the pool already holds). Every field the
|
|
146
|
+
* incoming value carries replaces the stored one, except the fields other
|
|
147
|
+
* writers accumulate: the user-visible add time stays, `lastUsed` keeps the
|
|
148
|
+
* later time, request counts stay (they change only by increments, so a
|
|
149
|
+
* stale copy must never overwrite them), and rate limits merge key by key.
|
|
150
|
+
*/
|
|
151
|
+
function mergeSameCredential(stored, incoming) {
|
|
152
|
+
const out = {
|
|
153
|
+
...stored,
|
|
154
|
+
...incoming,
|
|
155
|
+
addedAt: stored.addedAt,
|
|
156
|
+
lastUsed: Math.max(stored.lastUsed, incoming.lastUsed),
|
|
157
|
+
};
|
|
158
|
+
if (stored.dailyRequestCounts != null) {
|
|
159
|
+
out.dailyRequestCounts = stored.dailyRequestCounts;
|
|
160
|
+
}
|
|
161
|
+
if (incoming.rateLimitResetTimes !== undefined) {
|
|
162
|
+
out.rateLimitResetTimes = mergeRateLimitResets(stored.rateLimitResetTimes, incoming.rateLimitResetTimes);
|
|
163
|
+
}
|
|
164
|
+
const extensions = mergeExtensions(stored.extensions, incoming.extensions);
|
|
165
|
+
if (extensions !== undefined)
|
|
166
|
+
out.extensions = extensions;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fields a replaced row keeps whoever the new credential belongs to. They
|
|
171
|
+
* describe the existing row rather than the signed-in account: when it was
|
|
172
|
+
* added and last used, how its enabled flag was written, its label, its
|
|
173
|
+
* request counts and unknown extensions.
|
|
174
|
+
*/
|
|
175
|
+
const ROW_RETAINED_FIELDS = [
|
|
176
|
+
'addedAt',
|
|
177
|
+
'lastUsed',
|
|
178
|
+
'enabled',
|
|
179
|
+
'lastSwitchReason',
|
|
180
|
+
'label',
|
|
181
|
+
'dailyRequestCounts',
|
|
182
|
+
'extensions',
|
|
183
|
+
];
|
|
184
|
+
/**
|
|
185
|
+
* Account-describing fields a replaced row keeps only when the prior
|
|
186
|
+
* identity the store read under its locks equals the identity the replace
|
|
187
|
+
* records. The verification and eligibility fields are credential-bound too
|
|
188
|
+
* but are never kept: they describe the old credential.
|
|
189
|
+
*/
|
|
190
|
+
const SAME_ACCOUNT_RETAINED_FIELDS = [
|
|
191
|
+
'email',
|
|
192
|
+
'projectId',
|
|
193
|
+
'managedProjectId',
|
|
194
|
+
'fingerprint',
|
|
195
|
+
'fingerprintHistory',
|
|
196
|
+
'capturedTierId',
|
|
197
|
+
'capturedPaidTierId',
|
|
198
|
+
'capturedTierAt',
|
|
199
|
+
'capturedTierSchemaVersion',
|
|
200
|
+
];
|
|
201
|
+
function copyFields(from, to, keys) {
|
|
202
|
+
for (const key of keys) {
|
|
203
|
+
if (key in from)
|
|
204
|
+
to[key] = from[key];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The metadata a row keeps after `replace` gives it a new credential.
|
|
209
|
+
*
|
|
210
|
+
* The old credential's rate-limit resets, cooldown, and verification and
|
|
211
|
+
* eligibility fields are dropped. The row's own fields
|
|
212
|
+
* (`ROW_RETAINED_FIELDS`) are kept. The account-describing fields
|
|
213
|
+
* (`SAME_ACCOUNT_RETAINED_FIELDS`) are kept only when the store's locked
|
|
214
|
+
* `previousIdentity` and the identity the replace records are both known
|
|
215
|
+
* and equal; otherwise nothing proves the new credential is the same
|
|
216
|
+
* account, and only the incoming metadata supplies them. Fields the
|
|
217
|
+
* incoming metadata carries override kept ones, except the accumulated
|
|
218
|
+
* `addedAt`, `lastUsed` and `dailyRequestCounts`.
|
|
219
|
+
*/
|
|
220
|
+
function replacementMetadata(previous, replacement) {
|
|
221
|
+
const incoming = replacement.incoming?.metadata;
|
|
222
|
+
if (previous === undefined)
|
|
223
|
+
return incoming;
|
|
224
|
+
const sameAccount = replacement.previousIdentity !== undefined &&
|
|
225
|
+
replacement.identity !== undefined &&
|
|
226
|
+
replacement.previousIdentity === replacement.identity;
|
|
227
|
+
const kept = {};
|
|
228
|
+
copyFields(previous, kept, ROW_RETAINED_FIELDS);
|
|
229
|
+
if (sameAccount)
|
|
230
|
+
copyFields(previous, kept, SAME_ACCOUNT_RETAINED_FIELDS);
|
|
231
|
+
const base = {
|
|
232
|
+
...kept,
|
|
233
|
+
addedAt: previous.addedAt,
|
|
234
|
+
lastUsed: previous.lastUsed,
|
|
235
|
+
};
|
|
236
|
+
if (incoming === undefined)
|
|
237
|
+
return base;
|
|
238
|
+
return mergeSameCredential(base, incoming);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The repository's rules for combining provider metadata. The public store
|
|
242
|
+
* applies them, under its locks, through the codec `createProviderStateCodec`
|
|
243
|
+
* builds from them: `merge` when a same-credential write brings metadata,
|
|
244
|
+
* `onReplace` when `replace` gives a row a new credential.
|
|
245
|
+
*/
|
|
246
|
+
export const ACCOUNT_STATE_POLICY = {
|
|
247
|
+
merge(onDisk, incoming) {
|
|
248
|
+
const extensions = mergeExtensions(onDisk.extensions, incoming.extensions);
|
|
249
|
+
return {
|
|
250
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
251
|
+
metadata: mergeSameCredential(onDisk.metadata, incoming.metadata),
|
|
252
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
onReplace(previous, replacement) {
|
|
256
|
+
const metadata = replacementMetadata(previous?.metadata, replacement);
|
|
257
|
+
if (metadata === undefined)
|
|
258
|
+
return undefined;
|
|
259
|
+
const extensions = mergeExtensions(previous?.extensions, replacement.incoming?.extensions);
|
|
260
|
+
return {
|
|
261
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
262
|
+
metadata,
|
|
263
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Observations
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
export function applyProjectObservation(metadata, observation) {
|
|
271
|
+
const next = { ...metadata };
|
|
272
|
+
if (observation.projectId !== undefined)
|
|
273
|
+
next.projectId = observation.projectId;
|
|
274
|
+
if (observation.managedProjectId !== undefined) {
|
|
275
|
+
next.managedProjectId = observation.managedProjectId;
|
|
276
|
+
}
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
/** History is stored exactly as given: in its order and untruncated. */
|
|
280
|
+
export function applyFingerprintObservation(metadata, observation) {
|
|
281
|
+
return {
|
|
282
|
+
...metadata,
|
|
283
|
+
fingerprint: observation.fingerprint,
|
|
284
|
+
fingerprintHistory: [...observation.history],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** A tier reading older than the stored one leaves it as it is. */
|
|
288
|
+
export function applyTierObservation(metadata, observation) {
|
|
289
|
+
if (typeof metadata.capturedTierAt === 'number' &&
|
|
290
|
+
observation.observedAt < metadata.capturedTierAt) {
|
|
291
|
+
return metadata;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
...metadata,
|
|
295
|
+
capturedTierId: observation.tierId,
|
|
296
|
+
capturedPaidTierId: observation.paidTierId,
|
|
297
|
+
capturedTierAt: observation.observedAt,
|
|
298
|
+
capturedTierSchemaVersion: observation.schemaVersion,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** `null` ends the cooldown: both fields are removed. */
|
|
302
|
+
export function applyCooldownObservation(metadata, observation) {
|
|
303
|
+
const next = { ...metadata };
|
|
304
|
+
if (observation === null) {
|
|
305
|
+
delete next.coolingDownUntil;
|
|
306
|
+
delete next.cooldownReason;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
next.coolingDownUntil = observation.until;
|
|
310
|
+
next.cooldownReason = observation.reason;
|
|
311
|
+
}
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
export function applyRateLimitObservation(metadata, resets) {
|
|
315
|
+
return {
|
|
316
|
+
...metadata,
|
|
317
|
+
rateLimitResetTimes: mergeRateLimitResets(metadata.rateLimitResetTimes, resets),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export function applySwitch(metadata, reason) {
|
|
321
|
+
return { ...metadata, lastSwitchReason: reason };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Counts one request for the family on the UTC day of `now`, starting the
|
|
325
|
+
* day from zero when the stored counts belong to another day, and keeps the
|
|
326
|
+
* later `lastUsed`. Unknown keys of the stored counts stay when the day is
|
|
327
|
+
* the same.
|
|
328
|
+
*/
|
|
329
|
+
export function applyUsage(metadata, observation, now) {
|
|
330
|
+
const today = utcDay(now);
|
|
331
|
+
const stored = metadata.dailyRequestCounts;
|
|
332
|
+
const counts = stored != null && stored.date === today
|
|
333
|
+
? { ...stored }
|
|
334
|
+
: { date: today, claude: 0, gemini: 0 };
|
|
335
|
+
counts[observation.family] += 1;
|
|
336
|
+
return {
|
|
337
|
+
...metadata,
|
|
338
|
+
lastUsed: Math.max(metadata.lastUsed, observation.at),
|
|
339
|
+
dailyRequestCounts: counts,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Applies access evidence. A verdict observed before the newest stored
|
|
344
|
+
* evidence returns `undefined`: it must not undo what a later observation
|
|
345
|
+
* established.
|
|
346
|
+
*/
|
|
347
|
+
export function applyAccessVerdict(metadata, verdict) {
|
|
348
|
+
const evidenceAt = accessEvidenceAt(metadata);
|
|
349
|
+
if (evidenceAt !== undefined && verdict.observedAt < evidenceAt) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
const next = { ...metadata };
|
|
353
|
+
switch (verdict.kind) {
|
|
354
|
+
case 'verification-required': {
|
|
355
|
+
next.verificationRequired = true;
|
|
356
|
+
next.verificationRequiredAt = verdict.observedAt;
|
|
357
|
+
const reason = verdict.reason?.trim();
|
|
358
|
+
if (reason)
|
|
359
|
+
next.verificationRequiredReason = reason;
|
|
360
|
+
else
|
|
361
|
+
delete next.verificationRequiredReason;
|
|
362
|
+
const url = verdict.verificationUrl?.trim();
|
|
363
|
+
if (url)
|
|
364
|
+
next.verificationUrl = url;
|
|
365
|
+
if (next.accountIneligible === true ||
|
|
366
|
+
next.accountIneligibleAt != null ||
|
|
367
|
+
next.accountIneligibleReason != null) {
|
|
368
|
+
next.accountIneligible = false;
|
|
369
|
+
delete next.accountIneligibleAt;
|
|
370
|
+
delete next.accountIneligibleReason;
|
|
371
|
+
next.eligibilityStateUpdatedAt = verdict.observedAt;
|
|
372
|
+
}
|
|
373
|
+
return { metadata: next, transition: 'disable' };
|
|
374
|
+
}
|
|
375
|
+
case 'ineligible': {
|
|
376
|
+
next.accountIneligible = true;
|
|
377
|
+
next.accountIneligibleAt = verdict.observedAt;
|
|
378
|
+
next.accountIneligibleReason =
|
|
379
|
+
verdict.reason.trim() || 'Google marked this account as ineligible.';
|
|
380
|
+
next.eligibilityStateUpdatedAt = verdict.observedAt;
|
|
381
|
+
next.verificationRequired = false;
|
|
382
|
+
delete next.verificationRequiredAt;
|
|
383
|
+
delete next.verificationRequiredReason;
|
|
384
|
+
delete next.verificationUrl;
|
|
385
|
+
return { metadata: next, transition: 'disable' };
|
|
386
|
+
}
|
|
387
|
+
case 'cleared': {
|
|
388
|
+
const wasBlocked = metadata.verificationRequired === true ||
|
|
389
|
+
metadata.accountIneligible === true;
|
|
390
|
+
const hadEligibility = metadata.accountIneligible === true ||
|
|
391
|
+
metadata.eligibilityStateUpdatedAt != null;
|
|
392
|
+
next.verificationRequired = false;
|
|
393
|
+
delete next.verificationRequiredAt;
|
|
394
|
+
delete next.verificationRequiredReason;
|
|
395
|
+
delete next.verificationUrl;
|
|
396
|
+
next.accountIneligible = false;
|
|
397
|
+
delete next.accountIneligibleAt;
|
|
398
|
+
delete next.accountIneligibleReason;
|
|
399
|
+
if (hadEligibility)
|
|
400
|
+
next.eligibilityStateUpdatedAt = verdict.observedAt;
|
|
401
|
+
return {
|
|
402
|
+
metadata: next,
|
|
403
|
+
transition: verdict.enable && wasBlocked ? 'enable' : 'keep',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Value helpers
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
function envelopeOf(raw) {
|
|
412
|
+
return raw === undefined || raw === null
|
|
413
|
+
? undefined
|
|
414
|
+
: decodeProviderState(raw);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The row's provider state, refusing a row whose stored value is not shown
|
|
418
|
+
* (`providerStateDropped`): a write there would replace data the store kept
|
|
419
|
+
* on disk with a value regenerated from nothing.
|
|
420
|
+
*/
|
|
421
|
+
function shownEnvelope(operation, current, row) {
|
|
422
|
+
if (row.providerStateDropped !== undefined) {
|
|
423
|
+
throw new CallbackRefusal(refusal(operation, 'metadata-dropped', `row ${row.id} holds metadata the store does not show (${row.providerStateDropped})`, { rowId: row.id }));
|
|
424
|
+
}
|
|
425
|
+
return envelopeOf(current);
|
|
426
|
+
}
|
|
427
|
+
/** Stored metadata, or a starting value for a row that shows none. */
|
|
428
|
+
function metadataOrBase(envelope, row, now) {
|
|
429
|
+
return envelope?.metadata ?? { addedAt: row.addedAt ?? now, lastUsed: 0 };
|
|
430
|
+
}
|
|
431
|
+
function encodeEnvelope(prior, metadata) {
|
|
432
|
+
return encodeProviderState({
|
|
433
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
434
|
+
metadata,
|
|
435
|
+
...(prior?.extensions !== undefined
|
|
436
|
+
? { extensions: prior.extensions }
|
|
437
|
+
: {}),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function metadataOfStored(raw) {
|
|
441
|
+
return envelopeOf(raw)?.metadata;
|
|
442
|
+
}
|
|
443
|
+
function metadataView(row) {
|
|
444
|
+
if (row.providerStateDropped !== undefined) {
|
|
445
|
+
return { status: 'dropped', reason: row.providerStateDropped };
|
|
446
|
+
}
|
|
447
|
+
if (row.providerState === undefined)
|
|
448
|
+
return { status: 'absent' };
|
|
449
|
+
return {
|
|
450
|
+
status: 'present',
|
|
451
|
+
metadata: decodeProviderState(row.providerState).metadata,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function quotaView(row) {
|
|
455
|
+
return row.quota === undefined
|
|
456
|
+
? { status: 'absent' }
|
|
457
|
+
: { status: 'present', quota: decodeQuotaState(row.quota) };
|
|
458
|
+
}
|
|
459
|
+
export function toAccountRow(row, index) {
|
|
460
|
+
const credential = row.credential;
|
|
461
|
+
return {
|
|
462
|
+
ref: rowRefOf(row),
|
|
463
|
+
index,
|
|
464
|
+
enabled: row.enabled,
|
|
465
|
+
...(row.disabledReason !== undefined
|
|
466
|
+
? { disabledReason: row.disabledReason }
|
|
467
|
+
: {}),
|
|
468
|
+
...(row.addedAt !== undefined ? { storeAddedAt: row.addedAt } : {}),
|
|
469
|
+
...(credential?.type === 'oauth'
|
|
470
|
+
? {
|
|
471
|
+
credential: {
|
|
472
|
+
refreshToken: credential.refresh,
|
|
473
|
+
...(credential.access !== undefined
|
|
474
|
+
? { accessToken: credential.access }
|
|
475
|
+
: {}),
|
|
476
|
+
...(credential.expires !== undefined
|
|
477
|
+
? { expiresAt: credential.expires }
|
|
478
|
+
: {}),
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
: {}),
|
|
482
|
+
usable: row.candidate,
|
|
483
|
+
stamp: row.stamp ?? (credential === undefined ? 'none' : 'missing'),
|
|
484
|
+
...(row.torn ? { torn: true } : {}),
|
|
485
|
+
...(row.unbound ? { unbound: true } : {}),
|
|
486
|
+
...(row.invalid !== undefined ? { invalid: row.invalid } : {}),
|
|
487
|
+
metadata: metadataView(row),
|
|
488
|
+
quota: quotaView(row),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function isManagementKind(value) {
|
|
492
|
+
return value === 'migration' || value === 'clear' || value === 'replace-pool';
|
|
493
|
+
}
|
|
494
|
+
function isRecord(value) {
|
|
495
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
496
|
+
}
|
|
497
|
+
function isStringList(value) {
|
|
498
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* A management record's id: the lowercase UUID `randomUUID` gives. It also
|
|
502
|
+
* names the record's transfer file, so nothing else may select a path.
|
|
503
|
+
*/
|
|
504
|
+
const MANAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
505
|
+
/**
|
|
506
|
+
* Reject management steps outside each operation's published schema.
|
|
507
|
+
* Migration owns its phase list; this repository uses it to reject malformed
|
|
508
|
+
* journals and block ordinary work.
|
|
509
|
+
*/
|
|
510
|
+
const MANAGEMENT_STEPS = {
|
|
511
|
+
clear: ['remove'],
|
|
512
|
+
'replace-pool': ['remove', 'add', 'publish', 'verified'],
|
|
513
|
+
migration: ACCOUNT_MIGRATION_MANAGEMENT_STEPS,
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* JSON with the keys of every object sorted, so two values that differ only
|
|
517
|
+
* in key order serialize the same. Array order is kept.
|
|
518
|
+
*/
|
|
519
|
+
function canonicalJson(value) {
|
|
520
|
+
const sorted = (item) => {
|
|
521
|
+
if (Array.isArray(item))
|
|
522
|
+
return item.map(sorted);
|
|
523
|
+
if (item === null || typeof item !== 'object')
|
|
524
|
+
return item;
|
|
525
|
+
const out = {};
|
|
526
|
+
for (const key of Object.keys(item).sort()) {
|
|
527
|
+
const child = item[key];
|
|
528
|
+
if (child !== undefined)
|
|
529
|
+
out[key] = sorted(child);
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
};
|
|
533
|
+
return JSON.stringify(sorted(value));
|
|
534
|
+
}
|
|
535
|
+
/** A SHA-256 digest in lowercase hex. */
|
|
536
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
537
|
+
/**
|
|
538
|
+
* SHA-256 of the inputs' encoding as key-sorted JSON (`canonicalJson`), in
|
|
539
|
+
* their order: ids, refresh tokens, identities and metadata values all
|
|
540
|
+
* count, as do array order and an explicit `null`, while the order in which
|
|
541
|
+
* an object's keys were built does not. It identifies a replacement without
|
|
542
|
+
* the journal holding any credential.
|
|
543
|
+
*/
|
|
544
|
+
export function replacementInputDigest(inputs) {
|
|
545
|
+
return createHash('sha256')
|
|
546
|
+
.update(canonicalJson(inputs.map(encodeLoginInput)))
|
|
547
|
+
.digest('hex');
|
|
548
|
+
}
|
|
549
|
+
/** The record in its frozen public shape; `inputDigest` is omitted. */
|
|
550
|
+
function publicManagementRecord(record) {
|
|
551
|
+
return {
|
|
552
|
+
id: record.id,
|
|
553
|
+
kind: record.kind,
|
|
554
|
+
targets: [...record.targets],
|
|
555
|
+
progress: {
|
|
556
|
+
step: record.progress.step,
|
|
557
|
+
completedTargets: [...record.progress.completedTargets],
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** A list of non-empty ids with no id twice; throws otherwise. */
|
|
562
|
+
function uniqueIds(value, path) {
|
|
563
|
+
if (!isStringList(value)) {
|
|
564
|
+
throw new AccountCodecError(path, 'must be a list of ids');
|
|
565
|
+
}
|
|
566
|
+
const seen = new Set();
|
|
567
|
+
for (const id of value) {
|
|
568
|
+
if (!id)
|
|
569
|
+
throw new AccountCodecError(path, 'must not hold an empty id');
|
|
570
|
+
if (seen.has(id))
|
|
571
|
+
throw new AccountCodecError(path, 'must not repeat an id');
|
|
572
|
+
seen.add(id);
|
|
573
|
+
}
|
|
574
|
+
return [...value];
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Reads the record of a pending multi-row operation (`clear` or
|
|
578
|
+
* `replacePool`) kept under `MANAGEMENT_SETTINGS_KEY` in the store's
|
|
579
|
+
* settings; while it exists, ordinary operations are refused. Throws
|
|
580
|
+
* `AccountCodecError` when the record is malformed: an id that is not a
|
|
581
|
+
* UUID, a step its kind does not have, a repeated or empty id, or, while
|
|
582
|
+
* rows are being removed, a completed id that is not a target, and an
|
|
583
|
+
* `inputDigest` that is missing or malformed on a `replace-pool` record or
|
|
584
|
+
* present on any other. In the `add` and `verified` steps of `replace-pool`
|
|
585
|
+
* the completed ids are replacement input ids, which `replacePool` checks
|
|
586
|
+
* against the ids of the inputs.
|
|
587
|
+
*/
|
|
588
|
+
export function decodeManagementRecord(raw) {
|
|
589
|
+
if (!isRecord(raw)) {
|
|
590
|
+
throw new AccountCodecError('$', 'must be an object');
|
|
591
|
+
}
|
|
592
|
+
const record = raw;
|
|
593
|
+
const progress = record.progress;
|
|
594
|
+
if (typeof record.id !== 'string' || !MANAGEMENT_ID.test(record.id)) {
|
|
595
|
+
throw new AccountCodecError('$.id', 'must be a lowercase UUID');
|
|
596
|
+
}
|
|
597
|
+
if (!isManagementKind(record.kind)) {
|
|
598
|
+
throw new AccountCodecError('$.kind', 'must be a management kind');
|
|
599
|
+
}
|
|
600
|
+
const targets = uniqueIds(record.targets, '$.targets');
|
|
601
|
+
if (!isRecord(progress)) {
|
|
602
|
+
throw new AccountCodecError('$.progress', 'must be an object');
|
|
603
|
+
}
|
|
604
|
+
const { step } = progress;
|
|
605
|
+
const steps = MANAGEMENT_STEPS[record.kind];
|
|
606
|
+
if (typeof step !== 'string' || !steps.includes(step)) {
|
|
607
|
+
throw new AccountCodecError('$.progress.step', `must be a step of a ${record.kind} record`);
|
|
608
|
+
}
|
|
609
|
+
const completedTargets = uniqueIds(progress.completedTargets, '$.progress.completedTargets');
|
|
610
|
+
if (step === 'remove' &&
|
|
611
|
+
completedTargets.some((id) => !targets.includes(id))) {
|
|
612
|
+
throw new AccountCodecError('$.progress.completedTargets', 'must name only targets while rows are being removed');
|
|
613
|
+
}
|
|
614
|
+
const inputDigest = record.inputDigest;
|
|
615
|
+
if (record.publication !== undefined && record.kind !== 'replace-pool')
|
|
616
|
+
throw new AccountCodecError('$.publication', 'belongs only to a replace-pool record');
|
|
617
|
+
if (record.kind === 'replace-pool') {
|
|
618
|
+
if (typeof inputDigest !== 'string' || !SHA256_HEX.test(inputDigest)) {
|
|
619
|
+
throw new AccountCodecError('$.inputDigest', 'must be the SHA-256 of the replacement inputs');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else if (inputDigest !== undefined) {
|
|
623
|
+
throw new AccountCodecError('$.inputDigest', 'belongs only to a replace-pool record');
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
id: record.id,
|
|
627
|
+
kind: record.kind,
|
|
628
|
+
targets,
|
|
629
|
+
progress: { step, completedTargets },
|
|
630
|
+
...(typeof inputDigest === 'string' ? { inputDigest } : {}),
|
|
631
|
+
...(record.publication !== undefined
|
|
632
|
+
? { publication: decodePublicationPlan(record.publication, record.id) }
|
|
633
|
+
: {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function decodePublicationPlan(raw, operationId) {
|
|
637
|
+
if (!isRecord(raw) ||
|
|
638
|
+
raw.operationId !== operationId ||
|
|
639
|
+
!Array.isArray(raw.remove) ||
|
|
640
|
+
!Array.isArray(raw.finalize)) {
|
|
641
|
+
throw new AccountCodecError('$.publication', 'must hold this operation’s native publication plan');
|
|
642
|
+
}
|
|
643
|
+
const order = uniqueIds(raw.order, '$.publication.order');
|
|
644
|
+
const attribution = (value) => {
|
|
645
|
+
if (!isRecord(value) ||
|
|
646
|
+
typeof value.credentialEpoch !== 'number' ||
|
|
647
|
+
!Number.isSafeInteger(value.credentialEpoch) ||
|
|
648
|
+
value.credentialEpoch < 1 ||
|
|
649
|
+
(value.identity !== undefined &&
|
|
650
|
+
(typeof value.identity !== 'string' || !value.identity))) {
|
|
651
|
+
throw new AccountCodecError('$.publication.attribution', 'must hold an exact credential fence');
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
credentialEpoch: value.credentialEpoch,
|
|
655
|
+
...(typeof value.identity === 'string'
|
|
656
|
+
? { identity: value.identity }
|
|
657
|
+
: {}),
|
|
658
|
+
};
|
|
659
|
+
};
|
|
660
|
+
const remove = raw.remove.map((item) => {
|
|
661
|
+
if (!isRecord(item) ||
|
|
662
|
+
typeof item.id !== 'string' ||
|
|
663
|
+
!item.id ||
|
|
664
|
+
typeof item.fingerprint !== 'string' ||
|
|
665
|
+
!SHA256_HEX.test(item.fingerprint))
|
|
666
|
+
throw new AccountCodecError('$.publication.remove', 'must hold a row and secret fingerprint');
|
|
667
|
+
return {
|
|
668
|
+
id: item.id,
|
|
669
|
+
attribution: attribution(item.attribution),
|
|
670
|
+
fingerprint: item.fingerprint,
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
const finalize = raw.finalize.map((item) => {
|
|
674
|
+
if (!isRecord(item) ||
|
|
675
|
+
typeof item.id !== 'string' ||
|
|
676
|
+
!order.includes(item.id) ||
|
|
677
|
+
item.reservation !== operationId ||
|
|
678
|
+
typeof item.enabled !== 'boolean' ||
|
|
679
|
+
(item.enabled
|
|
680
|
+
? item.disabledReason !== undefined
|
|
681
|
+
: typeof item.disabledReason !== 'string'))
|
|
682
|
+
throw new AccountCodecError('$.publication.finalize', 'must hold this operation’s reserved row');
|
|
683
|
+
return {
|
|
684
|
+
id: item.id,
|
|
685
|
+
attribution: attribution(item.attribution),
|
|
686
|
+
reservation: operationId,
|
|
687
|
+
enabled: item.enabled,
|
|
688
|
+
...(typeof item.disabledReason === 'string'
|
|
689
|
+
? { disabledReason: item.disabledReason }
|
|
690
|
+
: {}),
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
uniqueIds([...remove.map((row) => row.id), ...finalize.map((row) => row.id)], '$.publication.rows');
|
|
694
|
+
return { operationId, remove, finalize, order };
|
|
695
|
+
}
|
|
696
|
+
function encodePublicationPlan(plan) {
|
|
697
|
+
return {
|
|
698
|
+
operationId: plan.operationId,
|
|
699
|
+
remove: plan.remove.map((row) => ({
|
|
700
|
+
id: row.id,
|
|
701
|
+
attribution: { ...row.attribution },
|
|
702
|
+
...(row.fingerprint !== undefined
|
|
703
|
+
? { fingerprint: row.fingerprint }
|
|
704
|
+
: {}),
|
|
705
|
+
})),
|
|
706
|
+
finalize: plan.finalize.map((row) => ({
|
|
707
|
+
...row,
|
|
708
|
+
attribution: { ...row.attribution },
|
|
709
|
+
})),
|
|
710
|
+
order: [...plan.order],
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function encodeManagementRecord(record) {
|
|
714
|
+
return {
|
|
715
|
+
id: record.id,
|
|
716
|
+
kind: record.kind,
|
|
717
|
+
targets: [...record.targets],
|
|
718
|
+
progress: {
|
|
719
|
+
step: record.progress.step,
|
|
720
|
+
completedTargets: [...record.progress.completedTargets],
|
|
721
|
+
},
|
|
722
|
+
...(record.inputDigest !== undefined
|
|
723
|
+
? { inputDigest: record.inputDigest }
|
|
724
|
+
: {}),
|
|
725
|
+
...(record.publication !== undefined
|
|
726
|
+
? { publication: encodePublicationPlan(record.publication) }
|
|
727
|
+
: {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Reads a transfer file written for the management record `managementId`.
|
|
732
|
+
* Throws `AccountCodecError` for a file of another record or version, and
|
|
733
|
+
* for inputs `replacePool` would itself refuse: a repeated or empty id, or
|
|
734
|
+
* an empty refresh token.
|
|
735
|
+
*/
|
|
736
|
+
export function decodeTransferFile(raw, managementId) {
|
|
737
|
+
if (!isRecord(raw) ||
|
|
738
|
+
raw.schemaVersion !== 1 ||
|
|
739
|
+
raw.managementId !== managementId ||
|
|
740
|
+
!Array.isArray(raw.inputs)) {
|
|
741
|
+
throw new AccountCodecError('transfer', 'does not belong to this management record');
|
|
742
|
+
}
|
|
743
|
+
const inputs = raw.inputs.map(decodeLoginInput);
|
|
744
|
+
uniqueIds(inputs.map((input) => input.id), 'transfer.inputs[].id');
|
|
745
|
+
if (inputs.some((input) => !input.refreshToken.trim())) {
|
|
746
|
+
throw new AccountCodecError('transfer.inputs[].refreshToken', 'must not be empty');
|
|
747
|
+
}
|
|
748
|
+
return inputs;
|
|
749
|
+
}
|
|
750
|
+
function encodeLoginInput(input) {
|
|
751
|
+
return {
|
|
752
|
+
id: input.id,
|
|
753
|
+
refreshToken: input.refreshToken,
|
|
754
|
+
...(input.identity !== undefined ? { identity: input.identity } : {}),
|
|
755
|
+
metadata: encodeProviderMetadata(input.metadata),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function decodeLoginInput(raw) {
|
|
759
|
+
if (!isRecord(raw)) {
|
|
760
|
+
throw new AccountCodecError('$.inputs[]', 'must be an object');
|
|
761
|
+
}
|
|
762
|
+
const value = raw;
|
|
763
|
+
if (typeof value.id !== 'string' || typeof value.refreshToken !== 'string') {
|
|
764
|
+
throw new AccountCodecError('$.inputs[]', 'must name an id and a refresh token');
|
|
765
|
+
}
|
|
766
|
+
if (value.identity !== undefined && typeof value.identity !== 'string') {
|
|
767
|
+
throw new AccountCodecError('$.inputs[].identity', 'must be a string');
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
id: value.id,
|
|
771
|
+
refreshToken: value.refreshToken,
|
|
772
|
+
...(typeof value.identity === 'string' ? { identity: value.identity } : {}),
|
|
773
|
+
metadata: decodeProviderMetadata(value.metadata),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// The repository
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
/** Refuse unpublished, pending, rolled-back or invalid store generations. */
|
|
780
|
+
function unbound(operation, binding) {
|
|
781
|
+
switch (binding.status) {
|
|
782
|
+
case 'pending':
|
|
783
|
+
return refusal(operation, 'pending-migration', 'the account store has a migration or rollback in progress', { retryable: true });
|
|
784
|
+
case 'initialization-required':
|
|
785
|
+
return refusal(operation, 'pending-migration', 'the account store has not been initialized', { retryable: true });
|
|
786
|
+
case 'inactive':
|
|
787
|
+
return refusal(operation, 'load-error', 'the account-store generation was rolled back');
|
|
788
|
+
case 'error':
|
|
789
|
+
return refusal(operation, 'load-error', binding.reason);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Return a reason when settings do not name the expected generation and
|
|
794
|
+
* directory; otherwise return undefined.
|
|
795
|
+
*/
|
|
796
|
+
function generationMismatch(settings, id, paths) {
|
|
797
|
+
try {
|
|
798
|
+
const generation = decodeAccountStoreGeneration(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]);
|
|
799
|
+
return generation.id === id && generation.storeDir === paths.storeDir
|
|
800
|
+
? undefined
|
|
801
|
+
: 'the store now belongs to another account-store generation';
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
return 'the store holds no valid account-store generation';
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The lock guarding the management record. Every write of the record, and
|
|
809
|
+
* every row removal a management operation makes, passes it to the store
|
|
810
|
+
* as an extra lock, which the store takes after the row lock and before
|
|
811
|
+
* its own save locks.
|
|
812
|
+
*/
|
|
813
|
+
function managementLock(paths) {
|
|
814
|
+
return { name: MANAGEMENT_LOCK_NAME, path: paths.configPath };
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Raised inside a store callback to refuse the write with a repository
|
|
818
|
+
* failure; the store rethrows it wrapped, and `failureOf` unwraps it.
|
|
819
|
+
*/
|
|
820
|
+
class CallbackRefusal extends Error {
|
|
821
|
+
error;
|
|
822
|
+
constructor(error) {
|
|
823
|
+
super(error.message);
|
|
824
|
+
this.name = 'CallbackRefusal';
|
|
825
|
+
this.error = error;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
class StoreAccountRepository {
|
|
829
|
+
store;
|
|
830
|
+
decline;
|
|
831
|
+
storeFailure;
|
|
832
|
+
locks;
|
|
833
|
+
storeModule;
|
|
834
|
+
/** Cached promise for the validated generation ID. Failed attempts are discarded. */
|
|
835
|
+
binding;
|
|
836
|
+
/** The canonical config path the topology lease is named after. */
|
|
837
|
+
leaseTarget;
|
|
838
|
+
paths;
|
|
839
|
+
now;
|
|
840
|
+
exchange;
|
|
841
|
+
/** Every started operation; each leaves the set once its promise settles. */
|
|
842
|
+
inFlight = new Set();
|
|
843
|
+
/**
|
|
844
|
+
* Failures since the last `flush`, kept so each is reported once: `flush`
|
|
845
|
+
* and `dispose` resolve with them in their report and then forget them.
|
|
846
|
+
*/
|
|
847
|
+
failures = [];
|
|
848
|
+
completed = 0;
|
|
849
|
+
/**
|
|
850
|
+
* The refresh running for each credential, keyed by `rowRefKey` (row id,
|
|
851
|
+
* epoch and identity), so concurrent refreshes of one credential in this
|
|
852
|
+
* process share a single exchange.
|
|
853
|
+
*/
|
|
854
|
+
refreshes = new Map();
|
|
855
|
+
closing = false;
|
|
856
|
+
constructor(modules, options, factoryOptions) {
|
|
857
|
+
const module = modules.store;
|
|
858
|
+
this.storeModule = module;
|
|
859
|
+
this.locks = modules.fs;
|
|
860
|
+
this.paths = options.paths;
|
|
861
|
+
this.now = options.now;
|
|
862
|
+
this.exchange = options.exchange;
|
|
863
|
+
this.decline = module.DECLINE_TRANSITION;
|
|
864
|
+
this.storeFailure = module.PoolOperationError;
|
|
865
|
+
this.store = module.openPoolStore({
|
|
866
|
+
provider: ACCOUNT_STORE_PROVIDER,
|
|
867
|
+
configPath: options.paths.configPath,
|
|
868
|
+
statePath: options.paths.statePath,
|
|
869
|
+
quota: QUOTA_CODEC,
|
|
870
|
+
providerState: createProviderStateCodec(ACCOUNT_STATE_POLICY),
|
|
871
|
+
requireCredentialStamps: true,
|
|
872
|
+
now: options.now,
|
|
873
|
+
...(factoryOptions.onLockEvent !== undefined
|
|
874
|
+
? { onLockEvent: factoryOptions.onLockEvent }
|
|
875
|
+
: {}),
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
// ---- failure conversion and tracking of started operations -------------
|
|
879
|
+
failureOf(operation, rowId, error) {
|
|
880
|
+
if (error instanceof AccountRepositoryError)
|
|
881
|
+
return error;
|
|
882
|
+
if (error instanceof CallbackRefusal)
|
|
883
|
+
return error.error;
|
|
884
|
+
if (error instanceof this.storeFailure) {
|
|
885
|
+
if (error.cause instanceof CallbackRefusal)
|
|
886
|
+
return error.cause.error;
|
|
887
|
+
if (error.cause instanceof AccountRepositoryError)
|
|
888
|
+
return error.cause;
|
|
889
|
+
const id = error.rowId ?? rowId;
|
|
890
|
+
return new AccountRepositoryError({
|
|
891
|
+
operation,
|
|
892
|
+
kind: error.cause instanceof AccountCodecError
|
|
893
|
+
? 'invalid-provider-state'
|
|
894
|
+
: error.kind,
|
|
895
|
+
retryable: error.retryable,
|
|
896
|
+
ambiguous: error.phase === 'after-first-write',
|
|
897
|
+
...(id !== undefined ? { rowId: id } : {}),
|
|
898
|
+
message: error.message,
|
|
899
|
+
}, { cause: error });
|
|
900
|
+
}
|
|
901
|
+
if (error instanceof this.locks.LockOwnershipError ||
|
|
902
|
+
error instanceof this.locks.LockContentionError) {
|
|
903
|
+
// The topology lease was lost or not taken in time: a retryable lock
|
|
904
|
+
// failure. A multi-row management operation keeps its progress record,
|
|
905
|
+
// so it is pending and resumes; an ordinary operation simply failed
|
|
906
|
+
// before its next write and has no record.
|
|
907
|
+
return new AccountRepositoryError({
|
|
908
|
+
operation,
|
|
909
|
+
kind: error instanceof this.locks.LockOwnershipError
|
|
910
|
+
? 'lock-ownership'
|
|
911
|
+
: 'lock-contention',
|
|
912
|
+
retryable: true,
|
|
913
|
+
ambiguous: false,
|
|
914
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
915
|
+
message: error.message,
|
|
916
|
+
}, { cause: error });
|
|
917
|
+
}
|
|
918
|
+
if (error instanceof AccountCodecError) {
|
|
919
|
+
return new AccountRepositoryError({
|
|
920
|
+
operation,
|
|
921
|
+
kind: 'invalid-input',
|
|
922
|
+
retryable: false,
|
|
923
|
+
ambiguous: false,
|
|
924
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
925
|
+
message: error.message,
|
|
926
|
+
}, { cause: error });
|
|
927
|
+
}
|
|
928
|
+
return new AccountRepositoryError({
|
|
929
|
+
operation,
|
|
930
|
+
kind: 'unexpected',
|
|
931
|
+
retryable: false,
|
|
932
|
+
// Nothing says how far a write got before an unknown error.
|
|
933
|
+
ambiguous: operation !== 'read' && operation !== 'flush',
|
|
934
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
935
|
+
message: error instanceof Error ? error.message : 'unexpected failure',
|
|
936
|
+
}, { cause: error });
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Runs one operation as owned work: refused once `dispose` has begun,
|
|
940
|
+
* counted by `flush`, and its failure recorded for the flush report while
|
|
941
|
+
* the caller still receives it.
|
|
942
|
+
*/
|
|
943
|
+
run(operation, rowId, body) {
|
|
944
|
+
if (this.closing) {
|
|
945
|
+
return Promise.reject(refusal(operation, 'disposed', 'the account repository is disposed', {
|
|
946
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
const work = (async () => {
|
|
950
|
+
try {
|
|
951
|
+
const result = await body();
|
|
952
|
+
if (operation !== 'read')
|
|
953
|
+
this.completed += 1;
|
|
954
|
+
return result;
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
const failure = this.failureOf(operation, rowId, error);
|
|
958
|
+
this.failures.push(failure.failure);
|
|
959
|
+
throw failure;
|
|
960
|
+
}
|
|
961
|
+
})();
|
|
962
|
+
this.inFlight.add(work);
|
|
963
|
+
const forget = () => this.inFlight.delete(work);
|
|
964
|
+
work.then(forget, forget);
|
|
965
|
+
return work;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Runs `body` holding the topology lease (`TOPOLOGY_LEASE`), named after
|
|
969
|
+
* the config file's canonical path so every repository on the pool, in
|
|
970
|
+
* any process, takes the same lease. `body` checks the lease before each
|
|
971
|
+
* write it makes; a lease found lost stops it there.
|
|
972
|
+
*/
|
|
973
|
+
async withTopology(operation, body) {
|
|
974
|
+
// The lease is named after the bound generation's config file, so the
|
|
975
|
+
// generation is bound (and its directory known to exist) first.
|
|
976
|
+
await this.bindGeneration(operation);
|
|
977
|
+
this.leaseTarget ??= realpath(dirname(this.paths.configPath)).then((dir) => join(dir, basename(this.paths.configPath)));
|
|
978
|
+
// A failed resolution is not cached, so a later call can retry it.
|
|
979
|
+
const target = await this.leaseTarget.catch((error) => {
|
|
980
|
+
this.leaseTarget = undefined;
|
|
981
|
+
throw error;
|
|
982
|
+
});
|
|
983
|
+
return this.locks.withLock(target, TOPOLOGY_LEASE, async (lease) => {
|
|
984
|
+
await lease.assertOwned();
|
|
985
|
+
return body(lease);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Verify which completed active generation owns these physical paths.
|
|
990
|
+
* Cache only successful binding; separate journal and row checks still
|
|
991
|
+
* decide serving readiness.
|
|
992
|
+
*/
|
|
993
|
+
bindGeneration(operation) {
|
|
994
|
+
if (this.binding === undefined) {
|
|
995
|
+
const binding = this.readBinding().then((result) => {
|
|
996
|
+
if (result.status !== 'bound')
|
|
997
|
+
throw unbound(operation, result);
|
|
998
|
+
if (!isDeepStrictEqual(result.paths, this.paths)) {
|
|
999
|
+
throw refusal(operation, 'load-error', "the published account-store generation is not this repository's");
|
|
1000
|
+
}
|
|
1001
|
+
return result.receipt.id;
|
|
1002
|
+
});
|
|
1003
|
+
this.binding = binding;
|
|
1004
|
+
binding.catch(() => {
|
|
1005
|
+
if (this.binding === binding)
|
|
1006
|
+
this.binding = undefined;
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return this.binding;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Read the migration's published-generation binding. When its directory
|
|
1013
|
+
* exists, acquire config then state save locks to avoid validating a
|
|
1014
|
+
* partial store write.
|
|
1015
|
+
*/
|
|
1016
|
+
async readBinding() {
|
|
1017
|
+
const read = () => readAccountStoreBinding(this.paths.legacyPath, { store: this.storeModule }, this.now);
|
|
1018
|
+
const directory = await stat(this.paths.storeDir).catch(() => undefined);
|
|
1019
|
+
if (directory === undefined || !directory.isDirectory())
|
|
1020
|
+
return read();
|
|
1021
|
+
return this.locks.withLock(this.paths.configPath, STORE_SAVE_LOCK, () => this.locks.withLock(this.paths.statePath, STORE_SAVE_LOCK, read));
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Read current settings and refuse if their generation no longer matches
|
|
1025
|
+
* this repository's binding.
|
|
1026
|
+
*/
|
|
1027
|
+
async boundSettings(operation) {
|
|
1028
|
+
const id = await this.bindGeneration(operation);
|
|
1029
|
+
const settings = await this.readSettings(operation);
|
|
1030
|
+
const mismatch = generationMismatch(settings, id, this.paths);
|
|
1031
|
+
if (mismatch !== undefined)
|
|
1032
|
+
throw refusal(operation, 'load-error', mismatch);
|
|
1033
|
+
return settings;
|
|
1034
|
+
}
|
|
1035
|
+
async admit(operation, rowId) {
|
|
1036
|
+
const settings = await this.boundSettings(operation);
|
|
1037
|
+
const raw = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1038
|
+
const pending = raw === undefined ? undefined : decodeManagementRecord(raw);
|
|
1039
|
+
if (pending !== undefined) {
|
|
1040
|
+
throw refusal(operation, 'management-pending', `a ${pending.kind} of the account pool is pending`, { retryable: true, ...(rowId !== undefined ? { rowId } : {}) });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async readSettings(operation) {
|
|
1044
|
+
const read = await this.store.readSettings();
|
|
1045
|
+
if (read.status === 'error') {
|
|
1046
|
+
throw refusal(operation, 'load-error', `the ${read.file} file could not be read: ${read.reason}`, {
|
|
1047
|
+
retryable: true,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return read.settings;
|
|
1051
|
+
}
|
|
1052
|
+
async readManagement(operation) {
|
|
1053
|
+
const raw = (await this.readSettings(operation))[MANAGEMENT_SETTINGS_KEY];
|
|
1054
|
+
return raw === undefined ? undefined : decodeManagementRecord(raw);
|
|
1055
|
+
}
|
|
1056
|
+
async readRows(operation) {
|
|
1057
|
+
const load = await this.store.read();
|
|
1058
|
+
if (load.status === 'pending-migration') {
|
|
1059
|
+
throw refusal(operation, 'pending-migration', 'the account pool awaits migration', {
|
|
1060
|
+
retryable: true,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
if (load.status === 'error') {
|
|
1064
|
+
throw refusal(operation, 'load-error', `the ${load.file} file could not be read: ${load.reason}`, {
|
|
1065
|
+
retryable: true,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
return load.rows;
|
|
1069
|
+
}
|
|
1070
|
+
async accountRowFor(operation, ref) {
|
|
1071
|
+
const rows = await this.readRows(operation);
|
|
1072
|
+
const index = rows.findIndex((row) => row.id === ref.id);
|
|
1073
|
+
const row = rows[index];
|
|
1074
|
+
if (row === undefined) {
|
|
1075
|
+
throw refusal(operation, 'unknown-row', `no row ${ref.id} in the pool`, {
|
|
1076
|
+
rowId: ref.id,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
return { index, row };
|
|
1080
|
+
}
|
|
1081
|
+
// ---- reads -------------------------------------------------------------
|
|
1082
|
+
read() {
|
|
1083
|
+
return this.run('read', undefined, async () => {
|
|
1084
|
+
let generation;
|
|
1085
|
+
try {
|
|
1086
|
+
generation = await this.bindGeneration('read');
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
if (!(error instanceof AccountRepositoryError))
|
|
1090
|
+
throw error;
|
|
1091
|
+
return error.failure.kind === 'pending-migration'
|
|
1092
|
+
? { status: 'pending-migration' }
|
|
1093
|
+
: { status: 'error', file: 'config', reason: error.failure.message };
|
|
1094
|
+
}
|
|
1095
|
+
const load = await this.store.read();
|
|
1096
|
+
if (load.status === 'pending-migration')
|
|
1097
|
+
return { status: 'pending-migration' };
|
|
1098
|
+
if (load.status === 'error') {
|
|
1099
|
+
return { status: 'error', file: load.file, reason: load.reason };
|
|
1100
|
+
}
|
|
1101
|
+
const settingsRead = await this.store.readSettings();
|
|
1102
|
+
if (settingsRead.status === 'error') {
|
|
1103
|
+
return {
|
|
1104
|
+
status: 'error',
|
|
1105
|
+
file: settingsRead.file,
|
|
1106
|
+
reason: settingsRead.reason,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const settings = settingsRead.settings;
|
|
1110
|
+
const mismatch = generationMismatch(settings, generation, this.paths);
|
|
1111
|
+
if (mismatch !== undefined) {
|
|
1112
|
+
return { status: 'error', file: 'settings', reason: mismatch };
|
|
1113
|
+
}
|
|
1114
|
+
let routing;
|
|
1115
|
+
try {
|
|
1116
|
+
const management = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1117
|
+
if (management !== undefined) {
|
|
1118
|
+
return {
|
|
1119
|
+
status: 'management-pending',
|
|
1120
|
+
management: publicManagementRecord(decodeManagementRecord(management)),
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
const rawRouting = settings[ROUTING_SETTINGS_KEY];
|
|
1124
|
+
routing =
|
|
1125
|
+
rawRouting === undefined
|
|
1126
|
+
? undefined
|
|
1127
|
+
: decodeRoutingSettings(rawRouting);
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
if (error instanceof AccountCodecError) {
|
|
1131
|
+
return { status: 'error', file: 'settings', reason: error.message };
|
|
1132
|
+
}
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
status: 'ready',
|
|
1137
|
+
rows: load.rows.map((row, index) => toAccountRow(row, index)),
|
|
1138
|
+
...(routing !== undefined ? { routing } : {}),
|
|
1139
|
+
};
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
settled() {
|
|
1143
|
+
return this.store.pullsSettled();
|
|
1144
|
+
}
|
|
1145
|
+
// ---- credentials ------------------------------------------------------
|
|
1146
|
+
login(input) {
|
|
1147
|
+
return this.run('login', input.id, () => this.withTopology('login', async (lease) => {
|
|
1148
|
+
await this.admit('login', input.id);
|
|
1149
|
+
return this.addLogin(input, lease);
|
|
1150
|
+
}));
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Matches a login to the pool (exact email first, then the secret) and
|
|
1154
|
+
* adds it. An email match holding another secret is a re-authentication,
|
|
1155
|
+
* which needs the caller's expected ref (`replaceCredential`); a secret
|
|
1156
|
+
* held by a row recorded for another email is a contradiction. Both refuse
|
|
1157
|
+
* before anything is written.
|
|
1158
|
+
*/
|
|
1159
|
+
async addLogin(input, lease) {
|
|
1160
|
+
if (!input.refreshToken.trim()) {
|
|
1161
|
+
throw refusal('login', 'invalid-input', 'a login needs a refresh token', {
|
|
1162
|
+
rowId: input.id,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
const rows = await this.readRows('login');
|
|
1166
|
+
const secretRow = rows.find((row) => row.credential?.type === 'oauth' &&
|
|
1167
|
+
row.credential.refresh === input.refreshToken);
|
|
1168
|
+
const email = input.metadata.email;
|
|
1169
|
+
if (typeof email === 'string') {
|
|
1170
|
+
const emailRows = rows.filter((row) => metadataOfStored(row.providerState)?.email === email);
|
|
1171
|
+
const other = emailRows.find((row) => row !== secretRow);
|
|
1172
|
+
if (other !== undefined) {
|
|
1173
|
+
throw refusal('login', 'duplicate-identity', `row ${other.id} already holds this account; re-authenticate it with its ref instead of adding a second row`, { rowId: other.id });
|
|
1174
|
+
}
|
|
1175
|
+
const secretEmail = secretRow === undefined
|
|
1176
|
+
? undefined
|
|
1177
|
+
: metadataOfStored(secretRow.providerState)?.email;
|
|
1178
|
+
if (typeof secretEmail === 'string' && secretEmail !== email) {
|
|
1179
|
+
throw refusal('login', 'duplicate-secret', `row ${secretRow?.id} holds this credential for another account`, { ...(secretRow !== undefined ? { rowId: secretRow.id } : {}) });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
await lease.assertOwned();
|
|
1183
|
+
const added = await this.store.add({
|
|
1184
|
+
id: input.id,
|
|
1185
|
+
credential: { type: 'oauth', refresh: input.refreshToken },
|
|
1186
|
+
...(input.identity !== undefined ? { identity: input.identity } : {}),
|
|
1187
|
+
providerState: encodeProviderState({
|
|
1188
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
1189
|
+
metadata: input.metadata,
|
|
1190
|
+
}),
|
|
1191
|
+
});
|
|
1192
|
+
const row = (await this.readRows('login')).find((r) => r.id === added.id);
|
|
1193
|
+
if (row === undefined) {
|
|
1194
|
+
throw refusal('login', 'unknown-row', `row ${added.id} was removed right after the login`, {
|
|
1195
|
+
rowId: added.id,
|
|
1196
|
+
retryable: true,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
if (added.outcome === 'exists') {
|
|
1200
|
+
throw refusal('login', 'management-pending', 'a staged row cannot be used as a login');
|
|
1201
|
+
}
|
|
1202
|
+
return { ref: rowRefOf(row), outcome: added.outcome };
|
|
1203
|
+
}
|
|
1204
|
+
replaceCredential(expected, input) {
|
|
1205
|
+
return this.run('replaceCredential', expected.id, () => this.withTopology('replaceCredential', async (lease) => {
|
|
1206
|
+
await this.admit('replaceCredential', expected.id);
|
|
1207
|
+
if (!input.refreshToken.trim()) {
|
|
1208
|
+
throw refusal('replaceCredential', 'invalid-input', 'a replacement needs a refresh token', {
|
|
1209
|
+
rowId: expected.id,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
// `expected` names the row id, epoch and recorded identity (absence
|
|
1213
|
+
// included). As its attribution, the store compares it with the row
|
|
1214
|
+
// read under its locks before it completes an interrupted replace,
|
|
1215
|
+
// writes anything, or calls `onReplace`. The prior identity the store
|
|
1216
|
+
// then hands `onReplace` only decides which metadata is retained.
|
|
1217
|
+
await lease.assertOwned();
|
|
1218
|
+
const replaced = await this.store.replace(expected.id, { type: 'oauth', refresh: input.refreshToken }, {
|
|
1219
|
+
...(input.identity !== undefined
|
|
1220
|
+
? { identity: input.identity }
|
|
1221
|
+
: {}),
|
|
1222
|
+
...(input.metadata !== undefined
|
|
1223
|
+
? {
|
|
1224
|
+
providerState: encodeProviderState({
|
|
1225
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
1226
|
+
metadata: input.metadata,
|
|
1227
|
+
}),
|
|
1228
|
+
}
|
|
1229
|
+
: {}),
|
|
1230
|
+
}, { attribution: credentialFenceOf(expected) });
|
|
1231
|
+
const ref = input.identity === undefined
|
|
1232
|
+
? { id: replaced.id, credentialEpoch: replaced.credentialEpoch }
|
|
1233
|
+
: {
|
|
1234
|
+
id: replaced.id,
|
|
1235
|
+
credentialEpoch: replaced.credentialEpoch,
|
|
1236
|
+
identity: input.identity,
|
|
1237
|
+
};
|
|
1238
|
+
if (input.disabled === 'enable') {
|
|
1239
|
+
try {
|
|
1240
|
+
await lease.assertOwned();
|
|
1241
|
+
await this.store.enable(ref.id, {
|
|
1242
|
+
attribution: credentialFenceOf(ref),
|
|
1243
|
+
providerState: (current, row) => this.withEnabledEncoding('replaceCredential', current, row, true),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
catch (error) {
|
|
1247
|
+
// The new credential is stored either way, so the caller still
|
|
1248
|
+
// gets its ref; the failed enable is reported by `flush`.
|
|
1249
|
+
this.failures.push(this.failureOf('replaceCredential', ref.id, error).failure);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return { ref };
|
|
1253
|
+
}));
|
|
1254
|
+
}
|
|
1255
|
+
refresh(ref, options) {
|
|
1256
|
+
// Concurrent refreshes of one credential in this process share one
|
|
1257
|
+
// exchange; other processes wait on the per-account provider lock.
|
|
1258
|
+
const key = rowRefKey(ref);
|
|
1259
|
+
const pending = this.refreshes.get(key);
|
|
1260
|
+
if (pending !== undefined)
|
|
1261
|
+
return pending;
|
|
1262
|
+
const work = this.run('refresh', ref.id, () => this.refreshOnce(ref, options));
|
|
1263
|
+
this.refreshes.set(key, work);
|
|
1264
|
+
const forget = () => {
|
|
1265
|
+
if (this.refreshes.get(key) === work)
|
|
1266
|
+
this.refreshes.delete(key);
|
|
1267
|
+
};
|
|
1268
|
+
work.then(forget, forget);
|
|
1269
|
+
return work;
|
|
1270
|
+
}
|
|
1271
|
+
async refreshOnce(ref, options) {
|
|
1272
|
+
await this.admit('refresh', ref.id);
|
|
1273
|
+
const { index } = await this.accountRowFor('refresh', ref);
|
|
1274
|
+
let stale;
|
|
1275
|
+
// The store calls this before taking its locks, on the locked re-read
|
|
1276
|
+
// and at commit. A row that no longer holds `ref` refuses the refresh
|
|
1277
|
+
// there, so no successor token is written to a replaced or re-added row.
|
|
1278
|
+
const refuse = async (row) => {
|
|
1279
|
+
const mismatch = refMismatchReason(row, ref);
|
|
1280
|
+
if (mismatch !== undefined) {
|
|
1281
|
+
stale = mismatch;
|
|
1282
|
+
return mismatch;
|
|
1283
|
+
}
|
|
1284
|
+
return options?.refuse?.(toAccountRow(row, index));
|
|
1285
|
+
};
|
|
1286
|
+
const outcome = await this.store.refresh(ref.id, async (credential, row) => {
|
|
1287
|
+
const result = await this.exchange({
|
|
1288
|
+
refreshToken: credential.refresh,
|
|
1289
|
+
row: toAccountRow(row, index),
|
|
1290
|
+
});
|
|
1291
|
+
const providerState = result.metadata === undefined
|
|
1292
|
+
? undefined
|
|
1293
|
+
: this.refreshedState(row, result.metadata);
|
|
1294
|
+
return {
|
|
1295
|
+
access: result.accessToken,
|
|
1296
|
+
refresh: result.refreshToken,
|
|
1297
|
+
expires: result.expiresAt,
|
|
1298
|
+
...(result.identity !== undefined
|
|
1299
|
+
? { identity: result.identity }
|
|
1300
|
+
: {}),
|
|
1301
|
+
...(providerState !== undefined ? { providerState } : {}),
|
|
1302
|
+
};
|
|
1303
|
+
}, {
|
|
1304
|
+
providerLock: refreshProviderLock(this.paths.statePath, ref),
|
|
1305
|
+
refuse,
|
|
1306
|
+
});
|
|
1307
|
+
switch (outcome.status) {
|
|
1308
|
+
case 'rotated': {
|
|
1309
|
+
const credential = outcome.credential;
|
|
1310
|
+
if (credential.type !== 'oauth' ||
|
|
1311
|
+
credential.access === undefined ||
|
|
1312
|
+
credential.expires === undefined) {
|
|
1313
|
+
throw refusal('refresh', 'unexpected', `row ${ref.id} committed a refresh without an access token`, {
|
|
1314
|
+
rowId: ref.id,
|
|
1315
|
+
ambiguous: true,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
const identity = outcome.identity ?? ref.identity;
|
|
1319
|
+
return {
|
|
1320
|
+
status: 'rotated',
|
|
1321
|
+
ref: identity === undefined
|
|
1322
|
+
? { id: ref.id, credentialEpoch: ref.credentialEpoch }
|
|
1323
|
+
: { id: ref.id, credentialEpoch: ref.credentialEpoch, identity },
|
|
1324
|
+
accessToken: credential.access,
|
|
1325
|
+
expiresAt: credential.expires,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
case 'identity-contradicted':
|
|
1329
|
+
return {
|
|
1330
|
+
status: 'identity-contradicted',
|
|
1331
|
+
ref,
|
|
1332
|
+
expectedIdentity: outcome.expectedIdentity,
|
|
1333
|
+
returnedIdentity: outcome.returnedIdentity,
|
|
1334
|
+
};
|
|
1335
|
+
case 'refused':
|
|
1336
|
+
if (stale !== undefined && outcome.reason === stale) {
|
|
1337
|
+
throw refusal('refresh', 'attribution', stale, {
|
|
1338
|
+
rowId: ref.id,
|
|
1339
|
+
retryable: true,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
return { status: 'refused', ref, reason: outcome.reason };
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* The provider state a refresh brings: the metadata read under the row
|
|
1347
|
+
* lock with the fields the exchange reported laid over it. At commit the
|
|
1348
|
+
* store merges it with the value on disk (`ACCOUNT_STATE_POLICY.merge`),
|
|
1349
|
+
* which keeps the stored `addedAt` and `dailyRequestCounts`, the later
|
|
1350
|
+
* `lastUsed` and the later reset time of each rate limit.
|
|
1351
|
+
*/
|
|
1352
|
+
refreshedState(row, learnt) {
|
|
1353
|
+
// `providerStateDropped`: the store keeps metadata this reader is not
|
|
1354
|
+
// shown. Leave it intact rather than rebuild it from incomplete fields;
|
|
1355
|
+
// the token refresh itself still goes ahead.
|
|
1356
|
+
if (row.providerStateDropped !== undefined)
|
|
1357
|
+
return undefined;
|
|
1358
|
+
const envelope = envelopeOf(row.providerState);
|
|
1359
|
+
const metadata = {
|
|
1360
|
+
...metadataOrBase(envelope, row, this.now()),
|
|
1361
|
+
};
|
|
1362
|
+
for (const [key, value] of Object.entries(learnt)) {
|
|
1363
|
+
if (value !== undefined)
|
|
1364
|
+
Object.assign(metadata, { [key]: value });
|
|
1365
|
+
}
|
|
1366
|
+
return encodeEnvelope(envelope, metadata);
|
|
1367
|
+
}
|
|
1368
|
+
recordIdentity(ref, identity) {
|
|
1369
|
+
return this.run('recordIdentity', ref.id, async () => {
|
|
1370
|
+
await this.admit('recordIdentity', ref.id);
|
|
1371
|
+
await this.store.recordIdentity(ref.id, identity, {
|
|
1372
|
+
credentialEpoch: ref.credentialEpoch,
|
|
1373
|
+
});
|
|
1374
|
+
return {
|
|
1375
|
+
ref: { id: ref.id, credentialEpoch: ref.credentialEpoch, identity },
|
|
1376
|
+
};
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
// ---- enabled flag and access -------------------------------------------
|
|
1380
|
+
/**
|
|
1381
|
+
* The row's metadata with `enabled` recording the flag an enable or
|
|
1382
|
+
* disable sets, written with that change; a row with no metadata keeps none.
|
|
1383
|
+
*/
|
|
1384
|
+
withEnabledEncoding(operation, current, row, enabled) {
|
|
1385
|
+
const envelope = shownEnvelope(operation, current, row);
|
|
1386
|
+
if (envelope === undefined)
|
|
1387
|
+
return undefined;
|
|
1388
|
+
return encodeEnvelope(envelope, { ...envelope.metadata, enabled });
|
|
1389
|
+
}
|
|
1390
|
+
setEnabled(ref, input) {
|
|
1391
|
+
return this.run('setEnabled', ref.id, async () => {
|
|
1392
|
+
await this.admit('setEnabled', ref.id);
|
|
1393
|
+
const { index, row: seen } = await this.accountRowFor('setEnabled', ref);
|
|
1394
|
+
const mutator = async (current, row) => {
|
|
1395
|
+
const envelope = shownEnvelope('setEnabled', current, row);
|
|
1396
|
+
if (input.enabled &&
|
|
1397
|
+
input.actor === 'user' &&
|
|
1398
|
+
envelope?.metadata.accountIneligible === true) {
|
|
1399
|
+
throw new CallbackRefusal(refusal('setEnabled', 'account-ineligible', `row ${ref.id} is marked ineligible; a user enable is refused`, {
|
|
1400
|
+
rowId: ref.id,
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
const transition = input.metadata
|
|
1404
|
+
? await input.metadata(envelope?.metadata, toAccountRow(row, index))
|
|
1405
|
+
: { kind: 'keep' };
|
|
1406
|
+
if (transition.kind === 'decline')
|
|
1407
|
+
return this.decline;
|
|
1408
|
+
if (transition.kind === 'clear')
|
|
1409
|
+
return undefined;
|
|
1410
|
+
const metadata = transition.kind === 'set' ? transition.metadata : envelope?.metadata;
|
|
1411
|
+
if (metadata === undefined)
|
|
1412
|
+
return undefined;
|
|
1413
|
+
return encodeEnvelope(envelope, { ...metadata, enabled: input.enabled });
|
|
1414
|
+
};
|
|
1415
|
+
// With `providerStateDropped`, only the attributed enabled flag is
|
|
1416
|
+
// switched and the hidden metadata is kept as stored. A requested
|
|
1417
|
+
// metadata change on such a row is refused under the lock.
|
|
1418
|
+
const options = seen.providerStateDropped !== undefined && input.metadata === undefined
|
|
1419
|
+
? { attribution: credentialFenceOf(ref) }
|
|
1420
|
+
: { attribution: credentialFenceOf(ref), providerState: mutator };
|
|
1421
|
+
const result = input.enabled
|
|
1422
|
+
? await this.store.enable(ref.id, options)
|
|
1423
|
+
: await this.store.disable(ref.id, input.reason ??
|
|
1424
|
+
(input.actor === 'user' ? 'disabled-by-user' : 'disabled'), options);
|
|
1425
|
+
return this.transitionResult(ref, result);
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
transitionResult(ref, result) {
|
|
1429
|
+
if (result.declined)
|
|
1430
|
+
return { ref, declined: true };
|
|
1431
|
+
const metadata = metadataOfStored(result.providerState);
|
|
1432
|
+
return {
|
|
1433
|
+
ref,
|
|
1434
|
+
...(result.providerStateOutcome !== undefined
|
|
1435
|
+
? { metadataOutcome: result.providerStateOutcome }
|
|
1436
|
+
: {}),
|
|
1437
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
recordAccessVerdict(ref, verdict) {
|
|
1441
|
+
return this.run('recordAccessVerdict', ref.id, async () => {
|
|
1442
|
+
await this.admit('recordAccessVerdict', ref.id);
|
|
1443
|
+
const fence = credentialFenceOf(ref);
|
|
1444
|
+
// The enabled flag and the access metadata land in one attributed
|
|
1445
|
+
// transition. The mutator compares the verdict's observation time with
|
|
1446
|
+
// the evidence times read under the lock and declines older evidence.
|
|
1447
|
+
const plan = (current, row) => {
|
|
1448
|
+
const envelope = shownEnvelope('recordAccessVerdict', current, row);
|
|
1449
|
+
const applied = applyAccessVerdict(metadataOrBase(envelope, row, this.now()), verdict);
|
|
1450
|
+
return { envelope, applied };
|
|
1451
|
+
};
|
|
1452
|
+
if (verdict.kind !== 'cleared') {
|
|
1453
|
+
const result = await this.store.disable(ref.id, verdict.kind === 'ineligible'
|
|
1454
|
+
? 'account-ineligible'
|
|
1455
|
+
: 'verification-required', {
|
|
1456
|
+
attribution: fence,
|
|
1457
|
+
providerState: (current, row) => {
|
|
1458
|
+
const { envelope, applied } = plan(current, row);
|
|
1459
|
+
if (applied === undefined)
|
|
1460
|
+
return this.decline;
|
|
1461
|
+
return encodeEnvelope(envelope, {
|
|
1462
|
+
...applied.metadata,
|
|
1463
|
+
enabled: false,
|
|
1464
|
+
});
|
|
1465
|
+
},
|
|
1466
|
+
});
|
|
1467
|
+
return this.transitionResult(ref, result);
|
|
1468
|
+
}
|
|
1469
|
+
if (verdict.enable) {
|
|
1470
|
+
const result = await this.store.enable(ref.id, {
|
|
1471
|
+
attribution: fence,
|
|
1472
|
+
providerState: (current, row) => {
|
|
1473
|
+
const { envelope, applied } = plan(current, row);
|
|
1474
|
+
if (applied === undefined || applied.transition !== 'enable') {
|
|
1475
|
+
return this.decline;
|
|
1476
|
+
}
|
|
1477
|
+
return encodeEnvelope(envelope, {
|
|
1478
|
+
...applied.metadata,
|
|
1479
|
+
enabled: true,
|
|
1480
|
+
});
|
|
1481
|
+
},
|
|
1482
|
+
});
|
|
1483
|
+
if (!result.declined)
|
|
1484
|
+
return this.transitionResult(ref, result);
|
|
1485
|
+
}
|
|
1486
|
+
// Clearing verification or ineligibility without re-enabling the
|
|
1487
|
+
// account (or when nothing was blocked): only the metadata changes.
|
|
1488
|
+
let stale = false;
|
|
1489
|
+
const result = await this.store.updateProviderState(ref.id, fence, (current, row) => {
|
|
1490
|
+
const { envelope, applied } = plan(current, row);
|
|
1491
|
+
if (applied === undefined) {
|
|
1492
|
+
stale = true;
|
|
1493
|
+
return current;
|
|
1494
|
+
}
|
|
1495
|
+
return encodeEnvelope(envelope, applied.metadata);
|
|
1496
|
+
});
|
|
1497
|
+
if (stale)
|
|
1498
|
+
return { ref, declined: true };
|
|
1499
|
+
const metadata = metadataOfStored(result.providerState);
|
|
1500
|
+
return {
|
|
1501
|
+
ref,
|
|
1502
|
+
metadataOutcome: result.outcome,
|
|
1503
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
1504
|
+
};
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
// ---- metadata ----------------------------------------------------------
|
|
1508
|
+
updateMetadata(ref, mutator) {
|
|
1509
|
+
return this.run('updateMetadata', ref.id, async () => {
|
|
1510
|
+
await this.admit('updateMetadata', ref.id);
|
|
1511
|
+
const { index } = await this.accountRowFor('updateMetadata', ref);
|
|
1512
|
+
const result = await this.store.updateProviderState(ref.id, credentialFenceOf(ref), async (current, row) => {
|
|
1513
|
+
const envelope = shownEnvelope('updateMetadata', current, row);
|
|
1514
|
+
const update = await mutator(envelope?.metadata, toAccountRow(row, index));
|
|
1515
|
+
if (update.kind === 'keep')
|
|
1516
|
+
return current;
|
|
1517
|
+
if (update.kind === 'clear')
|
|
1518
|
+
return undefined;
|
|
1519
|
+
return encodeEnvelope(envelope, update.metadata);
|
|
1520
|
+
});
|
|
1521
|
+
return this.metadataResult(ref, result);
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
metadataResult(ref, result) {
|
|
1525
|
+
const metadata = metadataOfStored(result.providerState);
|
|
1526
|
+
return {
|
|
1527
|
+
ref,
|
|
1528
|
+
outcome: result.outcome,
|
|
1529
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* One fenced metadata write: `apply` receives the metadata read under the
|
|
1534
|
+
* row lock, or `metadataOrBase`'s starting value for a row with none, and
|
|
1535
|
+
* returns the next metadata.
|
|
1536
|
+
*/
|
|
1537
|
+
applyToMetadata(operation, ref, apply) {
|
|
1538
|
+
return this.run(operation, ref.id, async () => {
|
|
1539
|
+
await this.admit(operation, ref.id);
|
|
1540
|
+
const result = await this.store.updateProviderState(ref.id, credentialFenceOf(ref), (current, row) => {
|
|
1541
|
+
const envelope = shownEnvelope(operation, current, row);
|
|
1542
|
+
const before = metadataOrBase(envelope, row, this.now());
|
|
1543
|
+
const after = apply(before);
|
|
1544
|
+
return after === before && envelope !== undefined
|
|
1545
|
+
? current
|
|
1546
|
+
: encodeEnvelope(envelope, after);
|
|
1547
|
+
});
|
|
1548
|
+
return this.metadataResult(ref, result);
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
recordProject(ref, observation) {
|
|
1552
|
+
return this.applyToMetadata('recordProject', ref, (m) => applyProjectObservation(m, observation));
|
|
1553
|
+
}
|
|
1554
|
+
recordFingerprint(ref, observation) {
|
|
1555
|
+
return this.applyToMetadata('recordFingerprint', ref, (m) => applyFingerprintObservation(m, observation));
|
|
1556
|
+
}
|
|
1557
|
+
recordTier(ref, observation) {
|
|
1558
|
+
return this.applyToMetadata('recordTier', ref, (m) => applyTierObservation(m, observation));
|
|
1559
|
+
}
|
|
1560
|
+
recordCooldown(ref, observation) {
|
|
1561
|
+
return this.applyToMetadata('recordCooldown', ref, (m) => applyCooldownObservation(m, observation));
|
|
1562
|
+
}
|
|
1563
|
+
recordRateLimits(ref, resets) {
|
|
1564
|
+
return this.applyToMetadata('recordRateLimits', ref, (m) => applyRateLimitObservation(m, resets));
|
|
1565
|
+
}
|
|
1566
|
+
recordSwitch(ref, reason) {
|
|
1567
|
+
return this.applyToMetadata('recordSwitch', ref, (m) => applySwitch(m, reason));
|
|
1568
|
+
}
|
|
1569
|
+
recordUsage(ref, observation) {
|
|
1570
|
+
return this.run('recordUsage', ref.id, async () => {
|
|
1571
|
+
await this.admit('recordUsage', ref.id);
|
|
1572
|
+
let result;
|
|
1573
|
+
try {
|
|
1574
|
+
// `dailyRequestCounts` and `lastUsed` are computed from the value read
|
|
1575
|
+
// under the row lock, so concurrent increments from any process add up.
|
|
1576
|
+
result = await this.store.updateProviderState(ref.id, credentialFenceOf(ref), (current, row) => {
|
|
1577
|
+
const envelope = shownEnvelope('recordUsage', current, row);
|
|
1578
|
+
return encodeEnvelope(envelope, applyUsage(metadataOrBase(envelope, row, this.now()), observation, this.now()));
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
catch (error) {
|
|
1582
|
+
const failure = this.failureOf('recordUsage', ref.id, error);
|
|
1583
|
+
// An increment whose landing is unknown is never retried: a retry
|
|
1584
|
+
// could count the request twice.
|
|
1585
|
+
throw new AccountRepositoryError({
|
|
1586
|
+
...failure.failure,
|
|
1587
|
+
retryable: failure.failure.ambiguous
|
|
1588
|
+
? false
|
|
1589
|
+
: failure.failure.retryable,
|
|
1590
|
+
}, { cause: error });
|
|
1591
|
+
}
|
|
1592
|
+
const metadata = metadataOfStored(result.providerState);
|
|
1593
|
+
if (metadata?.dailyRequestCounts == null) {
|
|
1594
|
+
throw refusal('recordUsage', 'unexpected', `row ${ref.id} shows no request counts after the increment`, {
|
|
1595
|
+
rowId: ref.id,
|
|
1596
|
+
ambiguous: true,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
ref,
|
|
1601
|
+
lastUsed: metadata.lastUsed,
|
|
1602
|
+
dailyRequestCounts: metadata.dailyRequestCounts,
|
|
1603
|
+
};
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
recordQuota(ref, observation) {
|
|
1607
|
+
return this.run('recordQuota', ref.id, async () => {
|
|
1608
|
+
await this.admit('recordQuota', ref.id);
|
|
1609
|
+
let encoded;
|
|
1610
|
+
try {
|
|
1611
|
+
encoded = encodeQuotaState(observation);
|
|
1612
|
+
}
|
|
1613
|
+
catch (error) {
|
|
1614
|
+
throw refusal('recordQuota', 'invalid-quota', error instanceof Error ? error.message : 'invalid quota', {
|
|
1615
|
+
rowId: ref.id,
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
await this.store.recordQuota(ref.id, credentialFenceOf(ref), encoded);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
// ---- selection and roster ---------------------------------------------
|
|
1622
|
+
selectAccount(target, row) {
|
|
1623
|
+
return this.run('selectAccount', row?.id, async () => {
|
|
1624
|
+
await this.admit('selectAccount', row?.id);
|
|
1625
|
+
await this.store.updateSettings((settings) => {
|
|
1626
|
+
const raw = settings[ROUTING_SETTINGS_KEY];
|
|
1627
|
+
const routing = raw === undefined
|
|
1628
|
+
? { schemaVersion: ROUTING_SETTINGS_SCHEMA_VERSION }
|
|
1629
|
+
: decodeRoutingSettings(raw);
|
|
1630
|
+
if (target === 'active') {
|
|
1631
|
+
routing.activeRow = row;
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
routing.activeRowByFamily = {
|
|
1635
|
+
...(routing.activeRowByFamily ?? {}),
|
|
1636
|
+
[target]: row,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
...settings,
|
|
1641
|
+
[ROUTING_SETTINGS_KEY]: encodeRoutingSettings(routing),
|
|
1642
|
+
};
|
|
1643
|
+
}, {});
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
reorder(ids) {
|
|
1647
|
+
return this.run('reorder', undefined, async () => {
|
|
1648
|
+
await this.admit('reorder');
|
|
1649
|
+
await this.store.reorder(ids);
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
remove(ref) {
|
|
1653
|
+
return this.run('remove', ref.id, async () => {
|
|
1654
|
+
await this.admit('remove', ref.id);
|
|
1655
|
+
await this.store.remove(ref.id, {
|
|
1656
|
+
// Runs under every lock before anything is written. A pending
|
|
1657
|
+
// management record refuses the removal, since that operation owns
|
|
1658
|
+
// the pool's rows until it finishes; a row that no longer holds
|
|
1659
|
+
// `ref` refuses it as stale.
|
|
1660
|
+
protect: (_id, view) => {
|
|
1661
|
+
if (view.config[MANAGEMENT_SETTINGS_KEY] !== undefined) {
|
|
1662
|
+
throw new CallbackRefusal(refusal('remove', 'management-pending', 'a management operation of the pool is pending', {
|
|
1663
|
+
rowId: ref.id,
|
|
1664
|
+
retryable: true,
|
|
1665
|
+
}));
|
|
1666
|
+
}
|
|
1667
|
+
// Only a state entry left by an interrupted removal: finishing it
|
|
1668
|
+
// writes nothing that could belong to a newer credential.
|
|
1669
|
+
if (view.row === undefined)
|
|
1670
|
+
return undefined;
|
|
1671
|
+
const mismatch = refMismatchReason(view.row, ref);
|
|
1672
|
+
if (mismatch !== undefined) {
|
|
1673
|
+
throw new CallbackRefusal(refusal('remove', 'attribution', mismatch, {
|
|
1674
|
+
rowId: ref.id,
|
|
1675
|
+
retryable: true,
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1678
|
+
return undefined;
|
|
1679
|
+
},
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
// ---- management --------------------------------------------------------
|
|
1684
|
+
clear() {
|
|
1685
|
+
return this.run('clear', undefined, () => this.withTopology('clear', async (lease) => {
|
|
1686
|
+
await this.boundSettings('clear');
|
|
1687
|
+
let record = await this.readManagement('clear');
|
|
1688
|
+
if (record !== undefined && record.kind !== 'clear') {
|
|
1689
|
+
throw refusal('clear', 'management-pending', `a ${record.kind} of the account pool is pending`, {
|
|
1690
|
+
retryable: true,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
if (record === undefined) {
|
|
1694
|
+
const rows = await this.readRows('clear');
|
|
1695
|
+
record = await this.startManagement('clear', {
|
|
1696
|
+
id: randomUUID(),
|
|
1697
|
+
kind: 'clear',
|
|
1698
|
+
targets: rows.map((row) => row.id),
|
|
1699
|
+
progress: { step: 'remove', completedTargets: [] },
|
|
1700
|
+
}, lease);
|
|
1701
|
+
}
|
|
1702
|
+
return this.continueManagement('clear', record, async (current) => this.removeTargets('clear', current, lease), lease);
|
|
1703
|
+
}));
|
|
1704
|
+
}
|
|
1705
|
+
replacePool(inputs) {
|
|
1706
|
+
return this.run('replacePool', undefined, () => this.withTopology('replacePool', async (lease) => {
|
|
1707
|
+
this.checkPoolInputs(inputs);
|
|
1708
|
+
const pending = await this.readManagement('replacePool');
|
|
1709
|
+
if (this.store.publishRoster &&
|
|
1710
|
+
this.store.publication &&
|
|
1711
|
+
this.storeModule.fingerprintOf &&
|
|
1712
|
+
(pending === undefined || pending.publication !== undefined)) {
|
|
1713
|
+
return this.replacePublishedPool(inputs, lease);
|
|
1714
|
+
}
|
|
1715
|
+
const inputDigest = replacementInputDigest(inputs);
|
|
1716
|
+
const inputIds = inputs.map((input) => input.id);
|
|
1717
|
+
await this.boundSettings('replacePool');
|
|
1718
|
+
let record = await this.readManagement('replacePool');
|
|
1719
|
+
if (record !== undefined) {
|
|
1720
|
+
if (record.kind !== 'replace-pool') {
|
|
1721
|
+
throw refusal('replacePool', 'management-pending', `a ${record.kind} of the account pool is pending`, {
|
|
1722
|
+
retryable: true,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
// A pending replacement resumes only for the inputs it was started
|
|
1726
|
+
// with; anything else would mix two replacements.
|
|
1727
|
+
if (record.inputDigest !== inputDigest) {
|
|
1728
|
+
throw refusal('replacePool', 'management-pending', 'another pool replacement is pending', {
|
|
1729
|
+
retryable: true,
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
else {
|
|
1734
|
+
const rows = await this.readRows('replacePool');
|
|
1735
|
+
// A replacement adds new rows. An input naming a row the pool
|
|
1736
|
+
// already holds is refused here, before anything is written: the
|
|
1737
|
+
// store does not re-add an id removed in this process, so removing
|
|
1738
|
+
// that row first would strand the pool without it.
|
|
1739
|
+
const held = new Set(rows.map((row) => row.id));
|
|
1740
|
+
const reused = inputs.find((input) => held.has(input.id));
|
|
1741
|
+
if (reused !== undefined) {
|
|
1742
|
+
throw refusal('replacePool', 'invalid-input', `input ${reused.id} names a row the pool already holds; a replacement adds new rows`, { rowId: reused.id });
|
|
1743
|
+
}
|
|
1744
|
+
// The current store cannot atomically publish a replacement roster.
|
|
1745
|
+
// Refuse before any journal, transfer or account write instead of
|
|
1746
|
+
// deleting working credentials before the new rows are accepted.
|
|
1747
|
+
if (rows.length > 0) {
|
|
1748
|
+
throw refusal('replacePool', 'replacement-unavailable', 'Replacing a non-empty account pool is temporarily unavailable. Existing accounts are unchanged.');
|
|
1749
|
+
}
|
|
1750
|
+
const id = randomUUID();
|
|
1751
|
+
// The inputs reach their private file before the record names it,
|
|
1752
|
+
// so a record that still needs its inputs always has them.
|
|
1753
|
+
await lease.assertOwned();
|
|
1754
|
+
await this.writeTransfer(id, inputs);
|
|
1755
|
+
record = await this.startManagement('replacePool', {
|
|
1756
|
+
id,
|
|
1757
|
+
kind: 'replace-pool',
|
|
1758
|
+
targets: rows.map((row) => row.id),
|
|
1759
|
+
progress: { step: 'remove', completedTargets: [] },
|
|
1760
|
+
inputDigest,
|
|
1761
|
+
}, lease);
|
|
1762
|
+
}
|
|
1763
|
+
return this.continueManagement('replacePool', record, async (current) => {
|
|
1764
|
+
let next = current;
|
|
1765
|
+
if (next.progress.step !== 'verified') {
|
|
1766
|
+
if (next.progress.step === 'remove') {
|
|
1767
|
+
if (next.targets.length > 0) {
|
|
1768
|
+
throw refusal('replacePool', 'replacement-unavailable', 'Replacing a non-empty account pool is temporarily unavailable. Existing accounts are unchanged.');
|
|
1769
|
+
}
|
|
1770
|
+
next = await this.removeTargets('replacePool', next, lease);
|
|
1771
|
+
next = await this.saveProgress('replacePool', next, 'add', [], lease);
|
|
1772
|
+
}
|
|
1773
|
+
// Before `verified` the transfer file must exist and hold exactly
|
|
1774
|
+
// the inputs the record was started with; a missing file fails
|
|
1775
|
+
// here and leaves the record pending.
|
|
1776
|
+
const pending = await this.readTransfer(next.id);
|
|
1777
|
+
if (replacementInputDigest(pending) !== next.inputDigest) {
|
|
1778
|
+
throw new AccountCodecError('transfer', 'does not hold the inputs this replacement was started with');
|
|
1779
|
+
}
|
|
1780
|
+
if (next.progress.completedTargets.some((id) => !inputIds.includes(id))) {
|
|
1781
|
+
throw new AccountCodecError('$.progress.completedTargets', 'must name only inputs of the transfer file while they are added');
|
|
1782
|
+
}
|
|
1783
|
+
for (const input of pending) {
|
|
1784
|
+
if (next.progress.completedTargets.includes(input.id))
|
|
1785
|
+
continue;
|
|
1786
|
+
// Adding again after a crash is safe: the store returns the row
|
|
1787
|
+
// already holding the secret.
|
|
1788
|
+
await this.addLogin(input, lease);
|
|
1789
|
+
next = await this.saveProgress('replacePool', next, 'add', [...next.progress.completedTargets, input.id], lease);
|
|
1790
|
+
}
|
|
1791
|
+
await this.verifyPool(pending);
|
|
1792
|
+
// `verified` is written before the inputs are deleted, so a resume
|
|
1793
|
+
// never needs inputs that are gone.
|
|
1794
|
+
next = await this.saveProgress('replacePool', next, 'verified', inputIds, lease);
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
// Resuming after `verified`: the inputs matched the record's
|
|
1798
|
+
// digest above, and the pool must still hold them.
|
|
1799
|
+
if (next.progress.completedTargets.length !== inputIds.length ||
|
|
1800
|
+
next.progress.completedTargets.some((id, i) => id !== inputIds[i])) {
|
|
1801
|
+
throw new AccountCodecError('$.progress.completedTargets', 'must name the inputs once the replacement is verified');
|
|
1802
|
+
}
|
|
1803
|
+
await this.verifyPool(inputs);
|
|
1804
|
+
}
|
|
1805
|
+
// Only a verified replacement deletes its transfer file; one already
|
|
1806
|
+
// deleted by an interrupted earlier attempt is not an error here.
|
|
1807
|
+
await lease.assertOwned();
|
|
1808
|
+
await this.discardTransfer(next.id);
|
|
1809
|
+
return next;
|
|
1810
|
+
}, lease);
|
|
1811
|
+
}));
|
|
1812
|
+
}
|
|
1813
|
+
// Keep prepared accounts disabled and marked with this operation's identifier.
|
|
1814
|
+
// Save their credential refs and the old accounts' refs before publishRoster
|
|
1815
|
+
// replaces the account list in one write. A restart replays that saved plan;
|
|
1816
|
+
// the store's permanent receipt prevents the operation from publishing twice.
|
|
1817
|
+
async replacePublishedPool(inputs, lease) {
|
|
1818
|
+
const publish = this.store.publishRoster;
|
|
1819
|
+
const publication = this.store.publication;
|
|
1820
|
+
const fingerprint = this.storeModule.fingerprintOf;
|
|
1821
|
+
if (!publish || !publication || !fingerprint)
|
|
1822
|
+
throw refusal('replacePool', 'replacement-unavailable', 'Atomic roster publication is unavailable');
|
|
1823
|
+
await this.boundSettings('replacePool');
|
|
1824
|
+
const inputDigest = replacementInputDigest(inputs);
|
|
1825
|
+
let record = await this.readManagement('replacePool');
|
|
1826
|
+
if (record) {
|
|
1827
|
+
if (record.kind !== 'replace-pool' ||
|
|
1828
|
+
record.inputDigest !== inputDigest ||
|
|
1829
|
+
!record.publication)
|
|
1830
|
+
throw refusal('replacePool', 'management-pending', 'another management operation needs recovery');
|
|
1831
|
+
}
|
|
1832
|
+
else {
|
|
1833
|
+
const rows = (await this.readRows('replacePool')).filter((row) => row.invalid !== 'roster');
|
|
1834
|
+
const reused = inputs.find((input) => rows.some((row) => row.id === input.id));
|
|
1835
|
+
if (reused)
|
|
1836
|
+
throw refusal('replacePool', 'invalid-input', `input ${reused.id} names a row the pool already holds; a replacement adds new rows`, { rowId: reused.id });
|
|
1837
|
+
const remove = rows.map((row) => {
|
|
1838
|
+
if (row.invalid ||
|
|
1839
|
+
row.torn ||
|
|
1840
|
+
row.stamp !== 'bound' ||
|
|
1841
|
+
!row.credential ||
|
|
1842
|
+
!row.credentialEpoch ||
|
|
1843
|
+
row.staged)
|
|
1844
|
+
throw refusal('replacePool', 'invalid-input', `complete row ${row.id} before replacing the pool`);
|
|
1845
|
+
return {
|
|
1846
|
+
id: row.id,
|
|
1847
|
+
attribution: {
|
|
1848
|
+
credentialEpoch: row.credentialEpoch,
|
|
1849
|
+
...(row.identity !== undefined ? { identity: row.identity } : {}),
|
|
1850
|
+
},
|
|
1851
|
+
fingerprint: fingerprint(row.credential),
|
|
1852
|
+
};
|
|
1853
|
+
});
|
|
1854
|
+
const id = randomUUID();
|
|
1855
|
+
await lease.assertOwned();
|
|
1856
|
+
await this.writeTransfer(id, inputs);
|
|
1857
|
+
record = await this.startManagement('replacePool', {
|
|
1858
|
+
id,
|
|
1859
|
+
kind: 'replace-pool',
|
|
1860
|
+
targets: remove.map((row) => row.id),
|
|
1861
|
+
inputDigest,
|
|
1862
|
+
progress: { step: 'add', completedTargets: [] },
|
|
1863
|
+
publication: {
|
|
1864
|
+
operationId: id,
|
|
1865
|
+
remove,
|
|
1866
|
+
finalize: [],
|
|
1867
|
+
order: inputs.map((input) => input.id),
|
|
1868
|
+
},
|
|
1869
|
+
}, lease);
|
|
1870
|
+
}
|
|
1871
|
+
return this.continueManagement('replacePool', record, async (current) => {
|
|
1872
|
+
let next = current;
|
|
1873
|
+
let plan = next.publication;
|
|
1874
|
+
if (!plan ||
|
|
1875
|
+
!isDeepStrictEqual(plan.order, inputs.map((input) => input.id)))
|
|
1876
|
+
throw new AccountCodecError('$.publication.order', 'must match the original replacement inputs');
|
|
1877
|
+
const protect = async (view) => {
|
|
1878
|
+
await lease.assertOwned();
|
|
1879
|
+
const raw = view.config[MANAGEMENT_SETTINGS_KEY];
|
|
1880
|
+
return raw !== undefined &&
|
|
1881
|
+
canonicalJson(encodeManagementRecord(decodeManagementRecord(raw))) === canonicalJson(encodeManagementRecord(next))
|
|
1882
|
+
? undefined
|
|
1883
|
+
: 'the replacement journal no longer holds this operation';
|
|
1884
|
+
};
|
|
1885
|
+
if (next.progress.step === 'add') {
|
|
1886
|
+
const pending = await this.readTransfer(next.id);
|
|
1887
|
+
if (replacementInputDigest(pending) !== inputDigest)
|
|
1888
|
+
throw new AccountCodecError('transfer', 'does not hold the original replacement inputs');
|
|
1889
|
+
for (const input of pending) {
|
|
1890
|
+
if (plan.finalize.some((row) => row.id === input.id))
|
|
1891
|
+
continue;
|
|
1892
|
+
await lease.assertOwned();
|
|
1893
|
+
const added = await this.store.add({
|
|
1894
|
+
id: input.id,
|
|
1895
|
+
credential: { type: 'oauth', refresh: input.refreshToken },
|
|
1896
|
+
...(input.identity !== undefined
|
|
1897
|
+
? { identity: input.identity }
|
|
1898
|
+
: {}),
|
|
1899
|
+
providerState: encodeProviderState({
|
|
1900
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
1901
|
+
metadata: input.metadata,
|
|
1902
|
+
}),
|
|
1903
|
+
disabled: { reason: 'replacement-pending' },
|
|
1904
|
+
stage: { reservation: next.id },
|
|
1905
|
+
}, { onExisting: 'stage-duplicate', protect });
|
|
1906
|
+
if (added.id !== input.id || !added.credentialEpoch)
|
|
1907
|
+
throw refusal('replacePool', 'unexpected', 'staging did not return the requested credential ref', { rowId: input.id });
|
|
1908
|
+
const enabled = input.metadata.enabled !== false;
|
|
1909
|
+
plan = {
|
|
1910
|
+
...plan,
|
|
1911
|
+
finalize: [
|
|
1912
|
+
...plan.finalize,
|
|
1913
|
+
{
|
|
1914
|
+
id: input.id,
|
|
1915
|
+
attribution: {
|
|
1916
|
+
credentialEpoch: added.credentialEpoch,
|
|
1917
|
+
...(input.identity !== undefined
|
|
1918
|
+
? { identity: input.identity }
|
|
1919
|
+
: {}),
|
|
1920
|
+
},
|
|
1921
|
+
reservation: next.id,
|
|
1922
|
+
enabled,
|
|
1923
|
+
...(!enabled ? { disabledReason: 'disabled-by-user' } : {}),
|
|
1924
|
+
},
|
|
1925
|
+
],
|
|
1926
|
+
};
|
|
1927
|
+
next = await this.saveProgress('replacePool', next, 'add', plan.finalize.map((row) => row.id), lease, plan);
|
|
1928
|
+
}
|
|
1929
|
+
next = await this.saveProgress('replacePool', next, 'publish', inputs.map((input) => input.id), lease);
|
|
1930
|
+
}
|
|
1931
|
+
if (next.progress.step === 'publish') {
|
|
1932
|
+
await lease.assertOwned();
|
|
1933
|
+
const existing = await publication.call(this.store, next.id);
|
|
1934
|
+
this.assertPublicationReceipt(plan, existing);
|
|
1935
|
+
if (!existing)
|
|
1936
|
+
await this.verifyPool(inputs, false, {
|
|
1937
|
+
reservation: next.id,
|
|
1938
|
+
finalize: plan.finalize,
|
|
1939
|
+
});
|
|
1940
|
+
await publish.call(this.store, plan, { protect });
|
|
1941
|
+
const receipt = await publication.call(this.store, next.id);
|
|
1942
|
+
this.assertPublicationReceipt(plan, receipt);
|
|
1943
|
+
if (receipt?.phase !== 'cleaned')
|
|
1944
|
+
throw refusal('replacePool', 'management-pending', 'publication cleanup is pending; replay the same inputs', { retryable: true });
|
|
1945
|
+
await this.verifyPool(inputs, true);
|
|
1946
|
+
next = await this.saveProgress('replacePool', next, 'verified', inputs.map((input) => input.id), lease);
|
|
1947
|
+
}
|
|
1948
|
+
else if (next.progress.step === 'verified') {
|
|
1949
|
+
const receipt = await publication.call(this.store, next.id);
|
|
1950
|
+
this.assertPublicationReceipt(plan, receipt);
|
|
1951
|
+
if (receipt?.phase !== 'cleaned')
|
|
1952
|
+
throw refusal('replacePool', 'management-pending', 'the native publication receipt is missing');
|
|
1953
|
+
await this.verifyPool(inputs, true);
|
|
1954
|
+
}
|
|
1955
|
+
else {
|
|
1956
|
+
throw new AccountCodecError('$.progress.step', 'is not a native publication phase');
|
|
1957
|
+
}
|
|
1958
|
+
await lease.assertOwned();
|
|
1959
|
+
await this.discardTransfer(next.id);
|
|
1960
|
+
return next;
|
|
1961
|
+
}, lease);
|
|
1962
|
+
}
|
|
1963
|
+
assertPublicationReceipt(plan, receipt) {
|
|
1964
|
+
if (!receipt)
|
|
1965
|
+
return;
|
|
1966
|
+
const digest = createHash('sha256')
|
|
1967
|
+
.update(canonicalJson(encodePublicationPlan(plan)))
|
|
1968
|
+
.digest('hex');
|
|
1969
|
+
const removed = plan.remove.map((row) => ({
|
|
1970
|
+
id: row.id,
|
|
1971
|
+
credentialEpoch: row.attribution.credentialEpoch,
|
|
1972
|
+
}));
|
|
1973
|
+
const finalized = plan.finalize.map((row) => ({
|
|
1974
|
+
id: row.id,
|
|
1975
|
+
credentialEpoch: row.attribution.credentialEpoch,
|
|
1976
|
+
reservation: row.reservation,
|
|
1977
|
+
enabled: row.enabled,
|
|
1978
|
+
}));
|
|
1979
|
+
if (receipt.operationId !== plan.operationId ||
|
|
1980
|
+
receipt.planDigest !== digest ||
|
|
1981
|
+
!isDeepStrictEqual(receipt.removed, removed) ||
|
|
1982
|
+
!isDeepStrictEqual(receipt.finalized, finalized)) {
|
|
1983
|
+
throw refusal('replacePool', 'publication-mismatch', 'the native receipt does not prove this replacement plan');
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
checkPoolInputs(inputs) {
|
|
1987
|
+
const ids = new Set();
|
|
1988
|
+
const secrets = new Set();
|
|
1989
|
+
const emails = new Set();
|
|
1990
|
+
const identities = new Set();
|
|
1991
|
+
for (const input of inputs) {
|
|
1992
|
+
encodeProviderState({
|
|
1993
|
+
schemaVersion: PROVIDER_STATE_SCHEMA_VERSION,
|
|
1994
|
+
metadata: input.metadata,
|
|
1995
|
+
});
|
|
1996
|
+
if (!input.id ||
|
|
1997
|
+
input.id.trim() !== input.id ||
|
|
1998
|
+
['__proto__', 'constructor', 'prototype'].includes(input.id) ||
|
|
1999
|
+
!input.refreshToken.trim() ||
|
|
2000
|
+
ids.has(input.id)) {
|
|
2001
|
+
throw refusal('replacePool', 'invalid-input', 'every input needs a refresh token and its own id');
|
|
2002
|
+
}
|
|
2003
|
+
if (secrets.has(input.refreshToken)) {
|
|
2004
|
+
throw refusal('replacePool', 'duplicate-secret', 'two inputs hold the same credential');
|
|
2005
|
+
}
|
|
2006
|
+
if (input.identity !== undefined) {
|
|
2007
|
+
if (!input.identity || identities.has(input.identity))
|
|
2008
|
+
throw refusal('replacePool', 'duplicate-identity', 'every input needs its own non-empty recorded identity');
|
|
2009
|
+
identities.add(input.identity);
|
|
2010
|
+
}
|
|
2011
|
+
const email = input.metadata.email;
|
|
2012
|
+
if (typeof email === 'string' && emails.has(email)) {
|
|
2013
|
+
throw refusal('replacePool', 'duplicate-identity', 'two inputs name the same account');
|
|
2014
|
+
}
|
|
2015
|
+
if (typeof email === 'string')
|
|
2016
|
+
emails.add(email);
|
|
2017
|
+
ids.add(input.id);
|
|
2018
|
+
secrets.add(input.refreshToken);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
async startManagement(operation, record, lease) {
|
|
2022
|
+
await lease.assertOwned();
|
|
2023
|
+
await this.store.updateSettings((settings) => {
|
|
2024
|
+
if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined) {
|
|
2025
|
+
throw new CallbackRefusal(refusal(operation, 'management-pending', 'another management operation started first', {
|
|
2026
|
+
retryable: true,
|
|
2027
|
+
}));
|
|
2028
|
+
}
|
|
2029
|
+
return {
|
|
2030
|
+
...settings,
|
|
2031
|
+
[MANAGEMENT_SETTINGS_KEY]: encodeManagementRecord(record),
|
|
2032
|
+
};
|
|
2033
|
+
}, { extraLocks: [managementLock(this.paths)] });
|
|
2034
|
+
return record;
|
|
2035
|
+
}
|
|
2036
|
+
ownsManagement(settings, record) {
|
|
2037
|
+
const raw = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2038
|
+
if (raw === undefined)
|
|
2039
|
+
return false;
|
|
2040
|
+
try {
|
|
2041
|
+
const current = decodeManagementRecord(raw);
|
|
2042
|
+
return (current.id === record.id &&
|
|
2043
|
+
current.kind === record.kind &&
|
|
2044
|
+
current.inputDigest === record.inputDigest);
|
|
2045
|
+
}
|
|
2046
|
+
catch {
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
async saveProgress(operation, record, step, completedTargets, lease, publication) {
|
|
2051
|
+
const next = {
|
|
2052
|
+
...record,
|
|
2053
|
+
...(publication !== undefined ? { publication } : {}),
|
|
2054
|
+
progress: { step, completedTargets },
|
|
2055
|
+
};
|
|
2056
|
+
await lease.assertOwned();
|
|
2057
|
+
await this.store.updateSettings((settings) => {
|
|
2058
|
+
if (!this.ownsManagement(settings, record)) {
|
|
2059
|
+
throw new CallbackRefusal(refusal(operation, 'management-pending', 'the management record changed under this operation'));
|
|
2060
|
+
}
|
|
2061
|
+
return {
|
|
2062
|
+
...settings,
|
|
2063
|
+
[MANAGEMENT_SETTINGS_KEY]: encodeManagementRecord(next),
|
|
2064
|
+
};
|
|
2065
|
+
}, { extraLocks: [managementLock(this.paths)] });
|
|
2066
|
+
return next;
|
|
2067
|
+
}
|
|
2068
|
+
async removeTargets(operation, record, lease) {
|
|
2069
|
+
let next = record;
|
|
2070
|
+
for (const target of record.targets) {
|
|
2071
|
+
if (next.progress.completedTargets.includes(target))
|
|
2072
|
+
continue;
|
|
2073
|
+
await lease.assertOwned();
|
|
2074
|
+
try {
|
|
2075
|
+
await this.store.remove(target, {
|
|
2076
|
+
extraLocks: [managementLock(this.paths)],
|
|
2077
|
+
protect: (_id, view) => this.ownsManagement(view.config, record)
|
|
2078
|
+
? undefined
|
|
2079
|
+
: 'the management record changed under this removal',
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
catch (error) {
|
|
2083
|
+
const failure = this.failureOf(operation, target, error);
|
|
2084
|
+
if (failure.failure.kind !== 'unknown-row')
|
|
2085
|
+
throw failure;
|
|
2086
|
+
}
|
|
2087
|
+
next = await this.saveProgress(operation, next, next.progress.step, [...next.progress.completedTargets, target], lease);
|
|
2088
|
+
}
|
|
2089
|
+
return next;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Runs a management operation's remaining steps, then drops its record.
|
|
2093
|
+
* A retryable failure (lock contention, a lost lease) leaves the record
|
|
2094
|
+
* pending and reports it instead of looping; the next call resumes.
|
|
2095
|
+
*/
|
|
2096
|
+
async continueManagement(operation, record, steps, lease) {
|
|
2097
|
+
let finished;
|
|
2098
|
+
try {
|
|
2099
|
+
finished = await steps(record);
|
|
2100
|
+
// The journal clear is the write that makes the operation complete;
|
|
2101
|
+
// with the lease lost before it, the operation stays pending. Under
|
|
2102
|
+
// the store's locks, a journal that no longer holds this record (it is
|
|
2103
|
+
// gone, or names another operation) refuses the clear: nothing is
|
|
2104
|
+
// written, and the operation is not reported completed.
|
|
2105
|
+
await lease.assertOwned();
|
|
2106
|
+
await this.store.updateSettings((settings) => {
|
|
2107
|
+
if (!this.ownsManagement(settings, record)) {
|
|
2108
|
+
throw new CallbackRefusal(refusal(operation, 'management-pending', 'the management journal no longer holds this operation; it was not cleared'));
|
|
2109
|
+
}
|
|
2110
|
+
const { [MANAGEMENT_SETTINGS_KEY]: _done, ...rest } = settings;
|
|
2111
|
+
return rest;
|
|
2112
|
+
}, { extraLocks: [managementLock(this.paths)] });
|
|
2113
|
+
}
|
|
2114
|
+
catch (error) {
|
|
2115
|
+
const failure = this.failureOf(operation, undefined, error);
|
|
2116
|
+
if (failure.failure.retryable) {
|
|
2117
|
+
const now = (await this.readManagement(operation)) ?? record;
|
|
2118
|
+
return { management: publicManagementRecord(now), outcome: 'pending' };
|
|
2119
|
+
}
|
|
2120
|
+
throw failure;
|
|
2121
|
+
}
|
|
2122
|
+
return {
|
|
2123
|
+
management: publicManagementRecord(finished),
|
|
2124
|
+
outcome: 'completed',
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Checks the pool a replacement leaves before the replacement is recorded
|
|
2129
|
+
* verified and its transfer file deleted. The pool must hold exactly the
|
|
2130
|
+
* inputs' rows, in input order, and each row must be valid and complete
|
|
2131
|
+
* (not invalid, not torn) with the input's id, its refresh token under a
|
|
2132
|
+
* bound stamp, its identity (absent only when the
|
|
2133
|
+
* input gives none), the metadata it requested and no quota reading. A
|
|
2134
|
+
* token present under another id, identity or metadata is not the input.
|
|
2135
|
+
* Any mismatch refuses, which leaves the journal and the transfer file in
|
|
2136
|
+
* place.
|
|
2137
|
+
*/
|
|
2138
|
+
async verifyPool(inputs, published = false, preparation) {
|
|
2139
|
+
const allRows = await this.readRows('replacePool');
|
|
2140
|
+
const rows = preparation
|
|
2141
|
+
? allRows.filter((row) => row.staged?.reservation === preparation.reservation)
|
|
2142
|
+
: published
|
|
2143
|
+
? allRows.filter((row) => row.invalid !== 'roster')
|
|
2144
|
+
: allRows;
|
|
2145
|
+
const mismatch = (input, problem) => refusal('replacePool', 'unexpected', `input ${input.id} ${problem} ${preparation ? 'during preparation' : 'after the replacement'}`, { rowId: input.id });
|
|
2146
|
+
for (const input of inputs) {
|
|
2147
|
+
const row = rows.find((candidate) => candidate.id === input.id);
|
|
2148
|
+
if (row === undefined ||
|
|
2149
|
+
row.invalid !== undefined ||
|
|
2150
|
+
row.torn === true ||
|
|
2151
|
+
row.credential?.type !== 'oauth' ||
|
|
2152
|
+
row.credential.refresh !== input.refreshToken ||
|
|
2153
|
+
row.stamp !== 'bound') {
|
|
2154
|
+
throw mismatch(input, 'is not in the pool');
|
|
2155
|
+
}
|
|
2156
|
+
if (preparation &&
|
|
2157
|
+
(row.enabled ||
|
|
2158
|
+
row.credentialEpoch !==
|
|
2159
|
+
preparation.finalize.find((ref) => ref.id === input.id)?.attribution
|
|
2160
|
+
.credentialEpoch))
|
|
2161
|
+
throw mismatch(input, 'does not hold its disabled prepared credential');
|
|
2162
|
+
if (published &&
|
|
2163
|
+
(row.staged || row.enabled !== (input.metadata.enabled !== false)))
|
|
2164
|
+
throw mismatch(input, 'has not been finalized with its requested enabled state');
|
|
2165
|
+
if (row.identity !== input.identity) {
|
|
2166
|
+
throw mismatch(input, 'is recorded for another identity');
|
|
2167
|
+
}
|
|
2168
|
+
if (row.providerStateDropped !== undefined ||
|
|
2169
|
+
row.providerState === undefined ||
|
|
2170
|
+
canonicalJson(encodeProviderMetadata(decodeProviderState(row.providerState).metadata)) !== canonicalJson(encodeProviderMetadata(input.metadata))) {
|
|
2171
|
+
throw mismatch(input, 'does not hold its requested metadata');
|
|
2172
|
+
}
|
|
2173
|
+
if (row.quota !== undefined) {
|
|
2174
|
+
throw mismatch(input, 'holds a quota reading');
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (rows.length !== inputs.length ||
|
|
2178
|
+
rows.some((row, index) => row.id !== inputs[index]?.id)) {
|
|
2179
|
+
throw refusal('replacePool', 'unexpected', 'the pool does not hold exactly the replacement inputs, in their order');
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
transferPath(id) {
|
|
2183
|
+
return join(this.paths.transfersDir, `${id}.json`);
|
|
2184
|
+
}
|
|
2185
|
+
async writeTransfer(id, inputs) {
|
|
2186
|
+
await mkdir(this.paths.transfersDir, { recursive: true, mode: 0o700 });
|
|
2187
|
+
const body = {
|
|
2188
|
+
schemaVersion: 1,
|
|
2189
|
+
managementId: id,
|
|
2190
|
+
inputs: inputs.map(encodeLoginInput),
|
|
2191
|
+
};
|
|
2192
|
+
// Exclusive and owner-only: the file holds refresh tokens.
|
|
2193
|
+
const handle = await open(this.transferPath(id), 'wx', 0o600);
|
|
2194
|
+
try {
|
|
2195
|
+
await handle.writeFile(JSON.stringify(body));
|
|
2196
|
+
await handle.sync();
|
|
2197
|
+
}
|
|
2198
|
+
finally {
|
|
2199
|
+
await handle.close();
|
|
2200
|
+
}
|
|
2201
|
+
// Persist both the transfer entry and its containing directory before
|
|
2202
|
+
// publishing a journal that requires the transfer to recover.
|
|
2203
|
+
for (const path of [this.paths.transfersDir, this.paths.storeDir]) {
|
|
2204
|
+
const directory = await open(path, 'r');
|
|
2205
|
+
try {
|
|
2206
|
+
await directory.sync();
|
|
2207
|
+
}
|
|
2208
|
+
finally {
|
|
2209
|
+
await directory.close();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
async readTransfer(id) {
|
|
2214
|
+
const raw = JSON.parse(await readFile(this.transferPath(id), 'utf8'));
|
|
2215
|
+
return decodeTransferFile(raw, id);
|
|
2216
|
+
}
|
|
2217
|
+
async discardTransfer(id) {
|
|
2218
|
+
try {
|
|
2219
|
+
await unlink(this.transferPath(id));
|
|
2220
|
+
}
|
|
2221
|
+
catch (error) {
|
|
2222
|
+
if (!(isRecord(error) && error.code === 'ENOENT'))
|
|
2223
|
+
throw error;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
// ---- draining ----------------------------------------------------------
|
|
2227
|
+
flush() {
|
|
2228
|
+
return this.drain();
|
|
2229
|
+
}
|
|
2230
|
+
async drain() {
|
|
2231
|
+
// Work admitted while draining is drained too.
|
|
2232
|
+
while (this.inFlight.size > 0) {
|
|
2233
|
+
await Promise.allSettled([...this.inFlight]);
|
|
2234
|
+
}
|
|
2235
|
+
const report = { completed: this.completed, failures: this.failures };
|
|
2236
|
+
this.completed = 0;
|
|
2237
|
+
this.failures = [];
|
|
2238
|
+
return report;
|
|
2239
|
+
}
|
|
2240
|
+
async dispose() {
|
|
2241
|
+
this.closing = true;
|
|
2242
|
+
return this.drain();
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Binds the repository to the genuine public `./store` and `./fs` modules
|
|
2247
|
+
* of one common-auth package. The returned function is the frozen
|
|
2248
|
+
* `CreateAccountRepository`.
|
|
2249
|
+
*/
|
|
2250
|
+
export function createAccountRepositoryFactory(modules, factoryOptions = {}) {
|
|
2251
|
+
return (options) => new StoreAccountRepository(modules, options, factoryOptions);
|
|
2252
|
+
}
|
|
2253
|
+
//# sourceMappingURL=account-repository.js.map
|