@cortexkit/antigravity-auth-core 2.2.1 → 3.0.1
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 +2284 -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-request-metadata.d.ts.map +1 -1
- package/dist/agy-request-metadata.js +8 -21
- package/dist/agy-request-metadata.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/fingerprint.d.ts +3 -3
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -4
- package/dist/fingerprint.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/model-registry.d.ts +16 -0
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +242 -30
- package/dist/model-registry.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/agy-generation.d.ts +3 -0
- package/dist/transform/agy-generation.d.ts.map +1 -0
- package/dist/transform/agy-generation.js +28 -0
- package/dist/transform/agy-generation.js.map +1 -0
- package/dist/transform/claude.d.ts +7 -3
- package/dist/transform/claude.d.ts.map +1 -1
- package/dist/transform/claude.js +8 -2
- package/dist/transform/claude.js.map +1 -1
- package/dist/transform/index.d.ts +1 -0
- package/dist/transform/index.d.ts.map +1 -1
- package/dist/transform/index.js +1 -0
- package/dist/transform/index.js.map +1 -1
- package/dist/transform/model-resolver.d.ts +2 -1
- package/dist/transform/model-resolver.d.ts.map +1 -1
- package/dist/transform/model-resolver.js +46 -5
- package/dist/transform/model-resolver.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,1258 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { PoolOperationError } from './errors.js';
|
|
3
|
+
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
4
|
+
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
|
|
5
|
+
import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
|
|
6
|
+
import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
7
|
+
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
8
|
+
import { boundProviderStateDigest, buildRawRows, CREDENTIAL_STAMP_KEY, canonicalDigest, credentialDigest, credentialProblem, dispatchDigest, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, POOL_KEY, PROVIDER_STATE_KEY, parseStamp, providerStateDigest, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
9
|
+
import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
|
|
10
|
+
export async function protectIn(tx, id, protect) {
|
|
11
|
+
if (!protect)
|
|
12
|
+
return;
|
|
13
|
+
const rows = tx.rows().map((row) => {
|
|
14
|
+
const { id, type, label, enabled, disabledReason, identity, credentialEpoch, stamp, staged, torn, invalid, } = row;
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
type,
|
|
18
|
+
label,
|
|
19
|
+
enabled,
|
|
20
|
+
disabledReason,
|
|
21
|
+
identity,
|
|
22
|
+
credentialEpoch,
|
|
23
|
+
stamp,
|
|
24
|
+
staged,
|
|
25
|
+
torn,
|
|
26
|
+
invalid,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const reason = await runInsideHook(tx.info.operation, () => protect({
|
|
30
|
+
id,
|
|
31
|
+
row: rows.find((row) => row.id === id),
|
|
32
|
+
rows,
|
|
33
|
+
config: structuredClone(tx.snapshot.config),
|
|
34
|
+
}));
|
|
35
|
+
if (reason !== undefined)
|
|
36
|
+
throw refusal(tx.info.operation, id, 'row-protected', reason);
|
|
37
|
+
}
|
|
38
|
+
/** Fields of a state entry that belong to the credential it replaces. */
|
|
39
|
+
const CREDENTIAL_STATE_FIELDS = [
|
|
40
|
+
'access',
|
|
41
|
+
'refresh',
|
|
42
|
+
'expires',
|
|
43
|
+
'lastRefreshedAt',
|
|
44
|
+
'apiKey',
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* The endpoint an API-key roster row sends its key to, as `buildRawRows` loads
|
|
48
|
+
* it: the trimmed `baseURL`, and a bearer header unless the row names
|
|
49
|
+
* `x-api-key`.
|
|
50
|
+
*/
|
|
51
|
+
function rowEndpoint(raw) {
|
|
52
|
+
return {
|
|
53
|
+
baseURL: String(raw.baseURL).trim(),
|
|
54
|
+
authHeader: raw.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The credential as it will sit in the row. The state file holds only an API
|
|
59
|
+
* key; its endpoint lives in the roster row. So an API key written into a row
|
|
60
|
+
* is sent wherever that row says, and one given for another `baseURL` or
|
|
61
|
+
* `authHeader` would silently be paired with an endpoint it was not issued
|
|
62
|
+
* for. A part the caller leaves out is the row's; a part it gives must equal
|
|
63
|
+
* the row's, else the write is refused (`endpoint-mismatch`) before anything
|
|
64
|
+
* is written. Moving a row to another endpoint is a `replace`, which writes
|
|
65
|
+
* the new endpoint first.
|
|
66
|
+
*/
|
|
67
|
+
function onRowEndpoint(tx, id, credential) {
|
|
68
|
+
if (credential.type !== 'api')
|
|
69
|
+
return credential;
|
|
70
|
+
const raw = tx.rosterRow(id);
|
|
71
|
+
if (!isRecord(raw)) {
|
|
72
|
+
if (credential.baseURL === undefined)
|
|
73
|
+
throw refusal(tx.info.operation, id, 'invalid-input', 'api credential needs a valid baseURL');
|
|
74
|
+
return { ...credential, baseURL: credential.baseURL };
|
|
75
|
+
}
|
|
76
|
+
const projected = tx.row(id)?.credential;
|
|
77
|
+
const endpoint = projected?.type === 'api'
|
|
78
|
+
? {
|
|
79
|
+
baseURL: projected.baseURL.trim(),
|
|
80
|
+
authHeader: projected.authHeader ?? 'authorization-bearer',
|
|
81
|
+
}
|
|
82
|
+
: rowEndpoint(raw);
|
|
83
|
+
const baseURL = credential.baseURL?.trim() ?? endpoint.baseURL;
|
|
84
|
+
const authHeader = credential.authHeader ?? endpoint.authHeader;
|
|
85
|
+
if (baseURL !== endpoint.baseURL || authHeader !== endpoint.authHeader)
|
|
86
|
+
throw refusal(tx.info.operation, id, 'endpoint-mismatch', `row ${id} sends its key to another endpoint or header; a key for another endpoint is a replacement`);
|
|
87
|
+
return { ...credential, baseURL, authHeader };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The binding of a credential written into a row without a replace: the
|
|
91
|
+
* config as it stands in `tx`, plus the identity the same operation is about
|
|
92
|
+
* to record (`learnt`), if any. The identity is the roster row's recorded
|
|
93
|
+
* one, read the way `buildRawRows` reads it. A learnt identity is stamped
|
|
94
|
+
* before the config records it, so a crash between the two writes leaves a
|
|
95
|
+
* stamp naming an identity the config lacks, which every reader completes
|
|
96
|
+
* forward (see `torn.ts`); the reverse order would leave a config identity
|
|
97
|
+
* no stamp proves. An API key's endpoint is the one it was checked against
|
|
98
|
+
* (see `onRowEndpoint`).
|
|
99
|
+
*/
|
|
100
|
+
function bindingInTx(tx, id, stored, learnt) {
|
|
101
|
+
const raw = tx.rosterRow(id);
|
|
102
|
+
const projected = tx.row(id);
|
|
103
|
+
const identity = learnt ??
|
|
104
|
+
(projected
|
|
105
|
+
? projected.identity
|
|
106
|
+
: isRecord(raw) && typeof raw.accountId === 'string' && raw.accountId
|
|
107
|
+
? raw.accountId
|
|
108
|
+
: undefined);
|
|
109
|
+
return {
|
|
110
|
+
...(identity !== undefined ? { identity } : {}),
|
|
111
|
+
...(stored.type === 'api'
|
|
112
|
+
? {
|
|
113
|
+
baseURL: stored.baseURL,
|
|
114
|
+
authHeader: stored.authHeader ?? 'authorization-bearer',
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build the credential and its state entry without writing. The stamp records
|
|
121
|
+
* the credential's epoch, identity and API endpoint so readers can verify that
|
|
122
|
+
* they belong together. A replacement records the new epoch and account; a
|
|
123
|
+
* rotation keeps the current epoch, account and quota. Pending writes are read
|
|
124
|
+
* as completed when choosing those fields, even while disk config is behind.
|
|
125
|
+
* Endpoint checks and provider-state encoding finish here, before any repair.
|
|
126
|
+
*/
|
|
127
|
+
function planRotationIn(rt, tx, id, given, extra = {}) {
|
|
128
|
+
const credential = onRowEndpoint(tx, id, given);
|
|
129
|
+
const prior = tx.stateAccount(id);
|
|
130
|
+
const stateWrite = extra.providerState ?? { kind: 'keep' };
|
|
131
|
+
const epoch = tx.row(id)?.credentialEpoch ?? tx.entry(id)?.credentialEpoch;
|
|
132
|
+
const credentialEpoch = typeof epoch === 'number' ? epoch : 1;
|
|
133
|
+
// The provider state goes in the same state write as the credential and
|
|
134
|
+
// its stamp, so no reader ever sees one without the other. A kept value is
|
|
135
|
+
// bound by the new stamp only if the old one bound it to this row at the
|
|
136
|
+
// epoch being written; a replace moves the epoch, so it never keeps one.
|
|
137
|
+
const providerStateBinding = providerStateCoverage(rt.ctx.providerState, stateWrite, prior, prior && Object.hasOwn(prior, PROVIDER_STATE_KEY) ? tx.row(id) : undefined, credentialEpoch);
|
|
138
|
+
const priorStamp = typeof prior?.lastRefreshedAt === 'number'
|
|
139
|
+
? prior.lastRefreshedAt
|
|
140
|
+
: undefined;
|
|
141
|
+
const stamp = credential.type === 'oauth'
|
|
142
|
+
? (extra.stamp ?? rotationStamp(priorStamp, rt.ctx.now()))
|
|
143
|
+
: undefined;
|
|
144
|
+
const kept = { ...(prior ?? {}) };
|
|
145
|
+
for (const field of CREDENTIAL_STATE_FIELDS)
|
|
146
|
+
delete kept[field];
|
|
147
|
+
if (extra.clearErrors) {
|
|
148
|
+
delete kept.lastRefreshError;
|
|
149
|
+
delete kept.lastQuotaRefreshError;
|
|
150
|
+
delete kept.quota;
|
|
151
|
+
}
|
|
152
|
+
if (stateWrite.kind === 'clear')
|
|
153
|
+
delete kept[PROVIDER_STATE_KEY];
|
|
154
|
+
else if (stateWrite.kind === 'set')
|
|
155
|
+
kept[PROVIDER_STATE_KEY] = stateWrite.value;
|
|
156
|
+
const stored = storedCredential(credential, stamp);
|
|
157
|
+
const account = {
|
|
158
|
+
...kept,
|
|
159
|
+
...stateFieldsFor(credential, stamp),
|
|
160
|
+
[CREDENTIAL_STAMP_KEY]: {
|
|
161
|
+
...(extra.staged ? { staged: extra.staged } : {}),
|
|
162
|
+
...(extra.transition !== undefined
|
|
163
|
+
? { [TRANSITION_STAMP_KEY]: extra.transition }
|
|
164
|
+
: {}),
|
|
165
|
+
...stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
|
|
166
|
+
replace: extra.binding !== undefined,
|
|
167
|
+
...(providerStateBinding !== undefined
|
|
168
|
+
? { providerState: providerStateBinding }
|
|
169
|
+
: {}),
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return { stored, account };
|
|
174
|
+
}
|
|
175
|
+
async function persistRotationIn(tx, id, plan) {
|
|
176
|
+
tx.setStateAccount(id, plan.account);
|
|
177
|
+
await tx.commitState(plan.stored);
|
|
178
|
+
return plan.stored;
|
|
179
|
+
}
|
|
180
|
+
/** Validate the endpoint and encode provider state and its stamp before committing state. */
|
|
181
|
+
export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
182
|
+
return persistRotationIn(tx, id, planRotationIn(rt, tx, id, given, extra));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Restamps a bound row's credential, unchanged, so its stamp names the
|
|
186
|
+
* identity the caller is about to record in the config (same epoch, same
|
|
187
|
+
* credential, one state write). Written before the config for the reason
|
|
188
|
+
* `bindingInTx` gives. A row whose stamp is not bound (possible only without
|
|
189
|
+
* `requireCredentialStamps`) gets no new stamp, because a fresh stamp would
|
|
190
|
+
* vouch for a credential this store never proved: its identity is recorded in
|
|
191
|
+
* the config only, and the row keeps the stamp status it had.
|
|
192
|
+
*/
|
|
193
|
+
async function stampIdentityIn(tx, row, identity) {
|
|
194
|
+
if (!row.credential || row.stamp !== 'bound')
|
|
195
|
+
return;
|
|
196
|
+
const account = tx.stateAccount(row.id);
|
|
197
|
+
// The provider state stays bound across the identity being learnt: the old
|
|
198
|
+
// stamp bound it to the row with no identity, the new one to the identity
|
|
199
|
+
// the config is about to record.
|
|
200
|
+
const providerState = boundProviderStateDigest(account, row.credential, row.credentialEpoch ?? 1, row.identity);
|
|
201
|
+
tx.setStateAccount(row.id, {
|
|
202
|
+
...(account ?? {}),
|
|
203
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity), providerState !== undefined ? { providerState } : {}),
|
|
204
|
+
});
|
|
205
|
+
await tx.commitState();
|
|
206
|
+
}
|
|
207
|
+
function checkInput(operation, id, credential) {
|
|
208
|
+
const idIssue = operation === 'add' ? idProblem(id) : undefined;
|
|
209
|
+
if (idIssue)
|
|
210
|
+
throw refusal(operation, id, 'invalid-input', idIssue);
|
|
211
|
+
const credentialIssue = credentialProblem(credential, {
|
|
212
|
+
baseURLOptional: operation === 'rotate',
|
|
213
|
+
});
|
|
214
|
+
if (credentialIssue)
|
|
215
|
+
throw refusal(operation, id, 'invalid-input', credentialIssue);
|
|
216
|
+
}
|
|
217
|
+
function requireUsableRow(operation, id, row, credential) {
|
|
218
|
+
if (!row)
|
|
219
|
+
throw unknownRow(operation, id);
|
|
220
|
+
if (row.invalid)
|
|
221
|
+
throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
|
|
222
|
+
if (credential && credential.type !== row.type)
|
|
223
|
+
throw refusal(operation, id, 'type-mismatch', `row ${id} holds a ${row.type} credential`);
|
|
224
|
+
return row;
|
|
225
|
+
}
|
|
226
|
+
export function validateAttribution(operation, id, fence) {
|
|
227
|
+
if (fence !== undefined &&
|
|
228
|
+
!isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
|
|
229
|
+
throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
|
|
230
|
+
}
|
|
231
|
+
/** Exact recorded identity, including absence, is part of a credential fence. */
|
|
232
|
+
export function assertRowAttribution(operation, id, row, fence) {
|
|
233
|
+
if (!row)
|
|
234
|
+
throw unknownRow(operation, id);
|
|
235
|
+
// A row that failed validation has no usable credential epoch to compare.
|
|
236
|
+
if (row.invalid)
|
|
237
|
+
throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
|
|
238
|
+
if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
|
|
239
|
+
row.identity !== fence.identity)
|
|
240
|
+
throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
|
|
241
|
+
}
|
|
242
|
+
/** The row is recorded for another account than the one given. */
|
|
243
|
+
function identityMismatch(operation, id) {
|
|
244
|
+
return refusal(operation, id, 'identity-mismatch', `row ${id} is recorded for another account; a credential of a different account is a replacement`);
|
|
245
|
+
}
|
|
246
|
+
/** Keying changed between the unlocked read and the locked one: retry. */
|
|
247
|
+
function keyChanged(operation, id) {
|
|
248
|
+
return refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A strict replay may finish a state-first add, but must not replace what the
|
|
252
|
+
* interrupted write stamped. Load the orphan against its stamped descriptor
|
|
253
|
+
* using the ordinary row/stamp machinery, then compare the caller's material.
|
|
254
|
+
* The roster has no endpoint or identity yet, so neither can be inferred from
|
|
255
|
+
* the incoming add. Refusal happens before even repairing other torn rows.
|
|
256
|
+
*/
|
|
257
|
+
function matchingInterruptedAdd(rt, tx, input, incoming) {
|
|
258
|
+
const { id, credential, identity } = input;
|
|
259
|
+
if (!rt.ctx.requireCredentialStamps ||
|
|
260
|
+
tx.rosterRow(id) ||
|
|
261
|
+
!hasStateAccount(tx.state, id))
|
|
262
|
+
return undefined;
|
|
263
|
+
const refuse = () => {
|
|
264
|
+
throw refusal('add', id, 'unbound-credential', `orphan ${id} cannot be bound to this add's material; remove it explicitly before adding different material`);
|
|
265
|
+
};
|
|
266
|
+
const account = tx.stateAccount(id);
|
|
267
|
+
const stamp = parseStamp(account?.[CREDENTIAL_STAMP_KEY]);
|
|
268
|
+
if (!stamp?.binding || stamp.dispatch === undefined || stamp.replace)
|
|
269
|
+
return refuse();
|
|
270
|
+
if (credential.type === 'api' &&
|
|
271
|
+
(stamp.binding.baseURL === undefined ||
|
|
272
|
+
stamp.binding.authHeader === undefined))
|
|
273
|
+
return refuse();
|
|
274
|
+
const descriptor = credential.type === 'api'
|
|
275
|
+
? {
|
|
276
|
+
...credential,
|
|
277
|
+
baseURL: stamp.binding.baseURL,
|
|
278
|
+
authHeader: stamp.binding.authHeader,
|
|
279
|
+
}
|
|
280
|
+
: credential;
|
|
281
|
+
const [orphan] = buildRawRows({
|
|
282
|
+
accounts: [
|
|
283
|
+
rosterRowFor({
|
|
284
|
+
id,
|
|
285
|
+
credential: descriptor,
|
|
286
|
+
identity: stamp.binding.identity,
|
|
287
|
+
addedAt: 0,
|
|
288
|
+
}),
|
|
289
|
+
],
|
|
290
|
+
[POOL_KEY]: {
|
|
291
|
+
rows: { [id]: { credentialEpoch: nextAddEpochIn(tx.config, id) } },
|
|
292
|
+
},
|
|
293
|
+
}, tx.state, rt.ctx.codec, rt.ctx.providerState);
|
|
294
|
+
if (!orphan?.credential || orphan.invalid || orphan.stamp !== 'bound')
|
|
295
|
+
return refuse();
|
|
296
|
+
if (identity !== undefined && identity !== orphan.identity)
|
|
297
|
+
return refuse();
|
|
298
|
+
if (credentialDigest(credential) !== stamp.digest)
|
|
299
|
+
return refuse();
|
|
300
|
+
if (credential.type === 'api') {
|
|
301
|
+
if (credential.baseURL.trim() !== stamp.binding.baseURL)
|
|
302
|
+
return refuse();
|
|
303
|
+
if ((credential.authHeader ?? 'authorization-bearer') !==
|
|
304
|
+
stamp.binding.authHeader)
|
|
305
|
+
return refuse();
|
|
306
|
+
}
|
|
307
|
+
else if (dispatchDigest(credential) !== stamp.dispatch)
|
|
308
|
+
return refuse();
|
|
309
|
+
// Provider-state coverage is separate from credential stamp status. Keep
|
|
310
|
+
// the interrupted write's value, but never complete a changed bound value.
|
|
311
|
+
if (stamp.providerState !== undefined && orphan.providerState === undefined)
|
|
312
|
+
return refuse();
|
|
313
|
+
if (incoming !== undefined &&
|
|
314
|
+
providerStateDigest(rt.ctx.providerState, incoming) !== stamp.providerState)
|
|
315
|
+
return refuse();
|
|
316
|
+
return orphan;
|
|
317
|
+
}
|
|
318
|
+
/** Complete an interrupted staged add only when its stamp and stored credential match. */
|
|
319
|
+
function replayStagedAdd(rt, tx, input, incoming) {
|
|
320
|
+
const { id, credential, identity, label } = input;
|
|
321
|
+
const raw = tx.rosterRow(id);
|
|
322
|
+
const fail = () => {
|
|
323
|
+
throw refusal('add', id, raw ? 'id-exists' : 'unbound-credential', `row ${id} does not match this staged add`);
|
|
324
|
+
};
|
|
325
|
+
const account = tx.stateAccount(id);
|
|
326
|
+
const stamp = parseStamp(account?.[CREDENTIAL_STAMP_KEY]);
|
|
327
|
+
const staged = stamp?.staged;
|
|
328
|
+
if (!stamp?.binding ||
|
|
329
|
+
!staged ||
|
|
330
|
+
stamp.replace ||
|
|
331
|
+
stamp.dispatch === undefined)
|
|
332
|
+
return fail();
|
|
333
|
+
if (raw &&
|
|
334
|
+
(raw.type !== credential.type ||
|
|
335
|
+
raw.label !== label ||
|
|
336
|
+
raw.accountId !== identity))
|
|
337
|
+
return fail();
|
|
338
|
+
if (staged.reservation !== input.stage?.reservation ||
|
|
339
|
+
staged.label !== label ||
|
|
340
|
+
staged.disabledReason !== input.disabled?.reason ||
|
|
341
|
+
stamp.binding.identity !== identity)
|
|
342
|
+
return fail();
|
|
343
|
+
const incomingDigest = incoming === undefined ? undefined : canonicalDigest(incoming);
|
|
344
|
+
const storedValue = account?.[PROVIDER_STATE_KEY];
|
|
345
|
+
const storedDigest = storedValue === undefined ? undefined : canonicalDigest(storedValue);
|
|
346
|
+
if (staged.providerState !== incomingDigest ||
|
|
347
|
+
staged.providerState !== storedDigest)
|
|
348
|
+
return fail();
|
|
349
|
+
if (credentialDigest(credential) !== stamp.digest ||
|
|
350
|
+
dispatchDigest(credential) !== stamp.dispatch)
|
|
351
|
+
return fail();
|
|
352
|
+
const config = raw
|
|
353
|
+
? tx.config
|
|
354
|
+
: {
|
|
355
|
+
accounts: [
|
|
356
|
+
{
|
|
357
|
+
...rosterRowFor({
|
|
358
|
+
id,
|
|
359
|
+
credential: credential.type === 'api'
|
|
360
|
+
? {
|
|
361
|
+
...credential,
|
|
362
|
+
baseURL: stamp.binding.baseURL ?? '',
|
|
363
|
+
authHeader: stamp.binding.authHeader,
|
|
364
|
+
}
|
|
365
|
+
: credential,
|
|
366
|
+
identity: stamp.binding.identity,
|
|
367
|
+
label: staged.label,
|
|
368
|
+
addedAt: 0,
|
|
369
|
+
}),
|
|
370
|
+
enabled: false,
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
[POOL_KEY]: {
|
|
374
|
+
rows: {
|
|
375
|
+
[id]: {
|
|
376
|
+
credentialEpoch: stamp.credentialEpoch,
|
|
377
|
+
disabledReason: staged.disabledReason,
|
|
378
|
+
staged: { reservation: staged.reservation },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
const row = buildRawRows(config, tx.state, rt.ctx.codec, rt.ctx.providerState).find((row) => row.id === id);
|
|
384
|
+
if (!row?.credential ||
|
|
385
|
+
row.invalid ||
|
|
386
|
+
row.stamp !== 'bound' ||
|
|
387
|
+
row.type !== credential.type ||
|
|
388
|
+
row.enabled ||
|
|
389
|
+
row.label !== staged.label ||
|
|
390
|
+
row.identity !== stamp.binding.identity ||
|
|
391
|
+
row.disabledReason !== staged.disabledReason ||
|
|
392
|
+
row.staged?.reservation !== staged.reservation)
|
|
393
|
+
return fail();
|
|
394
|
+
if (raw)
|
|
395
|
+
return {
|
|
396
|
+
id,
|
|
397
|
+
outcome: 'exists',
|
|
398
|
+
credential: row.credential,
|
|
399
|
+
credentialEpoch: stamp.credentialEpoch,
|
|
400
|
+
};
|
|
401
|
+
if (nextAddEpochIn(tx.config, id) !== stamp.credentialEpoch)
|
|
402
|
+
return fail();
|
|
403
|
+
tx.roster().push({
|
|
404
|
+
...rosterRowFor({ id, credential, identity, label, addedAt: rt.ctx.now() }),
|
|
405
|
+
enabled: false,
|
|
406
|
+
});
|
|
407
|
+
tx.setEntry(id, {
|
|
408
|
+
credentialEpoch: stamp.credentialEpoch,
|
|
409
|
+
needsFirstReading: credential.type === 'oauth',
|
|
410
|
+
disabledReason: staged.disabledReason,
|
|
411
|
+
staged: { reservation: staged.reservation },
|
|
412
|
+
});
|
|
413
|
+
return tx.commitConfig().then(() => ({
|
|
414
|
+
id,
|
|
415
|
+
outcome: 'completed',
|
|
416
|
+
credential: row.credential,
|
|
417
|
+
credentialEpoch: stamp.credentialEpoch,
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
export async function addRow(rt, input, options = {}) {
|
|
421
|
+
assertNotInsideHook('add');
|
|
422
|
+
const { ctx } = rt;
|
|
423
|
+
const { id, credential, identity, label } = input;
|
|
424
|
+
const preparing = input.stage !== undefined || input.disabled !== undefined;
|
|
425
|
+
const result = await runOperation(ctx, 'add', id, options.onFailure, async (locks, progress) => {
|
|
426
|
+
checkInput('add', id, credential);
|
|
427
|
+
const mode = options.onExisting ?? 'rotate';
|
|
428
|
+
if (input.stage !== undefined && mode === 'rotate')
|
|
429
|
+
throw refusal('add', id, 'invalid-input', "a staged add needs onExisting: 'refuse' or 'stage-duplicate'");
|
|
430
|
+
if (!['rotate', 'refuse', 'stage-duplicate'].includes(mode) ||
|
|
431
|
+
(input.stage !== undefined &&
|
|
432
|
+
((label !== undefined && typeof label !== 'string') ||
|
|
433
|
+
(identity !== undefined &&
|
|
434
|
+
(typeof identity !== 'string' || !identity)))) ||
|
|
435
|
+
(input.disabled !== undefined &&
|
|
436
|
+
(!isRecord(input.disabled) ||
|
|
437
|
+
typeof input.disabled.reason !== 'string')) ||
|
|
438
|
+
(input.stage !== undefined &&
|
|
439
|
+
(!isRecord(input.stage) ||
|
|
440
|
+
typeof input.stage.reservation !== 'string' ||
|
|
441
|
+
!input.stage.reservation ||
|
|
442
|
+
!input.disabled)) ||
|
|
443
|
+
(mode === 'stage-duplicate' && (!input.disabled || !input.stage)))
|
|
444
|
+
throw refusal('add', id, 'invalid-input', 'staging requires a reservation and a disabled reason');
|
|
445
|
+
const incoming = input.providerState === undefined
|
|
446
|
+
? undefined
|
|
447
|
+
: acceptProviderState(ctx.providerState, 'add', id, input.providerState);
|
|
448
|
+
await locks.acquire(rowLockSpec(rt, { id, identity }));
|
|
449
|
+
if (credential.type === 'oauth')
|
|
450
|
+
await locks.acquire(options.providerLock ?? rt.providerLock);
|
|
451
|
+
for (const extra of options.extraLocks ?? [])
|
|
452
|
+
await locks.acquire(extra);
|
|
453
|
+
return withTransaction(ctx, locks, progress, { operation: 'add', rowId: id }, async (tx) => {
|
|
454
|
+
await protectIn(tx, id, options.protect);
|
|
455
|
+
const stateAccounts = isRecord(tx.state.accounts)
|
|
456
|
+
? tx.state.accounts
|
|
457
|
+
: {};
|
|
458
|
+
const stagedOrphan = Object.entries(stateAccounts).find(([orphanId, account]) => {
|
|
459
|
+
if (orphanId === id ||
|
|
460
|
+
tx.rosterRow(orphanId) ||
|
|
461
|
+
!isRecord(account))
|
|
462
|
+
return false;
|
|
463
|
+
const stamp = account[CREDENTIAL_STAMP_KEY];
|
|
464
|
+
if (!isRecord(stamp) || !Object.hasOwn(stamp, 'staged'))
|
|
465
|
+
return false;
|
|
466
|
+
return credential.type === 'oauth'
|
|
467
|
+
? account.refresh === credential.refresh
|
|
468
|
+
: typeof account.apiKey === 'string' &&
|
|
469
|
+
account.apiKey.trim() === credential.apiKey.trim();
|
|
470
|
+
});
|
|
471
|
+
if (stagedOrphan)
|
|
472
|
+
throw refusal('add', stagedOrphan[0], 'row-staged', `orphan ${stagedOrphan[0]} reserves this secret until its staged add is replayed or removed`);
|
|
473
|
+
if (ctx.removedIds.has(id))
|
|
474
|
+
throw refusal('add', id, 'id-removed', `id ${id} was removed from the roster in this process and is not reused`);
|
|
475
|
+
if (input.stage &&
|
|
476
|
+
mode !== 'rotate' &&
|
|
477
|
+
(tx.rosterRow(id) || hasStateAccount(tx.state, id)))
|
|
478
|
+
return replayStagedAdd(rt, tx, input, incoming);
|
|
479
|
+
if (mode === 'rotate') {
|
|
480
|
+
tx.assertNotStaged(id);
|
|
481
|
+
const holder = tx
|
|
482
|
+
.rows()
|
|
483
|
+
.find((row) => row.fingerprint === fingerprintOf(credential));
|
|
484
|
+
if (holder)
|
|
485
|
+
tx.assertNotStaged(holder.id);
|
|
486
|
+
}
|
|
487
|
+
if (mode === 'refuse') {
|
|
488
|
+
const holder = tx
|
|
489
|
+
.rows()
|
|
490
|
+
.find((row) => row.fingerprint === fingerprintOf(credential) ||
|
|
491
|
+
(row.id === id && row.credential));
|
|
492
|
+
const accounts = isRecord(tx.state.accounts)
|
|
493
|
+
? tx.state.accounts
|
|
494
|
+
: {};
|
|
495
|
+
const stateHolder = Object.entries(accounts).find(([, account]) => {
|
|
496
|
+
if (!isRecord(account))
|
|
497
|
+
return false;
|
|
498
|
+
if (credential.type === 'oauth')
|
|
499
|
+
return account.refresh === credential.refresh;
|
|
500
|
+
return (typeof account.apiKey === 'string' &&
|
|
501
|
+
account.apiKey.trim() === credential.apiKey.trim());
|
|
502
|
+
})?.[0];
|
|
503
|
+
const holdingId = holder?.id ?? stateHolder ?? id;
|
|
504
|
+
if (holder ||
|
|
505
|
+
stateHolder !== undefined ||
|
|
506
|
+
hasStateAccount(tx.state, id))
|
|
507
|
+
throw refusal('add', holdingId, 'credential-exists', `row ${holdingId} already holds this secret or id`);
|
|
508
|
+
}
|
|
509
|
+
if (mode === 'stage-duplicate' &&
|
|
510
|
+
(tx.rosterRow(id) || hasStateAccount(tx.state, id)))
|
|
511
|
+
throw refusal('add', id, 'id-exists', `row ${id} already exists`);
|
|
512
|
+
const orphan = matchingInterruptedAdd(rt, tx, input, incoming);
|
|
513
|
+
const rows = tx.rows();
|
|
514
|
+
const fingerprint = fingerprintOf(credential);
|
|
515
|
+
const same = mode === 'rotate' &&
|
|
516
|
+
!orphan &&
|
|
517
|
+
rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
|
|
518
|
+
if (same) {
|
|
519
|
+
tx.assertNotStaged(same.id);
|
|
520
|
+
// Re-adding a secret whose stamp is not proved would rotate it in
|
|
521
|
+
// and keep the identity and quota recorded beside it, making that
|
|
522
|
+
// unproved record look bound. The add is refused instead, and the
|
|
523
|
+
// caller replaces the row, which starts a new credential epoch.
|
|
524
|
+
requireBound('add', same);
|
|
525
|
+
// The same secret is the same credential, so re-adding it rotates
|
|
526
|
+
// that row. An identity or endpoint given with it must match the
|
|
527
|
+
// row's; a different one is refused rather than silently replaced
|
|
528
|
+
// by what the row already holds.
|
|
529
|
+
if (identity !== undefined &&
|
|
530
|
+
same.identity !== undefined &&
|
|
531
|
+
identity !== same.identity)
|
|
532
|
+
throw identityMismatch('add', same.id);
|
|
533
|
+
const prepared = planRotationIn(rt, tx, same.id, credential, {
|
|
534
|
+
...(incoming !== undefined
|
|
535
|
+
? {
|
|
536
|
+
providerState: mergedProviderState(ctx.providerState, 'add', same.id, same.providerState, incoming),
|
|
537
|
+
}
|
|
538
|
+
: {}),
|
|
539
|
+
});
|
|
540
|
+
await tx.completeTorn();
|
|
541
|
+
tx.assertNotStaged(same.id);
|
|
542
|
+
const stored = await persistRotationIn(tx, same.id, prepared);
|
|
543
|
+
return {
|
|
544
|
+
id: same.id,
|
|
545
|
+
outcome: 'rotated',
|
|
546
|
+
credential: stored,
|
|
547
|
+
credentialEpoch: same.credentialEpoch ?? 1,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const existing = rows.find((row) => row.id === id);
|
|
551
|
+
if (existing) {
|
|
552
|
+
if (existing.invalid)
|
|
553
|
+
throw refusal('add', id, 'invalid-row', `row ${id} is invalid`);
|
|
554
|
+
if (existing.credential)
|
|
555
|
+
throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
|
|
556
|
+
// Completing a credential-less row keeps its epoch, identity and
|
|
557
|
+
// quota; when stamps are required those belong to no proved
|
|
558
|
+
// credential, so the row must be replaced instead.
|
|
559
|
+
requireBound('add', existing);
|
|
560
|
+
if (existing.type !== credential.type)
|
|
561
|
+
throw refusal('add', id, 'type-mismatch', `row ${id} is a ${existing.type} row`);
|
|
562
|
+
if (identity !== undefined &&
|
|
563
|
+
existing.identity !== undefined &&
|
|
564
|
+
identity !== existing.identity)
|
|
565
|
+
throw identityMismatch('add', id);
|
|
566
|
+
// A roster row without a credential (left by another writer, or
|
|
567
|
+
// by an add of an earlier version that stopped between its
|
|
568
|
+
// writes): writing the credential now completes it at epoch 1.
|
|
569
|
+
if (!existing.hasEntry)
|
|
570
|
+
tx.setEntry(id, {
|
|
571
|
+
credentialEpoch: 1,
|
|
572
|
+
needsFirstReading: credential.type === 'oauth',
|
|
573
|
+
});
|
|
574
|
+
const prepared = planRotationIn(rt, tx, id, credential, {
|
|
575
|
+
clearErrors: true,
|
|
576
|
+
...(incoming !== undefined
|
|
577
|
+
? { providerState: { kind: 'set', value: incoming } }
|
|
578
|
+
: {}),
|
|
579
|
+
});
|
|
580
|
+
await tx.completeTorn();
|
|
581
|
+
const stored = await persistRotationIn(tx, id, prepared);
|
|
582
|
+
if (!existing.hasEntry)
|
|
583
|
+
await tx.commitConfig();
|
|
584
|
+
return {
|
|
585
|
+
id,
|
|
586
|
+
outcome: 'completed',
|
|
587
|
+
credential: stored,
|
|
588
|
+
credentialEpoch: existing.credentialEpoch ?? 1,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// An id the pool held before starts past every epoch it held, so
|
|
592
|
+
// work attributed to the earlier row's credential, from this
|
|
593
|
+
// process or another, never matches the new one.
|
|
594
|
+
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
595
|
+
if (!isCredentialEpoch(credentialEpoch))
|
|
596
|
+
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
597
|
+
const addedIdentity = orphan?.identity ?? identity;
|
|
598
|
+
tx.roster().push(rosterRowFor({
|
|
599
|
+
id,
|
|
600
|
+
credential,
|
|
601
|
+
...(addedIdentity !== undefined
|
|
602
|
+
? { identity: addedIdentity }
|
|
603
|
+
: {}),
|
|
604
|
+
...(label !== undefined ? { label } : {}),
|
|
605
|
+
addedAt: ctx.now(),
|
|
606
|
+
}));
|
|
607
|
+
tx.setEntry(id, {
|
|
608
|
+
credentialEpoch,
|
|
609
|
+
needsFirstReading: credential.type === 'oauth',
|
|
610
|
+
...(input.stage
|
|
611
|
+
? { staged: { reservation: input.stage.reservation } }
|
|
612
|
+
: {}),
|
|
613
|
+
});
|
|
614
|
+
let outcome = 'added';
|
|
615
|
+
if (input.disabled) {
|
|
616
|
+
disableIn(tx, id, input.disabled.reason);
|
|
617
|
+
outcome = 'added-disabled';
|
|
618
|
+
}
|
|
619
|
+
if (!input.disabled &&
|
|
620
|
+
addedIdentity !== undefined &&
|
|
621
|
+
credential.type === 'oauth') {
|
|
622
|
+
// Same account, different credential: kept on disk, disabled.
|
|
623
|
+
const holder = rows.find((row) => row.invalid === undefined &&
|
|
624
|
+
row.type === 'oauth' &&
|
|
625
|
+
row.enabled &&
|
|
626
|
+
row.identity === addedIdentity);
|
|
627
|
+
if (holder) {
|
|
628
|
+
disableIn(tx, id, DUPLICATE_IDENTITY_REASON);
|
|
629
|
+
outcome = 'added-disabled';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (orphan?.credential) {
|
|
633
|
+
// The state half is already durable and proved. A config-only
|
|
634
|
+
// completion preserves the credential, stamp and provider state
|
|
635
|
+
// byte for byte; a crash leaves either this orphan or a whole row.
|
|
636
|
+
await tx.commitConfig();
|
|
637
|
+
return {
|
|
638
|
+
id,
|
|
639
|
+
outcome,
|
|
640
|
+
credential: orphan.credential,
|
|
641
|
+
credentialEpoch: orphan.credentialEpoch ?? credentialEpoch,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
// The credential is written first. A crash before the config write
|
|
645
|
+
// then leaves a state entry no roster row names, which no reader
|
|
646
|
+
// loads and `remove` drops; written the other way round, the new
|
|
647
|
+
// roster row could load beside a credential left under its id by an
|
|
648
|
+
// interrupted removal. Nothing of such a leftover entry is kept.
|
|
649
|
+
tx.dropStateAccount(id);
|
|
650
|
+
const prepared = planRotationIn(rt, tx, id, credential, {
|
|
651
|
+
...(input.stage && input.disabled
|
|
652
|
+
? {
|
|
653
|
+
staged: {
|
|
654
|
+
reservation: input.stage.reservation,
|
|
655
|
+
disabledReason: input.disabled.reason,
|
|
656
|
+
...(label !== undefined ? { label } : {}),
|
|
657
|
+
...(incoming !== undefined
|
|
658
|
+
? { providerState: canonicalDigest(incoming) }
|
|
659
|
+
: {}),
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
: {}),
|
|
663
|
+
...(incoming !== undefined
|
|
664
|
+
? { providerState: { kind: 'set', value: incoming } }
|
|
665
|
+
: {}),
|
|
666
|
+
});
|
|
667
|
+
if (mode === 'rotate')
|
|
668
|
+
await tx.completeTorn();
|
|
669
|
+
const stored = await persistRotationIn(tx, id, prepared);
|
|
670
|
+
await tx.commitConfig();
|
|
671
|
+
return { id, outcome, credential: stored, credentialEpoch };
|
|
672
|
+
}, { completeTorn: false });
|
|
673
|
+
});
|
|
674
|
+
// Even a pull that skips its disabled row can repair other interrupted rows.
|
|
675
|
+
// A disabled or staged add must leave those other rows untouched.
|
|
676
|
+
if (!preparing &&
|
|
677
|
+
credential.type === 'oauth' &&
|
|
678
|
+
(result.outcome === 'added' || result.outcome === 'completed'))
|
|
679
|
+
rt.firePull(result.id, 'add');
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
export async function replaceRow(rt, id, credential, input = {}, options = {}) {
|
|
683
|
+
assertNotInsideHook('replace');
|
|
684
|
+
const { ctx } = rt;
|
|
685
|
+
const result = await runOperation(ctx, 'replace', id, options.onFailure, async (locks, progress) => {
|
|
686
|
+
checkInput('replace', id, credential);
|
|
687
|
+
validateAttribution('replace', id, options.attribution);
|
|
688
|
+
const incoming = input.providerState === undefined
|
|
689
|
+
? undefined
|
|
690
|
+
: acceptProviderState(ctx.providerState, 'replace', id, input.providerState);
|
|
691
|
+
const { row: seen } = await readRow(rt, 'replace', id);
|
|
692
|
+
await locks.acquire(rowLockSpec(rt, seen));
|
|
693
|
+
if (credential.type === 'oauth')
|
|
694
|
+
await locks.acquire(options.providerLock ?? rt.providerLock);
|
|
695
|
+
for (const extra of options.extraLocks ?? [])
|
|
696
|
+
await locks.acquire(extra);
|
|
697
|
+
return withTransaction(ctx, locks, progress, { operation: 'replace', rowId: id }, async (tx) => {
|
|
698
|
+
if (options.attribution !== undefined) {
|
|
699
|
+
// A replace interrupted between its two file writes is read as
|
|
700
|
+
// already done: tx.row shows its new epoch and identity. Compare
|
|
701
|
+
// the fence with that row before finishing the interrupted write
|
|
702
|
+
// on disk, so a stale caller does not even complete a replacement
|
|
703
|
+
// it was never issued for.
|
|
704
|
+
assertRowAttribution('replace', id, tx.row(id), options.attribution);
|
|
705
|
+
await tx.completeTorn();
|
|
706
|
+
}
|
|
707
|
+
const row = requireUsableRow('replace', id, tx.row(id), credential);
|
|
708
|
+
if (rowLockKey(row) !== rowLockKey(seen))
|
|
709
|
+
throw keyChanged('replace', id);
|
|
710
|
+
const priorEpoch = tx.entry(id)?.credentialEpoch;
|
|
711
|
+
const credentialEpoch = (typeof priorEpoch === 'number' ? priorEpoch : 1) + 1;
|
|
712
|
+
// An epoch past the safe integer range could equal the one before
|
|
713
|
+
// it, so readers could not tell the new credential from the old.
|
|
714
|
+
// Refused before anything is written; the row keeps its credential.
|
|
715
|
+
if (!isCredentialEpoch(credentialEpoch))
|
|
716
|
+
throw refusal('replace', id, 'invalid-row', `row ${id}'s credential epoch cannot advance past ${Number.MAX_SAFE_INTEGER}; remove the row and add the new credential as a new row`);
|
|
717
|
+
// Decided before anything is written: a hook that throws or
|
|
718
|
+
// returns a value the codec rejects leaves the row as it was.
|
|
719
|
+
const providerState = replacementProviderState(ctx.providerState, row, credentialEpoch, input.identity, incoming);
|
|
720
|
+
const binding = {
|
|
721
|
+
...(input.identity !== undefined
|
|
722
|
+
? { identity: input.identity }
|
|
723
|
+
: {}),
|
|
724
|
+
...(credential.type === 'api'
|
|
725
|
+
? {
|
|
726
|
+
baseURL: credential.baseURL.trim(),
|
|
727
|
+
authHeader: credential.authHeader ?? 'authorization-bearer',
|
|
728
|
+
}
|
|
729
|
+
: {}),
|
|
730
|
+
};
|
|
731
|
+
bindReplacement(tx, id, credentialEpoch, binding);
|
|
732
|
+
if (input.identity !== undefined)
|
|
733
|
+
disableIdentityDuplicates(tx, input.identity);
|
|
734
|
+
// The new credential goes first, stamped with the new epoch and the
|
|
735
|
+
// binding. A crash before the config write leaves the stamp ahead
|
|
736
|
+
// of the config: every reader shows the row torn (completed, never
|
|
737
|
+
// a candidate) and the next store write completes the config from
|
|
738
|
+
// the stamp, so no reader pairs either credential with the other
|
|
739
|
+
// account's identity or endpoint.
|
|
740
|
+
const stored = await rotateIn(rt, tx, id, credential, {
|
|
741
|
+
clearErrors: true,
|
|
742
|
+
binding,
|
|
743
|
+
providerState,
|
|
744
|
+
});
|
|
745
|
+
await tx.commitConfig();
|
|
746
|
+
return { id, credential: stored, credentialEpoch };
|
|
747
|
+
}, { completeTorn: options.attribution === undefined });
|
|
748
|
+
});
|
|
749
|
+
if (credential.type === 'oauth')
|
|
750
|
+
rt.firePull(id, 'replace');
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
754
|
+
assertNotInsideHook('rotate');
|
|
755
|
+
const { ctx } = rt;
|
|
756
|
+
return runOperation(ctx, 'rotate', id, options.onFailure, async (locks, progress) => {
|
|
757
|
+
checkInput('rotate', id, credential);
|
|
758
|
+
validateAttribution('rotate', id, options.attribution);
|
|
759
|
+
const incoming = input.providerState === undefined
|
|
760
|
+
? undefined
|
|
761
|
+
: acceptProviderState(ctx.providerState, 'rotate', id, input.providerState);
|
|
762
|
+
const { row: seen } = await readRow(rt, 'rotate', id);
|
|
763
|
+
await locks.acquire(rowLockSpec(rt, seen));
|
|
764
|
+
if (input.identity !== undefined && credential.type === 'oauth')
|
|
765
|
+
await locks.acquire(options.providerLock ?? rt.providerLock);
|
|
766
|
+
for (const extra of options.extraLocks ?? [])
|
|
767
|
+
await locks.acquire(extra);
|
|
768
|
+
return withTransaction(ctx, locks, progress, { operation: 'rotate', rowId: id }, async (tx) => {
|
|
769
|
+
if (options.attribution !== undefined) {
|
|
770
|
+
assertRowAttribution('rotate', id, tx.row(id), options.attribution);
|
|
771
|
+
await tx.completeTorn();
|
|
772
|
+
}
|
|
773
|
+
const row = requireUsableRow('rotate', id, tx.row(id), credential);
|
|
774
|
+
if (rowLockKey(row) !== rowLockKey(seen))
|
|
775
|
+
throw keyChanged('rotate', id);
|
|
776
|
+
// A rotation keeps the row's epoch, identity and quota, so it must
|
|
777
|
+
// never be what stamps an unproved row as bound.
|
|
778
|
+
requireBound('rotate', row);
|
|
779
|
+
// A rotation stays with one account: it may record the first
|
|
780
|
+
// identity the row learns, but a credential of another known
|
|
781
|
+
// account is a replacement (new epoch, quota and errors dropped).
|
|
782
|
+
if (input.identity !== undefined &&
|
|
783
|
+
row.identity !== undefined &&
|
|
784
|
+
input.identity !== row.identity)
|
|
785
|
+
throw identityMismatch('rotate', id);
|
|
786
|
+
const learnt = input.identity !== undefined && row.identity === undefined
|
|
787
|
+
? input.identity
|
|
788
|
+
: undefined;
|
|
789
|
+
const stored = await rotateIn(rt, tx, id, credential, {
|
|
790
|
+
identity: learnt,
|
|
791
|
+
...(incoming !== undefined
|
|
792
|
+
? {
|
|
793
|
+
providerState: mergedProviderState(ctx.providerState, 'rotate', id, row.providerState, incoming),
|
|
794
|
+
}
|
|
795
|
+
: {}),
|
|
796
|
+
});
|
|
797
|
+
let configChanged = false;
|
|
798
|
+
if (!row.hasEntry) {
|
|
799
|
+
tx.setEntry(id, {
|
|
800
|
+
credentialEpoch: 1,
|
|
801
|
+
needsFirstReading: row.type === 'oauth',
|
|
802
|
+
});
|
|
803
|
+
configChanged = true;
|
|
804
|
+
}
|
|
805
|
+
if (input.identity !== undefined && row.identity !== input.identity) {
|
|
806
|
+
recordIdentityIn(tx, id, input.identity);
|
|
807
|
+
configChanged = true;
|
|
808
|
+
}
|
|
809
|
+
if (configChanged)
|
|
810
|
+
await tx.commitConfig();
|
|
811
|
+
return { id, credential: stored };
|
|
812
|
+
}, { completeTorn: options.attribution === undefined });
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* `disable` and `enable` in one place. Takes the row lock, then the extra
|
|
817
|
+
* locks, then the store locks, as every row write does, so it waits for a
|
|
818
|
+
* refresh of the row instead of landing during its provider call.
|
|
819
|
+
*
|
|
820
|
+
* With a provider-state mutator that changes the value, the transition is
|
|
821
|
+
* written as a replace is: the state file first, carrying the value and, in
|
|
822
|
+
* the stamp, the transition itself; then the config, flipping the row and
|
|
823
|
+
* recording the transition's mark (see `torn.ts`). A stop between the two
|
|
824
|
+
* leaves a row every reader shows transitioned beside its new value. When
|
|
825
|
+
* the config already says what the transition would write (an `enable` of
|
|
826
|
+
* an enabled row), the value alone is written, in one state write.
|
|
827
|
+
*/
|
|
828
|
+
async function transitionRow(rt, operation, id, flag, options) {
|
|
829
|
+
assertNotInsideHook(operation);
|
|
830
|
+
const { ctx } = rt;
|
|
831
|
+
const codec = ctx.providerState;
|
|
832
|
+
const fence = options.attribution;
|
|
833
|
+
const mutator = options.providerState;
|
|
834
|
+
return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
|
|
835
|
+
validateAttribution(operation, id, fence);
|
|
836
|
+
if (mutator !== undefined) {
|
|
837
|
+
if (typeof mutator !== 'function')
|
|
838
|
+
throw refusal(operation, id, 'invalid-input', 'the provider-state mutator must be a function');
|
|
839
|
+
// A provider-state value belongs to one credential, so a change to
|
|
840
|
+
// it must say which credential it was decided for.
|
|
841
|
+
if (fence === undefined)
|
|
842
|
+
throw refusal(operation, id, 'invalid-input', 'a provider-state change needs the attribution of the credential it is for');
|
|
843
|
+
if (!codec)
|
|
844
|
+
throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
|
|
845
|
+
}
|
|
846
|
+
const { row: seen } = await readRow(rt, operation, id);
|
|
847
|
+
await locks.acquire(rowLockSpec(rt, seen));
|
|
848
|
+
for (const extra of options.extraLocks ?? [])
|
|
849
|
+
await locks.acquire(extra);
|
|
850
|
+
return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
|
|
851
|
+
await protectIn(tx, id, options.protect);
|
|
852
|
+
tx.assertNotStaged(id);
|
|
853
|
+
const loaded = tx.row(id);
|
|
854
|
+
const row = flag.enabled
|
|
855
|
+
? requireUsableRow('enable', id, loaded)
|
|
856
|
+
: loaded;
|
|
857
|
+
if (!row || !tx.rosterRow(id))
|
|
858
|
+
throw unknownRow(operation, id);
|
|
859
|
+
if (rowLockKey(row) !== rowLockKey(seen))
|
|
860
|
+
throw keyChanged(operation, id);
|
|
861
|
+
if (fence !== undefined) {
|
|
862
|
+
assertRowAttribution(operation, id, row, fence);
|
|
863
|
+
}
|
|
864
|
+
if (flag.enabled &&
|
|
865
|
+
row.disabledReason?.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
|
|
866
|
+
throw refusal('enable', id, 'identity-contradicted', `row ${id} needs an identity-validated credential replacement before it can be enabled`);
|
|
867
|
+
// An enable of a row that is already enabled has nothing to write
|
|
868
|
+
// to the config; a disable always rewrites it, as it always has.
|
|
869
|
+
const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
|
|
870
|
+
if (flag.enabled && writesConfig && row.type === 'oauth') {
|
|
871
|
+
const holder = row.identity === undefined
|
|
872
|
+
? undefined
|
|
873
|
+
: tx
|
|
874
|
+
.rows()
|
|
875
|
+
.find((other) => other.id !== id &&
|
|
876
|
+
other.invalid === undefined &&
|
|
877
|
+
other.type === 'oauth' &&
|
|
878
|
+
other.enabled &&
|
|
879
|
+
other.identity === row.identity);
|
|
880
|
+
if (holder)
|
|
881
|
+
throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
|
|
882
|
+
}
|
|
883
|
+
if (mutator === undefined || codec === undefined) {
|
|
884
|
+
await tx.completeTorn();
|
|
885
|
+
if (!writesConfig)
|
|
886
|
+
return { id };
|
|
887
|
+
if (flag.enabled)
|
|
888
|
+
enableIn(tx, id);
|
|
889
|
+
else
|
|
890
|
+
disableIn(tx, id, flag.reason);
|
|
891
|
+
await tx.commitConfig();
|
|
892
|
+
return { id };
|
|
893
|
+
}
|
|
894
|
+
if (!row.credential)
|
|
895
|
+
throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
|
|
896
|
+
// A strict store refuses here, but not for an attribution alone:
|
|
897
|
+
// disabling or enabling a row whose credential the store cannot
|
|
898
|
+
// prove is harmless (an unbound row is never a candidate), while
|
|
899
|
+
// writing a provider state would vouch for that credential.
|
|
900
|
+
requireBound(operation, row);
|
|
901
|
+
const plan = await planProviderStateIn(tx, codec, operation, row, mutator, true);
|
|
902
|
+
if (plan.kind === 'declined')
|
|
903
|
+
return { id, declined: true };
|
|
904
|
+
await tx.completeTorn();
|
|
905
|
+
const result = {
|
|
906
|
+
id,
|
|
907
|
+
providerStateOutcome: plan.kind === 'unchanged'
|
|
908
|
+
? 'unchanged'
|
|
909
|
+
: plan.value === undefined
|
|
910
|
+
? 'cleared'
|
|
911
|
+
: 'updated',
|
|
912
|
+
...(plan.value !== undefined ? { providerState: plan.value } : {}),
|
|
913
|
+
};
|
|
914
|
+
if (plan.kind === 'unchanged') {
|
|
915
|
+
if (writesConfig) {
|
|
916
|
+
if (flag.enabled)
|
|
917
|
+
enableIn(tx, id);
|
|
918
|
+
else
|
|
919
|
+
disableIn(tx, id, flag.reason);
|
|
920
|
+
await tx.commitConfig();
|
|
921
|
+
}
|
|
922
|
+
return result;
|
|
923
|
+
}
|
|
924
|
+
if (!writesConfig) {
|
|
925
|
+
tx.setStateAccount(id, plan.account);
|
|
926
|
+
await tx.commitState();
|
|
927
|
+
return result;
|
|
928
|
+
}
|
|
929
|
+
const transition = flag.enabled
|
|
930
|
+
? { mark: randomUUID(), enabled: true }
|
|
931
|
+
: { mark: randomUUID(), enabled: false, reason: flag.reason };
|
|
932
|
+
const stamp = plan.account[CREDENTIAL_STAMP_KEY];
|
|
933
|
+
tx.setStateAccount(id, {
|
|
934
|
+
...plan.account,
|
|
935
|
+
[CREDENTIAL_STAMP_KEY]: {
|
|
936
|
+
...stamp,
|
|
937
|
+
[TRANSITION_STAMP_KEY]: transition,
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
await tx.commitState();
|
|
941
|
+
applyTransition(tx, id, transition);
|
|
942
|
+
await tx.commitConfig();
|
|
943
|
+
return result;
|
|
944
|
+
}, { completeTorn: false });
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Marks a row disabled with a reason. See `RowTransitionOptions` for the
|
|
949
|
+
* attributed form, which may change the provider state with it.
|
|
950
|
+
*/
|
|
951
|
+
export function disableRow(rt, id, reason, options = {}) {
|
|
952
|
+
return transitionRow(rt, 'disable', id, { enabled: false, reason }, options);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Clears a row's `enabled: false` and its `disabledReason` in one config
|
|
956
|
+
* write. An identity-contradicted row refuses with `identity-contradicted`
|
|
957
|
+
* until the caller validates a replacement's identity and supplies it to replace.
|
|
958
|
+
* An OAuth row whose recorded identity another enabled OAuth row holds
|
|
959
|
+
* stays disabled and the call refuses (`duplicate-identity`): the same rule
|
|
960
|
+
* that makes `add` store such a row disabled. Enabling a row that is already
|
|
961
|
+
* enabled writes nothing. See `RowTransitionOptions` for the attributed
|
|
962
|
+
* form, which may change the provider state with it.
|
|
963
|
+
*/
|
|
964
|
+
export function enableRow(rt, id, options = {}) {
|
|
965
|
+
return transitionRow(rt, 'enable', id, { enabled: true }, options);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Deletes a row: its roster row and per-row entry (quota, epoch; the identity
|
|
969
|
+
* lives in the roster row) in one config write, then its credential and
|
|
970
|
+
* runtime fields in one state write. The config goes first, so a crash
|
|
971
|
+
* between the two leaves a row every reader already sees as removed, with
|
|
972
|
+
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
973
|
+
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
974
|
+
* not reused by `add` in this process, and the config write records the
|
|
975
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
976
|
+
* past it (see `nextAddEpochIn`).
|
|
977
|
+
*/
|
|
978
|
+
export async function removeRow(rt, id, options = {}) {
|
|
979
|
+
assertNotInsideHook('remove');
|
|
980
|
+
const { ctx } = rt;
|
|
981
|
+
return runOperation(ctx, 'remove', id, options.onFailure, async (locks, progress) => {
|
|
982
|
+
// Only a non-string or empty id is refused: a roster row whose id the
|
|
983
|
+
// older readers would trim is invalid, and removing it is a repair.
|
|
984
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
985
|
+
throw refusal('remove', id, 'invalid-input', 'id must be non-empty');
|
|
986
|
+
const fence = options.attribution;
|
|
987
|
+
validateAttribution('remove', id, fence);
|
|
988
|
+
if (options.staged !== undefined &&
|
|
989
|
+
(!isRecord(options.staged) ||
|
|
990
|
+
typeof options.staged.reservation !== 'string' ||
|
|
991
|
+
!options.staged.reservation))
|
|
992
|
+
throw refusal('remove', id, 'invalid-input', 'a staged removal needs a reservation');
|
|
993
|
+
const result = await readPool(ctx);
|
|
994
|
+
if (result.status !== 'ready')
|
|
995
|
+
throw notReadyError(result, 'remove', id);
|
|
996
|
+
const seen = result.rows.find((row) => row.id === id);
|
|
997
|
+
if (!seen && !hasStateAccount(result.state, id))
|
|
998
|
+
throw unknownRow('remove', id);
|
|
999
|
+
const seenKey = rowLockKey(seen ?? { id });
|
|
1000
|
+
await locks.acquire(rowLockSpec(rt, seen ?? { id }));
|
|
1001
|
+
for (const extra of options.extraLocks ?? [])
|
|
1002
|
+
await locks.acquire(extra);
|
|
1003
|
+
return withTransaction(ctx, locks, progress, { operation: 'remove', rowId: id }, async (tx) => {
|
|
1004
|
+
const reservation = tx.reservation(id);
|
|
1005
|
+
if (reservation !== undefined) {
|
|
1006
|
+
if (!isRecord(reservation) ||
|
|
1007
|
+
!options.staged ||
|
|
1008
|
+
reservation.reservation !== options.staged.reservation)
|
|
1009
|
+
throw refusal('remove', id, 'row-staged', `row ${id} is reserved for roster publication`);
|
|
1010
|
+
}
|
|
1011
|
+
else if (options.staged)
|
|
1012
|
+
throw refusal('remove', id, 'attribution', `row ${id} is no longer reserved`);
|
|
1013
|
+
const row = tx.row(id);
|
|
1014
|
+
const orphan = hasStateAccount(tx.state, id);
|
|
1015
|
+
if (!row && !orphan)
|
|
1016
|
+
throw unknownRow('remove', id);
|
|
1017
|
+
if (rowLockKey(row ?? { id }) !== seenKey)
|
|
1018
|
+
throw keyChanged('remove', id);
|
|
1019
|
+
if (fence !== undefined) {
|
|
1020
|
+
if (row)
|
|
1021
|
+
assertBoundRowAttribution(id, row, fence);
|
|
1022
|
+
else
|
|
1023
|
+
assertOrphanAttribution(rt, tx, id, fence);
|
|
1024
|
+
}
|
|
1025
|
+
const protect = options.protect;
|
|
1026
|
+
if (protect) {
|
|
1027
|
+
const view = {
|
|
1028
|
+
row,
|
|
1029
|
+
config: tx.snapshot.config,
|
|
1030
|
+
state: tx.snapshot.state,
|
|
1031
|
+
};
|
|
1032
|
+
const reason = await runInsideHook('remove', () => protect(id, view));
|
|
1033
|
+
if (reason !== undefined)
|
|
1034
|
+
throw refusal('remove', id, 'row-protected', reason);
|
|
1035
|
+
}
|
|
1036
|
+
if (row) {
|
|
1037
|
+
if (fence === undefined)
|
|
1038
|
+
await tx.completeTorn();
|
|
1039
|
+
tx.dropRosterRows(id);
|
|
1040
|
+
await tx.commitConfig();
|
|
1041
|
+
}
|
|
1042
|
+
if (orphan) {
|
|
1043
|
+
tx.dropStateAccount(id);
|
|
1044
|
+
await tx.commitState();
|
|
1045
|
+
}
|
|
1046
|
+
return { id, outcome: row ? 'removed' : 'completed' };
|
|
1047
|
+
},
|
|
1048
|
+
// An attributed removal is refused, when it is, with nothing written:
|
|
1049
|
+
// completing another row's interrupted write first would be a write.
|
|
1050
|
+
{ completeTorn: false });
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
/** An attributed removal of a roster row: bound, not torn, same epoch and identity. */
|
|
1054
|
+
export function assertBoundRowAttribution(id, row, fence, operation = 'remove') {
|
|
1055
|
+
assertRowAttribution(operation, id, row, fence);
|
|
1056
|
+
// A torn row is between the two writes of another operation; which
|
|
1057
|
+
// credential it holds is not settled, so no attribution can match it.
|
|
1058
|
+
if (row.torn)
|
|
1059
|
+
throw refusal(operation, id, 'attribution', `row ${id} is between the writes of another operation; retry once it completes`, true);
|
|
1060
|
+
if (row.stamp !== 'bound')
|
|
1061
|
+
throw refusal(operation, id, 'unbound-credential', `row ${id}'s credential is not bound to its stamp, so the removal cannot be attributed to it`);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* An attributed removal of an orphan. The orphan is loaded the way an
|
|
1065
|
+
* interrupted add's replay loads it (`matchingInterruptedAdd`): its stamp
|
|
1066
|
+
* supplies the identity and endpoint the missing roster row would have held,
|
|
1067
|
+
* and the credential beside it must be the one that stamp names (status
|
|
1068
|
+
* `bound`). Only then does the stamp's epoch and identity mean anything.
|
|
1069
|
+
*/
|
|
1070
|
+
function assertOrphanAttribution(rt, tx, id, fence) {
|
|
1071
|
+
const unbound = () => refusal('remove', id, 'unbound-credential', `orphan ${id} has no credential stamp this store can bind, so the removal cannot be attributed to it`);
|
|
1072
|
+
const account = tx.stateAccount(id);
|
|
1073
|
+
const stamp = parseStamp(account?.[CREDENTIAL_STAMP_KEY]);
|
|
1074
|
+
// A replace's stamp only ever sits beside a roster row; one left without
|
|
1075
|
+
// a row is not a state this store writes.
|
|
1076
|
+
if (!account ||
|
|
1077
|
+
!stamp?.binding ||
|
|
1078
|
+
stamp.dispatch === undefined ||
|
|
1079
|
+
stamp.replace)
|
|
1080
|
+
throw unbound();
|
|
1081
|
+
let descriptor;
|
|
1082
|
+
if (typeof account.apiKey === 'string') {
|
|
1083
|
+
if (stamp.binding.baseURL === undefined ||
|
|
1084
|
+
stamp.binding.authHeader === undefined)
|
|
1085
|
+
throw unbound();
|
|
1086
|
+
descriptor = {
|
|
1087
|
+
type: 'api',
|
|
1088
|
+
apiKey: account.apiKey,
|
|
1089
|
+
baseURL: stamp.binding.baseURL,
|
|
1090
|
+
authHeader: stamp.binding.authHeader,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
else if (typeof account.refresh === 'string') {
|
|
1094
|
+
descriptor = { type: 'oauth', refresh: account.refresh };
|
|
1095
|
+
}
|
|
1096
|
+
else
|
|
1097
|
+
throw unbound();
|
|
1098
|
+
const [orphan] = buildRawRows({
|
|
1099
|
+
accounts: [
|
|
1100
|
+
rosterRowFor({
|
|
1101
|
+
id,
|
|
1102
|
+
credential: descriptor,
|
|
1103
|
+
identity: stamp.binding.identity,
|
|
1104
|
+
addedAt: 0,
|
|
1105
|
+
}),
|
|
1106
|
+
],
|
|
1107
|
+
[POOL_KEY]: {
|
|
1108
|
+
rows: { [id]: { credentialEpoch: stamp.credentialEpoch } },
|
|
1109
|
+
},
|
|
1110
|
+
}, tx.state, rt.ctx.codec, rt.ctx.providerState);
|
|
1111
|
+
if (!orphan?.credential || orphan.invalid || orphan.stamp !== 'bound')
|
|
1112
|
+
throw unbound();
|
|
1113
|
+
if (stamp.credentialEpoch !== fence.credentialEpoch ||
|
|
1114
|
+
stamp.binding.identity !== fence.identity)
|
|
1115
|
+
throw refusal('remove', id, 'attribution', `orphan ${id} was written for a credential or account other than the one this removal was decided for`, true);
|
|
1116
|
+
}
|
|
1117
|
+
function hasStateAccount(state, id) {
|
|
1118
|
+
const accounts = state.accounts;
|
|
1119
|
+
return isRecord(accounts) && Object.hasOwn(accounts, id);
|
|
1120
|
+
}
|
|
1121
|
+
/** The id a roster row carries, or undefined for a row that names none. */
|
|
1122
|
+
function rosterIdOf(raw) {
|
|
1123
|
+
return isRecord(raw) && typeof raw.id === 'string' ? raw.id : undefined;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Why `ids` is not an order of this roster: it must name every distinct
|
|
1127
|
+
* roster id exactly once and nothing else.
|
|
1128
|
+
*/
|
|
1129
|
+
function orderProblem(roster, ids) {
|
|
1130
|
+
if (!Array.isArray(ids))
|
|
1131
|
+
return 'ids must be an array of roster ids';
|
|
1132
|
+
const rosterIds = new Set();
|
|
1133
|
+
for (const raw of roster) {
|
|
1134
|
+
const id = rosterIdOf(raw);
|
|
1135
|
+
if (id !== undefined)
|
|
1136
|
+
rosterIds.add(id);
|
|
1137
|
+
}
|
|
1138
|
+
const given = new Set();
|
|
1139
|
+
for (const id of ids) {
|
|
1140
|
+
if (typeof id !== 'string')
|
|
1141
|
+
return 'ids must be an array of roster ids';
|
|
1142
|
+
if (given.has(id))
|
|
1143
|
+
return `id ${id} appears more than once`;
|
|
1144
|
+
if (!rosterIds.has(id))
|
|
1145
|
+
return `id ${id} is not in the roster`;
|
|
1146
|
+
given.add(id);
|
|
1147
|
+
}
|
|
1148
|
+
const missing = [...rosterIds].filter((id) => !given.has(id));
|
|
1149
|
+
if (missing.length > 0)
|
|
1150
|
+
return `the order leaves out roster id(s) ${missing.join(', ')}`;
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* The roster in the new order. Every roster row is kept as the same object,
|
|
1155
|
+
* so its serialized bytes are unchanged. Rows that carry an id fill the
|
|
1156
|
+
* positions such rows held before, in the order of `ids`; a second row with
|
|
1157
|
+
* an already-seen id (invalid, but preserved) travels right after the first.
|
|
1158
|
+
* A row that names no id cannot be ordered by id, so it keeps its position.
|
|
1159
|
+
*/
|
|
1160
|
+
function reorderedRoster(roster, ids) {
|
|
1161
|
+
const byId = new Map();
|
|
1162
|
+
for (const raw of roster) {
|
|
1163
|
+
const id = rosterIdOf(raw);
|
|
1164
|
+
if (id === undefined)
|
|
1165
|
+
continue;
|
|
1166
|
+
const group = byId.get(id);
|
|
1167
|
+
if (group)
|
|
1168
|
+
group.push(raw);
|
|
1169
|
+
else
|
|
1170
|
+
byId.set(id, [raw]);
|
|
1171
|
+
}
|
|
1172
|
+
const sequence = ids.flatMap((id) => byId.get(id) ?? []);
|
|
1173
|
+
let next = 0;
|
|
1174
|
+
return roster.map((raw) => rosterIdOf(raw) === undefined ? raw : sequence[next++]);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Sets the roster order in one config write. `ids` must name every roster id
|
|
1178
|
+
* exactly once; anything else refuses (`invalid-order`) before writing. The
|
|
1179
|
+
* roster rows, the per-row entries and the state file are left as they are:
|
|
1180
|
+
* only the order of the legacy `accounts` array changes, which older readers
|
|
1181
|
+
* load as is. It takes the extra locks, then the store locks, and no row or
|
|
1182
|
+
* provider-wide lock, since no row's credential, identity or quota changes.
|
|
1183
|
+
* An order equal to the current one writes nothing.
|
|
1184
|
+
*/
|
|
1185
|
+
export async function reorderRows(rt, ids, options = {}) {
|
|
1186
|
+
assertNotInsideHook('reorder');
|
|
1187
|
+
const { ctx } = rt;
|
|
1188
|
+
const onFailure = options.onFailure;
|
|
1189
|
+
return runOperation(ctx, 'reorder', undefined, onFailure && ((_rowId, error) => onFailure(error)), async (locks, progress) => {
|
|
1190
|
+
for (const extra of options.extraLocks ?? [])
|
|
1191
|
+
await locks.acquire(extra);
|
|
1192
|
+
return withTransaction(ctx, locks, progress, { operation: 'reorder', rowId: undefined }, async (tx) => {
|
|
1193
|
+
const roster = tx.roster();
|
|
1194
|
+
const problem = orderProblem(roster, ids);
|
|
1195
|
+
if (problem)
|
|
1196
|
+
throw new PoolOperationError({
|
|
1197
|
+
operation: 'reorder',
|
|
1198
|
+
phase: 'before-first-write',
|
|
1199
|
+
retryable: false,
|
|
1200
|
+
kind: 'invalid-order',
|
|
1201
|
+
message: problem,
|
|
1202
|
+
});
|
|
1203
|
+
const order = [...ids];
|
|
1204
|
+
const next = reorderedRoster(roster, order);
|
|
1205
|
+
if (next.every((raw, index) => raw === roster[index]))
|
|
1206
|
+
return { ids: order, outcome: 'unchanged' };
|
|
1207
|
+
tx.config.accounts = next;
|
|
1208
|
+
await tx.commitConfig();
|
|
1209
|
+
return { ids: order, outcome: 'reordered' };
|
|
1210
|
+
},
|
|
1211
|
+
// A reorder keeps every roster row and entry byte for byte, so it
|
|
1212
|
+
// leaves a torn row for a write on that row to complete.
|
|
1213
|
+
{ completeTorn: false });
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Records the wire identity an identity lookup found for a row's credential.
|
|
1218
|
+
* `attribution` is the credential epoch the lookup was issued for (a row
|
|
1219
|
+
* without an entry is at epoch 1): a lookup that completes after the row was
|
|
1220
|
+
* replaced is refused (`attribution`), as a quota reading would be, so the
|
|
1221
|
+
* first credential's account is never recorded on the second credential. A
|
|
1222
|
+
* row already recorded for another account refuses (`identity-mismatch`):
|
|
1223
|
+
* that is a replacement, not something learnt about the same credential.
|
|
1224
|
+
*/
|
|
1225
|
+
export async function recordRowIdentity(rt, id, identity, attribution, options = {}) {
|
|
1226
|
+
assertNotInsideHook('recordIdentity');
|
|
1227
|
+
return runOperation(rt.ctx, 'recordIdentity', id, options.onFailure, async (locks, progress) => {
|
|
1228
|
+
if (typeof identity !== 'string' || identity.length === 0)
|
|
1229
|
+
throw refusal('recordIdentity', id, 'invalid-input', 'identity must be non-empty');
|
|
1230
|
+
const captured = isRecord(attribution)
|
|
1231
|
+
? attribution.credentialEpoch
|
|
1232
|
+
: undefined;
|
|
1233
|
+
if (!isCredentialEpoch(captured))
|
|
1234
|
+
throw refusal('recordIdentity', id, 'invalid-input', 'the credential epoch the identity lookup was issued for is required');
|
|
1235
|
+
const { row: seen } = await readRow(rt, 'recordIdentity', id);
|
|
1236
|
+
await locks.acquire(rowLockSpec(rt, seen));
|
|
1237
|
+
await locks.acquire(options.providerLock ?? rt.providerLock);
|
|
1238
|
+
for (const extra of options.extraLocks ?? [])
|
|
1239
|
+
await locks.acquire(extra);
|
|
1240
|
+
return withTransaction(rt.ctx, locks, progress, { operation: 'recordIdentity', rowId: id }, async (tx) => {
|
|
1241
|
+
const row = requireUsableRow('recordIdentity', id, tx.row(id));
|
|
1242
|
+
if (rowLockKey(row) !== rowLockKey(seen))
|
|
1243
|
+
throw keyChanged('recordIdentity', id);
|
|
1244
|
+
requireBound('recordIdentity', row);
|
|
1245
|
+
if ((row.credentialEpoch ?? 1) !== captured)
|
|
1246
|
+
throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
|
|
1247
|
+
if (row.identity !== undefined && row.identity !== identity)
|
|
1248
|
+
throw identityMismatch('recordIdentity', id);
|
|
1249
|
+
// The stamp names the identity first; a crash before the config
|
|
1250
|
+
// write leaves a row every reader completes forward.
|
|
1251
|
+
if (row.identity === undefined)
|
|
1252
|
+
await stampIdentityIn(tx, row, identity);
|
|
1253
|
+
const disabled = recordIdentityIn(tx, id, identity);
|
|
1254
|
+
await tx.commitConfig();
|
|
1255
|
+
return { id, disabled };
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
}
|