@velum-labs/routekit-daemon 0.18.6 → 1.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 (155) hide show
  1. package/README.md +3 -2
  2. package/dist/account-enroll-service.d.ts +7 -0
  3. package/dist/account-enroll-service.js +290 -0
  4. package/dist/account-mutation-service.d.ts +7 -0
  5. package/dist/account-mutation-service.js +322 -0
  6. package/dist/account-query-service.d.ts +7 -0
  7. package/dist/account-query-service.js +96 -0
  8. package/dist/account-recovery-context.d.ts +6 -0
  9. package/dist/account-recovery-context.js +3 -0
  10. package/dist/account-services.d.ts +20 -0
  11. package/dist/account-services.js +18 -0
  12. package/dist/account-transaction.js +4 -9
  13. package/dist/application-services.d.ts +10 -0
  14. package/dist/application-services.js +22 -0
  15. package/dist/call-attribution-store.d.ts +1 -0
  16. package/dist/call-attribution-store.js +163 -12
  17. package/dist/cliproxy-sidecar.d.ts +23 -4
  18. package/dist/cliproxy-sidecar.js +63 -36
  19. package/dist/control-dispatch.d.ts +18 -0
  20. package/dist/control-dispatch.js +67 -0
  21. package/dist/daemon-bootstrap-preflight.d.ts +49 -0
  22. package/dist/daemon-bootstrap-preflight.js +55 -0
  23. package/dist/daemon-bootstrap.d.ts +34 -0
  24. package/dist/daemon-bootstrap.js +45 -0
  25. package/dist/daemon-control-handlers.d.ts +7 -0
  26. package/dist/daemon-control-handlers.js +36 -0
  27. package/dist/daemon-env-context.d.ts +20 -0
  28. package/dist/daemon-env-context.js +3 -0
  29. package/dist/daemon-generations.d.ts +47 -0
  30. package/dist/daemon-generations.js +176 -0
  31. package/dist/daemon-host-context.d.ts +11 -0
  32. package/dist/daemon-host-context.js +3 -0
  33. package/dist/daemon-lifecycle-service.d.ts +7 -0
  34. package/dist/daemon-lifecycle-service.js +107 -0
  35. package/dist/daemon-lifecycle.d.ts +54 -0
  36. package/dist/daemon-lifecycle.js +177 -0
  37. package/dist/daemon-maintenance.d.ts +114 -0
  38. package/dist/daemon-maintenance.js +139 -0
  39. package/dist/daemon-options.d.ts +26 -0
  40. package/dist/daemon-policy-context.d.ts +9 -0
  41. package/dist/daemon-policy-context.js +3 -0
  42. package/dist/daemon-runtime-state.d.ts +36 -0
  43. package/dist/daemon-runtime-state.js +103 -0
  44. package/dist/daemon-state-context.d.ts +10 -0
  45. package/dist/daemon-state-context.js +43 -0
  46. package/dist/daemon-state.d.ts +29 -0
  47. package/dist/daemon-state.js +93 -0
  48. package/dist/data-plane-context.d.ts +9 -0
  49. package/dist/data-plane-context.js +3 -0
  50. package/dist/doctor-service.d.ts +7 -0
  51. package/dist/doctor-service.js +103 -0
  52. package/dist/effect/daemon-live.d.ts +22 -0
  53. package/dist/effect/daemon-live.js +409 -0
  54. package/dist/effect-api.d.ts +30 -0
  55. package/dist/effect-api.js +18 -0
  56. package/dist/eval-routing-policy.d.ts +8 -0
  57. package/dist/eval-routing-policy.js +24 -0
  58. package/dist/eval-routing-service.d.ts +7 -0
  59. package/dist/eval-routing-service.js +89 -0
  60. package/dist/gateway-generation.d.ts +58 -0
  61. package/dist/gateway-generation.js +261 -0
  62. package/dist/host-generation-transaction.d.ts +17 -0
  63. package/dist/host-generation-transaction.js +26 -0
  64. package/dist/host-idempotency.d.ts +22 -0
  65. package/dist/host-idempotency.js +66 -0
  66. package/dist/host-protocol.d.ts +34 -2
  67. package/dist/host-protocol.js +1 -1
  68. package/dist/host-worker-session.d.ts +61 -0
  69. package/dist/host-worker-session.js +197 -0
  70. package/dist/host.d.ts +6 -4
  71. package/dist/host.js +451 -497
  72. package/dist/index.d.ts +13 -77
  73. package/dist/index.js +9 -1927
  74. package/dist/ipc-request-channel.d.ts +27 -0
  75. package/dist/ipc-request-channel.js +95 -0
  76. package/dist/launcher-service.d.ts +12 -0
  77. package/dist/launcher-service.js +94 -0
  78. package/dist/leaderboard-context.d.ts +12 -0
  79. package/dist/leaderboard-context.js +3 -0
  80. package/dist/leaderboard.d.ts +1 -1
  81. package/dist/leaderboard.js +5 -12
  82. package/dist/provider-query-service.d.ts +7 -0
  83. package/dist/provider-query-service.js +159 -0
  84. package/dist/router-generation-service.d.ts +7 -0
  85. package/dist/router-generation-service.js +101 -0
  86. package/dist/services/active-gateway/service.d.ts +23 -0
  87. package/dist/services/active-gateway/service.js +11 -0
  88. package/dist/services/call-attributions/service.d.ts +10 -0
  89. package/dist/services/call-attributions/service.js +11 -0
  90. package/dist/services/daemon-runtime/service.d.ts +19 -0
  91. package/dist/services/daemon-runtime/service.js +4 -0
  92. package/dist/services/eval-session/service.d.ts +36 -0
  93. package/dist/services/eval-session/service.js +150 -0
  94. package/dist/services/generations/service.d.ts +21 -0
  95. package/dist/services/generations/service.js +6 -0
  96. package/dist/services/telemetry/service.d.ts +27 -0
  97. package/dist/services/telemetry/service.js +85 -0
  98. package/dist/services/tokens/service.d.ts +36 -0
  99. package/dist/services/tokens/service.js +72 -0
  100. package/dist/services/worker-ipc/service.d.ts +20 -0
  101. package/dist/services/worker-ipc/service.js +34 -0
  102. package/dist/sidecar-context.d.ts +6 -0
  103. package/dist/sidecar-context.js +3 -0
  104. package/dist/state-api.d.ts +2 -0
  105. package/dist/state-api.js +1 -0
  106. package/dist/telemetry-application-service.d.ts +7 -0
  107. package/dist/telemetry-application-service.js +61 -0
  108. package/dist/test/account-transaction.test.js +1 -1
  109. package/dist/test/application-services.test.d.ts +1 -0
  110. package/dist/test/application-services.test.js +109 -0
  111. package/dist/test/call-attribution-store.test.js +105 -0
  112. package/dist/test/control-dispatch.test.d.ts +1 -0
  113. package/dist/test/control-dispatch.test.js +117 -0
  114. package/dist/test/daemon-accounts.test.d.ts +1 -0
  115. package/dist/test/daemon-accounts.test.js +211 -0
  116. package/dist/test/daemon-authority-recovery.test.d.ts +1 -0
  117. package/dist/test/daemon-authority-recovery.test.js +117 -0
  118. package/dist/test/daemon-data-plane.test.d.ts +1 -0
  119. package/dist/test/daemon-data-plane.test.js +697 -0
  120. package/dist/test/daemon-fixtures.d.ts +14 -0
  121. package/dist/test/daemon-fixtures.js +425 -0
  122. package/dist/test/daemon-generation-transaction.test.d.ts +1 -0
  123. package/dist/test/daemon-generation-transaction.test.js +91 -0
  124. package/dist/test/daemon-lifecycle.test.d.ts +1 -0
  125. package/dist/test/daemon-lifecycle.test.js +182 -0
  126. package/dist/test/daemon-runtime-state.test.d.ts +1 -0
  127. package/dist/test/daemon-runtime-state.test.js +56 -0
  128. package/dist/test/daemon-sidecar.test.d.ts +1 -0
  129. package/dist/test/daemon-sidecar.test.js +260 -0
  130. package/dist/test/effect-daemon.test.d.ts +1 -0
  131. package/dist/test/effect-daemon.test.js +53 -0
  132. package/dist/test/effect-host-generation.test.d.ts +1 -0
  133. package/dist/test/effect-host-generation.test.js +58 -0
  134. package/dist/test/eval-routing-application-service.test.d.ts +1 -0
  135. package/dist/test/eval-routing-application-service.test.js +122 -0
  136. package/dist/test/eval-routing-policy.test.d.ts +1 -0
  137. package/dist/test/eval-routing-policy.test.js +55 -0
  138. package/dist/test/eval-session-service.test.d.ts +1 -0
  139. package/dist/test/eval-session-service.test.js +124 -0
  140. package/dist/test/host-idempotency.test.d.ts +1 -0
  141. package/dist/test/host-idempotency.test.js +54 -0
  142. package/dist/test/host-worker-session.test.d.ts +1 -0
  143. package/dist/test/host-worker-session.test.js +56 -0
  144. package/dist/test/ipc-request-channel.test.d.ts +1 -0
  145. package/dist/test/ipc-request-channel.test.js +72 -0
  146. package/dist/test/leaderboard.test.js +6 -5
  147. package/dist/test/runtime-composition-types.test.d.ts +1 -0
  148. package/dist/test/runtime-composition-types.test.js +15 -0
  149. package/dist/token-application-service.d.ts +7 -0
  150. package/dist/token-application-service.js +59 -0
  151. package/dist/worker.d.ts +1 -1
  152. package/dist/worker.js +151 -121
  153. package/package.json +19 -9
  154. package/dist/test/daemon.test.js +0 -1500
  155. /package/dist/{test/daemon.test.d.ts → daemon-options.js} +0 -0
package/README.md CHANGED
@@ -14,5 +14,6 @@ worker serving traffic.
14
14
  tests. Production `routekit daemon run` uses `startRouteKitDaemonHost` in the
15
15
  primary and `runRouteKitDaemonWorker` in cluster workers.
16
16
 
17
- Applications normally use it through `@velum-labs/routekit`; embedders should keep
18
- using `@velum-labs/routekit-router` instead of claiming the singleton service record.
17
+ Applications normally use it through `@velum-labs/routekit`. Tests and specialized
18
+ hosts can acquire a scoped gateway generation from `@velum-labs/routekit-daemon/effect`
19
+ without claiming the singleton service record.
@@ -0,0 +1,7 @@
1
+ import type { EffectRouteKitControlHandlers } from "@velum-labs/routekit-control/effect";
2
+ type AccountEnrollHandlers = Pick<EffectRouteKitControlHandlers, "accounts.enroll" | "accounts.enrollActivate">;
3
+ /** Owns account enrollment and activation transactions. */
4
+ export declare class AccountEnrollService {
5
+ handlers(): AccountEnrollHandlers;
6
+ }
7
+ export {};
@@ -0,0 +1,290 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cliproxyAuthDirectory, defaultSubscriptionAccountDirectory, sanitizeSubscriptionLabel, subscriptionAccountIdentity, subscriptionCredentialFingerprint } from "@velum-labs/routekit-accounts";
4
+ import { resolveAccountConnector } from "@velum-labs/routekit-registry";
5
+ import { ControlError } from "@velum-labs/routekit-runtime/control";
6
+ import { writeFileAtomic } from "@velum-labs/routekit-runtime/filesystem";
7
+ import { routeKitError, toRouteKitFailure } from "@velum-labs/routekit-runtime/effect";
8
+ import { Effect } from "effect";
9
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
10
+ import { cleanupAccountTransaction, markAccountTransactionCommitted, prepareAccountTransaction, rollbackAccountTransaction } from "./account-transaction.js";
11
+ import { DaemonHost } from "./daemon-host-context.js";
12
+ import { daemonAccountServices } from "./account-services.js";
13
+ import { parseConfigDocument, safeCliproxyCredentialBlob, safeCliproxyLabel, safeCredentialBlob } from "./daemon-maintenance.js";
14
+ /** Owns account enrollment and activation transactions. */
15
+ export class AccountEnrollService {
16
+ handlers() {
17
+ return {
18
+ "accounts.enroll": (params) => Effect.gen(function* () {
19
+ const { env: daemonEnv, state: runtimeState, generations } = yield* daemonAccountServices;
20
+ const host = yield* DaemonHost;
21
+ const env = daemonEnv.env;
22
+ return yield* runtimeState.serializeEffect(Effect.gen(function* () {
23
+ const path = yield* Effect.try({
24
+ try: () => {
25
+ const label = sanitizeSubscriptionLabel(params.label);
26
+ if (label !== params.label || label.startsWith(".")) {
27
+ throw new ControlError({
28
+ code: "bad_request",
29
+ message: "account label must already be normalized"
30
+ });
31
+ }
32
+ const directory = defaultSubscriptionAccountDirectory(params.kind, env);
33
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
34
+ const credentialPath = join(directory, `${label}.json`);
35
+ if (existsSync(credentialPath)) {
36
+ throw new ControlError({
37
+ code: "conflict",
38
+ message: `${params.kind}/${label} is already enrolled; remove it before enrolling again`
39
+ });
40
+ }
41
+ writeFileAtomic(credentialPath, `${JSON.stringify(safeCredentialBlob(params.kind, params.credential), null, 2)}\n`, { mode: 0o600 });
42
+ chmodSync(credentialPath, 0o600);
43
+ return credentialPath;
44
+ },
45
+ catch: toRouteKitFailure
46
+ });
47
+ yield* generations
48
+ .replace(runtimeState.config, runtimeState.document, {
49
+ write: false,
50
+ accountRevision: true
51
+ })
52
+ .pipe(Effect.catch((error) => {
53
+ rmSync(path, { force: true });
54
+ return Effect.fail(routeKitError(error));
55
+ }));
56
+ return { enrolled: true, revision: runtimeState.revisions.accounts };
57
+ }));
58
+ }),
59
+ "accounts.enrollActivate": (params) => Effect.gen(function* () {
60
+ const { env: daemonEnv, state: runtimeState, generations, auth: authHealth, sidecar } = yield* daemonAccountServices;
61
+ const host = yield* DaemonHost;
62
+ const env = daemonEnv.env;
63
+ const home = daemonEnv.home;
64
+ const configPath = daemonEnv.configPath;
65
+ const replaceRouter = generations.replace;
66
+ const resolved = resolveAccountConnector(params.kind);
67
+ if (resolved === undefined) {
68
+ return yield* Effect.fail(new ControlError({
69
+ code: "bad_request",
70
+ message: `unknown subscription kind: ${params.kind}`
71
+ }));
72
+ }
73
+ const kind = resolved.kind;
74
+ const connector = resolved.info.connector;
75
+ const provider = connector === "cliproxy" ? "cliproxy" : kind;
76
+ const prepared = yield* Effect.try({
77
+ try: () => {
78
+ const seenLabels = new Set();
79
+ return params.accounts.map((account) => {
80
+ const label = connector === "native"
81
+ ? sanitizeSubscriptionLabel(account.label)
82
+ : safeCliproxyLabel(account.label);
83
+ if (label !== account.label || (connector === "native" && label.startsWith("."))) {
84
+ throw new ControlError({
85
+ code: "bad_request",
86
+ message: "account label must already be normalized"
87
+ });
88
+ }
89
+ if (seenLabels.has(label)) {
90
+ throw new ControlError({
91
+ code: "bad_request",
92
+ message: `duplicate account label: ${label}`
93
+ });
94
+ }
95
+ seenLabels.add(label);
96
+ const directory = connector === "native"
97
+ ? defaultSubscriptionAccountDirectory(kind, env)
98
+ : cliproxyAuthDirectory(env);
99
+ const path = join(directory, `${label}.json`);
100
+ let credential = account.credential;
101
+ if (credential === undefined) {
102
+ if (!existsSync(path)) {
103
+ throw new ControlError({
104
+ code: "not_found",
105
+ message: `${kind}/${label} is not enrolled`
106
+ });
107
+ }
108
+ try {
109
+ credential = JSON.parse(readFileSync(path, "utf8"));
110
+ }
111
+ catch {
112
+ throw new ControlError({
113
+ code: "bad_request",
114
+ message: `${kind}/${label} has an invalid stored credential`
115
+ });
116
+ }
117
+ }
118
+ const blob = connector === "native"
119
+ ? safeCredentialBlob(kind, credential)
120
+ : safeCliproxyCredentialBlob(kind, credential);
121
+ const content = `${JSON.stringify(blob, null, 2)}\n`;
122
+ if (connector === "native" &&
123
+ account.credential !== undefined &&
124
+ existsSync(path) &&
125
+ readFileSync(path, "utf8") !== content) {
126
+ throw new ControlError({
127
+ code: "conflict",
128
+ message: `${kind}/${label} is already enrolled with different credentials`
129
+ });
130
+ }
131
+ return {
132
+ label,
133
+ directory,
134
+ path,
135
+ content,
136
+ credentialProvided: account.credential !== undefined
137
+ };
138
+ });
139
+ },
140
+ catch: toRouteKitFailure
141
+ });
142
+ yield* runtimeState.serializeEffect(Effect.gen(function* () {
143
+ yield* Effect.try({
144
+ try: () => {
145
+ for (const entry of prepared) {
146
+ if (!entry.credentialProvided) {
147
+ if (!existsSync(entry.path)) {
148
+ throw new ControlError({
149
+ code: "not_found",
150
+ message: `${kind}/${entry.label} is not enrolled`
151
+ });
152
+ }
153
+ let stored;
154
+ try {
155
+ stored = JSON.parse(readFileSync(entry.path, "utf8"));
156
+ }
157
+ catch {
158
+ throw new ControlError({
159
+ code: "bad_request",
160
+ message: `${kind}/${entry.label} has an invalid stored credential`
161
+ });
162
+ }
163
+ const blob = connector === "native"
164
+ ? safeCredentialBlob(kind, stored)
165
+ : safeCliproxyCredentialBlob(kind, stored);
166
+ entry.content = `${JSON.stringify(blob, null, 2)}\n`;
167
+ }
168
+ else if (connector === "native" &&
169
+ existsSync(entry.path) &&
170
+ readFileSync(entry.path, "utf8") !== entry.content) {
171
+ throw new ControlError({
172
+ code: "conflict",
173
+ message: `${kind}/${entry.label} is already enrolled with different credentials`
174
+ });
175
+ }
176
+ }
177
+ },
178
+ catch: toRouteKitFailure
179
+ });
180
+ const unchanged = prepared.every((entry) => existsSync(entry.path) && readFileSync(entry.path, "utf8") === entry.content);
181
+ if (unchanged &&
182
+ runtimeState.config.providers[provider] !== undefined) {
183
+ return;
184
+ }
185
+ const raw = parseYaml(runtimeState.document);
186
+ const providers = typeof raw.providers === "object" &&
187
+ raw.providers !== null &&
188
+ !Array.isArray(raw.providers)
189
+ ? { ...raw.providers }
190
+ : {};
191
+ providers[provider] ??= {};
192
+ raw.providers = providers;
193
+ const document = stringifyYaml(raw);
194
+ const nextConfig = parseConfigDocument(document);
195
+ const transaction = prepareAccountTransaction({
196
+ home,
197
+ configPath,
198
+ accountPaths: [
199
+ ...prepared.map((entry) => entry.path),
200
+ ...(connector === "native"
201
+ ? [join(home, "subscriptions", "account-auth.v1.json")]
202
+ : [])
203
+ ],
204
+ accountRoots: [
205
+ ...new Set(prepared.map((entry) => entry.directory)),
206
+ ...(connector === "native" ? [join(home, "subscriptions")] : [])
207
+ ],
208
+ kind,
209
+ provider,
210
+ labels: prepared.map((entry) => entry.label)
211
+ });
212
+ host.onAccountTransactionPhase?.("prepared");
213
+ let routerReplaced = false;
214
+ const previousConfig = runtimeState.config;
215
+ const previousDocument = runtimeState.document;
216
+ yield* Effect.gen(function* () {
217
+ yield* Effect.try({
218
+ try: () => {
219
+ for (const entry of prepared) {
220
+ mkdirSync(entry.directory, { recursive: true, mode: 0o700 });
221
+ writeFileAtomic(entry.path, entry.content, { mode: 0o600 });
222
+ chmodSync(entry.path, 0o600);
223
+ }
224
+ host.onAccountTransactionPhase?.("credentials-written");
225
+ },
226
+ catch: toRouteKitFailure
227
+ });
228
+ yield* replaceRouter(nextConfig, document, {
229
+ write: true,
230
+ configRevision: true,
231
+ accountRevision: true,
232
+ persist: () => Effect.gen(function* () {
233
+ if (connector === "native") {
234
+ for (const entry of prepared) {
235
+ yield* authHealth.activateFingerprint(subscriptionAccountIdentity(kind, entry.label), subscriptionCredentialFingerprint(entry.path));
236
+ }
237
+ }
238
+ markAccountTransactionCommitted(transaction);
239
+ if (connector === "cliproxy") {
240
+ yield* sidecar.refresh;
241
+ }
242
+ host.onAccountTransactionPhase?.("committed");
243
+ })
244
+ });
245
+ routerReplaced = true;
246
+ host.onAccountTransactionPhase?.("router-swapped");
247
+ yield* Effect.try({
248
+ try: () => cleanupAccountTransaction(transaction),
249
+ catch: toRouteKitFailure
250
+ }).pipe(Effect.ignore);
251
+ }).pipe(Effect.catch((error) => Effect.gen(function* () {
252
+ const rollbackFailures = [];
253
+ yield* Effect.try({
254
+ try: () => {
255
+ rollbackAccountTransaction(transaction, home);
256
+ },
257
+ catch: toRouteKitFailure
258
+ }).pipe(Effect.flatMap(() => authHealth.reload), Effect.catch((rollbackError) => {
259
+ rollbackFailures.push(rollbackError);
260
+ return Effect.void;
261
+ }));
262
+ if (connector === "cliproxy") {
263
+ yield* sidecar.refresh.pipe(Effect.catch((rollbackError) => {
264
+ rollbackFailures.push(rollbackError);
265
+ return Effect.void;
266
+ }));
267
+ }
268
+ if (routerReplaced) {
269
+ yield* replaceRouter(previousConfig, previousDocument, { write: false }).pipe(Effect.catch((rollbackError) => {
270
+ rollbackFailures.push(rollbackError);
271
+ return Effect.void;
272
+ }));
273
+ }
274
+ if (rollbackFailures.length > 0) {
275
+ return yield* Effect.fail(new AggregateError([error, ...rollbackFailures], `could not activate ${kind}; rollback failed`));
276
+ }
277
+ return yield* Effect.fail(routeKitError(error));
278
+ })));
279
+ }));
280
+ return {
281
+ enrolled: prepared.map((entry) => ({ subscriptionKind: kind, label: entry.label })),
282
+ activated: true,
283
+ configPath,
284
+ configRevision: runtimeState.revisions.config,
285
+ accountRevision: runtimeState.revisions.accounts
286
+ };
287
+ })
288
+ };
289
+ }
290
+ }
@@ -0,0 +1,7 @@
1
+ import type { EffectRouteKitControlHandlers } from "@velum-labs/routekit-control/effect";
2
+ type AccountMutationHandlers = Pick<EffectRouteKitControlHandlers, "accounts.remove" | "accounts.rename" | "accounts.sync" | "accounts.resetCredits" | "accounts.redeemReset">;
3
+ /** Owns account removal, rename, sync, and credit redemption. */
4
+ export declare class AccountMutationService {
5
+ handlers(): AccountMutationHandlers;
6
+ }
7
+ export {};
@@ -0,0 +1,322 @@
1
+ import { chmodSync, existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { cliproxyAccountEntries, cliproxyAccountMatchesKind, defaultSubscriptionAccountDirectory, RateLimitTracker, removeCliproxyAccount, removeSubscriptionAccount, renameSubscriptionAccount, sanitizeSubscriptionLabel, subscriptionAccountIdentity } from "@velum-labs/routekit-accounts";
4
+ import { resolveAccountConnector } from "@velum-labs/routekit-registry";
5
+ import { ControlError } from "@velum-labs/routekit-runtime/control";
6
+ import { writeFileAtomic } from "@velum-labs/routekit-runtime/filesystem";
7
+ import { routeKitError, toRouteKitFailure } from "@velum-labs/routekit-runtime/effect";
8
+ import { Effect } from "effect";
9
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
10
+ import { cleanupAccountTransaction, markAccountTransactionCommitted, prepareAccountTransaction, rollbackAccountTransaction } from "./account-transaction.js";
11
+ import { DaemonHost } from "./daemon-host-context.js";
12
+ import { daemonAccountServices } from "./account-services.js";
13
+ import { accountEntries, parseConfigDocument } from "./daemon-maintenance.js";
14
+ function rollbackAccountCoordinators(transaction, home, activity, authHealth, error, message) {
15
+ return Effect.gen(function* () {
16
+ yield* Effect.try({
17
+ try: () => {
18
+ rollbackAccountTransaction(transaction, home);
19
+ },
20
+ catch: (rollbackError) => new AggregateError([error, rollbackError], message)
21
+ });
22
+ yield* activity.reload.pipe(Effect.mapError((rollbackError) => new AggregateError([error, rollbackError], message)));
23
+ yield* authHealth.reload.pipe(Effect.mapError((rollbackError) => new AggregateError([error, rollbackError], message)));
24
+ return yield* Effect.fail(routeKitError(error));
25
+ });
26
+ }
27
+ /** Owns account removal, rename, sync, and credit redemption. */
28
+ export class AccountMutationService {
29
+ handlers() {
30
+ return {
31
+ "accounts.remove": (params) => Effect.gen(function* () {
32
+ const { env: daemonEnv, state: runtimeState, generations, activity, auth: authHealth, sidecar } = yield* daemonAccountServices;
33
+ const env = daemonEnv.env;
34
+ const home = daemonEnv.home;
35
+ const configPath = daemonEnv.configPath;
36
+ const replaceRouter = generations.replace;
37
+ const resolved = resolveAccountConnector(params.kind);
38
+ if (resolved === undefined) {
39
+ return yield* Effect.fail(new ControlError({
40
+ code: "bad_request",
41
+ message: `unknown subscription kind: ${params.kind}`
42
+ }));
43
+ }
44
+ const kind = resolved.kind;
45
+ let removed = false;
46
+ yield* runtimeState.serializeEffect(Effect.gen(function* () {
47
+ const directory = resolved.info.connector === "native"
48
+ ? defaultSubscriptionAccountDirectory(kind, env)
49
+ : undefined;
50
+ const nativePath = directory === undefined ? undefined : join(directory, `${params.label}.json`);
51
+ if (nativePath !== undefined && existsSync(nativePath)) {
52
+ const nativeKind = kind;
53
+ const hasRemainingAccount = accountEntries(env).some((entry) => entry.connector === "native" &&
54
+ entry.subscriptionKind === nativeKind &&
55
+ entry.label !== params.label);
56
+ const raw = parseYaml(runtimeState.document);
57
+ const providers = typeof raw.providers === "object" &&
58
+ raw.providers !== null &&
59
+ !Array.isArray(raw.providers)
60
+ ? { ...raw.providers }
61
+ : {};
62
+ const disableProvider = !hasRemainingAccount && runtimeState.config.providers[nativeKind] !== undefined;
63
+ if (disableProvider) {
64
+ delete providers[nativeKind];
65
+ raw.providers = providers;
66
+ if (typeof raw.defaultModel === "string" &&
67
+ raw.defaultModel.startsWith(`${nativeKind}/`)) {
68
+ delete raw.defaultModel;
69
+ }
70
+ }
71
+ const document = disableProvider ? stringifyYaml(raw) : runtimeState.document;
72
+ const config = disableProvider
73
+ ? parseConfigDocument(document)
74
+ : runtimeState.config;
75
+ const transaction = prepareAccountTransaction({
76
+ home,
77
+ configPath,
78
+ accountPaths: [
79
+ nativePath,
80
+ join(home, "usage", "account-activity.v1.json"),
81
+ join(home, "subscriptions", "account-auth.v1.json")
82
+ ],
83
+ accountRoots: [dirname(nativePath), home, join(home, "subscriptions")],
84
+ kind: nativeKind,
85
+ provider: nativeKind,
86
+ labels: [params.label]
87
+ });
88
+ const rollbackMessage = `could not remove ${kind}/${params.label}; rollback failed`;
89
+ yield* Effect.gen(function* () {
90
+ const outcome = yield* Effect.try({
91
+ try: () => {
92
+ const result = removeSubscriptionAccount(nativeKind, params.label, {
93
+ accountsDirectory: dirname(nativePath)
94
+ });
95
+ if (!result.removed)
96
+ cleanupAccountTransaction(transaction);
97
+ return result;
98
+ },
99
+ catch: toRouteKitFailure
100
+ });
101
+ removed = outcome.removed;
102
+ if (!removed)
103
+ return;
104
+ yield* replaceRouter(config, document, {
105
+ write: disableProvider,
106
+ configRevision: disableProvider,
107
+ accountRevision: true,
108
+ persist: () => Effect.gen(function* () {
109
+ yield* activity.remove(subscriptionAccountIdentity(nativeKind, params.label));
110
+ yield* authHealth.remove(subscriptionAccountIdentity(nativeKind, params.label));
111
+ markAccountTransactionCommitted(transaction);
112
+ })
113
+ });
114
+ yield* Effect.try({
115
+ try: () => cleanupAccountTransaction(transaction),
116
+ catch: toRouteKitFailure
117
+ }).pipe(Effect.ignore);
118
+ }).pipe(Effect.catch((error) => rollbackAccountCoordinators(transaction, home, activity, authHealth, error, rollbackMessage)));
119
+ return;
120
+ }
121
+ const entry = cliproxyAccountEntries(env).find((candidate) => candidate.label === params.label && cliproxyAccountMatchesKind(candidate, kind));
122
+ if (entry === undefined)
123
+ return;
124
+ const previous = yield* Effect.try({
125
+ try: () => readFileSync(entry.path),
126
+ catch: toRouteKitFailure
127
+ });
128
+ const cliproxyResult = yield* Effect.try({
129
+ try: () => removeCliproxyAccount(params.label, env),
130
+ catch: toRouteKitFailure
131
+ });
132
+ removed = cliproxyResult.removed;
133
+ if (!removed)
134
+ return;
135
+ yield* Effect.gen(function* () {
136
+ yield* sidecar.refresh;
137
+ yield* replaceRouter(runtimeState.config, runtimeState.document, {
138
+ write: false,
139
+ accountRevision: true
140
+ });
141
+ }).pipe(Effect.catch((error) => Effect.gen(function* () {
142
+ yield* Effect.try({
143
+ try: () => {
144
+ writeFileAtomic(entry.path, previous.toString("utf8"), { mode: 0o600 });
145
+ chmodSync(entry.path, 0o600);
146
+ },
147
+ catch: toRouteKitFailure
148
+ });
149
+ yield* sidecar.refresh.pipe(Effect.ignore);
150
+ return yield* Effect.fail(routeKitError(error));
151
+ })));
152
+ }));
153
+ return { removed, revision: runtimeState.revisions.accounts };
154
+ }),
155
+ "accounts.rename": (params) => Effect.gen(function* () {
156
+ const { env: daemonEnv, state: runtimeState, generations, activity, auth: authHealth } = yield* daemonAccountServices;
157
+ const host = yield* DaemonHost;
158
+ const env = daemonEnv.env;
159
+ const home = daemonEnv.home;
160
+ const configPath = daemonEnv.configPath;
161
+ const replaceRouter = generations.replace;
162
+ const resolved = resolveAccountConnector(params.kind);
163
+ if (resolved === undefined || resolved.info.connector !== "native") {
164
+ return yield* Effect.fail(new ControlError({
165
+ code: "bad_request",
166
+ message: "account rename supports only claude-code and codex"
167
+ }));
168
+ }
169
+ for (const [field, label] of [
170
+ ["source", params.source],
171
+ ["target", params.target]
172
+ ]) {
173
+ if (sanitizeSubscriptionLabel(label) !== label || label.startsWith(".")) {
174
+ return yield* Effect.fail(new ControlError({
175
+ code: "bad_request",
176
+ message: `${field} account label must already be normalized`
177
+ }));
178
+ }
179
+ }
180
+ const kind = resolved.kind;
181
+ yield* runtimeState.serializeEffect(Effect.gen(function* () {
182
+ const directory = defaultSubscriptionAccountDirectory(kind, env);
183
+ const sourcePath = join(directory, `${params.source}.json`);
184
+ const targetPath = join(directory, `${params.target}.json`);
185
+ yield* Effect.try({
186
+ try: () => {
187
+ if (!existsSync(sourcePath)) {
188
+ throw new ControlError({
189
+ code: "not_found",
190
+ message: `${kind}/${params.source} is not enrolled`
191
+ });
192
+ }
193
+ try {
194
+ lstatSync(targetPath);
195
+ throw new ControlError({
196
+ code: "conflict",
197
+ message: `${kind}/${params.target} is already enrolled`
198
+ });
199
+ }
200
+ catch (error) {
201
+ if (error instanceof ControlError ||
202
+ typeof error !== "object" ||
203
+ error === null ||
204
+ !("code" in error) ||
205
+ error.code !== "ENOENT") {
206
+ throw error;
207
+ }
208
+ }
209
+ },
210
+ catch: toRouteKitFailure
211
+ });
212
+ const transaction = prepareAccountTransaction({
213
+ home,
214
+ configPath,
215
+ accountPaths: [
216
+ sourcePath,
217
+ targetPath,
218
+ join(directory, ".state.json"),
219
+ join(home, "usage", "account-activity.v1.json"),
220
+ join(home, "subscriptions", "account-auth.v1.json")
221
+ ],
222
+ accountRoots: [directory, home, join(home, "subscriptions")],
223
+ kind,
224
+ provider: kind,
225
+ labels: [params.source, params.target]
226
+ });
227
+ yield* Effect.gen(function* () {
228
+ yield* Effect.try({
229
+ try: () => {
230
+ renameSubscriptionAccount(kind, params.source, params.target, {
231
+ accountsDirectory: directory
232
+ });
233
+ },
234
+ catch: toRouteKitFailure
235
+ });
236
+ const tracker = yield* RateLimitTracker.open(join(directory, ".state.json"), kind);
237
+ yield* tracker.renameMember(params.source, params.target);
238
+ host.onAccountTransactionPhase?.("credentials-written");
239
+ yield* replaceRouter(runtimeState.config, runtimeState.document, {
240
+ write: false,
241
+ accountRevision: true,
242
+ persist: () => Effect.gen(function* () {
243
+ yield* activity.rename(subscriptionAccountIdentity(kind, params.source), subscriptionAccountIdentity(kind, params.target));
244
+ yield* authHealth.rename(subscriptionAccountIdentity(kind, params.source), subscriptionAccountIdentity(kind, params.target));
245
+ markAccountTransactionCommitted(transaction);
246
+ })
247
+ });
248
+ yield* Effect.try({
249
+ try: () => cleanupAccountTransaction(transaction),
250
+ catch: toRouteKitFailure
251
+ }).pipe(Effect.ignore);
252
+ }).pipe(Effect.catch((error) => rollbackAccountCoordinators(transaction, home, activity, authHealth, error, `could not rename ${kind}/${params.source}; rollback failed`)));
253
+ }));
254
+ return { renamed: true, revision: runtimeState.revisions.accounts };
255
+ }),
256
+ "accounts.sync": () => Effect.gen(function* () {
257
+ const { state: runtimeState, generations, sidecar } = yield* daemonAccountServices;
258
+ return yield* runtimeState.serializeEffect(Effect.gen(function* () {
259
+ yield* sidecar.refresh;
260
+ yield* generations.replace(runtimeState.config, runtimeState.document, {
261
+ write: false,
262
+ accountRevision: true
263
+ });
264
+ return { synced: true, revision: runtimeState.revisions.accounts };
265
+ }));
266
+ }),
267
+ "accounts.resetCredits": (params, context) => Effect.gen(function* () {
268
+ const { gateway } = yield* daemonAccountServices;
269
+ return yield* gateway
270
+ .router()
271
+ .listResetCredits(params.kind, params.label, context.signal)
272
+ .pipe(Effect.map((resetCredits) => ({
273
+ kind: params.kind,
274
+ label: params.label,
275
+ resetCredits
276
+ })), Effect.catch((error) => {
277
+ const message = error instanceof Error ? error.message : String(error);
278
+ if (message.includes("is not enrolled") ||
279
+ message.includes("no codex account pool")) {
280
+ return Effect.fail(new ControlError({ code: "not_found", message }));
281
+ }
282
+ return Effect.fail(routeKitError(error));
283
+ }));
284
+ }),
285
+ "accounts.redeemReset": (params, context) => Effect.gen(function* () {
286
+ const { gateway } = yield* daemonAccountServices;
287
+ return yield* gateway
288
+ .router()
289
+ .redeemReset({
290
+ kind: params.kind,
291
+ label: params.label,
292
+ ...(params.creditId !== undefined ? { creditId: params.creditId } : {}),
293
+ ...(params.redeemRequestId !== undefined
294
+ ? { redeemRequestId: params.redeemRequestId }
295
+ : {})
296
+ }, context.signal)
297
+ .pipe(Effect.map((result) => ({
298
+ ok: result.ok,
299
+ code: result.code,
300
+ kind: "codex",
301
+ label: result.label,
302
+ redeemRequestId: result.redeemRequestId,
303
+ ...(result.creditId !== undefined ? { creditId: result.creditId } : {}),
304
+ ...(result.windowsReset !== undefined ? { windowsReset: result.windowsReset } : {}),
305
+ usage: result.usage
306
+ })), Effect.catch((error) => {
307
+ const message = error instanceof Error ? error.message : String(error);
308
+ if (message.includes("is not enrolled") || message.includes("no redeemable")) {
309
+ return Effect.fail(new ControlError({ code: "not_found", message }));
310
+ }
311
+ if (message.includes("does not support") ||
312
+ message.includes("no codex account pool") ||
313
+ message.includes("creditId must not be empty") ||
314
+ message.includes("account label is required")) {
315
+ return Effect.fail(new ControlError({ code: "bad_request", message }));
316
+ }
317
+ return Effect.fail(new ControlError({ code: "internal", message }));
318
+ }));
319
+ })
320
+ };
321
+ }
322
+ }
@@ -0,0 +1,7 @@
1
+ import type { EffectRouteKitControlHandlers } from "@velum-labs/routekit-control/effect";
2
+ type AccountQueryHandlers = Pick<EffectRouteKitControlHandlers, "accounts.list" | "accounts.status" | "accounts.usage">;
3
+ /** Owns account inventory, live status, and usage queries. */
4
+ export declare class AccountQueryService {
5
+ handlers(): AccountQueryHandlers;
6
+ }
7
+ export {};