@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,759 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, } from 'node:fs/promises';
4
+ import { basename, dirname, extname, isAbsolute, join, resolve, } from 'node:path';
5
+ import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
6
+ import { acquireRefreshFileLock } from '../fs/index.js';
7
+ import { ClaustrumConsumerError } from './errors.js';
8
+ const ENROLLMENT_SCHEMA = 1;
9
+ const ENROLLMENT_FILE_MAX_BYTES = 16 * 1024;
10
+ const ENROLLMENT_LOCK_TTL_MS = 30_000;
11
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
12
+ /**
13
+ * Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
14
+ * client labels some module error frames transient/retry regardless, so the
15
+ * producer's code takes precedence over the client's action for these.
16
+ */
17
+ export const TERMINAL_ENROLLMENT_CODES = new Set([
18
+ 'invalid_params',
19
+ 'pending_exists',
20
+ 'not_found',
21
+ 'already_consumed',
22
+ 'superseded',
23
+ 'stale_generation',
24
+ ]);
25
+ /** Codes that are always worth another ceremony tick, whatever the action says. */
26
+ export const RETRYABLE_ENROLLMENT_CODES = new Set([
27
+ 'pending_queue_full',
28
+ ]);
29
+ /**
30
+ * Classify an enrollment transport refusal as `(code, disposition)`. The
31
+ * vault's own code wins: a terminal code is terminal even when the client
32
+ * says retry, queue saturation is retryable even when it says gone, and any
33
+ * other code follows the client's action. Errors that are not producer
34
+ * refusals return undefined and are the caller's to rethrow.
35
+ */
36
+ export function classifyEnrollmentError(error) {
37
+ if (!(error instanceof ClaustrumCredentialError))
38
+ return undefined;
39
+ if (TERMINAL_ENROLLMENT_CODES.has(error.code))
40
+ return { code: error.code, disposition: 'terminal' };
41
+ if (RETRYABLE_ENROLLMENT_CODES.has(error.code))
42
+ return { code: error.code, disposition: 'retry' };
43
+ return {
44
+ code: error.code,
45
+ disposition: error.action === 'retry' ? 'retry' : 'terminal',
46
+ };
47
+ }
48
+ /**
49
+ * The name a plugin proposes for one host, for example `openai-auth-opencode`.
50
+ * Each host enrolls separately so the operator can revoke one without the other.
51
+ */
52
+ export function enrollmentName(plugin, host) {
53
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(plugin) ||
54
+ !/^[a-z0-9][a-z0-9-]*$/.test(host))
55
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment names use lowercase letters, digits and dashes');
56
+ return `${plugin}-${host}`;
57
+ }
58
+ /**
59
+ * Connect the enrollment ceremony. Setup calls this; the request path never
60
+ * does. `connectionFile` is required because this library reads no
61
+ * environment and knows no host paths.
62
+ */
63
+ export function connectClaustrumEnrollmentClient(options) {
64
+ return ClaustrumWireClient.connect(options);
65
+ }
66
+ /** The ceremony state file sits next to the token: `x.json` pairs with `x-state.json`. */
67
+ export function getClaustrumEnrollmentPaths(tokenPath) {
68
+ const extension = extname(tokenPath) || '.json';
69
+ const stem = basename(tokenPath, extname(tokenPath));
70
+ return {
71
+ statePath: join(dirname(tokenPath), `${stem}-state${extension}`),
72
+ tokenPath,
73
+ };
74
+ }
75
+ /**
76
+ * One host's token and state paths. Every host gets its own pair under the
77
+ * plugin's state directory, so an OpenCode enrollment and a Pi enrollment can
78
+ * be approved and revoked independently. A plugin-resolved override replaces
79
+ * the default token path; a relative override resolves against `cwd`.
80
+ */
81
+ export function hostEnrollmentPaths(input) {
82
+ const override = input.override?.trim();
83
+ if (override && !isAbsolute(override) && !input.cwd)
84
+ throw new ClaustrumConsumerError('invalid-state', 'A relative Claustrum enrollment override needs a base directory');
85
+ const tokenPath = override
86
+ ? isAbsolute(override)
87
+ ? override
88
+ : resolve(input.cwd, override)
89
+ : join(input.stateDir, `${input.host}-enrollment.json`);
90
+ return getClaustrumEnrollmentPaths(tokenPath);
91
+ }
92
+ function isRecord(value) {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
94
+ }
95
+ function validTimestamp(value) {
96
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
97
+ }
98
+ function validGeneration(value) {
99
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
100
+ }
101
+ function invalidState() {
102
+ return new ClaustrumConsumerError('invalid-state', 'invalid Claustrum enrollment state');
103
+ }
104
+ function decodeEnrollmentState(value) {
105
+ if (!isRecord(value) ||
106
+ value.version !== ENROLLMENT_SCHEMA ||
107
+ typeof value.proposedName !== 'string' ||
108
+ value.proposedName.length === 0) {
109
+ throw invalidState();
110
+ }
111
+ if (value.phase === 'pending') {
112
+ if (!TOKEN_RE.test(String(value.requestSecret ?? '')) ||
113
+ (value.requestId !== undefined &&
114
+ (typeof value.requestId !== 'string' ||
115
+ value.requestId.length === 0)) ||
116
+ !validTimestamp(value.createdAt) ||
117
+ !validTimestamp(value.updatedAt)) {
118
+ throw invalidState();
119
+ }
120
+ return {
121
+ version: 1,
122
+ phase: 'pending',
123
+ proposedName: value.proposedName,
124
+ requestSecret: value.requestSecret,
125
+ ...(value.requestId !== undefined && {
126
+ requestId: value.requestId,
127
+ }),
128
+ createdAt: value.createdAt,
129
+ updatedAt: value.updatedAt,
130
+ };
131
+ }
132
+ if (value.phase === 'approved') {
133
+ if ((value.approvedName !== undefined &&
134
+ (typeof value.approvedName !== 'string' ||
135
+ value.approvedName.length === 0)) ||
136
+ !validGeneration(value.tokenGeneration) ||
137
+ !validTimestamp(value.updatedAt)) {
138
+ throw invalidState();
139
+ }
140
+ return {
141
+ version: 1,
142
+ phase: 'approved',
143
+ proposedName: value.proposedName,
144
+ ...(value.approvedName !== undefined && {
145
+ approvedName: value.approvedName,
146
+ }),
147
+ tokenGeneration: value.tokenGeneration,
148
+ updatedAt: value.updatedAt,
149
+ };
150
+ }
151
+ if (value.phase === 'denied' || value.phase === 'blocked') {
152
+ if ((value.errorCode !== undefined && typeof value.errorCode !== 'string') ||
153
+ !validTimestamp(value.updatedAt)) {
154
+ throw invalidState();
155
+ }
156
+ return {
157
+ version: 1,
158
+ phase: value.phase,
159
+ proposedName: value.proposedName,
160
+ ...(value.errorCode !== undefined && {
161
+ errorCode: value.errorCode,
162
+ }),
163
+ updatedAt: value.updatedAt,
164
+ };
165
+ }
166
+ throw invalidState();
167
+ }
168
+ function decodeTokenFile(value) {
169
+ if (!isRecord(value) ||
170
+ !TOKEN_RE.test(String(value.token ?? '')) ||
171
+ !validGeneration(value.token_generation)) {
172
+ throw new ClaustrumConsumerError('invalid-token', 'invalid Claustrum enrollment token file');
173
+ }
174
+ return {
175
+ token: value.token,
176
+ token_generation: value.token_generation,
177
+ };
178
+ }
179
+ function validateReadableSecretFile(metadata) {
180
+ if (!metadata.isFile()) {
181
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be a regular file');
182
+ }
183
+ if ((metadata.mode & 0o077) !== 0) {
184
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owner-only');
185
+ }
186
+ const expectedUid = process.geteuid?.();
187
+ if (expectedUid !== undefined && metadata.uid !== expectedUid) {
188
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owned by the current user');
189
+ }
190
+ }
191
+ /** Parse secret-bearing JSON without letting a parser message echo its bytes. */
192
+ function parseSecretJson(text) {
193
+ try {
194
+ return JSON.parse(text);
195
+ }
196
+ catch {
197
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment file is not valid JSON');
198
+ }
199
+ }
200
+ async function readBoundedJson(path) {
201
+ let descriptor;
202
+ try {
203
+ // O_NOFOLLOW: a symlink planted at the path must not redirect a secret read.
204
+ descriptor = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
205
+ }
206
+ catch (error) {
207
+ if (error.code === 'ENOENT')
208
+ return undefined;
209
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file could not be opened safely');
210
+ }
211
+ try {
212
+ validateReadableSecretFile(await descriptor.stat());
213
+ const source = Buffer.alloc(ENROLLMENT_FILE_MAX_BYTES + 1);
214
+ const { bytesRead } = await descriptor.read(source, 0, source.byteLength, 0);
215
+ if (bytesRead > ENROLLMENT_FILE_MAX_BYTES) {
216
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file is too large');
217
+ }
218
+ return parseSecretJson(source.subarray(0, bytesRead).toString('utf8'));
219
+ }
220
+ finally {
221
+ await descriptor.close();
222
+ }
223
+ }
224
+ function accountRecords(text, fields) {
225
+ const records = [];
226
+ for (const line of text.split('\n')) {
227
+ if (line.trim() === '' || line.startsWith('#'))
228
+ continue;
229
+ const parts = line.split(':');
230
+ if (parts.length !== fields ||
231
+ !/^[^\s:,]+$/.test(parts[0] ?? '') ||
232
+ !/^\d+$/.test(parts[2] ?? '') ||
233
+ !Number.isSafeInteger(Number(parts[2])) ||
234
+ (fields === 7 &&
235
+ (!/^\d+$/.test(parts[3] ?? '') ||
236
+ !Number.isSafeInteger(Number(parts[3])))) ||
237
+ (fields === 4 &&
238
+ parts[3] !== '' &&
239
+ !/^[^\s:,]+(?:,[^\s:,]+)*$/.test(parts[3] ?? '')))
240
+ return undefined;
241
+ records.push(parts);
242
+ }
243
+ return records;
244
+ }
245
+ async function isPrivateGroup(uid, gid, options) {
246
+ try {
247
+ const [passwdText, groupText] = await Promise.all([
248
+ readFile(options.passwdPath ?? '/etc/passwd', 'utf8'),
249
+ readFile(options.groupPath ?? '/etc/group', 'utf8'),
250
+ ]);
251
+ const users = accountRecords(passwdText, 7);
252
+ const groups = accountRecords(groupText, 4);
253
+ if (!users || !groups)
254
+ return false;
255
+ const owners = users.filter((user) => Number(user[2]) === uid);
256
+ const matchingGroups = groups.filter((group) => Number(group[2]) === gid);
257
+ if (owners.length !== 1 || matchingGroups.length !== 1)
258
+ return false;
259
+ const name = owners[0]?.[0];
260
+ const members = matchingGroups[0]?.[3] ?? '';
261
+ return ((members === '' ||
262
+ members.split(',').every((member) => member === name)) &&
263
+ !users.some((user) => Number(user[2]) !== uid && Number(user[3]) === gid));
264
+ }
265
+ catch {
266
+ // Missing local records (including NSS-only accounts) cannot prove exclusivity.
267
+ return false;
268
+ }
269
+ }
270
+ /**
271
+ * Protect setup writes from directory replacement. Group write access is safe
272
+ * only when local account records prove that the effective user's group is private.
273
+ */
274
+ export async function refuseWritableAncestor(parent, options = {}) {
275
+ const uid = process.geteuid?.();
276
+ if (uid === undefined)
277
+ return;
278
+ const gid = process.getegid?.();
279
+ let component;
280
+ try {
281
+ component = await realpath(parent);
282
+ }
283
+ catch {
284
+ return;
285
+ }
286
+ // Load lazily, once per traversal: account changes take effect on the next write.
287
+ let privateGroup;
288
+ for (;;) {
289
+ const metadata = await stat(component).catch(() => undefined);
290
+ if (metadata &&
291
+ (metadata.mode & 0o022) !== 0 &&
292
+ (metadata.mode & 0o1000) === 0) {
293
+ if (privateGroup === undefined) {
294
+ privateGroup =
295
+ gid !== undefined && (await isPrivateGroup(uid, gid, options));
296
+ }
297
+ if ((metadata.mode & 0o002) !== 0 ||
298
+ metadata.uid !== uid ||
299
+ metadata.gid !== gid ||
300
+ !privateGroup) {
301
+ throw new ClaustrumConsumerError('unsafe-file', `Claustrum enrollment path has a writable ancestor: ${component}. Another user could replace it. Run: chmod g-w,o-w ${component}`);
302
+ }
303
+ }
304
+ const next = dirname(component);
305
+ if (next === component)
306
+ return;
307
+ component = next;
308
+ }
309
+ }
310
+ /**
311
+ * Create the enrollment directory owner-only (0700), and tighten it to 0700
312
+ * when it already exists with group or other bits, so the token and the
313
+ * request secret never sit in a directory another account can list.
314
+ */
315
+ async function ensurePrivateDirectory(directory) {
316
+ await mkdir(directory, { recursive: true, mode: 0o700 });
317
+ const metadata = await stat(directory);
318
+ const uid = process.geteuid?.();
319
+ if (uid !== undefined && metadata.uid !== uid) {
320
+ throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment directory must be owned by the current user');
321
+ }
322
+ if ((metadata.mode & 0o077) !== 0)
323
+ await chmod(directory, 0o700);
324
+ }
325
+ async function writeStateAtomic(path, state, ancestorOptions = {}) {
326
+ const parent = dirname(path);
327
+ await ensurePrivateDirectory(parent);
328
+ await refuseWritableAncestor(parent, ancestorOptions);
329
+ const bytes = `${JSON.stringify(state)}\n`;
330
+ if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
331
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment state is too large');
332
+ }
333
+ const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
334
+ let descriptor;
335
+ let created = false;
336
+ try {
337
+ descriptor = await open(temporary, 'wx', 0o600);
338
+ created = true;
339
+ await descriptor.writeFile(bytes, 'utf8');
340
+ await descriptor.chmod(0o600);
341
+ await descriptor.sync();
342
+ await descriptor.close();
343
+ descriptor = undefined;
344
+ await rename(temporary, path);
345
+ created = false;
346
+ }
347
+ finally {
348
+ await descriptor?.close().catch(() => { });
349
+ if (created)
350
+ await unlink(temporary).catch(() => { });
351
+ }
352
+ }
353
+ export async function readClaustrumEnrollmentStatus(paths, proposedName) {
354
+ const tokenValue = await readBoundedJson(paths.tokenPath);
355
+ const token = tokenValue === undefined ? undefined : decodeTokenFile(tokenValue);
356
+ const stateValue = await readBoundedJson(paths.statePath);
357
+ const state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
358
+ if (token) {
359
+ return {
360
+ state: 'approved',
361
+ proposedName: state?.proposedName ?? proposedName,
362
+ ...(state?.phase === 'approved' &&
363
+ state.approvedName !== undefined && {
364
+ approvedName: state.approvedName,
365
+ }),
366
+ tokenGeneration: token.token_generation,
367
+ };
368
+ }
369
+ if (!state)
370
+ return { state: 'idle' };
371
+ if (state.phase === 'approved') {
372
+ return {
373
+ state: 'blocked',
374
+ proposedName: state.proposedName,
375
+ code: 'missing_token',
376
+ };
377
+ }
378
+ return statusFromState(state);
379
+ }
380
+ /** Resolve the host's account mode from its enrollment files without writing. */
381
+ export async function enrollmentAuthority(paths, proposedName) {
382
+ let status;
383
+ try {
384
+ status = await readClaustrumEnrollmentStatus(paths, proposedName);
385
+ }
386
+ catch (error) {
387
+ if (!(error instanceof ClaustrumConsumerError) ||
388
+ (error.kind !== 'invalid-state' && error.kind !== 'invalid-token')) {
389
+ return 'unreadable';
390
+ }
391
+ // An approved ceremony remains vault mode even when its token is corrupt.
392
+ // Re-read the state so a malformed or unreadable state cannot authorize it.
393
+ try {
394
+ const stateValue = await readBoundedJson(paths.statePath);
395
+ if (stateValue === undefined)
396
+ return 'unreadable';
397
+ const state = decodeEnrollmentState(stateValue);
398
+ return state.phase === 'approved' && state.proposedName === proposedName
399
+ ? 'vault'
400
+ : 'unreadable';
401
+ }
402
+ catch {
403
+ return 'unreadable';
404
+ }
405
+ }
406
+ if ('proposedName' in status && status.proposedName !== proposedName)
407
+ return 'unreadable';
408
+ if (status.state === 'approved' ||
409
+ (status.state === 'blocked' && status.code === 'missing_token')) {
410
+ return 'vault';
411
+ }
412
+ return 'local';
413
+ }
414
+ /**
415
+ * Read fresh bearer material for one scoped operation; never publish it.
416
+ * Re-reading per operation is what lets an operator reissue a token on disk.
417
+ */
418
+ export async function readClaustrumEnrollmentToken(tokenPath) {
419
+ // Reads rely on O_NOFOLLOW and regular, owner-only, effective-uid-owned files:
420
+ // another user cannot plant a file owned by us. Ancestor checks protect writes
421
+ // from placing our secret in a swapped directory, not reads; keep their
422
+ // realpath/stat and account-file I/O off the authorization path.
423
+ const value = await readBoundedJson(tokenPath);
424
+ if (value === undefined)
425
+ throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment is not configured');
426
+ return decodeTokenFile(value);
427
+ }
428
+ function statusFromState(state) {
429
+ if (state.phase === 'pending') {
430
+ return {
431
+ state: 'pending',
432
+ proposedName: state.proposedName,
433
+ ...(state.requestId !== undefined && { requestId: state.requestId }),
434
+ };
435
+ }
436
+ if (state.phase === 'approved') {
437
+ return {
438
+ state: 'approved',
439
+ proposedName: state.proposedName,
440
+ ...(state.approvedName !== undefined && {
441
+ approvedName: state.approvedName,
442
+ }),
443
+ tokenGeneration: state.tokenGeneration,
444
+ };
445
+ }
446
+ if (state.phase === 'denied')
447
+ return { state: 'denied', proposedName: state.proposedName };
448
+ return {
449
+ state: 'blocked',
450
+ proposedName: state.proposedName,
451
+ code: state.errorCode ?? 'unknown',
452
+ };
453
+ }
454
+ function wrongConsumer() {
455
+ return new ClaustrumConsumerError('wrong-consumer', 'Claustrum enrollment state belongs to a different consumer');
456
+ }
457
+ /**
458
+ * The enrollment ceremony for one host. Run it from setup only: it proposes,
459
+ * polls and persists, and every step can wait on an operator. The request
460
+ * path reads the resulting token and never calls into this class.
461
+ */
462
+ export class ClaustrumEnrollmentManager {
463
+ #client;
464
+ #paths;
465
+ #proposedName;
466
+ #now;
467
+ #mintSecret;
468
+ #writeTokenFile;
469
+ #ancestorOptions;
470
+ constructor(options) {
471
+ this.#ancestorOptions = options.ancestorOptions ?? {};
472
+ this.#client = options.client;
473
+ this.#paths = options.paths;
474
+ this.#proposedName = options.proposedName;
475
+ this.#now = options.now ?? Date.now;
476
+ this.#mintSecret =
477
+ options.mintSecret ?? (() => randomBytes(32).toString('hex'));
478
+ this.#writeTokenFile = options.writeTokenFile ?? writeEnrollmentTokenFile;
479
+ }
480
+ async status() {
481
+ return readClaustrumEnrollmentStatus(this.#paths, this.#proposedName);
482
+ }
483
+ async resetTerminal() {
484
+ return resetClaustrumEnrollmentState(this.#paths, this.#proposedName);
485
+ }
486
+ async #block(state, code) {
487
+ // Writing the terminal phase drops the request secret from disk.
488
+ const blocked = {
489
+ version: 1,
490
+ phase: 'blocked',
491
+ proposedName: state.proposedName,
492
+ errorCode: code,
493
+ updatedAt: this.#now(),
494
+ };
495
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
496
+ return statusFromState(blocked);
497
+ }
498
+ async reconcile() {
499
+ await ensurePrivateDirectory(dirname(this.#paths.statePath));
500
+ const lock = await acquireRefreshFileLock({
501
+ name: 'ceremony',
502
+ path: this.#paths.statePath,
503
+ ttlMs: ENROLLMENT_LOCK_TTL_MS,
504
+ renew: true,
505
+ });
506
+ if (!lock)
507
+ return { state: 'busy' };
508
+ try {
509
+ const existingToken = await readBoundedJson(this.#paths.tokenPath);
510
+ const token = existingToken === undefined ? undefined : decodeTokenFile(existingToken);
511
+ const stateValue = await readBoundedJson(this.#paths.statePath);
512
+ let state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
513
+ if (state && state.proposedName !== this.#proposedName)
514
+ throw wrongConsumer();
515
+ if (token) {
516
+ if (!state || state.phase === 'pending') {
517
+ const approved = {
518
+ version: 1,
519
+ phase: 'approved',
520
+ proposedName: state?.proposedName ?? this.#proposedName,
521
+ tokenGeneration: token.token_generation,
522
+ updatedAt: this.#now(),
523
+ };
524
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
525
+ state = approved;
526
+ }
527
+ return {
528
+ state: 'approved',
529
+ proposedName: state.proposedName,
530
+ ...(state.phase === 'approved' &&
531
+ state.approvedName !== undefined && {
532
+ approvedName: state.approvedName,
533
+ }),
534
+ tokenGeneration: token.token_generation,
535
+ };
536
+ }
537
+ if (state?.phase === 'approved') {
538
+ const blocked = {
539
+ version: 1,
540
+ phase: 'blocked',
541
+ proposedName: state.proposedName,
542
+ errorCode: 'missing_token',
543
+ updatedAt: this.#now(),
544
+ };
545
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
546
+ return statusFromState(blocked);
547
+ }
548
+ if (state && state.phase !== 'pending')
549
+ return statusFromState(state);
550
+ if (!state) {
551
+ const now = this.#now();
552
+ state = {
553
+ version: 1,
554
+ phase: 'pending',
555
+ proposedName: this.#proposedName,
556
+ requestSecret: this.#mintSecret(),
557
+ createdAt: now,
558
+ updatedAt: now,
559
+ };
560
+ if (!TOKEN_RE.test(state.requestSecret))
561
+ throw new ClaustrumConsumerError('invalid-state', 'invalid minted Claustrum enrollment secret');
562
+ // The secret is on disk before the proposal leaves the process: the
563
+ // vault answers a repeated proposal with the same secret with the same
564
+ // request id, so a crash between propose and saving the id costs nothing.
565
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
566
+ }
567
+ if (!state.requestId) {
568
+ try {
569
+ const requestSecretHash = createHash('sha256')
570
+ .update(Buffer.from(state.requestSecret, 'hex'))
571
+ .digest('hex');
572
+ const proposed = await this.#client.enrollPropose({
573
+ name: state.proposedName,
574
+ requestSecretHash,
575
+ });
576
+ state = {
577
+ ...state,
578
+ requestId: proposed.requestId,
579
+ updatedAt: this.#now(),
580
+ };
581
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
582
+ }
583
+ catch (error) {
584
+ const refusal = classifyEnrollmentError(error);
585
+ if (!refusal)
586
+ throw error;
587
+ if (refusal.disposition === 'retry') {
588
+ return {
589
+ state: 'pending',
590
+ proposedName: state.proposedName,
591
+ retryCode: refusal.code,
592
+ };
593
+ }
594
+ return this.#block(state, refusal.code);
595
+ }
596
+ }
597
+ const requestId = state.requestId;
598
+ if (!requestId)
599
+ return statusFromState(state);
600
+ try {
601
+ const outcome = await this.#client.enrollPoll({
602
+ requestId,
603
+ requestSecret: state.requestSecret,
604
+ });
605
+ if (outcome.status === 'pending')
606
+ return statusFromState(state);
607
+ if (outcome.status === 'denied') {
608
+ const denied = {
609
+ version: 1,
610
+ phase: 'denied',
611
+ proposedName: state.proposedName,
612
+ updatedAt: this.#now(),
613
+ };
614
+ await writeStateAtomic(this.#paths.statePath, denied, this.#ancestorOptions);
615
+ return statusFromState(denied);
616
+ }
617
+ // The vault returns the token exactly once: it reaches disk before the
618
+ // pending metadata (and its secret) is replaced.
619
+ await ensurePrivateDirectory(dirname(this.#paths.tokenPath));
620
+ await refuseWritableAncestor(dirname(this.#paths.tokenPath), this.#ancestorOptions);
621
+ await this.#writeTokenFile(this.#paths.tokenPath, {
622
+ token: outcome.token,
623
+ token_generation: outcome.tokenGeneration,
624
+ });
625
+ const approved = {
626
+ version: 1,
627
+ phase: 'approved',
628
+ proposedName: state.proposedName,
629
+ approvedName: outcome.name,
630
+ tokenGeneration: outcome.tokenGeneration,
631
+ updatedAt: this.#now(),
632
+ };
633
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
634
+ return statusFromState(approved);
635
+ }
636
+ catch (error) {
637
+ const refusal = classifyEnrollmentError(error);
638
+ if (!refusal)
639
+ throw error;
640
+ if (refusal.disposition === 'retry') {
641
+ return {
642
+ state: 'pending',
643
+ proposedName: state.proposedName,
644
+ requestId,
645
+ retryCode: refusal.code,
646
+ };
647
+ }
648
+ return this.#block(state, refusal.code);
649
+ }
650
+ }
651
+ finally {
652
+ await lock.release();
653
+ }
654
+ }
655
+ }
656
+ /** Reset local terminal metadata without connecting to the credential daemon. */
657
+ export async function resetClaustrumEnrollmentState(paths, proposedName) {
658
+ await ensurePrivateDirectory(dirname(paths.statePath));
659
+ const lock = await acquireRefreshFileLock({
660
+ name: 'ceremony',
661
+ path: paths.statePath,
662
+ ttlMs: ENROLLMENT_LOCK_TTL_MS,
663
+ renew: true,
664
+ });
665
+ if (!lock)
666
+ return 'busy';
667
+ try {
668
+ if ((await readBoundedJson(paths.tokenPath)) !== undefined)
669
+ return 'refused-approved';
670
+ const value = await readBoundedJson(paths.statePath);
671
+ if (value === undefined)
672
+ return 'idle';
673
+ const state = decodeEnrollmentState(value);
674
+ if (state.proposedName !== proposedName)
675
+ throw wrongConsumer();
676
+ if (state.phase === 'pending')
677
+ return 'refused-pending';
678
+ await lock.assertOwned();
679
+ await unlink(paths.statePath);
680
+ return 'reset';
681
+ }
682
+ finally {
683
+ await lock.release();
684
+ }
685
+ }
686
+ /** Remove one file, reporting whether it existed; a missing file is not an error. */
687
+ async function removeIfPresent(path) {
688
+ try {
689
+ await unlink(path);
690
+ return true;
691
+ }
692
+ catch (error) {
693
+ if (error.code === 'ENOENT')
694
+ return false;
695
+ throw error;
696
+ }
697
+ }
698
+ /**
699
+ * Disconnect this host from the vault on disk: remove its enrollment token,
700
+ * then its ceremony state, so `enrollmentAuthority` reports `local` again.
701
+ * This is the disconnect every plugin uses. Removing only the token is not
702
+ * enough: an approved state without a token is still vault mode and refuses,
703
+ * by design. It never contacts the credential daemon, so the enrollment stays
704
+ * approved in the vault until the operator revokes it there; a later Connect
705
+ * starts a new ceremony. A pending request is abandoned the same way.
706
+ *
707
+ * Takes the ceremony lock `reconcile()` and `resetClaustrumEnrollmentState`
708
+ * take, and returns `busy` without deleting anything while another process
709
+ * holds it. State naming another consumer refuses with `wrong-consumer` and
710
+ * both files stay. The token goes first: a crash between the two deletions
711
+ * leaves an approved state without a token, which is still vault mode (it
712
+ * refuses rather than falls back to local accounts), and calling this again
713
+ * finishes the job. Returns `idle` when neither file exists. Nothing else in
714
+ * the directory is touched. The token is removed without being parsed, so a
715
+ * corrupt token does not stand in the way. A malformed state file (bad JSON
716
+ * or wrong shape) is removed too, since it is what makes `enrollmentAuthority`
717
+ * report `unreadable`; a state file that fails the safety checks (symlink,
718
+ * wrong owner, not owner-only) refuses with `unsafe-file` and both files stay.
719
+ */
720
+ export async function disconnectClaustrumEnrollment(paths, proposedName) {
721
+ await ensurePrivateDirectory(dirname(paths.statePath));
722
+ const lock = await acquireRefreshFileLock({
723
+ // The same lock as the ceremony, so a token written by a concurrent
724
+ // reconcile() can never land after this removes the files.
725
+ name: 'ceremony',
726
+ path: paths.statePath,
727
+ ttlMs: ENROLLMENT_LOCK_TTL_MS,
728
+ renew: true,
729
+ });
730
+ if (!lock)
731
+ return 'busy';
732
+ try {
733
+ let state;
734
+ try {
735
+ const value = await readBoundedJson(paths.statePath);
736
+ state = value === undefined ? undefined : decodeEnrollmentState(value);
737
+ }
738
+ catch (error) {
739
+ // A malformed state at this host's own path is this host's: the path is
740
+ // per host, and disconnect is the remedy for the `unreadable` authority
741
+ // it causes, so it is removed like any other state. An unsafe file
742
+ // (symlink, wrong owner, group or other bits) is not provably ours and
743
+ // still refuses; those checks run before the file is parsed.
744
+ if (!(error instanceof ClaustrumConsumerError) ||
745
+ error.kind !== 'invalid-state')
746
+ throw error;
747
+ }
748
+ if (state && state.proposedName !== proposedName)
749
+ throw wrongConsumer();
750
+ await lock.assertOwned();
751
+ const removedToken = await removeIfPresent(paths.tokenPath);
752
+ await lock.assertOwned();
753
+ const removedState = await removeIfPresent(paths.statePath);
754
+ return removedToken || removedState ? 'disconnected' : 'idle';
755
+ }
756
+ finally {
757
+ await lock.release();
758
+ }
759
+ }