@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.
- package/README.md +3 -2
- package/dist/account-enroll-service.d.ts +7 -0
- package/dist/account-enroll-service.js +290 -0
- package/dist/account-mutation-service.d.ts +7 -0
- package/dist/account-mutation-service.js +322 -0
- package/dist/account-query-service.d.ts +7 -0
- package/dist/account-query-service.js +96 -0
- package/dist/account-recovery-context.d.ts +6 -0
- package/dist/account-recovery-context.js +3 -0
- package/dist/account-services.d.ts +20 -0
- package/dist/account-services.js +18 -0
- package/dist/account-transaction.js +4 -9
- package/dist/application-services.d.ts +10 -0
- package/dist/application-services.js +22 -0
- package/dist/call-attribution-store.d.ts +1 -0
- package/dist/call-attribution-store.js +163 -12
- package/dist/cliproxy-sidecar.d.ts +23 -4
- package/dist/cliproxy-sidecar.js +63 -36
- package/dist/control-dispatch.d.ts +18 -0
- package/dist/control-dispatch.js +67 -0
- package/dist/daemon-bootstrap-preflight.d.ts +49 -0
- package/dist/daemon-bootstrap-preflight.js +55 -0
- package/dist/daemon-bootstrap.d.ts +34 -0
- package/dist/daemon-bootstrap.js +45 -0
- package/dist/daemon-control-handlers.d.ts +7 -0
- package/dist/daemon-control-handlers.js +36 -0
- package/dist/daemon-env-context.d.ts +20 -0
- package/dist/daemon-env-context.js +3 -0
- package/dist/daemon-generations.d.ts +47 -0
- package/dist/daemon-generations.js +176 -0
- package/dist/daemon-host-context.d.ts +11 -0
- package/dist/daemon-host-context.js +3 -0
- package/dist/daemon-lifecycle-service.d.ts +7 -0
- package/dist/daemon-lifecycle-service.js +107 -0
- package/dist/daemon-lifecycle.d.ts +54 -0
- package/dist/daemon-lifecycle.js +177 -0
- package/dist/daemon-maintenance.d.ts +114 -0
- package/dist/daemon-maintenance.js +139 -0
- package/dist/daemon-options.d.ts +26 -0
- package/dist/daemon-policy-context.d.ts +9 -0
- package/dist/daemon-policy-context.js +3 -0
- package/dist/daemon-runtime-state.d.ts +36 -0
- package/dist/daemon-runtime-state.js +103 -0
- package/dist/daemon-state-context.d.ts +10 -0
- package/dist/daemon-state-context.js +43 -0
- package/dist/daemon-state.d.ts +29 -0
- package/dist/daemon-state.js +93 -0
- package/dist/data-plane-context.d.ts +9 -0
- package/dist/data-plane-context.js +3 -0
- package/dist/doctor-service.d.ts +7 -0
- package/dist/doctor-service.js +103 -0
- package/dist/effect/daemon-live.d.ts +22 -0
- package/dist/effect/daemon-live.js +409 -0
- package/dist/effect-api.d.ts +30 -0
- package/dist/effect-api.js +18 -0
- package/dist/eval-routing-policy.d.ts +8 -0
- package/dist/eval-routing-policy.js +24 -0
- package/dist/eval-routing-service.d.ts +7 -0
- package/dist/eval-routing-service.js +89 -0
- package/dist/gateway-generation.d.ts +58 -0
- package/dist/gateway-generation.js +261 -0
- package/dist/host-generation-transaction.d.ts +17 -0
- package/dist/host-generation-transaction.js +26 -0
- package/dist/host-idempotency.d.ts +22 -0
- package/dist/host-idempotency.js +66 -0
- package/dist/host-protocol.d.ts +34 -2
- package/dist/host-protocol.js +1 -1
- package/dist/host-worker-session.d.ts +61 -0
- package/dist/host-worker-session.js +197 -0
- package/dist/host.d.ts +6 -4
- package/dist/host.js +451 -497
- package/dist/index.d.ts +13 -77
- package/dist/index.js +9 -1927
- package/dist/ipc-request-channel.d.ts +27 -0
- package/dist/ipc-request-channel.js +95 -0
- package/dist/launcher-service.d.ts +12 -0
- package/dist/launcher-service.js +94 -0
- package/dist/leaderboard-context.d.ts +12 -0
- package/dist/leaderboard-context.js +3 -0
- package/dist/leaderboard.d.ts +1 -1
- package/dist/leaderboard.js +5 -12
- package/dist/provider-query-service.d.ts +7 -0
- package/dist/provider-query-service.js +159 -0
- package/dist/router-generation-service.d.ts +7 -0
- package/dist/router-generation-service.js +101 -0
- package/dist/services/active-gateway/service.d.ts +23 -0
- package/dist/services/active-gateway/service.js +11 -0
- package/dist/services/call-attributions/service.d.ts +10 -0
- package/dist/services/call-attributions/service.js +11 -0
- package/dist/services/daemon-runtime/service.d.ts +19 -0
- package/dist/services/daemon-runtime/service.js +4 -0
- package/dist/services/eval-session/service.d.ts +36 -0
- package/dist/services/eval-session/service.js +150 -0
- package/dist/services/generations/service.d.ts +21 -0
- package/dist/services/generations/service.js +6 -0
- package/dist/services/telemetry/service.d.ts +27 -0
- package/dist/services/telemetry/service.js +85 -0
- package/dist/services/tokens/service.d.ts +36 -0
- package/dist/services/tokens/service.js +72 -0
- package/dist/services/worker-ipc/service.d.ts +20 -0
- package/dist/services/worker-ipc/service.js +34 -0
- package/dist/sidecar-context.d.ts +6 -0
- package/dist/sidecar-context.js +3 -0
- package/dist/state-api.d.ts +2 -0
- package/dist/state-api.js +1 -0
- package/dist/telemetry-application-service.d.ts +7 -0
- package/dist/telemetry-application-service.js +61 -0
- package/dist/test/account-transaction.test.js +1 -1
- package/dist/test/application-services.test.d.ts +1 -0
- package/dist/test/application-services.test.js +109 -0
- package/dist/test/call-attribution-store.test.js +105 -0
- package/dist/test/control-dispatch.test.d.ts +1 -0
- package/dist/test/control-dispatch.test.js +117 -0
- package/dist/test/daemon-accounts.test.d.ts +1 -0
- package/dist/test/daemon-accounts.test.js +211 -0
- package/dist/test/daemon-authority-recovery.test.d.ts +1 -0
- package/dist/test/daemon-authority-recovery.test.js +117 -0
- package/dist/test/daemon-data-plane.test.d.ts +1 -0
- package/dist/test/daemon-data-plane.test.js +697 -0
- package/dist/test/daemon-fixtures.d.ts +14 -0
- package/dist/test/daemon-fixtures.js +425 -0
- package/dist/test/daemon-generation-transaction.test.d.ts +1 -0
- package/dist/test/daemon-generation-transaction.test.js +91 -0
- package/dist/test/daemon-lifecycle.test.d.ts +1 -0
- package/dist/test/daemon-lifecycle.test.js +182 -0
- package/dist/test/daemon-runtime-state.test.d.ts +1 -0
- package/dist/test/daemon-runtime-state.test.js +56 -0
- package/dist/test/daemon-sidecar.test.d.ts +1 -0
- package/dist/test/daemon-sidecar.test.js +260 -0
- package/dist/test/effect-daemon.test.d.ts +1 -0
- package/dist/test/effect-daemon.test.js +53 -0
- package/dist/test/effect-host-generation.test.d.ts +1 -0
- package/dist/test/effect-host-generation.test.js +58 -0
- package/dist/test/eval-routing-application-service.test.d.ts +1 -0
- package/dist/test/eval-routing-application-service.test.js +122 -0
- package/dist/test/eval-routing-policy.test.d.ts +1 -0
- package/dist/test/eval-routing-policy.test.js +55 -0
- package/dist/test/eval-session-service.test.d.ts +1 -0
- package/dist/test/eval-session-service.test.js +124 -0
- package/dist/test/host-idempotency.test.d.ts +1 -0
- package/dist/test/host-idempotency.test.js +54 -0
- package/dist/test/host-worker-session.test.d.ts +1 -0
- package/dist/test/host-worker-session.test.js +56 -0
- package/dist/test/ipc-request-channel.test.d.ts +1 -0
- package/dist/test/ipc-request-channel.test.js +72 -0
- package/dist/test/leaderboard.test.js +6 -5
- package/dist/test/runtime-composition-types.test.d.ts +1 -0
- package/dist/test/runtime-composition-types.test.js +15 -0
- package/dist/token-application-service.d.ts +7 -0
- package/dist/token-application-service.js +59 -0
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +151 -121
- package/package.json +19 -9
- package/dist/test/daemon.test.js +0 -1500
- /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
|
|
18
|
-
|
|
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 {};
|