@xandreed/plugin-models 0.13.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.
- package/README.md +1 -1
- package/dist/auth/anthropicOAuth.d.ts +45 -11
- package/dist/auth/anthropicOAuth.js +23 -23
- package/dist/auth/auth-store.conformance.d.ts +63 -0
- package/dist/auth/auth-store.conformance.js +152 -0
- package/dist/auth/credential-input.entity.d.ts +52 -0
- package/dist/auth/credential-input.entity.functions.d.ts +18 -0
- package/dist/auth/credential-input.entity.functions.js +12 -0
- package/dist/auth/credential-input.entity.js +22 -0
- package/dist/auth/credential-refresh.entity.d.ts +17 -0
- package/dist/auth/credential-refresh.entity.functions.d.ts +57 -0
- package/dist/auth/credential-refresh.entity.functions.js +21 -0
- package/dist/auth/credential-refresh.entity.js +2 -0
- package/dist/auth/localAuth.d.ts +12 -2
- package/dist/auth/localAuth.js +58 -86
- package/dist/auth/memory-auth.adapter.d.ts +35 -0
- package/dist/auth/memory-auth.adapter.js +35 -0
- package/dist/auth/openAiCodexOAuth.d.ts +28 -6
- package/dist/auth/openAiCodexOAuth.js +22 -32
- package/dist/auth/token-response.entity.d.ts +21 -0
- package/dist/auth/token-response.entity.functions.d.ts +20 -0
- package/dist/auth/token-response.entity.functions.js +10 -0
- package/dist/auth/token-response.entity.js +10 -0
- package/dist/compat/index.d.ts +2 -0
- package/dist/compat/index.js +1 -0
- package/dist/conformance/index.d.ts +6 -0
- package/dist/conformance/index.js +3 -0
- package/dist/index.d.ts +30 -13
- package/dist/index.js +25 -50
- package/dist/llm/chat-wire.entity.d.ts +123 -0
- package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
- package/dist/llm/chat-wire.entity.functions.js +10 -0
- package/dist/llm/chat-wire.entity.js +37 -0
- package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
- package/dist/llm/compat-telemetry.adapter.js +21 -0
- package/dist/llm/compat.d.ts +84 -33
- package/dist/llm/compat.js +188 -121
- package/dist/llm/model-completion.adapter.d.ts +49 -0
- package/dist/llm/model-completion.adapter.js +77 -0
- package/dist/llm/model-completion.entity.d.ts +4 -0
- package/dist/llm/model-completion.entity.functions.d.ts +3 -0
- package/dist/llm/model-completion.entity.functions.js +7 -0
- package/dist/llm/model-completion.entity.js +7 -0
- package/dist/llm/model-retry-policy.entity.d.ts +37 -0
- package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
- package/dist/llm/model-retry-policy.entity.functions.js +33 -0
- package/dist/llm/model-retry-policy.entity.js +23 -0
- package/dist/llm/model-settings.entity.d.ts +85 -0
- package/dist/llm/model-settings.entity.functions.d.ts +11 -0
- package/dist/llm/model-settings.entity.functions.js +49 -0
- package/dist/llm/model-settings.entity.js +28 -0
- package/dist/llm/model-usage.entity.d.ts +61 -0
- package/dist/llm/model-usage.entity.functions.d.ts +11 -0
- package/dist/llm/model-usage.entity.functions.js +24 -0
- package/dist/llm/model-usage.entity.js +19 -0
- package/dist/llm/modelCatalog.d.ts +47 -7
- package/dist/llm/modelCatalog.js +31 -57
- package/dist/llm/openAiCodex.d.ts +18 -7
- package/dist/llm/openAiCodex.js +57 -45
- package/dist/llm/openAiCodexWebSocket.d.ts +60 -7
- package/dist/llm/openAiCodexWebSocket.js +113 -105
- package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
- package/dist/llm/prompt-cache-frame.entity.js +10 -0
- package/dist/llm/prompt-cache.entity.d.ts +57 -0
- package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
- package/dist/llm/prompt-cache.entity.functions.js +73 -0
- package/dist/llm/prompt-cache.entity.js +27 -0
- package/dist/llm/provider-client.adapter.d.ts +9 -0
- package/dist/llm/provider-client.adapter.js +60 -0
- package/dist/llm/provider-failure.entity.d.ts +42 -0
- package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
- package/dist/llm/provider-failure.entity.functions.js +67 -0
- package/dist/llm/provider-failure.entity.js +20 -0
- package/dist/llm/provider-profile.entity.d.ts +79 -0
- package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
- package/dist/llm/provider-profile.entity.functions.js +83 -0
- package/dist/llm/provider-profile.entity.js +10 -0
- package/dist/llm/providers.d.ts +125 -29
- package/dist/llm/providers.js +311 -117
- package/dist/llm/retry.d.ts +11 -47
- package/dist/llm/retry.js +33 -52
- package/dist/llm/router.d.ts +56 -28
- package/dist/llm/router.js +125 -88
- package/dist/llm/sse.d.ts +58 -29
- package/dist/llm/sse.js +47 -39
- package/dist/llm/thinking-binding.entity.d.ts +49 -0
- package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
- package/dist/llm/thinking-binding.entity.functions.js +16 -0
- package/dist/llm/thinking-binding.entity.js +11 -0
- package/dist/llm/utilityLlm.d.ts +14 -5
- package/dist/llm/utilityLlm.js +18 -13
- package/dist/plugin.adapter.d.ts +154 -6
- package/dist/plugin.adapter.js +53 -10
- package/dist/ports/auth-store.port.d.ts +73 -0
- package/dist/ports/auth-store.port.js +24 -0
- package/dist/ports/host-credentials.port.d.ts +22 -0
- package/dist/ports/host-credentials.port.js +4 -0
- package/dist/ports/model-call-completion.port.d.ts +9 -0
- package/dist/ports/model-call-completion.port.js +2 -0
- package/dist/ports/model-endpoints.port.d.ts +66 -19
- package/dist/ports/model-endpoints.port.js +22 -4
- package/dist/ports/model-retry-policy.port.d.ts +17 -0
- package/dist/ports/model-retry-policy.port.js +3 -0
- package/dist/ports/model-settings.port.d.ts +32 -0
- package/dist/ports/model-settings.port.js +4 -0
- package/dist/ports/models-transport.port.d.ts +53 -0
- package/dist/ports/models-transport.port.js +7 -0
- package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
- package/dist/ports/prompt-cache-frame.port.js +4 -0
- package/dist/ports/settings-store.port.d.ts +70 -0
- package/dist/ports/settings-store.port.js +33 -0
- package/dist/ports/thinking-binding.port.d.ts +7 -0
- package/dist/ports/thinking-binding.port.js +4 -0
- package/dist/settings/localSettings.d.ts +2 -1
- package/dist/settings/localSettings.js +18 -61
- package/dist/settings/memory-settings.adapter.d.ts +12 -0
- package/dist/settings/memory-settings.adapter.js +11 -0
- package/dist/settings/roleView.d.ts +3 -1
- package/dist/settings/roleView.js +1 -1
- package/dist/settings/settings-store.conformance.d.ts +47 -0
- package/dist/settings/settings-store.conformance.js +100 -0
- package/dist/settings/settings-value.entity.d.ts +15 -0
- package/dist/settings/settings-value.entity.functions.d.ts +51 -0
- package/dist/settings/settings-value.entity.functions.js +42 -0
- package/dist/settings/settings-value.entity.js +2 -0
- package/dist/storage/json-file.d.ts +18 -0
- package/dist/storage/json-file.js +26 -0
- package/dist/transport/http-proxy.entity.d.ts +104 -0
- package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
- package/dist/transport/http-proxy.entity.functions.js +112 -0
- package/dist/transport/http-proxy.entity.js +42 -0
- package/dist/transport/model-request.conformance.d.ts +13 -0
- package/dist/transport/model-request.conformance.js +36 -0
- package/dist/transport/models-transport.adapter.d.ts +49 -0
- package/dist/transport/models-transport.adapter.js +99 -0
- package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
- package/dist/transport/resilient-http-client.adapter.js +177 -0
- package/dist/transport/undici-fetch.adapter.d.ts +13 -0
- package/dist/transport/undici-fetch.adapter.js +1 -0
- package/package.json +13 -6
- package/dist/auth/anthropicOAuth.test.d.ts +0 -1
- package/dist/auth/localAuth.test.d.ts +0 -1
- package/dist/llm/compat.test.d.ts +0 -1
- package/dist/llm/modelCatalog.test.d.ts +0 -1
- package/dist/llm/providers.test.d.ts +0 -1
- package/dist/llm/retry.test.d.ts +0 -1
- package/dist/llm/router.test.d.ts +0 -1
- package/dist/llm/sse.test.d.ts +0 -1
- package/dist/plugin.adapter.test.d.ts +0 -1
- package/dist/settings/localSettings.test.d.ts +0 -1
- package/dist/settings/roleView.test.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Effect, Option } from "effect";
|
|
2
|
-
import { AuthError } from "
|
|
1
|
+
import { Effect, Option, Redacted } from "effect";
|
|
2
|
+
import { AuthError } from "../ports/auth-store.port.js";
|
|
3
|
+
import type { ModelsFetch } from "../ports/models-transport.port.js";
|
|
3
4
|
/**
|
|
4
5
|
* Anthropic OAuth (Claude Pro/Max subscription) — the full protocol:
|
|
5
6
|
* authorization-code + PKCE (begin/exchange) and refresh. Constants match
|
|
@@ -15,20 +16,45 @@ export declare const ANTHROPIC_REDIRECT_URI = "http://localhost:53692/callback";
|
|
|
15
16
|
export declare const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
|
|
16
17
|
/** Anthropic rejects OAuth tokens unless the first system block is exactly this. */
|
|
17
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
|
+
*/
|
|
18
24
|
export interface RefreshedTokens {
|
|
19
|
-
readonly access: string
|
|
20
|
-
readonly refresh: string
|
|
25
|
+
readonly access: Redacted.Redacted<string>;
|
|
26
|
+
readonly refresh: Redacted.Redacted<string>;
|
|
21
27
|
/** Absolute expiry, epoch ms (with a 5-minute early-refresh skew baked in). */
|
|
22
28
|
readonly expires: number;
|
|
23
29
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* How one token request is made, for both subscription providers. `fetch`
|
|
32
|
+
* replaces the global fetch (a test stub, or a `ModelsTransport`'s fetch, so
|
|
33
|
+
* the request goes through the models plugin's proxy); `now` is
|
|
34
|
+
* the time the new token's expiry is counted from, in epoch ms (default: the
|
|
35
|
+
* Effect `Clock`).
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export interface TokenRequestOptions {
|
|
40
|
+
readonly fetch?: ModelsFetch;
|
|
41
|
+
readonly now?: number;
|
|
42
|
+
}
|
|
43
|
+
/** The time a token's expiry is counted from: the caller's, or the Effect Clock's. */
|
|
44
|
+
export declare const tokenTime: (options: TokenRequestOptions) => Effect.Effect<number>;
|
|
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>;
|
|
26
51
|
export interface Pkce {
|
|
27
52
|
readonly verifier: string;
|
|
28
53
|
readonly challenge: string;
|
|
29
54
|
}
|
|
30
|
-
/** Generate a PKCE verifier
|
|
55
|
+
/** Generate a PKCE verifier (32 bytes of the platform's cryptographic generator) + its S256 challenge. */
|
|
31
56
|
export declare const generatePkce: Effect.Effect<Pkce>;
|
|
57
|
+
/** @public */
|
|
32
58
|
export interface OAuthBegin {
|
|
33
59
|
/** The PKCE verifier — ALSO the `state`; the driver must verify the
|
|
34
60
|
* redirect echoes it (CSRF) before exchanging. */
|
|
@@ -37,14 +63,22 @@ export interface OAuthBegin {
|
|
|
37
63
|
readonly callbackPort: number;
|
|
38
64
|
readonly callbackPath: string;
|
|
39
65
|
}
|
|
40
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Start an authorization: PKCE material + the browser URL.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
41
71
|
export declare const beginAnthropicOAuth: Effect.Effect<OAuthBegin>;
|
|
42
|
-
/** Exchange the authorization code for tokens. */
|
|
43
|
-
export declare const exchangeAnthropicCode: (code: string, verifier: string) => Effect.Effect<RefreshedTokens, AuthError>;
|
|
72
|
+
/** Exchange the authorization code for tokens, `Redacted` (ready for `AuthStore.set`). */
|
|
73
|
+
export declare const exchangeAnthropicCode: (code: string, verifier: string, options?: TokenRequestOptions) => Effect.Effect<RefreshedTokens, AuthError>;
|
|
74
|
+
/** @public */
|
|
44
75
|
export interface AuthRedirect {
|
|
45
76
|
readonly code: Option.Option<string>;
|
|
46
77
|
readonly state: Option.Option<string>;
|
|
47
78
|
}
|
|
48
79
|
/** Pull `code`/`state` out of a pasted redirect URL, `code#state` pair,
|
|
49
|
-
* 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
|
+
*/
|
|
50
84
|
export declare const parseAuthorizationInput: (input: string) => AuthRedirect;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Clock, Effect, Option, Redacted } from "effect";
|
|
3
|
+
import { AuthError } from "../ports/auth-store.port.js";
|
|
4
|
+
import { redactCredentials } from "../transport/http-proxy.entity.functions.js";
|
|
5
|
+
import { decodeTokenResponse, tokenRefusalText } from "./token-response.entity.functions.js";
|
|
3
6
|
export const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
4
7
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize", TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
5
8
|
export const ANTHROPIC_CALLBACK_PORT = 53692, ANTHROPIC_CALLBACK_PATH = "/callback", ANTHROPIC_REDIRECT_URI = `http://localhost:${ANTHROPIC_CALLBACK_PORT}${ANTHROPIC_CALLBACK_PATH}`;
|
|
@@ -8,11 +11,13 @@ export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20", CLA
|
|
|
8
11
|
const oauthError = (stage) => (detail) => new AuthError({
|
|
9
12
|
provider: "anthropic",
|
|
10
13
|
message: `OAuth ${stage} failed: ${detail}`
|
|
11
|
-
})
|
|
14
|
+
});
|
|
15
|
+
export const tokenTime = (options) => options.now === void 0 ? Clock.currentTimeMillis : Effect.succeed(options.now);
|
|
16
|
+
const postToken = (body, stage, options, previous) => {
|
|
12
17
|
const fail = oauthError(stage);
|
|
13
18
|
return Effect.tryPromise({
|
|
14
19
|
try: async () => {
|
|
15
|
-
const res = await fetch(TOKEN_URL, {
|
|
20
|
+
const res = await (options.fetch ?? fetch)(TOKEN_URL, {
|
|
16
21
|
method: "POST",
|
|
17
22
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
18
23
|
body: JSON.stringify(body),
|
|
@@ -20,27 +25,22 @@ const oauthError = (stage) => (detail) => new AuthError({
|
|
|
20
25
|
});
|
|
21
26
|
return { ok: res.ok, status: res.status, text: await res.text() };
|
|
22
27
|
},
|
|
23
|
-
catch: (e) => fail(String(e))
|
|
24
|
-
}).pipe(Effect.filterOrFail((res) => res.ok, (res) => fail(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
refresh: data.refresh_token,
|
|
30
|
-
expires: Date.now() + data.expires_in * 1000 - 300000
|
|
31
|
-
})));
|
|
28
|
+
catch: (e) => fail(redactCredentials(String(e)))
|
|
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
|
|
33
|
+
}))));
|
|
32
34
|
};
|
|
33
|
-
export const refreshAnthropicToken = (refreshToken) => postToken({
|
|
35
|
+
export const refreshAnthropicToken = (refreshToken, options = {}) => postToken({
|
|
34
36
|
grant_type: "refresh_token",
|
|
35
37
|
client_id: ANTHROPIC_CLIENT_ID,
|
|
36
|
-
refresh_token: refreshToken
|
|
37
|
-
}, "token refresh");
|
|
38
|
+
refresh_token: Redacted.value(refreshToken)
|
|
39
|
+
}, "token refresh", options, Option.some(refreshToken));
|
|
38
40
|
const base64url = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
39
|
-
export const generatePkce = Effect.
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const verifier = base64url(verifierBytes), digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
43
|
-
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())) };
|
|
44
44
|
}), beginAnthropicOAuth = Effect.map(generatePkce, (pkce) => ({
|
|
45
45
|
verifier: pkce.verifier,
|
|
46
46
|
authorizeUrl: `${AUTHORIZE_URL}?${new URLSearchParams({
|
|
@@ -55,14 +55,14 @@ export const generatePkce = Effect.promise(async () => {
|
|
|
55
55
|
}).toString()}`,
|
|
56
56
|
callbackPort: ANTHROPIC_CALLBACK_PORT,
|
|
57
57
|
callbackPath: ANTHROPIC_CALLBACK_PATH
|
|
58
|
-
})), exchangeAnthropicCode = (code, verifier) => postToken({
|
|
58
|
+
})), exchangeAnthropicCode = (code, verifier, options = {}) => postToken({
|
|
59
59
|
grant_type: "authorization_code",
|
|
60
60
|
client_id: ANTHROPIC_CLIENT_ID,
|
|
61
61
|
code,
|
|
62
62
|
redirect_uri: ANTHROPIC_REDIRECT_URI,
|
|
63
63
|
code_verifier: verifier,
|
|
64
64
|
state: verifier
|
|
65
|
-
}, "code exchange");
|
|
65
|
+
}, "code exchange", options, Option.none());
|
|
66
66
|
const fromNullableTrimmed = (value) => Option.filter(Option.fromNullishOr(value), (v) => v.trim().length > 0);
|
|
67
67
|
export const parseAuthorizationInput = (input) => {
|
|
68
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
|
+
];
|
|
@@ -0,0 +1,52 @@
|
|
|
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;
|
|
16
|
+
export declare const CredentialInput: Schema.Union<readonly [Schema.Struct<{
|
|
17
|
+
readonly type: Schema.Literal<"api_key">;
|
|
18
|
+
readonly key: Schema.Redacted<Schema.String>;
|
|
19
|
+
}>, Schema.Struct<{
|
|
20
|
+
readonly type: Schema.Literal<"oauth">;
|
|
21
|
+
readonly access: Schema.Redacted<Schema.String>;
|
|
22
|
+
readonly refresh: Schema.Redacted<Schema.String>;
|
|
23
|
+
/** Absolute expiry, epoch ms. */
|
|
24
|
+
readonly expires: Schema.Number;
|
|
25
|
+
readonly accountId: Schema.optionalKey<Schema.brand<Schema.String, "AccountId">>;
|
|
26
|
+
readonly installationId: Schema.optionalKey<Schema.brand<Schema.String, "InstallationId">>;
|
|
27
|
+
}>, Schema.Struct<{
|
|
28
|
+
readonly type: Schema.Literal<"local">;
|
|
29
|
+
readonly baseUrl: Schema.optionalKey<Schema.String>;
|
|
30
|
+
}>]>;
|
|
31
|
+
export type CredentialInput = typeof CredentialInput.Type;
|
|
32
|
+
/**
|
|
33
|
+
* Credentials by provider id.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export declare const CredentialInputs: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.Struct<{
|
|
38
|
+
readonly type: Schema.Literal<"api_key">;
|
|
39
|
+
readonly key: Schema.Redacted<Schema.String>;
|
|
40
|
+
}>, Schema.Struct<{
|
|
41
|
+
readonly type: Schema.Literal<"oauth">;
|
|
42
|
+
readonly access: Schema.Redacted<Schema.String>;
|
|
43
|
+
readonly refresh: Schema.Redacted<Schema.String>;
|
|
44
|
+
/** Absolute expiry, epoch ms. */
|
|
45
|
+
readonly expires: Schema.Number;
|
|
46
|
+
readonly accountId: Schema.optionalKey<Schema.brand<Schema.String, "AccountId">>;
|
|
47
|
+
readonly installationId: Schema.optionalKey<Schema.brand<Schema.String, "InstallationId">>;
|
|
48
|
+
}>, Schema.Struct<{
|
|
49
|
+
readonly type: Schema.Literal<"local">;
|
|
50
|
+
readonly baseUrl: Schema.optionalKey<Schema.String>;
|
|
51
|
+
}>]>>;
|
|
52
|
+
export type CredentialInputs = typeof CredentialInputs.Type;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { AuthError, Credential } from "../ports/auth-store.port.js";
|
|
3
|
+
import type { CredentialInput, CredentialInputs } from "./credential-input.entity.js";
|
|
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
|
+
*/
|
|
16
|
+
export declare const toCredentialInput: (credential: Credential) => CredentialInput;
|
|
17
|
+
/** Handed-over credentials, by provider id, as the store keeps them. */
|
|
18
|
+
export declare const credentialsOf: (inputs: CredentialInputs) => ReadonlyMap<string, Credential>;
|
|
@@ -0,0 +1,12 @@
|
|
|
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));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
const secret = Schema.Redacted(Schema.String).annotate({
|
|
3
|
+
message: "a secret must be a Redacted value (Redacted.make(...)); JSON configuration names an environment variable in credentialEnv instead"
|
|
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" };
|
|
7
|
+
export const CredentialInput = Schema.Union([
|
|
8
|
+
Schema.Struct({ type: Schema.Literal("api_key"), key: secret }).annotate(unexpectedKey),
|
|
9
|
+
Schema.Struct({
|
|
10
|
+
type: Schema.Literal("oauth"),
|
|
11
|
+
access: secret,
|
|
12
|
+
refresh: secret,
|
|
13
|
+
expires: Schema.Number.annotate({ message: "expires is the access token's expiry, in epoch ms" }).check(Schema.isFinite()),
|
|
14
|
+
accountId: Schema.optionalKey(AccountId),
|
|
15
|
+
installationId: Schema.optionalKey(InstallationId)
|
|
16
|
+
}).annotate(unexpectedKey),
|
|
17
|
+
Schema.Struct({ type: Schema.Literal("local"), baseUrl: Schema.optionalKey(Schema.String) }).annotate(unexpectedKey)
|
|
18
|
+
]).annotate({
|
|
19
|
+
message: 'a credential is { type: "api_key", key }, { type: "oauth", access, refresh, expires, accountId?, installationId? } or { type: "local", baseUrl? }, with Redacted secrets'
|
|
20
|
+
}), CredentialInputs = Schema.Record(Schema.String, CredentialInput).annotate({
|
|
21
|
+
message: "credentials maps provider ids to credentials"
|
|
22
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
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
|
+
*/
|
|
8
|
+
export declare const RefreshableProvider: Schema.Literals<readonly ["anthropic", "openai-codex"]>;
|
|
9
|
+
export type RefreshableProvider = typeof RefreshableProvider.Type;
|
|
10
|
+
/**
|
|
11
|
+
* An OAuth subscription credential: an access token, its refresh token and its expiry (epoch ms).
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export type OAuthCredential = Extract<Credential, {
|
|
16
|
+
readonly type: "oauth";
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,57 @@
|
|
|
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";
|
|
6
|
+
import type { TokenRequestOptions } from "./anthropicOAuth.js";
|
|
7
|
+
import type { OAuthCredential } from "./credential-refresh.entity.js";
|
|
8
|
+
/**
|
|
9
|
+
* Refresh this many ms before the recorded expiry.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export declare const REFRESH_SKEW_MS = 60000;
|
|
14
|
+
/**
|
|
15
|
+
* Whether an OAuth credential can still be used at `now`: more than the skew before its expiry.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
export declare const isUsableAt: (credential: OAuthCredential, now: number) => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Refresh one provider's OAuth credential through that provider's token
|
|
22
|
+
* endpoint: the same credential with the new access token, refresh token
|
|
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
|
|
28
|
+
*/
|
|
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>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Clock, Effect, Equal, Match, Option, Schema } from "effect";
|
|
2
|
+
import { AuthError } from "../ports/auth-store.port.js";
|
|
3
|
+
import { refreshAnthropicToken } from "./anthropicOAuth.js";
|
|
4
|
+
import { RefreshableProvider } from "./credential-refresh.entity.js";
|
|
5
|
+
import { refreshOpenAiCodexToken } from "./openAiCodexOAuth.js";
|
|
6
|
+
export const REFRESH_SKEW_MS = 60000, isUsableAt = (credential, now) => credential.expires > now + REFRESH_SKEW_MS, refreshCredential = (provider, credential, options = {}) => Option.match(Schema.decodeUnknownOption(RefreshableProvider)(provider), {
|
|
7
|
+
onNone: () => Effect.fail(new AuthError({
|
|
8
|
+
provider,
|
|
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
|
+
})),
|
|
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
|
+
}), 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
|
+
};
|
package/dist/auth/localAuth.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { Layer } from "effect";
|
|
2
|
-
import { AuthStore } from "
|
|
2
|
+
import { AuthStore } from "../ports/auth-store.port.js";
|
|
3
3
|
export declare const authPaths: (cwd: string, home: string, directory?: string) => ReadonlyArray<string>;
|
|
4
4
|
/**
|
|
5
5
|
* `LocalAuthStoreLive(cwd, home)` — read-through: every call re-reads disk,
|
|
6
|
-
* so a credential added mid-session applies on the next request.
|
|
6
|
+
* so a credential added mid-session applies on the next request. A token
|
|
7
|
+
* refresh goes through the `ModelsTransport` the layer is built over, if any.
|
|
8
|
+
*
|
|
9
|
+
* A file that cannot be read or is not a JSON object fails every call with
|
|
10
|
+
* an `AuthError` of code `auth.corrupt`; only a missing file reads as empty.
|
|
11
|
+
* Each write re-reads its file under that file's lock, which every store in
|
|
12
|
+
* the process shares, and replaces it through a temp file of its own. One
|
|
13
|
+
* refresh of a provider's token runs at a time, and stores its token only if
|
|
14
|
+
* the provider still holds the one it refreshed, so a login or logout made
|
|
15
|
+
* during the refresh wins. The locks hold within one process: two processes
|
|
16
|
+
* sharing these files can still lose an update.
|
|
7
17
|
*/
|
|
8
18
|
export declare const LocalAuthStoreLive: (cwd: string, home: string, directory?: string, fallbackDirectory?: string) => Layer.Layer<AuthStore, never, never>;
|