@xandreed/plugin-models 0.14.0 → 0.15.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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +30 -10
  3. package/dist/auth/anthropicOAuth.js +15 -18
  4. package/dist/auth/auth-store.conformance.d.ts +63 -0
  5. package/dist/auth/auth-store.conformance.js +152 -0
  6. package/dist/auth/credential-input.entity.d.ts +23 -5
  7. package/dist/auth/credential-input.entity.functions.d.ts +15 -5
  8. package/dist/auth/credential-input.entity.functions.js +12 -9
  9. package/dist/auth/credential-input.entity.js +5 -3
  10. package/dist/auth/credential-refresh.entity.d.ts +11 -3
  11. package/dist/auth/credential-refresh.entity.functions.d.ts +48 -6
  12. package/dist/auth/credential-refresh.entity.functions.js +12 -3
  13. package/dist/auth/localAuth.d.ts +10 -1
  14. package/dist/auth/localAuth.js +53 -79
  15. package/dist/auth/memory-auth.adapter.d.ts +12 -4
  16. package/dist/auth/memory-auth.adapter.js +13 -25
  17. package/dist/auth/openAiCodexOAuth.d.ts +26 -5
  18. package/dist/auth/openAiCodexOAuth.js +17 -28
  19. package/dist/auth/token-response.entity.d.ts +21 -0
  20. package/dist/auth/token-response.entity.functions.d.ts +20 -0
  21. package/dist/auth/token-response.entity.functions.js +10 -0
  22. package/dist/auth/token-response.entity.js +10 -0
  23. package/dist/compat/index.d.ts +2 -0
  24. package/dist/compat/index.js +1 -0
  25. package/dist/conformance/index.d.ts +6 -0
  26. package/dist/conformance/index.js +3 -0
  27. package/dist/index.d.ts +19 -19
  28. package/dist/index.js +17 -69
  29. package/dist/llm/chat-wire.entity.d.ts +123 -0
  30. package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
  31. package/dist/llm/chat-wire.entity.functions.js +10 -0
  32. package/dist/llm/chat-wire.entity.js +37 -0
  33. package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
  34. package/dist/llm/compat-telemetry.adapter.js +21 -0
  35. package/dist/llm/compat.d.ts +50 -30
  36. package/dist/llm/compat.js +149 -133
  37. package/dist/llm/model-completion.adapter.d.ts +49 -0
  38. package/dist/llm/model-completion.adapter.js +77 -0
  39. package/dist/llm/model-completion.entity.d.ts +4 -0
  40. package/dist/llm/model-completion.entity.functions.d.ts +3 -0
  41. package/dist/llm/model-completion.entity.functions.js +7 -0
  42. package/dist/llm/model-completion.entity.js +7 -0
  43. package/dist/llm/model-retry-policy.entity.d.ts +37 -0
  44. package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
  45. package/dist/llm/model-retry-policy.entity.functions.js +33 -0
  46. package/dist/llm/model-retry-policy.entity.js +23 -0
  47. package/dist/llm/model-settings.entity.d.ts +85 -0
  48. package/dist/llm/model-settings.entity.functions.d.ts +11 -0
  49. package/dist/llm/model-settings.entity.functions.js +49 -0
  50. package/dist/llm/model-settings.entity.js +28 -0
  51. package/dist/llm/model-usage.entity.d.ts +61 -0
  52. package/dist/llm/model-usage.entity.functions.d.ts +11 -0
  53. package/dist/llm/model-usage.entity.functions.js +24 -0
  54. package/dist/llm/model-usage.entity.js +19 -0
  55. package/dist/llm/modelCatalog.d.ts +29 -8
  56. package/dist/llm/modelCatalog.js +28 -66
  57. package/dist/llm/openAiCodex.d.ts +15 -7
  58. package/dist/llm/openAiCodex.js +57 -45
  59. package/dist/llm/openAiCodexWebSocket.d.ts +48 -15
  60. package/dist/llm/openAiCodexWebSocket.js +101 -147
  61. package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
  62. package/dist/llm/prompt-cache-frame.entity.js +10 -0
  63. package/dist/llm/prompt-cache.entity.d.ts +57 -0
  64. package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
  65. package/dist/llm/prompt-cache.entity.functions.js +73 -0
  66. package/dist/llm/prompt-cache.entity.js +27 -0
  67. package/dist/llm/provider-client.adapter.d.ts +9 -0
  68. package/dist/llm/provider-client.adapter.js +60 -0
  69. package/dist/llm/provider-failure.entity.d.ts +42 -0
  70. package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
  71. package/dist/llm/provider-failure.entity.functions.js +67 -0
  72. package/dist/llm/provider-failure.entity.js +20 -0
  73. package/dist/llm/provider-profile.entity.d.ts +79 -0
  74. package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
  75. package/dist/llm/provider-profile.entity.functions.js +83 -0
  76. package/dist/llm/provider-profile.entity.js +10 -0
  77. package/dist/llm/providers.d.ts +116 -34
  78. package/dist/llm/providers.js +298 -129
  79. package/dist/llm/retry.d.ts +11 -47
  80. package/dist/llm/retry.js +33 -52
  81. package/dist/llm/router.d.ts +54 -36
  82. package/dist/llm/router.js +91 -78
  83. package/dist/llm/sse.d.ts +44 -37
  84. package/dist/llm/sse.js +43 -35
  85. package/dist/llm/thinking-binding.entity.d.ts +49 -0
  86. package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
  87. package/dist/llm/thinking-binding.entity.functions.js +16 -0
  88. package/dist/llm/thinking-binding.entity.js +11 -0
  89. package/dist/llm/utilityLlm.d.ts +14 -5
  90. package/dist/llm/utilityLlm.js +18 -13
  91. package/dist/plugin.adapter.d.ts +94 -6
  92. package/dist/plugin.adapter.js +18 -13
  93. package/dist/ports/auth-store.port.d.ts +73 -0
  94. package/dist/ports/auth-store.port.js +24 -0
  95. package/dist/ports/host-credentials.port.d.ts +2 -1
  96. package/dist/ports/model-call-completion.port.d.ts +9 -0
  97. package/dist/ports/model-call-completion.port.js +2 -0
  98. package/dist/ports/model-endpoints.port.d.ts +60 -26
  99. package/dist/ports/model-endpoints.port.js +17 -3
  100. package/dist/ports/model-retry-policy.port.d.ts +17 -0
  101. package/dist/ports/model-retry-policy.port.js +3 -0
  102. package/dist/ports/model-settings.port.d.ts +32 -0
  103. package/dist/ports/model-settings.port.js +4 -0
  104. package/dist/ports/models-transport.port.d.ts +9 -2
  105. package/dist/ports/models-transport.port.js +4 -1
  106. package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
  107. package/dist/ports/prompt-cache-frame.port.js +4 -0
  108. package/dist/ports/settings-store.port.d.ts +70 -0
  109. package/dist/ports/settings-store.port.js +33 -0
  110. package/dist/ports/thinking-binding.port.d.ts +7 -0
  111. package/dist/ports/thinking-binding.port.js +4 -0
  112. package/dist/settings/localSettings.d.ts +2 -1
  113. package/dist/settings/localSettings.js +17 -20
  114. package/dist/settings/memory-settings.adapter.d.ts +3 -1
  115. package/dist/settings/memory-settings.adapter.js +1 -1
  116. package/dist/settings/roleView.d.ts +3 -1
  117. package/dist/settings/roleView.js +1 -1
  118. package/dist/settings/settings-store.conformance.d.ts +47 -0
  119. package/dist/settings/settings-store.conformance.js +100 -0
  120. package/dist/settings/settings-value.entity.d.ts +10 -2
  121. package/dist/settings/settings-value.entity.functions.d.ts +31 -9
  122. package/dist/settings/settings-value.entity.functions.js +5 -6
  123. package/dist/storage/json-file.d.ts +18 -0
  124. package/dist/storage/json-file.js +26 -0
  125. package/dist/transport/http-proxy.entity.d.ts +17 -3
  126. package/dist/transport/model-request.conformance.d.ts +13 -0
  127. package/dist/transport/model-request.conformance.js +36 -0
  128. package/dist/transport/models-transport.adapter.d.ts +18 -17
  129. package/dist/transport/models-transport.adapter.js +28 -31
  130. package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
  131. package/dist/transport/resilient-http-client.adapter.js +177 -0
  132. package/package.json +11 -5
  133. package/dist/auth/anthropicOAuth.test.d.ts +0 -1
  134. package/dist/auth/credential-refresh.entity.functions.test.d.ts +0 -1
  135. package/dist/auth/localAuth.test.d.ts +0 -1
  136. package/dist/auth/memory-auth.adapter.test.d.ts +0 -1
  137. package/dist/llm/compat.test.d.ts +0 -1
  138. package/dist/llm/modelCatalog.test.d.ts +0 -1
  139. package/dist/llm/openAiCodexWebSocket.test.d.ts +0 -1
  140. package/dist/llm/providers.test.d.ts +0 -1
  141. package/dist/llm/retry.test.d.ts +0 -1
  142. package/dist/llm/router.test.d.ts +0 -1
  143. package/dist/llm/sse.test.d.ts +0 -1
  144. package/dist/plugin.adapter.test.d.ts +0 -1
  145. package/dist/settings/localSettings.test.d.ts +0 -1
  146. package/dist/settings/memory-settings.adapter.test.d.ts +0 -1
  147. package/dist/settings/roleView.test.d.ts +0 -1
  148. package/dist/settings/settings-value.entity.functions.test.d.ts +0 -1
  149. package/dist/transport/http-proxy.entity.functions.test.d.ts +0 -1
  150. package/dist/transport/models-transport.adapter.test.d.ts +0 -1
  151. package/dist/transport/recording-proxy.testing.d.ts +0 -33
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-models
2
2
 
3
- Efferent composable agent framework, 0.14.0. Node 24.5 or Bun.
3
+ Efferent composable agent framework, 0.15.0. Node 24.5 or Bun.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
@@ -1,5 +1,5 @@
1
- import { Effect, Option } from "effect";
2
- import { AuthError } from "@xandreed/core";
1
+ import { Effect, Option, Redacted } from "effect";
2
+ import { AuthError } from "../ports/auth-store.port.js";
3
3
  import type { ModelsFetch } from "../ports/models-transport.port.js";
4
4
  /**
5
5
  * Anthropic OAuth (Claude Pro/Max subscription) — the full protocol:
@@ -16,9 +16,14 @@ export declare const ANTHROPIC_REDIRECT_URI = "http://localhost:53692/callback";
16
16
  export declare const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
17
17
  /** Anthropic rejects OAuth tokens unless the first system block is exactly this. */
18
18
  export declare const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
19
+ /**
20
+ * The tokens a sign-in or a refresh grants, `Redacted` from the token response on.
21
+ *
22
+ * @public
23
+ */
19
24
  export interface RefreshedTokens {
20
- readonly access: string;
21
- readonly refresh: string;
25
+ readonly access: Redacted.Redacted<string>;
26
+ readonly refresh: Redacted.Redacted<string>;
22
27
  /** Absolute expiry, epoch ms (with a 5-minute early-refresh skew baked in). */
23
28
  readonly expires: number;
24
29
  }
@@ -28,6 +33,8 @@ export interface RefreshedTokens {
28
33
  * the request goes through the models plugin's proxy); `now` is
29
34
  * the time the new token's expiry is counted from, in epoch ms (default: the
30
35
  * Effect `Clock`).
36
+ *
37
+ * @public
31
38
  */
32
39
  export interface TokenRequestOptions {
33
40
  readonly fetch?: ModelsFetch;
@@ -35,14 +42,19 @@ export interface TokenRequestOptions {
35
42
  }
36
43
  /** The time a token's expiry is counted from: the caller's, or the Effect Clock's. */
37
44
  export declare const tokenTime: (options: TokenRequestOptions) => Effect.Effect<number>;
38
- /** Refresh an expired (or near-expiry) access token. */
39
- export declare const refreshAnthropicToken: (refreshToken: string, options?: TokenRequestOptions) => Effect.Effect<RefreshedTokens, AuthError>;
45
+ /**
46
+ * Refresh an expired (or near-expiry) access token; the refresh token is unwrapped only into the request body.
47
+ *
48
+ * @public
49
+ */
50
+ export declare const refreshAnthropicToken: (refreshToken: Redacted.Redacted<string>, options?: TokenRequestOptions) => Effect.Effect<RefreshedTokens, AuthError>;
40
51
  export interface Pkce {
41
52
  readonly verifier: string;
42
53
  readonly challenge: string;
43
54
  }
44
- /** Generate a PKCE verifier + S256 challenge (Web Crypto; Bun-global). */
55
+ /** Generate a PKCE verifier (32 bytes of the platform's cryptographic generator) + its S256 challenge. */
45
56
  export declare const generatePkce: Effect.Effect<Pkce>;
57
+ /** @public */
46
58
  export interface OAuthBegin {
47
59
  /** The PKCE verifier — ALSO the `state`; the driver must verify the
48
60
  * redirect echoes it (CSRF) before exchanging. */
@@ -51,14 +63,22 @@ export interface OAuthBegin {
51
63
  readonly callbackPort: number;
52
64
  readonly callbackPath: string;
53
65
  }
54
- /** Start an authorization: PKCE material + the browser URL. */
66
+ /**
67
+ * Start an authorization: PKCE material + the browser URL.
68
+ *
69
+ * @public
70
+ */
55
71
  export declare const beginAnthropicOAuth: Effect.Effect<OAuthBegin>;
56
- /** Exchange the authorization code for tokens. */
72
+ /** Exchange the authorization code for tokens, `Redacted` (ready for `AuthStore.set`). */
57
73
  export declare const exchangeAnthropicCode: (code: string, verifier: string, options?: TokenRequestOptions) => Effect.Effect<RefreshedTokens, AuthError>;
74
+ /** @public */
58
75
  export interface AuthRedirect {
59
76
  readonly code: Option.Option<string>;
60
77
  readonly state: Option.Option<string>;
61
78
  }
62
79
  /** Pull `code`/`state` out of a pasted redirect URL, `code#state` pair,
63
- * bare query string, or raw code. Total — garbage is `{none, none}`. */
80
+ * bare query string, or raw code. Total — garbage is `{none, none}`.
81
+ *
82
+ * @public
83
+ */
64
84
  export declare const parseAuthorizationInput: (input: string) => AuthRedirect;
@@ -1,6 +1,8 @@
1
- import { Clock, Effect, Option } from "effect";
2
- import { AuthError } from "@xandreed/core";
1
+ import { createHash } from "node:crypto";
2
+ import { Clock, Effect, Option, Redacted } from "effect";
3
+ import { AuthError } from "../ports/auth-store.port.js";
3
4
  import { redactCredentials } from "../transport/http-proxy.entity.functions.js";
5
+ import { decodeTokenResponse, tokenRefusalText } from "./token-response.entity.functions.js";
4
6
  export const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
5
7
  const AUTHORIZE_URL = "https://claude.ai/oauth/authorize", TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
6
8
  export const ANTHROPIC_CALLBACK_PORT = 53692, ANTHROPIC_CALLBACK_PATH = "/callback", ANTHROPIC_REDIRECT_URI = `http://localhost:${ANTHROPIC_CALLBACK_PORT}${ANTHROPIC_CALLBACK_PATH}`;
@@ -11,7 +13,7 @@ const oauthError = (stage) => (detail) => new AuthError({
11
13
  message: `OAuth ${stage} failed: ${detail}`
12
14
  });
13
15
  export const tokenTime = (options) => options.now === void 0 ? Clock.currentTimeMillis : Effect.succeed(options.now);
14
- const postToken = (body, stage, options) => {
16
+ const postToken = (body, stage, options, previous) => {
15
17
  const fail = oauthError(stage);
16
18
  return Effect.tryPromise({
17
19
  try: async () => {
@@ -24,26 +26,21 @@ const postToken = (body, stage, options) => {
24
26
  return { ok: res.ok, status: res.status, text: await res.text() };
25
27
  },
26
28
  catch: (e) => fail(redactCredentials(String(e)))
27
- }).pipe(Effect.filterOrFail((res) => res.ok, (res) => fail(`status ${res.status}: ${res.text.slice(0, 200)}`)), Effect.flatMap((res) => Effect.try({
28
- try: () => JSON.parse(res.text),
29
- catch: () => fail("the token response was not JSON")
30
- })), Effect.flatMap((data) => Effect.map(tokenTime(options), (now) => ({
31
- access: data.access_token,
32
- refresh: data.refresh_token,
33
- expires: now + data.expires_in * 1000 - 300000
29
+ }).pipe(Effect.filterOrFail((res) => res.ok, (res) => fail(tokenRefusalText(res.status, res.text, body))), Effect.flatMap((res) => decodeTokenResponse(res.text, previous).pipe(Effect.mapError(fail))), Effect.flatMap((issued) => Effect.map(tokenTime(options), (now) => ({
30
+ access: issued.access,
31
+ refresh: issued.refresh,
32
+ expires: now + issued.lifetimeMs - 300000
34
33
  }))));
35
34
  };
36
35
  export const refreshAnthropicToken = (refreshToken, options = {}) => postToken({
37
36
  grant_type: "refresh_token",
38
37
  client_id: ANTHROPIC_CLIENT_ID,
39
- refresh_token: refreshToken
40
- }, "token refresh", options);
38
+ refresh_token: Redacted.value(refreshToken)
39
+ }, "token refresh", options, Option.some(refreshToken));
41
40
  const base64url = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
42
- export const generatePkce = Effect.promise(async () => {
43
- const verifierBytes = new Uint8Array(32);
44
- crypto.getRandomValues(verifierBytes);
45
- const verifier = base64url(verifierBytes), digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
46
- return { verifier, challenge: base64url(new Uint8Array(digest)) };
41
+ export const generatePkce = Effect.sync(() => {
42
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
43
+ return { verifier, challenge: base64url(new Uint8Array(createHash("sha256").update(verifier).digest())) };
47
44
  }), beginAnthropicOAuth = Effect.map(generatePkce, (pkce) => ({
48
45
  verifier: pkce.verifier,
49
46
  authorizeUrl: `${AUTHORIZE_URL}?${new URLSearchParams({
@@ -65,7 +62,7 @@ export const generatePkce = Effect.promise(async () => {
65
62
  redirect_uri: ANTHROPIC_REDIRECT_URI,
66
63
  code_verifier: verifier,
67
64
  state: verifier
68
- }, "code exchange", options);
65
+ }, "code exchange", options, Option.none());
69
66
  const fromNullableTrimmed = (value) => Option.filter(Option.fromNullishOr(value), (v) => v.trim().length > 0);
70
67
  export const parseAuthorizationInput = (input) => {
71
68
  const value = input.trim();
@@ -0,0 +1,63 @@
1
+ import { Effect } from "effect";
2
+ import type { Context, Scope } from "effect";
3
+ import { ProviderId } from "@xandreed/core";
4
+ import type { ConformanceCheck } from "@xandreed/core/conformance";
5
+ import type { AuthStore } from "../ports/auth-store.port.js";
6
+ type Store = Context.Service.Shape<typeof AuthStore>;
7
+ /**
8
+ * The token endpoint a store refreshes through, as the kit sees it: the
9
+ * test builds the store over a fetch (or an equivalent) that it controls,
10
+ * and hands the kit this view of it.
11
+ *
12
+ * @public
13
+ */
14
+ export interface AuthStoreRefreshProbe {
15
+ /**
16
+ * A provider whose expired OAuth token the store refreshes through this
17
+ * endpoint (`anthropic`, `openai-codex`). The refresh checks write and
18
+ * remove its credential, then put it back as they found it.
19
+ */
20
+ readonly provider: ProviderId;
21
+ /** The token requests the endpoint has received so far. */
22
+ readonly requests: Effect.Effect<number>;
23
+ /**
24
+ * Hold the endpoint: a request that arrives waits, unanswered, until the
25
+ * returned effect runs. Running that effect again does nothing.
26
+ */
27
+ readonly hold: Effect.Effect<Effect.Effect<void>>;
28
+ }
29
+ /**
30
+ * What the kit can do to a store beyond calling it; each option turns on its checks.
31
+ *
32
+ * @public
33
+ */
34
+ export interface AuthStoreConformanceOptions {
35
+ /** The endpoint the store refreshes through: the refresh-once and refresh-remove checks. */
36
+ readonly refresh?: AuthStoreRefreshProbe;
37
+ /**
38
+ * Damage the store's backing data (a file that is not JSON) for the
39
+ * scope's lifetime, and repair it when the scope closes: the corrupt check.
40
+ * A store with nothing to damage, such as one in memory, leaves it out.
41
+ */
42
+ readonly corrupt?: Effect.Effect<void, never, Scope.Scope>;
43
+ }
44
+ /**
45
+ * The AuthStore contract, as checks any store must pass: what `get`, `all`
46
+ * and `resolveKey` say about each kind of credential (every secret
47
+ * `Redacted`, holding the value that was set), what `set` and `remove`
48
+ * change, and that providers do not share state. Every check uses
49
+ * fresh provider ids (`conformance-<uuid>`, which no store can refresh) and
50
+ * removes them when it ends, so the kit can run against a shared store.
51
+ *
52
+ * With `options.refresh`, two checks refresh the probe's provider through
53
+ * the endpoint the test controls: concurrent calls on one expired token send
54
+ * one token request, and a `remove` made while it is in flight sticks. They
55
+ * wait for the store's requests on the live clock (up to two seconds), so
56
+ * run them without a `TestClock`. With
57
+ * `options.corrupt`, a store whose backing data cannot be read fails every
58
+ * call with an `AuthError` of code `auth.corrupt` instead of reading empty.
59
+ *
60
+ * @public
61
+ */
62
+ export declare const authStoreConformance: (store: Store, options?: AuthStoreConformanceOptions) => ReadonlyArray<ConformanceCheck>;
63
+ export {};
@@ -0,0 +1,152 @@
1
+ import { Clock, Effect, Fiber, Match, Option, Redacted, Schema } from "effect";
2
+ import { harnessCodeOf, ProviderId } from "@xandreed/core";
3
+ import { check, ConformanceFailure } from "@xandreed/core/conformance";
4
+ import { randomUuid } from "@xandreed/core/internal";
5
+ import { Credential } from "../ports/auth-store.port.js";
6
+ const fail = (id, message) => Effect.fail(new ConformanceFailure({ check: id, message })), expect = (id, holds, message) => holds ? Effect.void : fail(id, message), HOUR = 3600000, CALLERS = 4, WAIT = "2 seconds", SETTLE = "100 millis", freshProvider = Effect.map(randomUuid, (id) => ProviderId.make(`conformance-${id}`)), secret = (label) => Effect.map(randomUuid, (id) => Redacted.make(`conformance-${label}-${id}`)), secretsOf = (credential) => Match.value(credential).pipe(Match.discriminatorsExhaustive("type")({
7
+ api_key: ({ key }) => [key],
8
+ oauth: ({ access, refresh }) => [access, refresh],
9
+ local: () => []
10
+ })), redactedThroughout = (credential) => secretsOf(credential).every(Redacted.isRedacted), equivalent = Schema.toEquivalence(Credential), same = (a, b) => Option.exists(a, (value) => redactedThroughout(value) && equivalent(value, b)), keyOf = (id, resolved) => Option.match(resolved, {
11
+ onNone: () => Effect.succeed(Option.none()),
12
+ onSome: (key) => Redacted.isRedacted(key) ? Effect.succeed(Option.some(Redacted.value(key))) : fail(id, "the resolved key is not Redacted")
13
+ }), withProviders = (store, count, body) => Effect.forEach(Array.from({ length: count }), () => freshProvider, { concurrency: 1 }).pipe(Effect.flatMap((providers) => body(providers).pipe(Effect.ensuring(Effect.forEach(providers, (provider) => store.remove(provider).pipe(Effect.ignore({ log: "Warn", message: `the kit could not remove its provider ${provider}` })), { concurrency: 1, discard: !0 }))))), withProbeProvider = (store, probe, body) => store.get(probe.provider).pipe(Effect.flatMap((before) => body(probe.provider).pipe(Effect.ensuring(Option.match(before, {
14
+ onNone: () => store.remove(probe.provider),
15
+ onSome: (credential) => store.set(probe.provider, credential)
16
+ }).pipe(Effect.ignore({ log: "Warn", message: `the kit could not put back the credential of ${probe.provider}` })))))), expiredToken = Effect.gen(function* () {
17
+ const now = yield* Clock.currentTimeMillis;
18
+ return { type: "oauth", access: yield* secret("stale"), refresh: yield* secret("refresh"), expires: now - HOUR };
19
+ }), requestsAbove = (probe, floor, within) => {
20
+ const poll = probe.requests.pipe(Effect.flatMap((count) => count > floor ? Effect.succeed(count) : Effect.sleep("2 millis").pipe(Effect.andThen(Effect.suspend(() => poll)))));
21
+ return Effect.timeoutOption(poll, within).pipe(Effect.flatMap(Option.match({ onNone: () => probe.requests, onSome: Effect.succeed })));
22
+ }, corrupted = (what, effect) => Effect.result(effect).pipe(Effect.flatMap((result) => {
23
+ if (result._tag === "Success")
24
+ return fail("corrupt", `${what} succeeded on a store whose backing data cannot be read`);
25
+ const error = result.failure, tag = typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "an error", code = Option.getOrElse(harnessCodeOf(error), () => "no code");
26
+ return expect("corrupt", tag === "AuthError" && code === "auth.corrupt", `${what} failed with ${tag} (${code}), not an AuthError of code auth.corrupt`);
27
+ })), refreshChecks = (store, probe) => [
28
+ check(`${CALLERS} concurrent calls on one expired OAuth token send one refresh request and all get its new token`, "refresh-once", () => withProbeProvider(store, probe, (provider) => Effect.gen(function* () {
29
+ const stale = yield* expiredToken;
30
+ yield* store.set(provider, stale);
31
+ const before = yield* probe.requests, keys = yield* Effect.acquireUseRelease(probe.hold, (release) => Effect.gen(function* () {
32
+ const callers = yield* Effect.forEach(Array.from({ length: CALLERS }), () => Effect.forkChild(store.resolveKey(provider)), { concurrency: 1 });
33
+ yield* expect("refresh-once", (yield* requestsAbove(probe, before, WAIT)) > before, `no refresh request reached the endpoint within ${WAIT}`);
34
+ yield* requestsAbove(probe, before + 1, SETTLE);
35
+ yield* release;
36
+ return yield* Fiber.joinAll(callers);
37
+ }), (release) => release), sent = (yield* probe.requests) - before;
38
+ yield* expect("refresh-once", sent === 1, `${CALLERS} concurrent calls on one expired token sent ${sent} refresh requests`);
39
+ const values = yield* Effect.forEach(keys, (key) => keyOf("refresh-once", key), { concurrency: 1 }), first = Option.getOrUndefined(values[0] ?? Option.none());
40
+ yield* expect("refresh-once", first !== void 0 && first !== Redacted.value(stale.access), "the first call did not get a new token");
41
+ yield* expect("refresh-once", values.every((value) => Option.getOrUndefined(value) === first), "the concurrent calls got different keys");
42
+ const stored = yield* store.get(provider);
43
+ yield* expect("refresh-once", Option.exists(stored, (credential) => credential.type === "oauth" && redactedThroughout(credential) && Redacted.value(credential.access) === first), "the store does not hold the refreshed token");
44
+ }))),
45
+ check("a remove made while a refresh is in flight stays a remove: the refreshed token is not stored", "refresh-remove", () => withProbeProvider(store, probe, (provider) => Effect.gen(function* () {
46
+ yield* store.set(provider, yield* expiredToken);
47
+ const before = yield* probe.requests;
48
+ yield* Effect.acquireUseRelease(probe.hold, (release) => Effect.gen(function* () {
49
+ const refreshing = yield* Effect.forkChild(store.resolveKey(provider));
50
+ yield* expect("refresh-remove", (yield* requestsAbove(probe, before, WAIT)) > before, `no refresh request reached the endpoint within ${WAIT}`);
51
+ const removing = yield* Effect.forkChild(store.remove(provider));
52
+ yield* Effect.timeoutOption(Fiber.await(removing), SETTLE);
53
+ yield* release;
54
+ yield* Fiber.join(removing);
55
+ yield* Effect.result(Fiber.join(refreshing));
56
+ }), (release) => release);
57
+ yield* expect("refresh-remove", Option.isNone(yield* store.get(provider)), "get finds a credential after the remove");
58
+ yield* expect("refresh-remove", !(yield* store.all).has(provider), "all lists the provider after the remove");
59
+ yield* expect("refresh-remove", Option.isNone(yield* store.resolveKey(provider)), "resolveKey finds a key after the remove");
60
+ })))
61
+ ], corruptCheck = (store, corrupt) => check("a store whose backing data cannot be read fails every call with an AuthError of code auth.corrupt, never reads it as empty", "corrupt", () => withProviders(store, 2, ([provider, other]) => Effect.gen(function* () {
62
+ const id = provider, kept = { type: "api_key", key: yield* secret("kept") };
63
+ yield* store.set(id, kept);
64
+ yield* Effect.scoped(Effect.gen(function* () {
65
+ yield* corrupt;
66
+ yield* corrupted("get", store.get(id));
67
+ yield* corrupted("all", store.all);
68
+ yield* corrupted("resolveKey", store.resolveKey(id));
69
+ yield* corrupted("set", store.set(other, { type: "api_key", key: yield* secret("other") }));
70
+ yield* corrupted("remove", store.remove(id));
71
+ }));
72
+ yield* expect("corrupt", same(yield* store.get(id), kept), "once the backing data is repaired, the store does not read it again");
73
+ })));
74
+ export const authStoreConformance = (store, options = {}) => [
75
+ check("an absent provider has no credential, no key and no entry", "absent", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
76
+ const id = provider;
77
+ yield* expect("absent", Option.isNone(yield* store.get(id)), "get found a credential");
78
+ yield* expect("absent", Option.isNone(yield* store.resolveKey(id)), "resolveKey found a key");
79
+ yield* expect("absent", !(yield* store.all).has(id), "all lists the provider");
80
+ }))),
81
+ check("an api_key credential reads back, is listed, and resolves to its key as Redacted", "api-key", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
82
+ const id = provider, credential = { type: "api_key", key: yield* secret("key") };
83
+ yield* store.set(id, credential);
84
+ yield* expect("api-key", same(yield* store.get(id), credential), "get returned a different credential");
85
+ yield* expect("api-key", same(Option.fromNullishOr((yield* store.all).get(id)), credential), "all lists a different credential, or none");
86
+ const key = yield* keyOf("api-key", yield* store.resolveKey(id));
87
+ yield* expect("api-key", Option.getOrUndefined(key) === Redacted.value(credential.key), "resolveKey did not return the stored key");
88
+ }))),
89
+ check("a local credential reads back and resolves to no key", "local", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
90
+ const id = provider, credential = { type: "local", baseUrl: "http://127.0.0.1:1/v1" };
91
+ yield* store.set(id, credential);
92
+ yield* expect("local", same(yield* store.get(id), credential), "get returned a different credential");
93
+ yield* expect("local", Option.isNone(yield* store.resolveKey(id)), "resolveKey returned a key for a local credential");
94
+ }))),
95
+ check("an OAuth token that has not expired resolves to its access token", "oauth-valid", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
96
+ const id = provider, now = yield* Clock.currentTimeMillis, credential = { type: "oauth", access: yield* secret("access"), refresh: yield* secret("refresh"), expires: now + HOUR };
97
+ yield* store.set(id, credential);
98
+ yield* expect("oauth-valid", same(yield* store.get(id), credential), "get returned a different credential");
99
+ const key = yield* keyOf("oauth-valid", yield* store.resolveKey(id));
100
+ yield* expect("oauth-valid", Option.getOrUndefined(key) === Redacted.value(credential.access), "resolveKey did not return the access token");
101
+ }))),
102
+ check("get and all hand out every secret Redacted", "redacted", () => withProviders(store, 2, ([first, second]) => Effect.gen(function* () {
103
+ const now = yield* Clock.currentTimeMillis, written = [
104
+ [first, { type: "api_key", key: yield* secret("key") }],
105
+ [second, { type: "oauth", access: yield* secret("access"), refresh: yield* secret("refresh"), expires: now + HOUR }]
106
+ ];
107
+ yield* Effect.forEach(written, ([id, credential]) => store.set(id, credential), { concurrency: 1, discard: !0 });
108
+ const all = yield* store.all;
109
+ yield* Effect.forEach(written, ([id, credential]) => Effect.gen(function* () {
110
+ yield* expect("redacted", Option.exists(yield* store.get(id), redactedThroughout), `get returned no ${credential.type} credential, or one whose secrets are not all Redacted`);
111
+ yield* expect("redacted", Option.exists(Option.fromNullishOr(all.get(id)), redactedThroughout), `all lists no ${credential.type} credential, or one whose secrets are not all Redacted`);
112
+ }), { concurrency: 1, discard: !0 });
113
+ }))),
114
+ check("an expired OAuth token the store cannot refresh fails with an AuthError naming the provider, never the stale token", "oauth-expired", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
115
+ const id = provider, now = yield* Clock.currentTimeMillis;
116
+ yield* store.set(id, { type: "oauth", access: yield* secret("stale"), refresh: yield* secret("refresh"), expires: now - HOUR });
117
+ const resolved = yield* Effect.result(store.resolveKey(id));
118
+ if (resolved._tag === "Success")
119
+ return yield* fail("oauth-expired", Option.isSome(resolved.success) ? "resolveKey returned the expired token" : "resolveKey found no key instead of failing");
120
+ yield* expect("oauth-expired", resolved.failure._tag === "AuthError" && resolved.failure.provider === id, `failed with ${resolved.failure._tag} for ${resolved.failure.provider}`);
121
+ }))),
122
+ check("a second set replaces the first", "overwrite", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
123
+ const id = provider, second = { type: "api_key", key: yield* secret("second") };
124
+ yield* store.set(id, { type: "api_key", key: yield* secret("first") });
125
+ yield* store.set(id, second);
126
+ yield* expect("overwrite", same(yield* store.get(id), second), "get still returns the first credential");
127
+ const key = yield* keyOf("overwrite", yield* store.resolveKey(id));
128
+ yield* expect("overwrite", Option.getOrUndefined(key) === Redacted.value(second.key), "resolveKey still returns the first key");
129
+ }))),
130
+ check("remove deletes a credential wherever it is read, and removing an absent provider succeeds", "remove", () => withProviders(store, 2, ([provider, unknown]) => Effect.gen(function* () {
131
+ const id = provider;
132
+ yield* store.set(id, { type: "api_key", key: yield* secret("removed") });
133
+ yield* store.remove(id);
134
+ yield* expect("remove", Option.isNone(yield* store.get(id)), "get still finds the credential");
135
+ yield* expect("remove", Option.isNone(yield* store.resolveKey(id)), "resolveKey still finds a key");
136
+ yield* expect("remove", !(yield* store.all).has(id), "all still lists the provider");
137
+ yield* store.remove(id);
138
+ yield* store.remove(unknown);
139
+ }))),
140
+ check("providers are isolated: writing or removing one leaves another as it was", "isolation", () => withProviders(store, 2, ([first, second]) => Effect.gen(function* () {
141
+ const a = first, b = second, kept = { type: "api_key", key: yield* secret("kept") };
142
+ yield* store.set(b, kept);
143
+ yield* store.set(a, { type: "api_key", key: yield* secret("other") });
144
+ yield* store.remove(a);
145
+ yield* store.set(a, { type: "local" });
146
+ yield* expect("isolation", same(yield* store.get(b), kept), "another provider's credential changed");
147
+ const key = yield* keyOf("isolation", yield* store.resolveKey(b));
148
+ yield* expect("isolation", Option.getOrUndefined(key) === Redacted.value(kept.key), "another provider's key changed");
149
+ }))),
150
+ ...Option.match(Option.fromNullishOr(options.refresh), { onNone: () => [], onSome: (probe) => refreshChecks(store, probe) }),
151
+ ...Option.match(Option.fromNullishOr(options.corrupt), { onNone: () => [], onSome: (corrupt) => [corruptCheck(store, corrupt)] })
152
+ ];
@@ -1,4 +1,18 @@
1
1
  import { Schema } from "effect";
2
+ /**
3
+ * The provider account an OAuth credential is for (the ChatGPT account of an `openai-codex` subscription).
4
+ *
5
+ * @public
6
+ */
7
+ export declare const AccountId: Schema.brand<Schema.String, "AccountId">;
8
+ export type AccountId = typeof AccountId.Type;
9
+ /**
10
+ * The installation an OAuth credential was issued to, when its provider names one.
11
+ *
12
+ * @public
13
+ */
14
+ export declare const InstallationId: Schema.brand<Schema.String, "InstallationId">;
15
+ export type InstallationId = typeof InstallationId.Type;
2
16
  export declare const CredentialInput: Schema.Union<readonly [Schema.Struct<{
3
17
  readonly type: Schema.Literal<"api_key">;
4
18
  readonly key: Schema.Redacted<Schema.String>;
@@ -8,14 +22,18 @@ export declare const CredentialInput: Schema.Union<readonly [Schema.Struct<{
8
22
  readonly refresh: Schema.Redacted<Schema.String>;
9
23
  /** Absolute expiry, epoch ms. */
10
24
  readonly expires: Schema.Number;
11
- readonly accountId: Schema.optionalKey<Schema.String>;
12
- readonly installationId: Schema.optionalKey<Schema.String>;
25
+ readonly accountId: Schema.optionalKey<Schema.brand<Schema.String, "AccountId">>;
26
+ readonly installationId: Schema.optionalKey<Schema.brand<Schema.String, "InstallationId">>;
13
27
  }>, Schema.Struct<{
14
28
  readonly type: Schema.Literal<"local">;
15
29
  readonly baseUrl: Schema.optionalKey<Schema.String>;
16
30
  }>]>;
17
31
  export type CredentialInput = typeof CredentialInput.Type;
18
- /** Credentials by provider id. */
32
+ /**
33
+ * Credentials by provider id.
34
+ *
35
+ * @public
36
+ */
19
37
  export declare const CredentialInputs: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.Struct<{
20
38
  readonly type: Schema.Literal<"api_key">;
21
39
  readonly key: Schema.Redacted<Schema.String>;
@@ -25,8 +43,8 @@ export declare const CredentialInputs: Schema.$Record<Schema.String, Schema.Unio
25
43
  readonly refresh: Schema.Redacted<Schema.String>;
26
44
  /** Absolute expiry, epoch ms. */
27
45
  readonly expires: Schema.Number;
28
- readonly accountId: Schema.optionalKey<Schema.String>;
29
- readonly installationId: Schema.optionalKey<Schema.String>;
46
+ readonly accountId: Schema.optionalKey<Schema.brand<Schema.String, "AccountId">>;
47
+ readonly installationId: Schema.optionalKey<Schema.brand<Schema.String, "InstallationId">>;
30
48
  }>, Schema.Struct<{
31
49
  readonly type: Schema.Literal<"local">;
32
50
  readonly baseUrl: Schema.optionalKey<Schema.String>;
@@ -1,8 +1,18 @@
1
- import type { Credential } from "@xandreed/core";
1
+ import { Effect } from "effect";
2
+ import { AuthError, Credential } from "../ports/auth-store.port.js";
2
3
  import type { CredentialInput, CredentialInputs } from "./credential-input.entity.js";
3
- /** The stored form of a credential a host handed over: its secrets unwrapped. */
4
- export declare const toCredential: (input: CredentialInput) => Credential;
5
- /** A stored credential as a host receives it: its secrets `Redacted`. */
4
+ /**
5
+ * A credential in its stored form, secrets in the clear: what `auth.json`
6
+ * holds for it. A secret that is not `Redacted` (a plain string from
7
+ * untyped code), or a field the credential does not type so, fails with an
8
+ * `AuthError` naming the provider, never the value.
9
+ */
10
+ export declare const encodeCredential: (provider: string, credential: Credential) => Effect.Effect<typeof Credential.Encoded, AuthError>;
11
+ /**
12
+ * A stored credential as a host receives it: the same secrets, already
13
+ * `Redacted`, and an OAuth credential's account and installation ids
14
+ * branded (`AccountId`, `InstallationId`).
15
+ */
6
16
  export declare const toCredentialInput: (credential: Credential) => CredentialInput;
7
- /** Handed-over credentials, by provider id, in their stored form. */
17
+ /** Handed-over credentials, by provider id, as the store keeps them. */
8
18
  export declare const credentialsOf: (inputs: CredentialInputs) => ReadonlyMap<string, Credential>;
@@ -1,9 +1,12 @@
1
- import { Match, Redacted } from "effect";
2
- export const toCredential = (input) => Match.value(input).pipe(Match.when({ type: "api_key" }, ({ key }) => ({ type: "api_key", key: Redacted.value(key) })), Match.when({ type: "oauth" }, ({ access, refresh, ...rest }) => ({ ...rest, access: Redacted.value(access), refresh: Redacted.value(refresh) })), Match.when({ type: "local" }, (local) => local), Match.exhaustive), toCredentialInput = (credential) => Match.value(credential).pipe(Match.when({ type: "api_key" }, ({ key }) => ({ type: "api_key", key: Redacted.make(key) })), Match.when({ type: "oauth" }, ({ type, access, refresh, expires, accountId, installationId }) => ({
3
- type,
4
- expires,
5
- access: Redacted.make(access),
6
- refresh: Redacted.make(refresh),
7
- ...accountId === void 0 ? {} : { accountId },
8
- ...installationId === void 0 ? {} : { installationId }
9
- })), Match.when({ type: "local" }, ({ type, baseUrl }) => ({ type, ...baseUrl === void 0 ? {} : { baseUrl } })), Match.exhaustive), credentialsOf = (inputs) => new Map(Object.entries(inputs).map(([provider, input]) => [provider, toCredential(input)]));
1
+ import { Effect, Match, Schema } from "effect";
2
+ import { AuthError, Credential } from "../ports/auth-store.port.js";
3
+ import { AccountId, InstallationId } from "./credential-input.entity.js";
4
+ const encode = Schema.encodeEffect(Credential);
5
+ export const encodeCredential = (provider, credential) => encode(credential).pipe(Effect.mapError(() => new AuthError({
6
+ provider,
7
+ message: `the ${provider} credential cannot be stored: its secrets must be Redacted values (Redacted.make(...)), and its other fields those of a credential`
8
+ }))), toCredentialInput = (credential) => Match.value(credential).pipe(Match.when({ type: "api_key" }, (apiKey) => apiKey), Match.when({ type: "oauth" }, ({ accountId, installationId, ...oauth }) => ({
9
+ ...oauth,
10
+ ...accountId === void 0 ? {} : { accountId: AccountId.make(accountId) },
11
+ ...installationId === void 0 ? {} : { installationId: InstallationId.make(installationId) }
12
+ })), Match.when({ type: "local" }, (local) => local), Match.exhaustive), credentialsOf = (inputs) => new Map(Object.entries(inputs));
@@ -1,7 +1,9 @@
1
1
  import { Schema } from "effect";
2
2
  const secret = Schema.Redacted(Schema.String).annotate({
3
3
  message: "a secret must be a Redacted value (Redacted.make(...)); JSON configuration names an environment variable in credentialEnv instead"
4
- }), unexpectedKey = { messageUnexpectedKey: "not a field of a credential" };
4
+ });
5
+ export const AccountId = Schema.String.pipe(Schema.brand("AccountId")), InstallationId = Schema.String.pipe(Schema.brand("InstallationId"));
6
+ const unexpectedKey = { messageUnexpectedKey: "not a field of a credential" };
5
7
  export const CredentialInput = Schema.Union([
6
8
  Schema.Struct({ type: Schema.Literal("api_key"), key: secret }).annotate(unexpectedKey),
7
9
  Schema.Struct({
@@ -9,8 +11,8 @@ export const CredentialInput = Schema.Union([
9
11
  access: secret,
10
12
  refresh: secret,
11
13
  expires: Schema.Number.annotate({ message: "expires is the access token's expiry, in epoch ms" }).check(Schema.isFinite()),
12
- accountId: Schema.optionalKey(Schema.String),
13
- installationId: Schema.optionalKey(Schema.String)
14
+ accountId: Schema.optionalKey(AccountId),
15
+ installationId: Schema.optionalKey(InstallationId)
14
16
  }).annotate(unexpectedKey),
15
17
  Schema.Struct({ type: Schema.Literal("local"), baseUrl: Schema.optionalKey(Schema.String) }).annotate(unexpectedKey)
16
18
  ]).annotate({
@@ -1,9 +1,17 @@
1
1
  import { Schema } from "effect";
2
- import type { Credential } from "@xandreed/core";
3
- /** The providers whose OAuth tokens a store can refresh: each has its token endpoint in an auth module. */
2
+ import type { Credential } from "../ports/auth-store.port.js";
3
+ /**
4
+ * The providers whose OAuth tokens a store can refresh: each has its token endpoint in an auth module.
5
+ *
6
+ * @public
7
+ */
4
8
  export declare const RefreshableProvider: Schema.Literals<readonly ["anthropic", "openai-codex"]>;
5
9
  export type RefreshableProvider = typeof RefreshableProvider.Type;
6
- /** An OAuth subscription credential: an access token, its refresh token and its expiry (epoch ms). */
10
+ /**
11
+ * An OAuth subscription credential: an access token, its refresh token and its expiry (epoch ms).
12
+ *
13
+ * @public
14
+ */
7
15
  export type OAuthCredential = Extract<Credential, {
8
16
  readonly type: "oauth";
9
17
  }>;
@@ -1,15 +1,57 @@
1
- import { Effect } from "effect";
2
- import { AuthError } from "@xandreed/core";
1
+ import { Effect, Option } from "effect";
2
+ import type { Redacted, Semaphore } from "effect";
3
+ import { AuthError } from "../ports/auth-store.port.js";
4
+ import type { ProviderId } from "@xandreed/core";
5
+ import type { Credential } from "../ports/auth-store.port.js";
3
6
  import type { TokenRequestOptions } from "./anthropicOAuth.js";
4
7
  import type { OAuthCredential } from "./credential-refresh.entity.js";
5
- /** Refresh this many ms before the recorded expiry. */
8
+ /**
9
+ * Refresh this many ms before the recorded expiry.
10
+ *
11
+ * @public
12
+ */
6
13
  export declare const REFRESH_SKEW_MS = 60000;
7
- /** Whether an OAuth credential can still be used at `now`: more than the skew before its expiry. */
14
+ /**
15
+ * Whether an OAuth credential can still be used at `now`: more than the skew before its expiry.
16
+ *
17
+ * @public
18
+ */
8
19
  export declare const isUsableAt: (credential: OAuthCredential, now: number) => boolean;
9
20
  /**
10
21
  * Refresh one provider's OAuth credential through that provider's token
11
22
  * endpoint: the same credential with the new access token, refresh token
12
- * and expiry (and account id, where the provider sends one). A provider
13
- * with no refresh flow fails with an `AuthError` naming it.
23
+ * and expiry (and account id, where the provider sends one), its tokens
24
+ * `Redacted`. A provider with no refresh flow fails with an `AuthError`
25
+ * naming it.
26
+ *
27
+ * @public
14
28
  */
15
29
  export declare const refreshCredential: (provider: string, credential: OAuthCredential, options?: TokenRequestOptions) => Effect.Effect<OAuthCredential, AuthError>;
30
+ /** Whether `held` is still the token `stale` is: the same access token, refresh token and expiry. */
31
+ export declare const isSameToken: (held: Credential, stale: OAuthCredential) => boolean;
32
+ /** One provider's credential as a store keeps it, seen by the refresh. */
33
+ export interface CredentialSlot {
34
+ /** The credential the store holds now, read again on every call. */
35
+ readonly current: Effect.Effect<Option.Option<Credential>, AuthError>;
36
+ /**
37
+ * Store `fresh` only if the provider still holds `stale` (`isSameToken`),
38
+ * atomically with the store's other writes; whether it was stored.
39
+ */
40
+ readonly commit: (stale: OAuthCredential, fresh: OAuthCredential) => Effect.Effect<boolean, AuthError>;
41
+ /** Held while a refresh runs, so one refresh of the credential runs at a time. */
42
+ readonly refreshLock: Effect.Effect<Semaphore.Semaphore>;
43
+ }
44
+ /**
45
+ * The key a slot's credential resolves to: an API key as it is, a usable
46
+ * OAuth access token, or the token a refresh gives a near-expiry one. A
47
+ * `local` credential, or none, has no key.
48
+ *
49
+ * One refresh runs at a time. Once the lock is held, a credential that is no
50
+ * longer the expired one (refreshed by the call ahead, replaced or removed)
51
+ * is resolved again instead, so concurrent calls send one token request. A
52
+ * refresh stores its token only if the credential is still the one it
53
+ * refreshed: a login or logout made meanwhile wins. The token request and
54
+ * its commit run uninterrupted, since a provider that rotated the refresh
55
+ * token has already spent the old one.
56
+ */
57
+ export declare const resolveSlotKey: (provider: ProviderId, slot: CredentialSlot, tokens: TokenRequestOptions) => Effect.Effect<Option.Option<Redacted.Redacted<string>>, AuthError>;
@@ -1,5 +1,5 @@
1
- import { Effect, Match, Option, Schema } from "effect";
2
- import { AuthError } from "@xandreed/core";
1
+ import { Clock, Effect, Equal, Match, Option, Schema } from "effect";
2
+ import { AuthError } from "../ports/auth-store.port.js";
3
3
  import { refreshAnthropicToken } from "./anthropicOAuth.js";
4
4
  import { RefreshableProvider } from "./credential-refresh.entity.js";
5
5
  import { refreshOpenAiCodexToken } from "./openAiCodexOAuth.js";
@@ -9,4 +9,13 @@ export const REFRESH_SKEW_MS = 60000, isUsableAt = (credential, now) => credenti
9
9
  message: `the ${provider} OAuth token expired and only anthropic and openai-codex tokens can be refreshed \u2014 sign in again or use an API key`
10
10
  })),
11
11
  onSome: (refreshable) => Match.value(refreshable).pipe(Match.when("anthropic", () => refreshAnthropicToken(credential.refresh, options)), Match.when("openai-codex", () => refreshOpenAiCodexToken(credential.refresh, options)), Match.exhaustive).pipe(Effect.map((fresh) => ({ ...credential, ...fresh })))
12
- });
12
+ }), isSameToken = (held, stale) => held.type === "oauth" && Equal.equals(held.access, stale.access) && Equal.equals(held.refresh, stale.refresh) && held.expires === stale.expires, resolveSlotKey = (provider, slot, tokens) => {
13
+ const refreshing = (stale) => slot.refreshLock.pipe(Effect.flatMap((lock) => lock.withPermit(slot.current.pipe(Effect.flatMap((before) => Option.exists(before, (held) => isSameToken(held, stale)) ? Effect.uninterruptible(refreshCredential(provider, stale, tokens).pipe(Effect.flatMap((fresh) => slot.commit(stale, fresh).pipe(Effect.map((stored) => stored ? Option.some(fresh.access) : Option.none()))))) : Effect.succeedNone)))), Effect.flatMap(Option.match({
14
+ onSome: (access) => Effect.succeedSome(access),
15
+ onNone: () => resolveSlotKey(provider, slot, tokens)
16
+ })));
17
+ return slot.current.pipe(Effect.flatMap(Option.match({
18
+ onNone: () => Effect.succeedNone,
19
+ onSome: (credential) => Match.value(credential).pipe(Match.when({ type: "api_key" }, ({ key }) => Effect.succeedSome(key)), Match.when({ type: "local" }, () => Effect.succeedNone), Match.when({ type: "oauth" }, (oauth) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => isUsableAt(oauth, now) ? Effect.succeedSome(oauth.access) : refreshing(oauth)))), Match.exhaustive)
20
+ })));
21
+ };