clanka 0.0.1
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 -0
- package/dist/Agent.d.ts +119 -0
- package/dist/Agent.d.ts.map +1 -0
- package/dist/Agent.js +240 -0
- package/dist/Agent.js.map +1 -0
- package/dist/AgentTools.d.ts +246 -0
- package/dist/AgentTools.d.ts.map +1 -0
- package/dist/AgentTools.js +374 -0
- package/dist/AgentTools.js.map +1 -0
- package/dist/AgentTools.test.d.ts +2 -0
- package/dist/AgentTools.test.d.ts.map +1 -0
- package/dist/AgentTools.test.js +147 -0
- package/dist/AgentTools.test.js.map +1 -0
- package/dist/ApplyPatch.d.ts +27 -0
- package/dist/ApplyPatch.d.ts.map +1 -0
- package/dist/ApplyPatch.js +343 -0
- package/dist/ApplyPatch.js.map +1 -0
- package/dist/ApplyPatch.test.d.ts +2 -0
- package/dist/ApplyPatch.test.d.ts.map +1 -0
- package/dist/ApplyPatch.test.js +99 -0
- package/dist/ApplyPatch.test.js.map +1 -0
- package/dist/Codex.d.ts +11 -0
- package/dist/Codex.d.ts.map +1 -0
- package/dist/Codex.js +14 -0
- package/dist/Codex.js.map +1 -0
- package/dist/CodexAuth.d.ts +68 -0
- package/dist/CodexAuth.d.ts.map +1 -0
- package/dist/CodexAuth.js +270 -0
- package/dist/CodexAuth.js.map +1 -0
- package/dist/CodexAuth.test.d.ts +2 -0
- package/dist/CodexAuth.test.d.ts.map +1 -0
- package/dist/CodexAuth.test.js +425 -0
- package/dist/CodexAuth.test.js.map +1 -0
- package/dist/Executor.d.ts +20 -0
- package/dist/Executor.d.ts.map +1 -0
- package/dist/Executor.js +76 -0
- package/dist/Executor.js.map +1 -0
- package/dist/OutputFormatter.d.ts +11 -0
- package/dist/OutputFormatter.d.ts.map +1 -0
- package/dist/OutputFormatter.js +5 -0
- package/dist/OutputFormatter.js.map +1 -0
- package/dist/ToolkitRenderer.d.ts +17 -0
- package/dist/ToolkitRenderer.d.ts.map +1 -0
- package/dist/ToolkitRenderer.js +25 -0
- package/dist/ToolkitRenderer.js.map +1 -0
- package/dist/TypeBuilder.d.ts +11 -0
- package/dist/TypeBuilder.d.ts.map +1 -0
- package/dist/TypeBuilder.js +383 -0
- package/dist/TypeBuilder.js.map +1 -0
- package/dist/TypeBuilder.test.d.ts +2 -0
- package/dist/TypeBuilder.test.d.ts.map +1 -0
- package/dist/TypeBuilder.test.js +243 -0
- package/dist/TypeBuilder.test.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/Agent.ts +398 -0
- package/src/AgentTools.test.ts +215 -0
- package/src/AgentTools.ts +507 -0
- package/src/ApplyPatch.test.ts +154 -0
- package/src/ApplyPatch.ts +473 -0
- package/src/Codex.ts +14 -0
- package/src/CodexAuth.test.ts +729 -0
- package/src/CodexAuth.ts +571 -0
- package/src/Executor.ts +129 -0
- package/src/OutputFormatter.ts +17 -0
- package/src/ToolkitRenderer.ts +39 -0
- package/src/TypeBuilder.test.ts +508 -0
- package/src/TypeBuilder.ts +670 -0
- package/src/index.ts +29 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
2
|
+
import { Deferred, Effect, Encoding, Fiber, Option, Ref } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpClientRequest,
|
|
6
|
+
HttpClientResponse,
|
|
7
|
+
} from "effect/unstable/http"
|
|
8
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
9
|
+
import {
|
|
10
|
+
CodexAuth,
|
|
11
|
+
CodexAuthError,
|
|
12
|
+
ISSUER,
|
|
13
|
+
STORE_PREFIX,
|
|
14
|
+
STORE_TOKEN_KEY,
|
|
15
|
+
TOKEN_EXPIRY_BUFFER_MS,
|
|
16
|
+
TokenData,
|
|
17
|
+
extractAccountIdFromClaims,
|
|
18
|
+
extractAccountIdFromToken,
|
|
19
|
+
parseJwtClaims,
|
|
20
|
+
toCodexAuthKeyValueStore,
|
|
21
|
+
toTokenStore,
|
|
22
|
+
} from "./CodexAuth.ts"
|
|
23
|
+
|
|
24
|
+
const createJwt = (payload: string): string =>
|
|
25
|
+
`${Encoding.encodeBase64Url(JSON.stringify({ alg: "none" }))}.${Encoding.encodeBase64Url(payload)}.sig`
|
|
26
|
+
|
|
27
|
+
const createTestJwt = (payload: Record<string, unknown>): string =>
|
|
28
|
+
createJwt(JSON.stringify(payload))
|
|
29
|
+
|
|
30
|
+
const jsonResponse = (body: unknown, status = 200): Response =>
|
|
31
|
+
new Response(JSON.stringify(body), {
|
|
32
|
+
status,
|
|
33
|
+
headers: {
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const getBody = (request: HttpClientRequest.HttpClientRequest): string => {
|
|
39
|
+
if (request.body._tag !== "Uint8Array") {
|
|
40
|
+
throw new Error("Expected request body to be a Uint8Array payload")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new TextDecoder().decode(request.body.body)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const makeClient = Effect.fn("makeClient")(function* (
|
|
47
|
+
handler: (
|
|
48
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
49
|
+
attempt: number,
|
|
50
|
+
) => Response,
|
|
51
|
+
) {
|
|
52
|
+
const attempts = yield* Ref.make(0)
|
|
53
|
+
const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
|
|
54
|
+
[],
|
|
55
|
+
)
|
|
56
|
+
const client = HttpClient.make((request) =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
|
|
59
|
+
yield* Ref.update(requests, (current) => [...current, request])
|
|
60
|
+
return HttpClientResponse.fromWeb(request, handler(request, attempt))
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
attempts,
|
|
66
|
+
client,
|
|
67
|
+
requests,
|
|
68
|
+
} as const
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const makeEffectClient = Effect.fn("makeEffectClient")(function* (
|
|
72
|
+
handler: (
|
|
73
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
74
|
+
attempt: number,
|
|
75
|
+
) => Effect.Effect<Response>,
|
|
76
|
+
) {
|
|
77
|
+
const attempts = yield* Ref.make(0)
|
|
78
|
+
const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
|
|
79
|
+
[],
|
|
80
|
+
)
|
|
81
|
+
const client = HttpClient.make((request) =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
|
|
84
|
+
yield* Ref.update(requests, (current) => [...current, request])
|
|
85
|
+
return HttpClientResponse.fromWeb(
|
|
86
|
+
request,
|
|
87
|
+
yield* handler(request, attempt),
|
|
88
|
+
)
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
attempts,
|
|
94
|
+
client,
|
|
95
|
+
requests,
|
|
96
|
+
} as const
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("CodexAuth", () => {
|
|
100
|
+
it.effect(
|
|
101
|
+
"persists token data through the prefixed schema store",
|
|
102
|
+
Effect.fn(function* () {
|
|
103
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
104
|
+
const tokenStore = toTokenStore(kvs)
|
|
105
|
+
const token = new TokenData({
|
|
106
|
+
access: "access-token",
|
|
107
|
+
refresh: "refresh-token",
|
|
108
|
+
expires: 1_700_000_000_000,
|
|
109
|
+
accountId: Option.some("account_123"),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
yield* Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token))
|
|
113
|
+
|
|
114
|
+
const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
|
|
115
|
+
|
|
116
|
+
assert.strictEqual(Option.isSome(stored), true)
|
|
117
|
+
if (Option.isNone(stored)) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
assert.strictEqual(stored.value.access, token.access)
|
|
122
|
+
assert.strictEqual(stored.value.refresh, token.refresh)
|
|
123
|
+
assert.strictEqual(stored.value.expires, token.expires)
|
|
124
|
+
assert.strictEqual(Option.isSome(stored.value.accountId), true)
|
|
125
|
+
if (Option.isSome(stored.value.accountId)) {
|
|
126
|
+
assert.strictEqual(stored.value.accountId.value, "account_123")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rawValue = yield* Effect.orDie(
|
|
130
|
+
kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`),
|
|
131
|
+
)
|
|
132
|
+
const unprefixedValue = yield* Effect.orDie(kvs.get(STORE_TOKEN_KEY))
|
|
133
|
+
|
|
134
|
+
assert.strictEqual(typeof rawValue, "string")
|
|
135
|
+
assert.strictEqual(unprefixedValue, undefined)
|
|
136
|
+
}, Effect.provide(KeyValueStore.layerMemory)),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
it.effect(
|
|
140
|
+
"round-trips missing account ids as Option.none",
|
|
141
|
+
Effect.fn(function* () {
|
|
142
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
143
|
+
const prefixedStore = toCodexAuthKeyValueStore(kvs)
|
|
144
|
+
const tokenStore = toTokenStore(kvs)
|
|
145
|
+
const token = new TokenData({
|
|
146
|
+
access: "access-token",
|
|
147
|
+
refresh: "refresh-token",
|
|
148
|
+
expires: 1_700_000_000_000,
|
|
149
|
+
accountId: Option.none(),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
yield* Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token))
|
|
153
|
+
|
|
154
|
+
const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
|
|
155
|
+
|
|
156
|
+
assert.strictEqual(Option.isSome(stored), true)
|
|
157
|
+
if (Option.isNone(stored)) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
assert.strictEqual(Option.isNone(stored.value.accountId), true)
|
|
162
|
+
assert.strictEqual(
|
|
163
|
+
yield* Effect.orDie(prefixedStore.has(STORE_TOKEN_KEY)),
|
|
164
|
+
true,
|
|
165
|
+
)
|
|
166
|
+
}, Effect.provide(KeyValueStore.layerMemory)),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
it("marks tokens expired using the refresh buffer", () => {
|
|
170
|
+
const now = Date.now()
|
|
171
|
+
const expiredSoon = new TokenData({
|
|
172
|
+
access: "access-token",
|
|
173
|
+
refresh: "refresh-token",
|
|
174
|
+
expires: now + TOKEN_EXPIRY_BUFFER_MS - 1_000,
|
|
175
|
+
accountId: Option.none(),
|
|
176
|
+
})
|
|
177
|
+
const stillValid = new TokenData({
|
|
178
|
+
access: "access-token",
|
|
179
|
+
refresh: "refresh-token",
|
|
180
|
+
expires: now + TOKEN_EXPIRY_BUFFER_MS + 60_000,
|
|
181
|
+
accountId: Option.none(),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
assert.strictEqual(expiredSoon.isExpired(), true)
|
|
185
|
+
assert.strictEqual(stillValid.isExpired(), false)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("constructs CodexAuthError with the expected tagged shape", () => {
|
|
189
|
+
const error = new CodexAuthError({
|
|
190
|
+
reason: "RefreshFailed",
|
|
191
|
+
message: "Could not refresh the token",
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
assert.strictEqual(error._tag, "CodexAuthError")
|
|
195
|
+
assert.strictEqual(error.reason, "RefreshFailed")
|
|
196
|
+
assert.strictEqual(error.message, "Could not refresh the token")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("parses valid JWT claims from a base64url payload", () => {
|
|
200
|
+
assert.deepStrictEqual(
|
|
201
|
+
Option.getOrUndefined(
|
|
202
|
+
parseJwtClaims(
|
|
203
|
+
createTestJwt({
|
|
204
|
+
email: "test@example.com",
|
|
205
|
+
chatgpt_account_id: "acc-123",
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
{
|
|
210
|
+
chatgpt_account_id: "acc-123",
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("returns none for JWTs without three parts", () => {
|
|
216
|
+
assert.strictEqual(Option.isNone(parseJwtClaims("invalid")), true)
|
|
217
|
+
assert.strictEqual(Option.isNone(parseJwtClaims("only.two")), true)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("returns none for invalid base64url payloads", () => {
|
|
221
|
+
assert.strictEqual(Option.isNone(parseJwtClaims("a.!!!invalid!!!.b")), true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("returns none for invalid JSON payloads", () => {
|
|
225
|
+
assert.strictEqual(
|
|
226
|
+
Option.isNone(parseJwtClaims(createJwt("not json"))),
|
|
227
|
+
true,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("ignores malformed claim types instead of failing the parse", () => {
|
|
232
|
+
assert.deepStrictEqual(
|
|
233
|
+
Option.getOrUndefined(
|
|
234
|
+
parseJwtClaims(createTestJwt({ chatgpt_account_id: 123 })),
|
|
235
|
+
),
|
|
236
|
+
{},
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("keeps valid account ids when other claim locations are malformed", () => {
|
|
241
|
+
assert.strictEqual(
|
|
242
|
+
Option.getOrUndefined(
|
|
243
|
+
extractAccountIdFromToken(
|
|
244
|
+
createTestJwt({
|
|
245
|
+
chatgpt_account_id: "acc-root",
|
|
246
|
+
"https://api.openai.com/auth": "invalid",
|
|
247
|
+
}),
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
"acc-root",
|
|
251
|
+
)
|
|
252
|
+
assert.strictEqual(
|
|
253
|
+
Option.getOrUndefined(
|
|
254
|
+
extractAccountIdFromToken(
|
|
255
|
+
createTestJwt({
|
|
256
|
+
"https://api.openai.com/auth": {
|
|
257
|
+
chatgpt_account_id: "acc-nested",
|
|
258
|
+
},
|
|
259
|
+
organizations: [123],
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
"acc-nested",
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("extracts account ids from the documented claim locations", () => {
|
|
268
|
+
assert.strictEqual(
|
|
269
|
+
Option.getOrUndefined(
|
|
270
|
+
extractAccountIdFromClaims({ chatgpt_account_id: "acc-root" }),
|
|
271
|
+
),
|
|
272
|
+
"acc-root",
|
|
273
|
+
)
|
|
274
|
+
assert.strictEqual(
|
|
275
|
+
Option.getOrUndefined(
|
|
276
|
+
extractAccountIdFromClaims({
|
|
277
|
+
"https://api.openai.com/auth": {
|
|
278
|
+
chatgpt_account_id: "acc-nested",
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
"acc-nested",
|
|
283
|
+
)
|
|
284
|
+
assert.strictEqual(
|
|
285
|
+
Option.getOrUndefined(
|
|
286
|
+
extractAccountIdFromClaims({
|
|
287
|
+
organizations: [{ id: "org-123" }, { id: "org-456" }],
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
290
|
+
"org-123",
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("prefers root claims over nested and organization fallbacks", () => {
|
|
295
|
+
assert.strictEqual(
|
|
296
|
+
Option.getOrUndefined(
|
|
297
|
+
extractAccountIdFromClaims({
|
|
298
|
+
chatgpt_account_id: "acc-root",
|
|
299
|
+
"https://api.openai.com/auth": {
|
|
300
|
+
chatgpt_account_id: "acc-nested",
|
|
301
|
+
},
|
|
302
|
+
organizations: [{ id: "org-123" }],
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
305
|
+
"acc-root",
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("treats empty root and nested account ids as missing", () => {
|
|
310
|
+
assert.strictEqual(
|
|
311
|
+
Option.getOrUndefined(
|
|
312
|
+
extractAccountIdFromClaims({
|
|
313
|
+
chatgpt_account_id: "",
|
|
314
|
+
"https://api.openai.com/auth": {
|
|
315
|
+
chatgpt_account_id: "acc-nested",
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
"acc-nested",
|
|
320
|
+
)
|
|
321
|
+
assert.strictEqual(
|
|
322
|
+
Option.getOrUndefined(
|
|
323
|
+
extractAccountIdFromClaims({
|
|
324
|
+
chatgpt_account_id: "",
|
|
325
|
+
"https://api.openai.com/auth": {
|
|
326
|
+
chatgpt_account_id: "",
|
|
327
|
+
},
|
|
328
|
+
organizations: [{ id: "org-123" }],
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
331
|
+
"org-123",
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it("treats empty organization ids as missing", () => {
|
|
336
|
+
assert.strictEqual(
|
|
337
|
+
Option.isNone(
|
|
338
|
+
extractAccountIdFromClaims({
|
|
339
|
+
organizations: [{ id: "" }],
|
|
340
|
+
}),
|
|
341
|
+
),
|
|
342
|
+
true,
|
|
343
|
+
)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it("returns none when no account id claim is present", () => {
|
|
347
|
+
assert.strictEqual(
|
|
348
|
+
Option.isNone(
|
|
349
|
+
extractAccountIdFromClaims({
|
|
350
|
+
organizations: [],
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
true,
|
|
354
|
+
)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it("extracts account ids directly from JWTs", () => {
|
|
358
|
+
assert.strictEqual(
|
|
359
|
+
Option.getOrUndefined(
|
|
360
|
+
extractAccountIdFromToken(
|
|
361
|
+
createTestJwt({
|
|
362
|
+
"https://api.openai.com/auth": {
|
|
363
|
+
chatgpt_account_id: "acc-token",
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
"acc-token",
|
|
369
|
+
)
|
|
370
|
+
assert.strictEqual(
|
|
371
|
+
Option.isNone(extractAccountIdFromToken("invalid")),
|
|
372
|
+
true,
|
|
373
|
+
)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it.effect(
|
|
377
|
+
"preserves the stored account id when refreshed tokens omit parseable claims",
|
|
378
|
+
() =>
|
|
379
|
+
Effect.gen(function* () {
|
|
380
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
381
|
+
const tokenStore = toTokenStore(kvs)
|
|
382
|
+
yield* Effect.orDie(
|
|
383
|
+
tokenStore.set(
|
|
384
|
+
STORE_TOKEN_KEY,
|
|
385
|
+
new TokenData({
|
|
386
|
+
access: "stale-access-token",
|
|
387
|
+
refresh: "stale-refresh-token",
|
|
388
|
+
expires: Date.now() - 60_000,
|
|
389
|
+
accountId: Option.some("persisted-account"),
|
|
390
|
+
}),
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
const { attempts, client } = yield* makeClient(() =>
|
|
395
|
+
jsonResponse({
|
|
396
|
+
id_token: "invalid",
|
|
397
|
+
access_token: "also-invalid",
|
|
398
|
+
refresh_token: "next-refresh-token",
|
|
399
|
+
expires_in: 120,
|
|
400
|
+
}),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
const auth = yield* CodexAuth.make.pipe(
|
|
404
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
const token = yield* auth.get
|
|
408
|
+
|
|
409
|
+
assert.strictEqual(token.refresh, "next-refresh-token")
|
|
410
|
+
assert.strictEqual(
|
|
411
|
+
Option.getOrUndefined(token.accountId),
|
|
412
|
+
"persisted-account",
|
|
413
|
+
)
|
|
414
|
+
assert.strictEqual(yield* Ref.get(attempts), 1)
|
|
415
|
+
|
|
416
|
+
const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
|
|
417
|
+
assert.strictEqual(Option.isSome(stored), true)
|
|
418
|
+
if (Option.isNone(stored)) {
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
assert.strictEqual(
|
|
423
|
+
Option.getOrUndefined(stored.value.accountId),
|
|
424
|
+
"persisted-account",
|
|
425
|
+
)
|
|
426
|
+
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
it.effect(
|
|
430
|
+
"falls back to device auth when refreshing an expired token fails",
|
|
431
|
+
() =>
|
|
432
|
+
Effect.gen(function* () {
|
|
433
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
434
|
+
const tokenStore = toTokenStore(kvs)
|
|
435
|
+
yield* Effect.orDie(
|
|
436
|
+
tokenStore.set(
|
|
437
|
+
STORE_TOKEN_KEY,
|
|
438
|
+
new TokenData({
|
|
439
|
+
access: "stale-access-token",
|
|
440
|
+
refresh: "stale-refresh-token",
|
|
441
|
+
expires: Date.now() - 60_000,
|
|
442
|
+
accountId: Option.some("stale-account"),
|
|
443
|
+
}),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const { client, requests } = yield* makeClient((request) => {
|
|
448
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
|
|
449
|
+
return jsonResponse({
|
|
450
|
+
device_auth_id: "device-auth-id",
|
|
451
|
+
user_code: "WXYZ-9876",
|
|
452
|
+
interval: "1",
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
|
|
457
|
+
return jsonResponse({
|
|
458
|
+
authorization_code: "authorization-code",
|
|
459
|
+
code_verifier: "code-verifier",
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (request.url === `${ISSUER}/oauth/token`) {
|
|
464
|
+
const body = new URLSearchParams(getBody(request))
|
|
465
|
+
if (body.get("grant_type") === "refresh_token") {
|
|
466
|
+
return new Response(null, { status: 401 })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return jsonResponse({
|
|
470
|
+
id_token: createTestJwt({
|
|
471
|
+
chatgpt_account_id: "account-from-id",
|
|
472
|
+
}),
|
|
473
|
+
access_token: createTestJwt({
|
|
474
|
+
chatgpt_account_id: "account-from-access",
|
|
475
|
+
}),
|
|
476
|
+
refresh_token: "next-refresh-token",
|
|
477
|
+
expires_in: 120,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return new Response(null, { status: 500 })
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
const auth = yield* CodexAuth.make.pipe(
|
|
485
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
const token = yield* auth.get
|
|
489
|
+
|
|
490
|
+
assert.strictEqual(token.refresh, "next-refresh-token")
|
|
491
|
+
assert.strictEqual(
|
|
492
|
+
Option.getOrUndefined(token.accountId),
|
|
493
|
+
"account-from-id",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
|
|
497
|
+
assert.strictEqual(Option.isSome(stored), true)
|
|
498
|
+
if (Option.isSome(stored)) {
|
|
499
|
+
assert.strictEqual(stored.value.refresh, "next-refresh-token")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const seenRequests = yield* Ref.get(requests)
|
|
503
|
+
assert.strictEqual(seenRequests.length, 4)
|
|
504
|
+
assert.strictEqual(seenRequests[0]?.url, `${ISSUER}/oauth/token`)
|
|
505
|
+
assert.strictEqual(
|
|
506
|
+
new URLSearchParams(getBody(seenRequests[0]!)).get("grant_type"),
|
|
507
|
+
"refresh_token",
|
|
508
|
+
)
|
|
509
|
+
assert.strictEqual(
|
|
510
|
+
new URLSearchParams(getBody(seenRequests[3]!)).get("grant_type"),
|
|
511
|
+
"authorization_code",
|
|
512
|
+
)
|
|
513
|
+
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
it.effect("clears corrupted persisted tokens before re-authenticating", () =>
|
|
517
|
+
Effect.gen(function* () {
|
|
518
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
519
|
+
yield* Effect.orDie(
|
|
520
|
+
kvs.set(`${STORE_PREFIX}${STORE_TOKEN_KEY}`, "not-json"),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
const { attempts, client } = yield* makeClient((request) => {
|
|
524
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
|
|
525
|
+
return jsonResponse({
|
|
526
|
+
device_auth_id: "device-auth-id",
|
|
527
|
+
user_code: "ABCD-EFGH",
|
|
528
|
+
interval: "1",
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
|
|
533
|
+
return jsonResponse({
|
|
534
|
+
authorization_code: "authorization-code",
|
|
535
|
+
code_verifier: "code-verifier",
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (request.url === `${ISSUER}/oauth/token`) {
|
|
540
|
+
return jsonResponse({
|
|
541
|
+
access_token: createTestJwt({
|
|
542
|
+
chatgpt_account_id: "fresh-account",
|
|
543
|
+
}),
|
|
544
|
+
refresh_token: "fresh-refresh-token",
|
|
545
|
+
expires_in: 120,
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return new Response(null, { status: 500 })
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
const auth = yield* CodexAuth.make.pipe(
|
|
553
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
assert.strictEqual(
|
|
557
|
+
yield* Effect.orDie(kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`)),
|
|
558
|
+
undefined,
|
|
559
|
+
)
|
|
560
|
+
assert.strictEqual(yield* Ref.get(attempts), 0)
|
|
561
|
+
|
|
562
|
+
const token = yield* auth.get
|
|
563
|
+
|
|
564
|
+
assert.strictEqual(token.refresh, "fresh-refresh-token")
|
|
565
|
+
assert.strictEqual(
|
|
566
|
+
Option.getOrUndefined(token.accountId),
|
|
567
|
+
"fresh-account",
|
|
568
|
+
)
|
|
569
|
+
assert.strictEqual(yield* Ref.get(attempts), 3)
|
|
570
|
+
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
it.effect("serializes concurrent get calls behind one refresh", () =>
|
|
574
|
+
Effect.gen(function* () {
|
|
575
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
576
|
+
const tokenStore = toTokenStore(kvs)
|
|
577
|
+
yield* Effect.orDie(
|
|
578
|
+
tokenStore.set(
|
|
579
|
+
STORE_TOKEN_KEY,
|
|
580
|
+
new TokenData({
|
|
581
|
+
access: "expired-access-token",
|
|
582
|
+
refresh: "refresh-token",
|
|
583
|
+
expires: Date.now() - 60_000,
|
|
584
|
+
accountId: Option.none(),
|
|
585
|
+
}),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
const refreshStarted = yield* Deferred.make<void>()
|
|
590
|
+
const releaseRefresh = yield* Deferred.make<void>()
|
|
591
|
+
const { attempts, client } = yield* makeEffectClient((request) => {
|
|
592
|
+
if (request.url !== `${ISSUER}/oauth/token`) {
|
|
593
|
+
return Effect.succeed(new Response(null, { status: 500 }))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const body = new URLSearchParams(getBody(request))
|
|
597
|
+
if (body.get("grant_type") !== "refresh_token") {
|
|
598
|
+
return Effect.succeed(new Response(null, { status: 500 }))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return Effect.gen(function* () {
|
|
602
|
+
yield* Deferred.succeed(refreshStarted, void 0)
|
|
603
|
+
yield* Deferred.await(releaseRefresh)
|
|
604
|
+
return jsonResponse({
|
|
605
|
+
access_token: createTestJwt({
|
|
606
|
+
chatgpt_account_id: "fresh-account",
|
|
607
|
+
}),
|
|
608
|
+
refresh_token: "fresh-refresh-token",
|
|
609
|
+
expires_in: 120,
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
const auth = yield* CodexAuth.make.pipe(
|
|
615
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
const firstFiber = yield* auth.get.pipe(
|
|
619
|
+
Effect.forkChild({ startImmediately: true }),
|
|
620
|
+
)
|
|
621
|
+
yield* Deferred.await(refreshStarted)
|
|
622
|
+
|
|
623
|
+
const secondFiber = yield* auth.get.pipe(
|
|
624
|
+
Effect.forkChild({ startImmediately: true }),
|
|
625
|
+
)
|
|
626
|
+
yield* Effect.yieldNow
|
|
627
|
+
|
|
628
|
+
assert.strictEqual(yield* Ref.get(attempts), 1)
|
|
629
|
+
|
|
630
|
+
yield* Deferred.succeed(releaseRefresh, void 0)
|
|
631
|
+
|
|
632
|
+
const first = yield* Fiber.join(firstFiber)
|
|
633
|
+
const second = yield* Fiber.join(secondFiber)
|
|
634
|
+
|
|
635
|
+
assert.strictEqual(yield* Ref.get(attempts), 1)
|
|
636
|
+
assert.strictEqual(first.refresh, "fresh-refresh-token")
|
|
637
|
+
assert.strictEqual(second.refresh, "fresh-refresh-token")
|
|
638
|
+
assert.strictEqual(
|
|
639
|
+
Option.getOrUndefined(first.accountId),
|
|
640
|
+
"fresh-account",
|
|
641
|
+
)
|
|
642
|
+
assert.strictEqual(
|
|
643
|
+
Option.getOrUndefined(second.accountId),
|
|
644
|
+
"fresh-account",
|
|
645
|
+
)
|
|
646
|
+
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
it.effect(
|
|
650
|
+
"forces device auth on authenticate and clears cache plus storage on logout",
|
|
651
|
+
() =>
|
|
652
|
+
Effect.gen(function* () {
|
|
653
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
654
|
+
const tokenStore = toTokenStore(kvs)
|
|
655
|
+
yield* Effect.orDie(
|
|
656
|
+
tokenStore.set(
|
|
657
|
+
STORE_TOKEN_KEY,
|
|
658
|
+
new TokenData({
|
|
659
|
+
access: "cached-access-token",
|
|
660
|
+
refresh: "cached-refresh-token",
|
|
661
|
+
expires: Date.now() + 600_000,
|
|
662
|
+
accountId: Option.some("cached-account"),
|
|
663
|
+
}),
|
|
664
|
+
),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
let deviceFlowCount = 0
|
|
668
|
+
const { attempts, client } = yield* makeClient((request) => {
|
|
669
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
|
|
670
|
+
deviceFlowCount += 1
|
|
671
|
+
return jsonResponse({
|
|
672
|
+
device_auth_id: `device-auth-${deviceFlowCount}`,
|
|
673
|
+
user_code: `CODE-${deviceFlowCount}`,
|
|
674
|
+
interval: "1",
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
|
|
679
|
+
return jsonResponse({
|
|
680
|
+
authorization_code: `authorization-code-${deviceFlowCount}`,
|
|
681
|
+
code_verifier: `code-verifier-${deviceFlowCount}`,
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (request.url === `${ISSUER}/oauth/token`) {
|
|
686
|
+
return jsonResponse({
|
|
687
|
+
access_token: createTestJwt({
|
|
688
|
+
chatgpt_account_id: `device-account-${deviceFlowCount}`,
|
|
689
|
+
}),
|
|
690
|
+
refresh_token: `device-refresh-${deviceFlowCount}`,
|
|
691
|
+
expires_in: 120,
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return new Response(null, { status: 500 })
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
const auth = yield* CodexAuth.make.pipe(
|
|
699
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
const cachedToken = yield* auth.get
|
|
703
|
+
assert.strictEqual(cachedToken.refresh, "cached-refresh-token")
|
|
704
|
+
assert.strictEqual(yield* Ref.get(attempts), 0)
|
|
705
|
+
|
|
706
|
+
const authenticated = yield* auth.authenticate
|
|
707
|
+
assert.strictEqual(authenticated.refresh, "device-refresh-1")
|
|
708
|
+
assert.strictEqual(
|
|
709
|
+
Option.getOrUndefined(authenticated.accountId),
|
|
710
|
+
"device-account-1",
|
|
711
|
+
)
|
|
712
|
+
assert.strictEqual(yield* Ref.get(attempts), 3)
|
|
713
|
+
|
|
714
|
+
yield* auth.logout
|
|
715
|
+
const storedAfterLogout = yield* Effect.orDie(
|
|
716
|
+
tokenStore.get(STORE_TOKEN_KEY),
|
|
717
|
+
)
|
|
718
|
+
assert.strictEqual(Option.isNone(storedAfterLogout), true)
|
|
719
|
+
|
|
720
|
+
const tokenAfterLogout = yield* auth.get
|
|
721
|
+
assert.strictEqual(tokenAfterLogout.refresh, "device-refresh-2")
|
|
722
|
+
assert.strictEqual(
|
|
723
|
+
Option.getOrUndefined(tokenAfterLogout.accountId),
|
|
724
|
+
"device-account-2",
|
|
725
|
+
)
|
|
726
|
+
assert.strictEqual(yield* Ref.get(attempts), 6)
|
|
727
|
+
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
|
|
728
|
+
)
|
|
729
|
+
})
|