@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.
Files changed (230) hide show
  1. package/README.md +24 -2
  2. package/dist/abort-forwarding.d.ts +3 -0
  3. package/dist/abort-forwarding.d.ts.map +1 -0
  4. package/dist/abort-forwarding.js +19 -0
  5. package/dist/abort-forwarding.js.map +1 -0
  6. package/dist/account-identity.d.ts +52 -0
  7. package/dist/account-identity.d.ts.map +1 -0
  8. package/dist/account-identity.js +66 -0
  9. package/dist/account-identity.js.map +1 -0
  10. package/dist/account-manager.d.ts +228 -43
  11. package/dist/account-manager.d.ts.map +1 -1
  12. package/dist/account-manager.js +942 -903
  13. package/dist/account-manager.js.map +1 -1
  14. package/dist/account-migration.d.ts +309 -0
  15. package/dist/account-migration.d.ts.map +1 -0
  16. package/dist/account-migration.js +2282 -0
  17. package/dist/account-migration.js.map +1 -0
  18. package/dist/account-repository-codecs.d.ts +82 -0
  19. package/dist/account-repository-codecs.d.ts.map +1 -0
  20. package/dist/account-repository-codecs.js +609 -0
  21. package/dist/account-repository-codecs.js.map +1 -0
  22. package/dist/account-repository-types.d.ts +831 -0
  23. package/dist/account-repository-types.d.ts.map +1 -0
  24. package/dist/account-repository-types.js +137 -0
  25. package/dist/account-repository-types.js.map +1 -0
  26. package/dist/account-repository.d.ts +442 -0
  27. package/dist/account-repository.d.ts.map +1 -0
  28. package/dist/account-repository.js +2253 -0
  29. package/dist/account-repository.js.map +1 -0
  30. package/dist/account-selector.d.ts +470 -0
  31. package/dist/account-selector.d.ts.map +1 -0
  32. package/dist/account-selector.js +1383 -0
  33. package/dist/account-selector.js.map +1 -0
  34. package/dist/account-storage.d.ts.map +1 -1
  35. package/dist/account-storage.js +5 -0
  36. package/dist/account-storage.js.map +1 -1
  37. package/dist/agy-transport.d.ts.map +1 -1
  38. package/dist/agy-transport.js +21 -35
  39. package/dist/agy-transport.js.map +1 -1
  40. package/dist/antigravity-command-menu.d.ts +171 -0
  41. package/dist/antigravity-command-menu.d.ts.map +1 -0
  42. package/dist/antigravity-command-menu.js +523 -0
  43. package/dist/antigravity-command-menu.js.map +1 -0
  44. package/dist/atomic-write.d.ts +18 -1
  45. package/dist/atomic-write.d.ts.map +1 -1
  46. package/dist/atomic-write.js +15 -3
  47. package/dist/atomic-write.js.map +1 -1
  48. package/dist/common-auth-embedded/NOTICE.txt +21 -0
  49. package/dist/common-auth-embedded/auth-menu/accounts.d.ts +97 -0
  50. package/dist/common-auth-embedded/auth-menu/accounts.js +387 -0
  51. package/dist/common-auth-embedded/auth-menu/ansi.d.ts +26 -0
  52. package/dist/common-auth-embedded/auth-menu/ansi.js +98 -0
  53. package/dist/common-auth-embedded/auth-menu/confirm.d.ts +7 -0
  54. package/dist/common-auth-embedded/auth-menu/confirm.js +21 -0
  55. package/dist/common-auth-embedded/auth-menu/doctor.d.ts +52 -0
  56. package/dist/common-auth-embedded/auth-menu/doctor.js +81 -0
  57. package/dist/common-auth-embedded/auth-menu/index.d.ts +17 -0
  58. package/dist/common-auth-embedded/auth-menu/index.js +12 -0
  59. package/dist/common-auth-embedded/auth-menu/login.d.ts +55 -0
  60. package/dist/common-auth-embedded/auth-menu/login.js +83 -0
  61. package/dist/common-auth-embedded/auth-menu/menu.d.ts +59 -0
  62. package/dist/common-auth-embedded/auth-menu/menu.js +73 -0
  63. package/dist/common-auth-embedded/auth-menu/opencode-v1.d.ts +49 -0
  64. package/dist/common-auth-embedded/auth-menu/opencode-v1.js +47 -0
  65. package/dist/common-auth-embedded/auth-menu/select.d.ts +21 -0
  66. package/dist/common-auth-embedded/auth-menu/select.js +159 -0
  67. package/dist/common-auth-embedded/auth-menu/terminal.d.ts +43 -0
  68. package/dist/common-auth-embedded/auth-menu/terminal.js +19 -0
  69. package/dist/common-auth-embedded/claustrum/consumer.d.ts +102 -0
  70. package/dist/common-auth-embedded/claustrum/consumer.js +298 -0
  71. package/dist/common-auth-embedded/claustrum/custody.d.ts +175 -0
  72. package/dist/common-auth-embedded/claustrum/custody.js +354 -0
  73. package/dist/common-auth-embedded/claustrum/enrollment.d.ts +158 -0
  74. package/dist/common-auth-embedded/claustrum/enrollment.js +759 -0
  75. package/dist/common-auth-embedded/claustrum/errors.d.ts +16 -0
  76. package/dist/common-auth-embedded/claustrum/errors.js +8 -0
  77. package/dist/common-auth-embedded/claustrum/host-slot.d.ts +39 -0
  78. package/dist/common-auth-embedded/claustrum/host-slot.js +72 -0
  79. package/dist/common-auth-embedded/claustrum/index.d.ts +18 -0
  80. package/dist/common-auth-embedded/claustrum/index.js +22 -0
  81. package/dist/common-auth-embedded/claustrum/interlock.d.ts +27 -0
  82. package/dist/common-auth-embedded/claustrum/interlock.js +29 -0
  83. package/dist/common-auth-embedded/claustrum/roster.d.ts +208 -0
  84. package/dist/common-auth-embedded/claustrum/roster.js +536 -0
  85. package/dist/common-auth-embedded/commands/builtins.d.ts +71 -0
  86. package/dist/common-auth-embedded/commands/builtins.js +534 -0
  87. package/dist/common-auth-embedded/commands/index.d.ts +10 -0
  88. package/dist/common-auth-embedded/commands/index.js +5 -0
  89. package/dist/common-auth-embedded/commands/menu.d.ts +61 -0
  90. package/dist/common-auth-embedded/commands/menu.js +300 -0
  91. package/dist/common-auth-embedded/commands/model.d.ts +238 -0
  92. package/dist/common-auth-embedded/commands/model.js +16 -0
  93. package/dist/common-auth-embedded/commands/pi.d.ts +23 -0
  94. package/dist/common-auth-embedded/commands/pi.js +206 -0
  95. package/dist/common-auth-embedded/commands/seam.d.ts +72 -0
  96. package/dist/common-auth-embedded/commands/seam.js +301 -0
  97. package/dist/common-auth-embedded/fs/atomic-write.d.ts +20 -0
  98. package/dist/common-auth-embedded/fs/atomic-write.js +80 -0
  99. package/dist/common-auth-embedded/fs/index.d.ts +7 -0
  100. package/dist/common-auth-embedded/fs/index.js +6 -0
  101. package/dist/common-auth-embedded/fs/lock-constants.d.ts +14 -0
  102. package/dist/common-auth-embedded/fs/lock-constants.js +14 -0
  103. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +33 -0
  104. package/dist/common-auth-embedded/fs/refresh-file-lock.js +501 -0
  105. package/dist/common-auth-embedded/fs/with-lock.d.ts +33 -0
  106. package/dist/common-auth-embedded/fs/with-lock.js +48 -0
  107. package/dist/common-auth-embedded/logger/capture-sink.d.ts +14 -0
  108. package/dist/common-auth-embedded/logger/capture-sink.js +13 -0
  109. package/dist/common-auth-embedded/logger/engine.d.ts +100 -0
  110. package/dist/common-auth-embedded/logger/engine.js +246 -0
  111. package/dist/common-auth-embedded/logger/index.d.ts +6 -0
  112. package/dist/common-auth-embedded/logger/index.js +3 -0
  113. package/dist/common-auth-embedded/logger/redact.d.ts +11 -0
  114. package/dist/common-auth-embedded/logger/redact.js +83 -0
  115. package/dist/common-auth-embedded/pi-slot/index.d.ts +3 -0
  116. package/dist/common-auth-embedded/pi-slot/index.js +2 -0
  117. package/dist/common-auth-embedded/pi-slot/lock.js +212 -0
  118. package/dist/common-auth-embedded/pi-slot/slot.d.ts +49 -0
  119. package/dist/common-auth-embedded/pi-slot/slot.js +383 -0
  120. package/dist/common-auth-embedded/quota/codec.d.ts +11 -0
  121. package/dist/common-auth-embedded/quota/codec.js +12 -0
  122. package/dist/common-auth-embedded/quota/format.d.ts +46 -0
  123. package/dist/common-auth-embedded/quota/format.js +138 -0
  124. package/dist/common-auth-embedded/quota/index.d.ts +10 -0
  125. package/dist/common-auth-embedded/quota/index.js +5 -0
  126. package/dist/common-auth-embedded/quota/map.d.ts +63 -0
  127. package/dist/common-auth-embedded/quota/map.js +105 -0
  128. package/dist/common-auth-embedded/quota/merge.d.ts +48 -0
  129. package/dist/common-auth-embedded/quota/merge.js +162 -0
  130. package/dist/common-auth-embedded/quota/projection.d.ts +64 -0
  131. package/dist/common-auth-embedded/quota/projection.js +134 -0
  132. package/dist/common-auth-embedded/routing/admission.d.ts +93 -0
  133. package/dist/common-auth-embedded/routing/admission.js +142 -0
  134. package/dist/common-auth-embedded/routing/index.d.ts +8 -0
  135. package/dist/common-auth-embedded/routing/index.js +4 -0
  136. package/dist/common-auth-embedded/routing/ordered.d.ts +47 -0
  137. package/dist/common-auth-embedded/routing/ordered.js +58 -0
  138. package/dist/common-auth-embedded/routing/pins.d.ts +22 -0
  139. package/dist/common-auth-embedded/routing/pins.js +34 -0
  140. package/dist/common-auth-embedded/routing/sticky.d.ts +158 -0
  141. package/dist/common-auth-embedded/routing/sticky.js +339 -0
  142. package/dist/common-auth-embedded/source-output.json +1090 -0
  143. package/dist/common-auth-embedded/store/attribution.d.ts +28 -0
  144. package/dist/common-auth-embedded/store/attribution.js +60 -0
  145. package/dist/common-auth-embedded/store/errors.d.ts +55 -0
  146. package/dist/common-auth-embedded/store/errors.js +38 -0
  147. package/dist/common-auth-embedded/store/hooks.d.ts +12 -0
  148. package/dist/common-auth-embedded/store/hooks.js +34 -0
  149. package/dist/common-auth-embedded/store/identity.d.ts +39 -0
  150. package/dist/common-auth-embedded/store/identity.js +79 -0
  151. package/dist/common-auth-embedded/store/index.d.ts +21 -0
  152. package/dist/common-auth-embedded/store/index.js +7 -0
  153. package/dist/common-auth-embedded/store/mutate.d.ts +189 -0
  154. package/dist/common-auth-embedded/store/mutate.js +500 -0
  155. package/dist/common-auth-embedded/store/pool.d.ts +196 -0
  156. package/dist/common-auth-embedded/store/pool.js +120 -0
  157. package/dist/common-auth-embedded/store/provider-state.d.ts +131 -0
  158. package/dist/common-auth-embedded/store/provider-state.js +226 -0
  159. package/dist/common-auth-embedded/store/publication.d.ts +47 -0
  160. package/dist/common-auth-embedded/store/publication.js +304 -0
  161. package/dist/common-auth-embedded/store/pull.d.ts +36 -0
  162. package/dist/common-auth-embedded/store/pull.js +143 -0
  163. package/dist/common-auth-embedded/store/refresh-lock.d.ts +63 -0
  164. package/dist/common-auth-embedded/store/refresh-lock.js +136 -0
  165. package/dist/common-auth-embedded/store/refresh.d.ts +72 -0
  166. package/dist/common-auth-embedded/store/refresh.js +225 -0
  167. package/dist/common-auth-embedded/store/reserved.js +13 -0
  168. package/dist/common-auth-embedded/store/rows.d.ts +313 -0
  169. package/dist/common-auth-embedded/store/rows.js +1258 -0
  170. package/dist/common-auth-embedded/store/runtime.d.ts +36 -0
  171. package/dist/common-auth-embedded/store/runtime.js +68 -0
  172. package/dist/common-auth-embedded/store/schema.d.ts +443 -0
  173. package/dist/common-auth-embedded/store/schema.js +754 -0
  174. package/dist/common-auth-embedded/store/settings.d.ts +63 -0
  175. package/dist/common-auth-embedded/store/settings.js +120 -0
  176. package/dist/common-auth-embedded/store/torn.d.ts +78 -0
  177. package/dist/common-auth-embedded/store/torn.js +294 -0
  178. package/dist/common-auth-embedded/util/any-signal.js +42 -0
  179. package/dist/common-auth-runtime.d.ts +73 -0
  180. package/dist/common-auth-runtime.d.ts.map +1 -0
  181. package/dist/common-auth-runtime.js +65 -0
  182. package/dist/common-auth-runtime.js.map +1 -0
  183. package/dist/credential-redaction.d.ts +13 -0
  184. package/dist/credential-redaction.d.ts.map +1 -0
  185. package/dist/credential-redaction.js +230 -0
  186. package/dist/credential-redaction.js.map +1 -0
  187. package/dist/fetch-timeout.d.ts +4 -2
  188. package/dist/fetch-timeout.d.ts.map +1 -1
  189. package/dist/fetch-timeout.js +121 -14
  190. package/dist/fetch-timeout.js.map +1 -1
  191. package/dist/index.d.ts +16 -1
  192. package/dist/index.d.ts.map +1 -1
  193. package/dist/index.js +15 -0
  194. package/dist/index.js.map +1 -1
  195. package/dist/quota-manager.d.ts.map +1 -1
  196. package/dist/quota-manager.js +35 -10
  197. package/dist/quota-manager.js.map +1 -1
  198. package/dist/quota-presentation.d.ts +10 -0
  199. package/dist/quota-presentation.d.ts.map +1 -0
  200. package/dist/quota-presentation.js +60 -0
  201. package/dist/quota-presentation.js.map +1 -0
  202. package/dist/quota-types.d.ts +2 -0
  203. package/dist/quota-types.d.ts.map +1 -1
  204. package/dist/rotation.d.ts +8 -0
  205. package/dist/rotation.d.ts.map +1 -1
  206. package/dist/rotation.js +24 -0
  207. package/dist/rotation.js.map +1 -1
  208. package/dist/transform/claude.d.ts +7 -3
  209. package/dist/transform/claude.d.ts.map +1 -1
  210. package/dist/transform/claude.js +6 -2
  211. package/dist/transform/claude.js.map +1 -1
  212. package/dist/transform/types.d.ts +1 -1
  213. package/dist/transform/types.d.ts.map +1 -1
  214. package/dist/vault-account-source.d.ts +183 -0
  215. package/dist/vault-account-source.d.ts.map +1 -0
  216. package/dist/vault-account-source.js +296 -0
  217. package/dist/vault-account-source.js.map +1 -0
  218. package/dist/vault-enrollment-authority.d.ts +16 -0
  219. package/dist/vault-enrollment-authority.d.ts.map +1 -0
  220. package/dist/vault-enrollment-authority.js +19 -0
  221. package/dist/vault-enrollment-authority.js.map +1 -0
  222. package/dist/vault-family.d.ts +11 -0
  223. package/dist/vault-family.d.ts.map +1 -0
  224. package/dist/vault-family.js +24 -0
  225. package/dist/vault-family.js.map +1 -0
  226. package/dist/vault-provider-state.d.ts +162 -0
  227. package/dist/vault-provider-state.d.ts.map +1 -0
  228. package/dist/vault-provider-state.js +422 -0
  229. package/dist/vault-provider-state.js.map +1 -0
  230. 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
+ }