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