@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,536 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from '../fs/index.js';
4
+ import { isQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
5
+ import { ClaustrumConsumerError } from './errors.js';
6
+ import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
7
+ export const DEFAULT_ROUTE_PREFIX = 'vault:';
8
+ function hash(value) {
9
+ return createHash('sha256').update(value).digest('hex');
10
+ }
11
+ function defaultLabel(credential) {
12
+ // `oauth:<provider>:<label>` is the vault's conventional spelling; use the
13
+ // trailing label when present and fall back to the account's email.
14
+ const parts = credential.credentialId.split(':');
15
+ const label = parts.length >= 3 ? parts.slice(2).join(':').trim() : undefined;
16
+ return label || credential.email || credential.credentialId;
17
+ }
18
+ function groupKey(member) {
19
+ return member.identity !== undefined
20
+ ? `identity:${member.identity}`
21
+ : `credential:${member.credential.credentialId}`;
22
+ }
23
+ function memberIds(row) {
24
+ return [row.credentialId, ...(row.aliases ?? [])];
25
+ }
26
+ /**
27
+ * Project the vault's list onto the previous roster. Pure: callers serialize
28
+ * discovery and commit. Route ids, quota and the time an account was added
29
+ * survive for the same account; a credential that now logs into a different
30
+ * known account gets a new route id and no inherited quota.
31
+ *
32
+ * A record listed without an identity keeps the last account it was known to
33
+ * log into (the row is marked `unclaimed`). Dropping that binding would let a
34
+ * later different account look like an identity learned for the first time
35
+ * and inherit the first account's route and quota.
36
+ *
37
+ * A reply with unusable records is incomplete: a previous member it does not
38
+ * account for (its record was rejected, or a rejected record has no usable id
39
+ * and could be any of them) is kept, as an alias of its live account or as a
40
+ * `stale` row. Only a complete reply removes an account.
41
+ */
42
+ export function projectVaultRoster(previous, inventory, options = {}) {
43
+ const now = options.now ?? Date.now();
44
+ const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
45
+ const reserved = options.reservedRouteIds ?? new Set();
46
+ const previousRows = previous?.rows ?? [];
47
+ // Declines follow the account and outlive its records, so none is pruned.
48
+ const declined = [...(previous?.declined ?? [])];
49
+ const complete = inventory.skipped.length === 0;
50
+ const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
51
+ const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
52
+ const unrecoverable = inventory.skipped.some((record) => record.credentialId === undefined);
53
+ const unaccounted = (id) => !listed.has(id) && (rejectedIds.has(id) || unrecoverable);
54
+ const lastKnown = new Map();
55
+ for (const row of previousRows) {
56
+ if (row.accountIdentity === undefined)
57
+ continue;
58
+ for (const id of memberIds(row))
59
+ if (!lastKnown.has(id))
60
+ lastKnown.set(id, row.accountIdentity);
61
+ }
62
+ const groups = new Map();
63
+ for (const credential of inventory.credentials) {
64
+ const member = credential.accountIdentity !== undefined
65
+ ? { credential, identity: credential.accountIdentity, claimed: true }
66
+ : {
67
+ credential,
68
+ identity: lastKnown.get(credential.credentialId),
69
+ claimed: false,
70
+ };
71
+ const key = groupKey(member);
72
+ const group = groups.get(key) ?? [];
73
+ group.push(member);
74
+ groups.set(key, group);
75
+ }
76
+ const used = new Set();
77
+ const taken = new Set(reserved);
78
+ const projected = [];
79
+ const byId = (left, right) => left.credential.credentialId.localeCompare(right.credential.credentialId);
80
+ const sortedGroups = [...groups.values()]
81
+ .map((group) => group.toSorted(byId))
82
+ .sort((left, right) => (left[0] && right[0] ? byId(left[0], right[0]) : 0));
83
+ for (const ordered of sortedGroups) {
84
+ const identity = ordered[0]?.identity;
85
+ const active = ordered.filter((entry) => entry.credential.state === 'active');
86
+ const usable = active.length ? active : ordered;
87
+ // A record that claims the account itself is preferred over one that is
88
+ // only remembered as belonging to it.
89
+ const claimed = usable.filter((entry) => entry.claimed);
90
+ const choices = claimed.length ? claimed : usable;
91
+ const members = new Set(ordered.map((entry) => entry.credential.credentialId));
92
+ // An unknown previous account may be learned now; a known one must match.
93
+ const bound = previousRows.find((row) => !used.has(row) &&
94
+ memberIds(row).some((id) => members.has(id)) &&
95
+ (row.accountIdentity === undefined || row.accountIdentity === identity));
96
+ const chosen = choices.find((entry) => entry.credential.credentialId === bound?.credentialId) ?? choices[0];
97
+ if (!chosen)
98
+ continue;
99
+ const representative = chosen.credential;
100
+ const existing = bound ??
101
+ (identity === undefined
102
+ ? undefined
103
+ : previousRows.find((row) => !used.has(row) && row.accountIdentity === identity));
104
+ if (existing)
105
+ used.add(existing);
106
+ let routeId = existing?.routeId;
107
+ if (!routeId || reserved.has(routeId)) {
108
+ const base = `${prefix}${representative.credentialId}${identity === undefined ? '' : `~${hash(identity).slice(0, 10)}`}`;
109
+ routeId = base;
110
+ for (let suffix = 2; taken.has(routeId); suffix++)
111
+ routeId = `${base}~${suffix}`;
112
+ }
113
+ taken.add(routeId);
114
+ const retained = existing ? memberIds(existing).filter(unaccounted) : [];
115
+ const aliases = [
116
+ ...new Set([
117
+ ...ordered.map((entry) => entry.credential.credentialId),
118
+ ...retained,
119
+ ]),
120
+ ]
121
+ .filter((id) => id !== representative.credentialId)
122
+ .sort((left, right) => left.localeCompare(right));
123
+ const mapped = options.mapAccount?.(representative) ?? {};
124
+ projected.push({
125
+ previousIndex: existing ? previousRows.indexOf(existing) : Infinity,
126
+ row: {
127
+ routeId,
128
+ credentialId: representative.credentialId,
129
+ credentialType: representative.credentialType,
130
+ ...(identity !== undefined && { accountIdentity: identity }),
131
+ ...(aliases.length && { aliases }),
132
+ state: representative.state,
133
+ label: mapped.label?.trim() || defaultLabel(representative),
134
+ ...(representative.email !== undefined && {
135
+ email: representative.email,
136
+ }),
137
+ ...(representative.orgName !== undefined && {
138
+ orgName: representative.orgName,
139
+ }),
140
+ enabled: ![representative.credentialId, ...aliases].some((id) => isDeclined(declined, id, identity)),
141
+ addedAt: existing?.addedAt ?? now,
142
+ ...(existing?.quota !== undefined && { quota: existing.quota }),
143
+ ...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
144
+ },
145
+ });
146
+ }
147
+ // An account the reply does not account for keeps its last good projection:
148
+ // a rejected record must not read as the account having been removed. A
149
+ // credential this reply lists belongs to the live row it was projected into,
150
+ // so it is taken out of the stale row: otherwise one credential could be
151
+ // authorized, and its readings recorded, under two account bindings. A
152
+ // stale row left with no member is dropped.
153
+ previousRows.forEach((row, previousIndex) => {
154
+ if (used.has(row) || !memberIds(row).some(unaccounted))
155
+ return;
156
+ if (taken.has(row.routeId))
157
+ return;
158
+ const [credentialId, ...aliases] = memberIds(row).filter((id) => !listed.has(id));
159
+ if (credentialId === undefined)
160
+ return;
161
+ taken.add(row.routeId);
162
+ const { aliases: _previousAliases, ...rest } = row;
163
+ projected.push({
164
+ previousIndex,
165
+ row: {
166
+ ...rest,
167
+ credentialId,
168
+ ...(aliases.length && { aliases }),
169
+ stale: true,
170
+ },
171
+ });
172
+ });
173
+ projected.sort((left, right) => left.previousIndex === right.previousIndex
174
+ ? left.row.credentialId.localeCompare(right.row.credentialId)
175
+ : left.previousIndex - right.previousIndex);
176
+ return {
177
+ version: 1,
178
+ view: inventory.view,
179
+ complete,
180
+ ...(!complete && {
181
+ rejected: inventory.skipped.map((record) => ({
182
+ ...(record.credentialId !== undefined && {
183
+ credentialId: record.credentialId,
184
+ }),
185
+ reason: record.reason,
186
+ })),
187
+ }),
188
+ rows: projected.map((entry) => entry.row),
189
+ declined,
190
+ };
191
+ }
192
+ /**
193
+ * The rows `/routing` selects among. Only enabled, active accounts route;
194
+ * a declined or cold vault account stays listed for the menu but never
195
+ * reaches admission.
196
+ */
197
+ export function vaultRoutingRows(roster) {
198
+ return (roster?.rows ?? [])
199
+ .filter((row) => row.enabled && row.state === 'active')
200
+ .map((row) => ({
201
+ id: row.routeId,
202
+ kind: row.credentialType === 'oauth' ? 'oauth' : 'api-key',
203
+ ...(row.quota !== undefined && { quota: row.quota }),
204
+ }));
205
+ }
206
+ function isRecord(value) {
207
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
208
+ }
209
+ function optionalString(value) {
210
+ return value === undefined || typeof value === 'string';
211
+ }
212
+ function decodeRow(value) {
213
+ if (!isRecord(value) ||
214
+ typeof value.routeId !== 'string' ||
215
+ typeof value.credentialId !== 'string' ||
216
+ (value.credentialType !== 'oauth' && value.credentialType !== 'api_key') ||
217
+ typeof value.state !== 'string' ||
218
+ typeof value.label !== 'string' ||
219
+ typeof value.enabled !== 'boolean' ||
220
+ typeof value.addedAt !== 'number' ||
221
+ !optionalString(value.accountIdentity) ||
222
+ !optionalString(value.email) ||
223
+ !optionalString(value.orgName) ||
224
+ (value.aliases !== undefined &&
225
+ (!Array.isArray(value.aliases) ||
226
+ !value.aliases.every((alias) => typeof alias === 'string'))) ||
227
+ (value.quota !== undefined && !isQuotaMap(value.quota)) ||
228
+ (value.stale !== undefined && value.stale !== true) ||
229
+ (value.unclaimed !== undefined && value.unclaimed !== true))
230
+ return undefined;
231
+ return value;
232
+ }
233
+ const SKIPPED_REASONS = new Set([
234
+ 'empty credential id',
235
+ 'duplicate credential id',
236
+ 'blank account identity',
237
+ 'empty state',
238
+ ]);
239
+ function decodeRejected(value) {
240
+ if (!Array.isArray(value))
241
+ return undefined;
242
+ const records = [];
243
+ for (const entry of value) {
244
+ if (!isRecord(entry) ||
245
+ !optionalString(entry.credentialId) ||
246
+ typeof entry.reason !== 'string' ||
247
+ !SKIPPED_REASONS.has(entry.reason))
248
+ return undefined;
249
+ records.push(entry);
250
+ }
251
+ return records;
252
+ }
253
+ function decodeRoster(value) {
254
+ if (!isRecord(value) ||
255
+ value.version !== 1 ||
256
+ !optionalString(value.view) ||
257
+ !Array.isArray(value.rows) ||
258
+ !Array.isArray(value.declined) ||
259
+ (value.complete !== undefined && typeof value.complete !== 'boolean') ||
260
+ (value.rejected !== undefined && !decodeRejected(value.rejected)))
261
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
262
+ const rows = [];
263
+ for (const entry of value.rows) {
264
+ const row = decodeRow(entry);
265
+ if (!row)
266
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
267
+ rows.push(row);
268
+ }
269
+ const declined = [];
270
+ for (const entry of value.declined) {
271
+ if (!isRecord(entry) ||
272
+ typeof entry.credentialId !== 'string' ||
273
+ !optionalString(entry.accountIdentity))
274
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
275
+ declined.push(entry);
276
+ }
277
+ const rejected = decodeRejected(value.rejected);
278
+ return {
279
+ version: 1,
280
+ ...(typeof value.view === 'string' && { view: value.view }),
281
+ // A file written before completeness was recorded came from a reply
282
+ // that was used whole, so it reads as complete.
283
+ complete: value.complete !== false,
284
+ ...(rejected && { rejected }),
285
+ rows,
286
+ declined,
287
+ };
288
+ }
289
+ export async function readVaultRoster(path) {
290
+ let text;
291
+ try {
292
+ text = await readFile(path, 'utf8');
293
+ }
294
+ catch (error) {
295
+ if (error.code === 'ENOENT')
296
+ return undefined;
297
+ throw error;
298
+ }
299
+ let value;
300
+ try {
301
+ value = JSON.parse(text);
302
+ }
303
+ catch {
304
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
305
+ }
306
+ return decodeRoster(value);
307
+ }
308
+ const WRITE_LOCK = {
309
+ name: 'claustrum-roster-write',
310
+ ttlMs: 10_000,
311
+ timeoutMs: 5_000,
312
+ };
313
+ const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
314
+ /**
315
+ * Read, change and write the roster under its write lock. `change` returns
316
+ * undefined to leave the file as it is. Call `assertOwned()` immediately
317
+ * before any rename performed inside `change`; if it throws, do not rename.
318
+ */
319
+ export async function mutateVaultRoster(path, change, options = {}) {
320
+ return withLock(path, WRITE_LOCK, async (lock) => {
321
+ const current = await readVaultRoster(path);
322
+ const { next, result } = await change(current, {
323
+ assertOwned: () => lock.assertOwned(),
324
+ });
325
+ if (next) {
326
+ await lock.assertOwned();
327
+ // Staging the file takes several awaited steps; check both the caller's
328
+ // lease and this lock again at the last moment before the rename.
329
+ await writeJsonAtomic(path, next, {
330
+ beforeRename: async () => {
331
+ await options.beforePublish?.();
332
+ await lock.assertOwned();
333
+ },
334
+ });
335
+ }
336
+ return result;
337
+ });
338
+ }
339
+ /** Mark a vault account declined; it stays listed but never routes. */
340
+ export function declineVaultRoute(path, routeId) {
341
+ return mutateVaultRoster(path, (current) => {
342
+ const row = current?.rows.find((entry) => entry.routeId === routeId);
343
+ if (!current || !row)
344
+ throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is not in the roster');
345
+ const declined = declineAccount(current.declined, row.credentialId, row.accountIdentity);
346
+ return {
347
+ next: {
348
+ ...current,
349
+ declined,
350
+ rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: false } : entry),
351
+ },
352
+ result: undefined,
353
+ };
354
+ });
355
+ }
356
+ /** Lift the user's decline for a vault account and every alias of it. */
357
+ export function acceptVaultRoute(path, routeId) {
358
+ return mutateVaultRoster(path, (current) => {
359
+ const row = current?.rows.find((entry) => entry.routeId === routeId);
360
+ if (!current || !row)
361
+ throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is not in the roster');
362
+ return {
363
+ next: {
364
+ ...current,
365
+ declined: acceptAccount(current.declined, memberIds(row), row.accountIdentity),
366
+ rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: true } : entry),
367
+ },
368
+ result: undefined,
369
+ };
370
+ });
371
+ }
372
+ /**
373
+ * What a receipt may do to a row's quota: `hold` (the row holds the receipt's
374
+ * credential and account), `bind` (the row has never had a known account and
375
+ * the receipt proves one, so the row takes that account with the reading) or
376
+ * `refuse`.
377
+ */
378
+ function receiptFence(row, receipt) {
379
+ if (!memberIds(row).includes(receipt.credentialId))
380
+ return 'refuse';
381
+ // While the vault makes no claim, the roster's own expectation proves
382
+ // nothing about which account the reading came from.
383
+ if (row.unclaimed &&
384
+ (receipt.accountIdentitySource === 'expected' ||
385
+ receipt.accountIdentitySource === 'none'))
386
+ return 'refuse';
387
+ // Equality, never compatibility: a known account on one side does not match
388
+ // an absent one on the other. The only absent-absent match is a row that
389
+ // has never had a known account and a receipt that names none either.
390
+ if (row.accountIdentity === receipt.accountIdentity)
391
+ return 'hold';
392
+ // A row that has never had a known account, reached by a receipt issued for
393
+ // it in that state whose served or parsed identity names an account. The
394
+ // reading belongs to that account, so keeping it without the account would
395
+ // let the next list that names a different account inherit it as if that
396
+ // account had been learned for the first time. The row takes the account
397
+ // with the reading instead: a later list naming another account is then a
398
+ // known replacement and inherits nothing. Only a single-credential row is
399
+ // bound, since one credential's account says nothing about the others.
400
+ if (row.accountIdentity === undefined &&
401
+ receipt.expectedAccountIdentity === undefined &&
402
+ receipt.accountIdentity !== undefined &&
403
+ (receipt.accountIdentitySource === 'asserted' ||
404
+ receipt.accountIdentitySource === 'parsed') &&
405
+ memberIds(row).length === 1)
406
+ return 'bind';
407
+ return 'refuse';
408
+ }
409
+ /**
410
+ * Merge a quota observation into a vault row through `/quota`'s merge. The
411
+ * write is fenced on the receipt the reading was taken with: it lands only
412
+ * while the row still holds that receipt's credential (as representative or
413
+ * alias) and account, so a slow read for a replaced account can never land on
414
+ * its successor. Returns whether the observation was kept.
415
+ */
416
+ export function recordVaultQuota(path, input) {
417
+ return mutateVaultRoster(path, (current) => {
418
+ const row = current?.rows.find((entry) => entry.routeId === input.routeId);
419
+ const fence = !current || !row ? 'refuse' : receiptFence(row, input);
420
+ if (!current || !row || fence === 'refuse')
421
+ return { result: false };
422
+ const quota = mergeQuotaObservation(row.quota, input.observation);
423
+ // A bound account is `unclaimed`: the vault's list has not named it, and
424
+ // the next projection keeps it as the account this credential last
425
+ // logged into until the vault names one.
426
+ const bound = fence === 'bind'
427
+ ? { accountIdentity: input.accountIdentity, unclaimed: true }
428
+ : {};
429
+ return {
430
+ next: {
431
+ ...current,
432
+ rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
433
+ },
434
+ result: true,
435
+ };
436
+ });
437
+ }
438
+ /**
439
+ * Discover and commit the roster. The lease covers the list call and the
440
+ * commit, not just the write: an opaque view cannot tell a delayed old reply
441
+ * from a newer one, so a run that lost its lease is refused at commit. A
442
+ * failed list, or custody switching off mid-run, never writes anything; a
443
+ * peer holding the lease means its last committed roster is served instead.
444
+ */
445
+ export async function refreshVaultRoster(options) {
446
+ options.signal?.throwIfAborted();
447
+ const active = async () => (await options.isActive?.()) ?? true;
448
+ if (!(await active()))
449
+ return undefined;
450
+ const lease = await acquireRefreshFileLock({
451
+ ...DISCOVERY_LEASE,
452
+ path: options.path,
453
+ renew: true,
454
+ });
455
+ if (!lease) {
456
+ const persisted = await readVaultRoster(options.path);
457
+ options.signal?.throwIfAborted();
458
+ if (!persisted?.view)
459
+ throw new ClaustrumConsumerError('roster-busy', 'Claustrum account discovery is already in progress');
460
+ return persisted;
461
+ }
462
+ try {
463
+ const inventory = await options.custody.discover(options.signal);
464
+ options.signal?.throwIfAborted();
465
+ return await mutateVaultRoster(options.path, async (current) => {
466
+ if (!(await active()))
467
+ return { result: undefined };
468
+ options.signal?.throwIfAborted();
469
+ await lease.assertOwned();
470
+ const projection = typeof options.projection === 'function'
471
+ ? options.projection()
472
+ : options.projection;
473
+ const next = projectVaultRoster(current, inventory, projection);
474
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
475
+ return { ...(changed && { next }), result: next };
476
+ });
477
+ }
478
+ finally {
479
+ await lease.release();
480
+ }
481
+ }
482
+ /**
483
+ * The seam for a plugin that keeps a main account: one fixed account the host
484
+ * owns under a logical route, outside the pool's rotating rows. The library
485
+ * has no main convention of its own: the plugin names the
486
+ * vault record that holds the role (`primaryCredentialId`) and its logical
487
+ * route. Main follows the account that record claims; another credential of
488
+ * that same account may serve it when the record itself is not active, but a
489
+ * credential of any other account is never promoted into main.
490
+ *
491
+ * Inputs are the plugin's filtered inventory, whether that inventory is
492
+ * complete (defaults to the reply having no unusable records; pass the
493
+ * roster's `complete` or false to override), the previous verified binding
494
+ * and, on the request path, the account the chosen request expects.
495
+ */
496
+ export function resolveVaultPrimary(input) {
497
+ const { inventory, previous } = input;
498
+ const unavailable = (reason) => ({
499
+ status: 'unavailable',
500
+ reason,
501
+ ...(previous && { lastVerified: previous }),
502
+ });
503
+ const record = inventory.credentials.find((credential) => credential.credentialId === input.primaryCredentialId);
504
+ if (!record) {
505
+ if (inventory.skipped.some((skipped) => skipped.credentialId === input.primaryCredentialId))
506
+ return unavailable('malformed');
507
+ // A rejected record without a usable id could be the primary record.
508
+ const incomplete = input.complete === false ||
509
+ inventory.skipped.some((skipped) => skipped.credentialId === undefined);
510
+ return incomplete ? unavailable('incomplete') : { status: 'absent' };
511
+ }
512
+ const identity = record.accountIdentity;
513
+ if (identity === undefined)
514
+ return unavailable('unclaimed');
515
+ if (input.expectedAccountIdentity !== undefined &&
516
+ input.expectedAccountIdentity !== identity)
517
+ return unavailable('identity-changed');
518
+ const representative = record.state === 'active'
519
+ ? record
520
+ : (inventory.credentials
521
+ .filter((credential) => credential.accountIdentity === identity &&
522
+ credential.state === 'active')
523
+ .toSorted((left, right) => left.credentialId.localeCompare(right.credentialId))[0] ?? record);
524
+ const binding = {
525
+ routeId: input.routeId,
526
+ credentialId: representative.credentialId,
527
+ accountIdentity: identity,
528
+ view: inventory.view,
529
+ };
530
+ return {
531
+ status: 'ready',
532
+ binding,
533
+ ...(previous &&
534
+ previous.accountIdentity !== identity && { replaced: previous }),
535
+ };
536
+ }
@@ -0,0 +1,71 @@
1
+ import type { AddInput, PoolLockSpec, PoolRow, PoolStore, RemoveOptions } from '../store/index.js';
2
+ import type { CommandInvocation, KnobValues, MenuChoice, MenuKnob } from './model.js';
3
+ import { type ResolvedSection } from './seam.js';
4
+ /**
5
+ * What a plugin's login hands back. `ready` adds the account now. `pending`
6
+ * means the user must finish somewhere else (a browser, a device code):
7
+ * `message` tells them how, and the account is added when `completion`
8
+ * settles, with the result sent to the invocation that started it.
9
+ */
10
+ export type LoginOutcome = {
11
+ status: 'ready';
12
+ account: AddInput;
13
+ } | {
14
+ status: 'pending';
15
+ message: string;
16
+ /** Resolves with the account to add, or undefined when the user gave up. */
17
+ completion: Promise<AddInput | undefined>;
18
+ } | {
19
+ status: 'cancelled';
20
+ message?: string;
21
+ };
22
+ export interface AccountsSectionOptions {
23
+ /** What to show as an account's identity; defaults to its recorded identity. */
24
+ describeIdentity?(row: PoolRow): string | undefined;
25
+ /** The plugin's login; without it the section offers no add action. */
26
+ login?: {
27
+ label?: string;
28
+ knobs?: MenuKnob[];
29
+ run(values: KnobValues, invocation: CommandInvocation): Promise<LoginOutcome>;
30
+ };
31
+ /** Passed to `store.remove`: a reason refuses removing that id. */
32
+ protect?: RemoveOptions['protect'];
33
+ }
34
+ export interface QuotaSectionOptions {
35
+ /** The quota scope to show (`all` or a model family); defaults to `all`. */
36
+ scope?: string;
37
+ /**
38
+ * Checks quota now for the named rows. Without it, the menu asks the store
39
+ * for a reading of each row and waits for the pulls to settle.
40
+ */
41
+ check?(ids: readonly string[], invocation: CommandInvocation): Promise<void>;
42
+ }
43
+ export interface RoutingSectionOptions {
44
+ /**
45
+ * Further `routing.mode` values the plugin routes as `ordered` with the
46
+ * former main row moved (`main-first`, `fallback-first`), offered between
47
+ * `ordered` and `sticky-balanced`.
48
+ */
49
+ orderedVariants?: MenuChoice[];
50
+ formerMainId?: string;
51
+ }
52
+ export interface LimitsSectionOptions {
53
+ /**
54
+ * The quota window labels a floor can be set for. Defaults to every label
55
+ * the pool's quota readings carry, or `primary` when there are none.
56
+ */
57
+ labels?: readonly string[];
58
+ }
59
+ export interface BuiltinOptions {
60
+ store: PoolStore;
61
+ extraLocks?: readonly PoolLockSpec[];
62
+ now: () => number;
63
+ accounts?: AccountsSectionOptions;
64
+ quota?: QuotaSectionOptions;
65
+ routing?: RoutingSectionOptions;
66
+ limits?: LimitsSectionOptions;
67
+ }
68
+ /** `disabledReason` recorded when the user disables an account from the menu. */
69
+ export declare const MENU_DISABLED_REASON = "disabled from the command menu";
70
+ /** The four built-in sections, in their fixed order, from one read of the store. */
71
+ export declare function builtinSections(options: BuiltinOptions): Promise<ResolvedSection[]>;