1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as TestClock from "effect/testing/TestClock";
7import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
8import * as SharedAccounts from "./SharedAccounts.ts";
9
10const credentials = {
11 driver: "codex" as const,
12 accountId: "account-a",
13 accessToken: "access-a",
14 refreshToken: "refresh-a",
15 idToken: "id-a",
16 expiresAt: 600_000,
17};
18
19it.effect(
20 "replacing credentials fences an earlier refresher and retries do not create extra accounts",
21 () =>
22 Effect.gen(function* () {
23 const { service, id } = yield* fixture();
24 const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 });
25 if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim");
26 const replaced = yield* service.execute({
27 type: "import",
28 replaceId: id,
29 label: "Personal",
30 credentials: { ...credentials, refreshToken: "replacement" },
31 });
32 if (replaced.type !== "imported") return yield* Effect.die("Expected replacement");
33 assert.strictEqual(replaced.account.revision, 2);
34 assert.strictEqual(
35 (yield* service
36 .execute({ type: "commit", id, revision: 1, leaseToken: claim.leaseToken, credentials })
37 .pipe(Effect.flip)).reason,
38 "stale",
39 );
40 assert.deepStrictEqual(
41 yield* service.execute({
42 type: "import",
43 replaceId: id,
44 label: "Personal",
45 credentials: { ...credentials, refreshToken: "replacement" },
46 }),
47 replaced,
48 );
49 assert.deepStrictEqual(
50 yield* service.execute({ type: "import", label: "Personal", credentials }),
51 replaced,
52 );
53 }),
54);
55
56function fixture() {
57 const data = new Map<string, Uint8Array>();
58 const store = ServerSecretStore.ServerSecretStore.of({
59 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
60 set: (key, value) =>
61 Effect.sync(() => {
62 data.set(key, value);
63 }),
64 remove: (key) =>
65 Effect.sync(() => {
66 data.delete(key);
67 }),
68 create: () => Effect.die("unused"),
69 getOrCreateRandom: () => Effect.die("unused"),
70 });
71 const layer = SharedAccounts.layer.pipe(
72 Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)),
73 Layer.provide(NodeCrypto.layer),
74 );
75 return Effect.gen(function* () {
76 const service = yield* SharedAccounts.SharedAccounts.pipe(Effect.provide(layer));
77 const imported = yield* service.execute({ type: "import", label: "Personal", credentials });
78 if (imported.type !== "imported") return yield* Effect.die("Expected an imported account");
79 return {
80 service,
81 id: imported.account.id,
82 restart: SharedAccounts.SharedAccounts.pipe(Effect.provide(layer)),
83 };
84 });
85}
86
87it.effect("lists account labels without credential or refresh-claim material", () =>
88 Effect.gen(function* () {
89 const { service, id } = yield* fixture();
90 assert.deepStrictEqual(yield* service.execute({ type: "list" }), {
91 type: "list",
92 accounts: [{ id, label: "Personal", driver: "codex", revision: 1 }],
93 });
94 assert.deepStrictEqual(yield* service.execute({ type: "acquire", id }), {
95 type: "access",
96 account: { id, label: "Personal", driver: "codex", revision: 1 },
97 credentials: {
98 driver: "codex",
99 accountId: "account-a",
100 accessToken: "access-a",
101 idToken: "id-a",
102 expiresAt: 600_000,
103 },
104 });
105 }),
106);
107
108it.effect("only one host acquires a refresh claim and its replacement survives restart", () =>
109 Effect.gen(function* () {
110 const { service, id, restart } = yield* fixture();
111 const results = yield* Effect.all(
112 [
113 service.execute({ type: "acquire", id, refreshRevision: 1 }),
114 service.execute({ type: "acquire", id, refreshRevision: 1 }),
115 ],
116 { concurrency: "unbounded" },
117 );
118 const claim = results.find((result) => result.type === "refresh");
119 if (claim?.type !== "refresh") return yield* Effect.die("Expected a refresh claim");
120 assert.strictEqual(results.filter((result) => result.type === "refresh").length, 1);
121 assert.strictEqual(results.filter((result) => result.type === "busy").length, 1);
122
123 const result = yield* service.execute({
124 type: "commit",
125 id,
126 revision: 1,
127 leaseToken: claim.leaseToken,
128 credentials: { ...credentials, accessToken: "access-b", refreshToken: "refresh-b" },
129 });
130 if (result.type !== "access") return yield* Effect.die("Expected current access");
131 assert.strictEqual(result.account.revision, 2);
132 assert.strictEqual(result.credentials.accessToken, "access-b");
133 const next = yield* restart;
134 assert.deepStrictEqual(
135 yield* next.execute({ type: "acquire", id, refreshRevision: 1 }),
136 result,
137 );
138 }),
139);
140
141it.effect("a new host takes over an expired claim and fences out the previous host", () =>
142 Effect.gen(function* () {
143 const { service, id, restart } = yield* fixture();
144 const first = yield* service.execute({ type: "acquire", id, refreshRevision: 1 });
145 if (first.type !== "refresh") return yield* Effect.die("Expected a refresh claim");
146 const next = yield* restart;
147 assert.strictEqual(
148 (yield* next.execute({ type: "acquire", id, refreshRevision: 1 })).type,
149 "busy",
150 );
151 yield* TestClock.adjust("31 seconds");
152 const second = yield* next.execute({ type: "acquire", id, refreshRevision: 1 });
153 if (second.type !== "refresh") return yield* Effect.die("Expected takeover");
154 assert.notStrictEqual(second.leaseToken, first.leaseToken);
155 const rejected = yield* next
156 .execute({ type: "commit", id, revision: 1, leaseToken: first.leaseToken, credentials })
157 .pipe(Effect.flip);
158 assert.strictEqual(rejected.reason, "stale");
159 assert.strictEqual(
160 (yield* next.execute({
161 type: "commit",
162 id,
163 revision: 1,
164 leaseToken: second.leaseToken,
165 credentials,
166 })).type,
167 "access",
168 );
169 }),
170);
171
172it.effect("does not change account identity when committing a refresh", () =>
173 Effect.gen(function* () {
174 const { service, id } = yield* fixture();
175 const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 });
176 if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim");
177 const rejected = yield* service
178 .execute({
179 type: "commit",
180 id,
181 revision: 1,
182 leaseToken: claim.leaseToken,
183 credentials: { ...credentials, accountId: "wrong-account" },
184 })
185 .pipe(Effect.flip);
186 assert.strictEqual(rejected.reason, "identity");
187 }),
188);
189
190it.effect("Claude setup tokens need no refresh claim and removed accounts stay removed", () =>
191 Effect.gen(function* () {
192 const { service, restart } = yield* fixture();
193 const imported = yield* service.execute({
194 type: "import",
195 label: "Claude",
196 credentials: { driver: "claudeAgent", accessToken: "setup-token" },
197 });
198 if (imported.type !== "imported") return yield* Effect.die("Expected an account");
199 const id = imported.account.id;
200 const access = yield* service.execute({ type: "acquire", id });
201 assert.strictEqual(access.type, "access");
202 yield* service.execute({ type: "remove", id });
203 const next = yield* restart;
204 const missing = yield* next.execute({ type: "acquire", id }).pipe(Effect.flip);
205 assert.strictEqual(missing.reason, "missing");
206 }),
207);