@retinue/agentkit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +72 -0
- package/dist/server/bin.js +304 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli-worker.js +6 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +10 -5
- package/dist/server/config.d.ts +11 -1
- package/dist/server/config.js +18 -0
- package/dist/server/doctor.d.ts +85 -0
- package/dist/server/doctor.js +280 -0
- package/dist/server/pool.d.ts +47 -0
- package/dist/server/pool.js +73 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +29 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
|
|
2
|
+
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5; widened by REQ-063 (#259), #260.
|
|
3
3
|
*
|
|
4
|
-
* A toolkit needs a
|
|
4
|
+
* A toolkit needs a secret. The question is who holds it and when it is read, and getting that wrong is the
|
|
5
5
|
* decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
|
|
6
6
|
*
|
|
7
7
|
* ## Why a tool must not read the environment
|
|
@@ -27,14 +27,81 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
|
|
29
29
|
* away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
|
|
30
|
+
*
|
|
31
|
+
* ## Why a string was not enough — #260
|
|
32
|
+
*
|
|
33
|
+
* `resolve()` returned `Promise<string>`, which covers a bearer token and nothing else. Jira and Confluence
|
|
34
|
+
* want an account email *and* an API token as Basic auth; Atlassian's OAuth needs an access token *and* a cloud
|
|
35
|
+
* id discovered after consent; WhatsApp needs a token *and* a phone number id; a vendor using `X-Api-Key` needs
|
|
36
|
+
* a header name. Four of the fourteen integrations specified in `docs/23` cannot be expressed as a string, and
|
|
37
|
+
* every one of them would have grown its own side-channel.
|
|
38
|
+
*
|
|
39
|
+
* Two axes, deliberately kept apart, because they are answered by different people:
|
|
40
|
+
*
|
|
41
|
+
* - **`CredentialScheme`** — how the secret is presented on the wire. The toolkit knows this.
|
|
42
|
+
* - **`AuthMode`** — how the tenant *obtained* it: pasted a token, or completed an OAuth flow. The deployment
|
|
43
|
+
* knows this, and it is what decides whether an unconnected tool can pause a run for consent (#264) or must
|
|
44
|
+
* simply fail, since a token has no login URL to redirect to.
|
|
30
45
|
*/
|
|
31
46
|
import type { ExecutionContext } from "../core/context.js";
|
|
32
|
-
import type
|
|
47
|
+
import { type PlatformError } from "../core/errors.js";
|
|
33
48
|
/** An opaque handle. Its meaning belongs to the resolver, and no tool interprets it. */
|
|
34
49
|
export type CredentialRef = string;
|
|
50
|
+
/** How a secret is presented on the wire. */
|
|
51
|
+
export declare const CREDENTIAL_SCHEMES: readonly ["bearer", "basic", "custom-header"];
|
|
52
|
+
export type CredentialScheme = (typeof CREDENTIAL_SCHEMES)[number];
|
|
53
|
+
/**
|
|
54
|
+
* How a tenant supplied the credential.
|
|
55
|
+
*
|
|
56
|
+
* Not the same question as the scheme: an OAuth access token is presented as a bearer, so the two would collapse
|
|
57
|
+
* if they shared a type — and the collapse would lose exactly the fact #264 needs, which is whether there is a
|
|
58
|
+
* login URL to send someone to.
|
|
59
|
+
*/
|
|
60
|
+
export declare const AUTH_MODES: readonly ["token", "oauth2"];
|
|
61
|
+
export type AuthMode = (typeof AUTH_MODES)[number];
|
|
62
|
+
/**
|
|
63
|
+
* Non-secret vendor identifiers that travel with the credential.
|
|
64
|
+
*
|
|
65
|
+
* Atlassian's cloud id, WhatsApp's phone number id, Slack's team id — discovered at connection time, needed on
|
|
66
|
+
* every request, and **not secrets**. They live here rather than in a toolkit's configuration because they are
|
|
67
|
+
* per *connection*: two tenants using the same toolkit have different ones, which is the same reason the token
|
|
68
|
+
* is not configuration either.
|
|
69
|
+
*/
|
|
70
|
+
export type CredentialMetadata = Readonly<Record<string, string>>;
|
|
71
|
+
type WithMetadata = {
|
|
72
|
+
readonly metadata?: CredentialMetadata;
|
|
73
|
+
readonly mode?: AuthMode;
|
|
74
|
+
};
|
|
75
|
+
export type Credential = (WithMetadata & {
|
|
76
|
+
readonly scheme: "bearer";
|
|
77
|
+
readonly token: string;
|
|
78
|
+
}) | (WithMetadata & {
|
|
79
|
+
readonly scheme: "basic";
|
|
80
|
+
readonly username: string;
|
|
81
|
+
readonly password: string;
|
|
82
|
+
}) | (WithMetadata & {
|
|
83
|
+
readonly scheme: "custom-header";
|
|
84
|
+
readonly header: string;
|
|
85
|
+
readonly value: string;
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Builds a credential whose secret is **not enumerable** — AC-7.
|
|
89
|
+
*
|
|
90
|
+
* A typed object is far more likely to reach a log line than a bare string was: it gets spread into an error's
|
|
91
|
+
* `details`, passed to a structured logger, or serialised into an audit row, and every one of those uses
|
|
92
|
+
* `JSON.stringify` or an object spread. So the secret is defined non-enumerably and `toJSON`/`toString`/
|
|
93
|
+
* `util.inspect` are overridden. `credential.token` still reads normally; `{ ...credential }`,
|
|
94
|
+
* `JSON.stringify(credential)` and `console.log(credential)` do not.
|
|
95
|
+
*
|
|
96
|
+
* This is defence in depth, not a licence: a caller that reads `.token` and logs *that* is still logging a
|
|
97
|
+
* secret, and no type can stop it.
|
|
98
|
+
*/
|
|
99
|
+
export declare const createCredential: (input: Credential) => Credential;
|
|
100
|
+
/** A bearer credential, which is what most vendors want and what a plain string used to mean. */
|
|
101
|
+
export declare const bearer: (token: string, metadata?: CredentialMetadata, mode?: AuthMode) => Credential;
|
|
35
102
|
export interface CredentialResolver {
|
|
36
103
|
/**
|
|
37
|
-
* The
|
|
104
|
+
* The credential behind a reference, for this caller.
|
|
38
105
|
*
|
|
39
106
|
* Takes the context because a reference is resolved *per tenant*: two tenants using the same toolkit name the
|
|
40
107
|
* same credential and mean different secrets, and a resolver that ignored the caller would hand one tenant
|
|
@@ -43,15 +110,207 @@ export interface CredentialResolver {
|
|
|
43
110
|
resolve(input: {
|
|
44
111
|
readonly ref: CredentialRef;
|
|
45
112
|
readonly context: ExecutionContext;
|
|
46
|
-
}): Promise<
|
|
113
|
+
}): Promise<Credential>;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Told about every resolution — `docs/21`'s Connections section, "an audit record of every resolution".
|
|
117
|
+
*
|
|
118
|
+
* A sink rather than a return value, so a toolkit cannot forget to report and a host cannot be surprised by
|
|
119
|
+
* one. **Both outcomes are reported**: a refused resolution is the more interesting audit event, because a
|
|
120
|
+
* successful one is the normal case and a refused one is somebody asking for something they do not have.
|
|
121
|
+
*
|
|
122
|
+
* It never receives the credential. An audit trail that carries the secret is a second copy of the secret in a
|
|
123
|
+
* place designed to be kept for a long time.
|
|
124
|
+
*/
|
|
125
|
+
export interface CredentialAudit {
|
|
126
|
+
onResolved(input: {
|
|
127
|
+
readonly ref: CredentialRef;
|
|
128
|
+
readonly context: ExecutionContext;
|
|
129
|
+
readonly scheme: CredentialScheme;
|
|
130
|
+
}): Promise<void> | void;
|
|
131
|
+
onRefused(input: {
|
|
132
|
+
readonly ref: CredentialRef;
|
|
133
|
+
readonly context: ExecutionContext;
|
|
134
|
+
readonly reason: string;
|
|
135
|
+
}): Promise<void> | void;
|
|
47
136
|
}
|
|
48
137
|
export declare const credentialMissing: (ref: CredentialRef, detail?: string) => PlatformError;
|
|
138
|
+
/**
|
|
139
|
+
* The credential resolved is not the shape this toolkit can present — #260 AC-2.
|
|
140
|
+
*
|
|
141
|
+
* Raised at **construction** where possible, and at resolution otherwise. A misconfiguration discovered as a
|
|
142
|
+
* vendor 401 an hour later is the failure this exists to prevent: the vendor's message says the token is
|
|
143
|
+
* invalid, which sends an operator to rotate a token that was never the problem.
|
|
144
|
+
*/
|
|
145
|
+
export declare const credentialSchemeMismatch: (ref: CredentialRef, expected: readonly CredentialScheme[], got: CredentialScheme) => PlatformError;
|
|
49
146
|
/**
|
|
50
147
|
* A resolver over a plain map, for a single-tenant deployment and for tests.
|
|
51
148
|
*
|
|
52
149
|
* Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
|
|
53
150
|
* fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
|
|
54
151
|
* backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
|
|
152
|
+
*
|
|
153
|
+
* **A bare string still works**, and stays the common case: it means a bearer token, which is what it meant
|
|
154
|
+
* before #260. The single-tenant path must not get harder because multi-tenant got possible.
|
|
155
|
+
*/
|
|
156
|
+
export declare const createStaticCredentialResolver: (secrets: Readonly<Record<string, string | Credential>>) => CredentialResolver;
|
|
157
|
+
/**
|
|
158
|
+
* Wraps a resolver so every resolution is audited, and so a scheme mismatch is caught here rather than by the
|
|
159
|
+
* vendor — #260 AC-2 and AC-8.
|
|
160
|
+
*
|
|
161
|
+
* A wrapper rather than a change to every resolver: a host's own resolver stays a four-line object, and the
|
|
162
|
+
* cross-cutting obligations are added once, where they cannot be forgotten per toolkit.
|
|
163
|
+
*/
|
|
164
|
+
export declare const withCredentialAudit: (resolver: CredentialResolver, audit: CredentialAudit, expected?: readonly CredentialScheme[]) => CredentialResolver;
|
|
165
|
+
/**
|
|
166
|
+
* The `Authorization`-style header a credential presents as.
|
|
167
|
+
*
|
|
168
|
+
* One function, so twenty toolkits do not each write their own base64 and get the padding wrong. Returns the
|
|
169
|
+
* header **name and value**, because `custom-header` does not use `Authorization`.
|
|
170
|
+
*/
|
|
171
|
+
export declare const credentialHeader: (credential: Credential) => readonly [string, string];
|
|
172
|
+
/**
|
|
173
|
+
* What a toolkit accepts — #260 AC-2.
|
|
174
|
+
*
|
|
175
|
+
* Declared by the toolkit and checked at **construction**, so wiring a Basic-auth vendor with a bearer token is
|
|
176
|
+
* a startup error naming both, rather than a vendor 401 an hour later whose message says the token is invalid.
|
|
177
|
+
*
|
|
178
|
+
* `modes` is the other axis and is not the platform's business to validate — it is a fact about the vendor that
|
|
179
|
+
* a deployment reads: "GitHub takes a PAT or OAuth", "Google is OAuth only". #262 uses it to decide which
|
|
180
|
+
* connection flows to offer, and #264 uses it to decide whether an unconnected tool can pause a run for consent
|
|
181
|
+
* or must simply fail, since a token has no login URL.
|
|
182
|
+
*/
|
|
183
|
+
export type ToolkitAuth = {
|
|
184
|
+
/** How a tenant may obtain the credential. */
|
|
185
|
+
readonly modes: readonly AuthMode[];
|
|
186
|
+
/** The scheme(s) this toolkit can present. A credential of any other scheme is refused. */
|
|
187
|
+
readonly schemes: readonly CredentialScheme[];
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Refuses a toolkit configuration whose credential cannot be presented — at construction.
|
|
191
|
+
*
|
|
192
|
+
* Takes the *declared* scheme rather than resolving, because resolution needs a context and construction has
|
|
193
|
+
* none. A host that wires a static map can therefore be told immediately; a host whose resolver is dynamic is
|
|
194
|
+
* caught by `withCredentialAudit` at the first call instead. Both are before the vendor sees anything.
|
|
195
|
+
*/
|
|
196
|
+
export declare const assertToolkitAuth: (ref: CredentialRef, auth: ToolkitAuth, declared: CredentialScheme | undefined) => void;
|
|
197
|
+
/**
|
|
198
|
+
* A credential that stops working at a known time.
|
|
199
|
+
*
|
|
200
|
+
* Additive by construction: `RefreshableCredential` is a `Credential` with one more field, so a resolver that
|
|
201
|
+
* returns a plain one is unchanged and the eight shipped toolkits compile untouched. That is AC-1, and it is a
|
|
202
|
+
* property of the *type* rather than a claim about the code.
|
|
203
|
+
*
|
|
204
|
+
* The refresh token is deliberately **not here.** It lives wherever the host keeps it — `ConnectionStore` seals
|
|
205
|
+
* it (#261) — and only the refresher ever sees it. Putting it on the credential would mean the longest-lived
|
|
206
|
+
* secret in an OAuth grant travelling through every toolkit that only needed the short-lived one.
|
|
207
|
+
*/
|
|
208
|
+
export type RefreshableCredential = Credential & {
|
|
209
|
+
/** ISO 8601. When the vendor stops accepting this. */
|
|
210
|
+
readonly expiresAt: string;
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Adds an expiry to a credential **without losing its secret protection** — AC-6.
|
|
214
|
+
*
|
|
215
|
+
* This exists because the obvious way to build one is wrong, and wrong invisibly. A host writing a
|
|
216
|
+
* `CredentialRefresher` reaches for:
|
|
217
|
+
*
|
|
218
|
+
* ```ts
|
|
219
|
+
* return { ...bearer(accessToken), expiresAt }; // ← the secret is now enumerable
|
|
220
|
+
* ```
|
|
221
|
+
*
|
|
222
|
+
* `createCredential` defines the secret **non-enumerably**, which is precisely what makes it survive a
|
|
223
|
+
* `JSON.stringify` into a log line — and precisely what a spread drops. The result looks identical, works
|
|
224
|
+
* identically, and serialises the token into the first structured log that touches it.
|
|
225
|
+
*
|
|
226
|
+
* Found by the AC-6 test failing against this repository's own test helper, which had made exactly that
|
|
227
|
+
* mistake. If the helper made it, a host will.
|
|
228
|
+
*/
|
|
229
|
+
export declare const refreshable: (credential: Credential, expiresAt: string) => RefreshableCredential;
|
|
230
|
+
export declare const isRefreshable: (credential: Credential) => credential is RefreshableCredential;
|
|
231
|
+
/**
|
|
232
|
+
* How early a token is replaced.
|
|
233
|
+
*
|
|
234
|
+
* Sixty seconds, and the number is a *commitment* rather than a guess: a tool call can take tens of seconds —
|
|
235
|
+
* a slow vendor, a large upload, a retry — and a token that was valid when the call started must still be
|
|
236
|
+
* valid when it arrives. Refreshing exactly at expiry makes "expired mid-flight" the common case rather than
|
|
237
|
+
* the rare one, and that failure looks like an intermittent authentication bug.
|
|
238
|
+
*
|
|
239
|
+
* AC-5. Configurable because a deployment whose calls are slower than this needs more.
|
|
240
|
+
*/
|
|
241
|
+
export declare const DEFAULT_REFRESH_SKEW_MS = 60000;
|
|
242
|
+
/** Whether a credential is expired, or close enough that it should be replaced before use. */
|
|
243
|
+
export declare const isExpiring: (credential: Credential, skewMs: number, now: number) => boolean;
|
|
244
|
+
/**
|
|
245
|
+
* Obtains a fresh credential for a reference.
|
|
246
|
+
*
|
|
247
|
+
* A port, because how a token is renewed differs entirely by vendor and by where the grant is stored. The
|
|
248
|
+
* host's implementation reads its own `ConnectionStore`, calls the vendor's token endpoint, re-seals the new
|
|
249
|
+
* refresh token and returns the new access credential — none of which the runtime should know about.
|
|
250
|
+
*
|
|
251
|
+
* It is given the ref and the context, and **not** the expired credential: it has to look the grant up anyway,
|
|
252
|
+
* and handing it a dead secret would be one more copy of one for no purpose.
|
|
253
|
+
*/
|
|
254
|
+
export interface CredentialRefresher {
|
|
255
|
+
refresh(input: {
|
|
256
|
+
readonly ref: CredentialRef;
|
|
257
|
+
readonly context: ExecutionContext;
|
|
258
|
+
}): Promise<RefreshableCredential>;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* A refresh that failed because the grant is gone, rather than because the network was.
|
|
262
|
+
*
|
|
263
|
+
* The distinction is the whole of AC-3, and it is not cosmetic: `invalid_grant` means a person must consent
|
|
264
|
+
* again and no amount of retrying will help, while a timeout means try again in a second. A runtime that
|
|
265
|
+
* conflates them either retries a dead grant forever or asks a user to re-authorise because of a blip.
|
|
266
|
+
*/
|
|
267
|
+
export declare const REFRESH_GRANT_ERRORS: readonly ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied"];
|
|
268
|
+
export declare const isGrantError: (error: unknown) => boolean;
|
|
269
|
+
export type RefreshingResolverOptions = {
|
|
270
|
+
readonly skewMs?: number;
|
|
271
|
+
/** Injectable so a test can move time without waiting for it. */
|
|
272
|
+
readonly now?: () => number;
|
|
273
|
+
/**
|
|
274
|
+
* Told that a refresh happened. Never told the token — see `CredentialAudit`, same reasoning.
|
|
275
|
+
*
|
|
276
|
+
* `expiresAt` is included because it is not a secret and it is the one thing an operator debugging a
|
|
277
|
+
* refresh loop actually needs.
|
|
278
|
+
*/
|
|
279
|
+
readonly onRefreshed?: (input: {
|
|
280
|
+
readonly ref: CredentialRef;
|
|
281
|
+
readonly tenantId: string;
|
|
282
|
+
readonly expiresAt: string;
|
|
283
|
+
}) => void;
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* Wraps a resolver so an expiring credential is renewed before it is handed out.
|
|
287
|
+
*
|
|
288
|
+
* A wrapper, like `withCredentialAudit`, and for the same reason: the eight shipped toolkits already resolve
|
|
289
|
+
* **per call**, so they pick this up without a line changing. A toolkit that cached a credential at
|
|
290
|
+
* construction would defeat it, which is why `createGitHubToolkit` and every sibling resolve inside `call()`.
|
|
291
|
+
*
|
|
292
|
+
* ## Time-driven, never 401-driven — AC-7
|
|
293
|
+
*
|
|
294
|
+
* The obvious design is to refresh when the vendor returns 401. It is wrong, and worth stating plainly because
|
|
295
|
+
* it is what most integrations do:
|
|
296
|
+
*
|
|
297
|
+
* A 401 is what a vendor returns for an expired token, a **revoked** grant, a token for the wrong tenant, and a
|
|
298
|
+
* scope the grant never had. Refreshing on 401 therefore turns a revoked grant into an infinite refresh loop
|
|
299
|
+
* against the vendor's token endpoint, and turns a missing scope into a refresh that succeeds and a call that
|
|
300
|
+
* fails again identically. Neither is diagnosable from the outside.
|
|
301
|
+
*
|
|
302
|
+
* Time is the only signal that means what it says: a token with an expiry in the past is expired, and nothing
|
|
303
|
+
* else is inferred from it. A 401 on a freshly-refreshed token is a real error and is surfaced as one.
|
|
304
|
+
*
|
|
305
|
+
* ## One refresh, not N — AC-2
|
|
306
|
+
*
|
|
307
|
+
* Twenty concurrent tool calls hitting an expired token must produce **one** refresh. Refresh endpoints rate
|
|
308
|
+
* limit, and — worse — several vendors invalidate the previous refresh token when one is used, so N concurrent
|
|
309
|
+
* refreshes race to invalidate each other and log the deployment out permanently.
|
|
310
|
+
*
|
|
311
|
+
* The in-flight promise is stored *before* the first await, so a second caller entering the function
|
|
312
|
+
* synchronously after the first still finds it.
|
|
55
313
|
*/
|
|
56
|
-
export declare const
|
|
314
|
+
export declare const withRefreshingCredentials: (resolver: CredentialResolver, refresher: CredentialRefresher, options?: RefreshingResolverOptions) => CredentialResolver;
|
|
315
|
+
export {};
|
|
57
316
|
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
|
|
2
|
+
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5; widened by REQ-063 (#259), #260.
|
|
3
3
|
*
|
|
4
|
-
* A toolkit needs a
|
|
4
|
+
* A toolkit needs a secret. The question is who holds it and when it is read, and getting that wrong is the
|
|
5
5
|
* decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
|
|
6
6
|
*
|
|
7
7
|
* ## Why a tool must not read the environment
|
|
@@ -27,7 +27,74 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
|
|
29
29
|
* away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
|
|
30
|
+
*
|
|
31
|
+
* ## Why a string was not enough — #260
|
|
32
|
+
*
|
|
33
|
+
* `resolve()` returned `Promise<string>`, which covers a bearer token and nothing else. Jira and Confluence
|
|
34
|
+
* want an account email *and* an API token as Basic auth; Atlassian's OAuth needs an access token *and* a cloud
|
|
35
|
+
* id discovered after consent; WhatsApp needs a token *and* a phone number id; a vendor using `X-Api-Key` needs
|
|
36
|
+
* a header name. Four of the fourteen integrations specified in `docs/23` cannot be expressed as a string, and
|
|
37
|
+
* every one of them would have grown its own side-channel.
|
|
38
|
+
*
|
|
39
|
+
* Two axes, deliberately kept apart, because they are answered by different people:
|
|
40
|
+
*
|
|
41
|
+
* - **`CredentialScheme`** — how the secret is presented on the wire. The toolkit knows this.
|
|
42
|
+
* - **`AuthMode`** — how the tenant *obtained* it: pasted a token, or completed an OAuth flow. The deployment
|
|
43
|
+
* knows this, and it is what decides whether an unconnected tool can pause a run for consent (#264) or must
|
|
44
|
+
* simply fail, since a token has no login URL to redirect to.
|
|
45
|
+
*/
|
|
46
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
47
|
+
/** How a secret is presented on the wire. */
|
|
48
|
+
export const CREDENTIAL_SCHEMES = ["bearer", "basic", "custom-header"];
|
|
49
|
+
/**
|
|
50
|
+
* How a tenant supplied the credential.
|
|
51
|
+
*
|
|
52
|
+
* Not the same question as the scheme: an OAuth access token is presented as a bearer, so the two would collapse
|
|
53
|
+
* if they shared a type — and the collapse would lose exactly the fact #264 needs, which is whether there is a
|
|
54
|
+
* login URL to send someone to.
|
|
30
55
|
*/
|
|
56
|
+
export const AUTH_MODES = ["token", "oauth2"];
|
|
57
|
+
/** The secret-bearing property of each scheme — the ones that must never be enumerable. */
|
|
58
|
+
const SECRET_KEYS = {
|
|
59
|
+
bearer: ["token"],
|
|
60
|
+
basic: ["password"],
|
|
61
|
+
"custom-header": ["value"],
|
|
62
|
+
};
|
|
63
|
+
const REDACTED = "[credential redacted]";
|
|
64
|
+
/**
|
|
65
|
+
* Builds a credential whose secret is **not enumerable** — AC-7.
|
|
66
|
+
*
|
|
67
|
+
* A typed object is far more likely to reach a log line than a bare string was: it gets spread into an error's
|
|
68
|
+
* `details`, passed to a structured logger, or serialised into an audit row, and every one of those uses
|
|
69
|
+
* `JSON.stringify` or an object spread. So the secret is defined non-enumerably and `toJSON`/`toString`/
|
|
70
|
+
* `util.inspect` are overridden. `credential.token` still reads normally; `{ ...credential }`,
|
|
71
|
+
* `JSON.stringify(credential)` and `console.log(credential)` do not.
|
|
72
|
+
*
|
|
73
|
+
* This is defence in depth, not a licence: a caller that reads `.token` and logs *that* is still logging a
|
|
74
|
+
* secret, and no type can stop it.
|
|
75
|
+
*/
|
|
76
|
+
export const createCredential = (input) => {
|
|
77
|
+
const secrets = SECRET_KEYS[input.scheme];
|
|
78
|
+
const credential = { ...input };
|
|
79
|
+
for (const key of secrets) {
|
|
80
|
+
const value = credential[key];
|
|
81
|
+
delete credential[key];
|
|
82
|
+
Object.defineProperty(credential, key, { value, enumerable: false, writable: false, configurable: false });
|
|
83
|
+
}
|
|
84
|
+
Object.defineProperty(credential, "toJSON", {
|
|
85
|
+
value: () => ({ scheme: input.scheme, ...(input.mode === undefined ? {} : { mode: input.mode }), secret: REDACTED }),
|
|
86
|
+
enumerable: false,
|
|
87
|
+
});
|
|
88
|
+
Object.defineProperty(credential, "toString", { value: () => REDACTED, enumerable: false });
|
|
89
|
+
// Node's `console.log` and `util.inspect` ignore `toString`; this is the hook they do read.
|
|
90
|
+
Object.defineProperty(credential, Symbol.for("nodejs.util.inspect.custom"), {
|
|
91
|
+
value: () => `Credential(${input.scheme}) ${REDACTED}`,
|
|
92
|
+
enumerable: false,
|
|
93
|
+
});
|
|
94
|
+
return Object.freeze(credential);
|
|
95
|
+
};
|
|
96
|
+
/** A bearer credential, which is what most vendors want and what a plain string used to mean. */
|
|
97
|
+
export const bearer = (token, metadata, mode) => createCredential({ scheme: "bearer", token, ...(metadata === undefined ? {} : { metadata }), ...(mode === undefined ? {} : { mode }) });
|
|
31
98
|
export const credentialMissing = (ref, detail) => ({
|
|
32
99
|
code: "capability_unavailable",
|
|
33
100
|
message: `No credential is wired for reference "${ref}"` +
|
|
@@ -36,19 +103,267 @@ export const credentialMissing = (ref, detail) => ({
|
|
|
36
103
|
// Retrying an unwired credential cannot help; something has to be configured.
|
|
37
104
|
retryable: false,
|
|
38
105
|
});
|
|
106
|
+
/**
|
|
107
|
+
* The credential resolved is not the shape this toolkit can present — #260 AC-2.
|
|
108
|
+
*
|
|
109
|
+
* Raised at **construction** where possible, and at resolution otherwise. A misconfiguration discovered as a
|
|
110
|
+
* vendor 401 an hour later is the failure this exists to prevent: the vendor's message says the token is
|
|
111
|
+
* invalid, which sends an operator to rotate a token that was never the problem.
|
|
112
|
+
*/
|
|
113
|
+
export const credentialSchemeMismatch = (ref, expected, got) => ({
|
|
114
|
+
code: "capability_unavailable",
|
|
115
|
+
message: `Credential "${ref}" is a ${got} credential and this toolkit presents ${expected.join(" or ")}. ` +
|
|
116
|
+
"The vendor would answer 401 and the message would say the token is invalid, which is not the problem.",
|
|
117
|
+
retryable: false,
|
|
118
|
+
});
|
|
39
119
|
/**
|
|
40
120
|
* A resolver over a plain map, for a single-tenant deployment and for tests.
|
|
41
121
|
*
|
|
42
122
|
* Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
|
|
43
123
|
* fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
|
|
44
124
|
* backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
|
|
125
|
+
*
|
|
126
|
+
* **A bare string still works**, and stays the common case: it means a bearer token, which is what it meant
|
|
127
|
+
* before #260. The single-tenant path must not get harder because multi-tenant got possible.
|
|
45
128
|
*/
|
|
46
129
|
export const createStaticCredentialResolver = (secrets) => ({
|
|
47
130
|
async resolve({ ref }) {
|
|
48
131
|
const secret = secrets[ref];
|
|
49
132
|
if (secret === undefined || secret === "")
|
|
50
133
|
throw credentialMissing(ref, "not present in the static map");
|
|
51
|
-
return secret;
|
|
134
|
+
return typeof secret === "string" ? bearer(secret) : secret;
|
|
52
135
|
},
|
|
53
136
|
});
|
|
137
|
+
/**
|
|
138
|
+
* Wraps a resolver so every resolution is audited, and so a scheme mismatch is caught here rather than by the
|
|
139
|
+
* vendor — #260 AC-2 and AC-8.
|
|
140
|
+
*
|
|
141
|
+
* A wrapper rather than a change to every resolver: a host's own resolver stays a four-line object, and the
|
|
142
|
+
* cross-cutting obligations are added once, where they cannot be forgotten per toolkit.
|
|
143
|
+
*/
|
|
144
|
+
export const withCredentialAudit = (resolver, audit, expected) => ({
|
|
145
|
+
async resolve(input) {
|
|
146
|
+
let credential;
|
|
147
|
+
try {
|
|
148
|
+
credential = await resolver.resolve(input);
|
|
149
|
+
}
|
|
150
|
+
catch (thrown) {
|
|
151
|
+
const reason = thrown instanceof Error ? thrown.message : String(thrown);
|
|
152
|
+
await audit.onRefused({ ref: input.ref, context: input.context, reason });
|
|
153
|
+
throw thrown;
|
|
154
|
+
}
|
|
155
|
+
if (expected !== undefined && !expected.includes(credential.scheme)) {
|
|
156
|
+
const error = credentialSchemeMismatch(input.ref, expected, credential.scheme);
|
|
157
|
+
await audit.onRefused({ ref: input.ref, context: input.context, reason: error.message });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
await audit.onResolved({ ref: input.ref, context: input.context, scheme: credential.scheme });
|
|
161
|
+
return credential;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* The `Authorization`-style header a credential presents as.
|
|
166
|
+
*
|
|
167
|
+
* One function, so twenty toolkits do not each write their own base64 and get the padding wrong. Returns the
|
|
168
|
+
* header **name and value**, because `custom-header` does not use `Authorization`.
|
|
169
|
+
*/
|
|
170
|
+
export const credentialHeader = (credential) => {
|
|
171
|
+
switch (credential.scheme) {
|
|
172
|
+
case "bearer":
|
|
173
|
+
return ["Authorization", `Bearer ${credential.token}`];
|
|
174
|
+
case "basic":
|
|
175
|
+
return ["Authorization", `Basic ${Buffer.from(`${credential.username}:${credential.password}`).toString("base64")}`];
|
|
176
|
+
case "custom-header":
|
|
177
|
+
return [credential.header, credential.value];
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Refuses a toolkit configuration whose credential cannot be presented — at construction.
|
|
182
|
+
*
|
|
183
|
+
* Takes the *declared* scheme rather than resolving, because resolution needs a context and construction has
|
|
184
|
+
* none. A host that wires a static map can therefore be told immediately; a host whose resolver is dynamic is
|
|
185
|
+
* caught by `withCredentialAudit` at the first call instead. Both are before the vendor sees anything.
|
|
186
|
+
*/
|
|
187
|
+
export const assertToolkitAuth = (ref, auth, declared) => {
|
|
188
|
+
if (auth.schemes.length === 0)
|
|
189
|
+
throw new Error(`toolkit auth for "${ref}" declares no schemes, so no credential could ever satisfy it`);
|
|
190
|
+
if (declared !== undefined && !auth.schemes.includes(declared)) {
|
|
191
|
+
const error = credentialSchemeMismatch(ref, auth.schemes, declared);
|
|
192
|
+
throw Object.assign(new Error(error.message), error);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Adds an expiry to a credential **without losing its secret protection** — AC-6.
|
|
197
|
+
*
|
|
198
|
+
* This exists because the obvious way to build one is wrong, and wrong invisibly. A host writing a
|
|
199
|
+
* `CredentialRefresher` reaches for:
|
|
200
|
+
*
|
|
201
|
+
* ```ts
|
|
202
|
+
* return { ...bearer(accessToken), expiresAt }; // ← the secret is now enumerable
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* `createCredential` defines the secret **non-enumerably**, which is precisely what makes it survive a
|
|
206
|
+
* `JSON.stringify` into a log line — and precisely what a spread drops. The result looks identical, works
|
|
207
|
+
* identically, and serialises the token into the first structured log that touches it.
|
|
208
|
+
*
|
|
209
|
+
* Found by the AC-6 test failing against this repository's own test helper, which had made exactly that
|
|
210
|
+
* mistake. If the helper made it, a host will.
|
|
211
|
+
*/
|
|
212
|
+
export const refreshable = (credential, expiresAt) => {
|
|
213
|
+
const secrets = SECRET_KEYS[credential.scheme];
|
|
214
|
+
/**
|
|
215
|
+
* Rebuilt **through** `createCredential`, with the expiry passed in rather than added after.
|
|
216
|
+
*
|
|
217
|
+
* Two reasons it has to be this way round. The protection must be *applied* rather than copied — a copy of a
|
|
218
|
+
* non-enumerable property is an enumerable one — and `createCredential` freezes what it returns, so there is
|
|
219
|
+
* no "after" to add a field in. The secrets are read back explicitly because the spread above cannot see
|
|
220
|
+
* them, which is the whole point of them.
|
|
221
|
+
*/
|
|
222
|
+
return createCredential({
|
|
223
|
+
...credential,
|
|
224
|
+
...Object.fromEntries(secrets.map((key) => [key, credential[key]])),
|
|
225
|
+
expiresAt,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
export const isRefreshable = (credential) => typeof credential.expiresAt === "string";
|
|
229
|
+
/**
|
|
230
|
+
* How early a token is replaced.
|
|
231
|
+
*
|
|
232
|
+
* Sixty seconds, and the number is a *commitment* rather than a guess: a tool call can take tens of seconds —
|
|
233
|
+
* a slow vendor, a large upload, a retry — and a token that was valid when the call started must still be
|
|
234
|
+
* valid when it arrives. Refreshing exactly at expiry makes "expired mid-flight" the common case rather than
|
|
235
|
+
* the rare one, and that failure looks like an intermittent authentication bug.
|
|
236
|
+
*
|
|
237
|
+
* AC-5. Configurable because a deployment whose calls are slower than this needs more.
|
|
238
|
+
*/
|
|
239
|
+
export const DEFAULT_REFRESH_SKEW_MS = 60_000;
|
|
240
|
+
/** Whether a credential is expired, or close enough that it should be replaced before use. */
|
|
241
|
+
export const isExpiring = (credential, skewMs, now) => {
|
|
242
|
+
if (!isRefreshable(credential))
|
|
243
|
+
return false;
|
|
244
|
+
const expiresAt = Date.parse(credential.expiresAt);
|
|
245
|
+
// An unparseable expiry is treated as expiring. The alternative is using a credential whose lifetime is
|
|
246
|
+
// unknown, and the cost of an unnecessary refresh is one call.
|
|
247
|
+
return Number.isNaN(expiresAt) || expiresAt - now <= skewMs;
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* A refresh that failed because the grant is gone, rather than because the network was.
|
|
251
|
+
*
|
|
252
|
+
* The distinction is the whole of AC-3, and it is not cosmetic: `invalid_grant` means a person must consent
|
|
253
|
+
* again and no amount of retrying will help, while a timeout means try again in a second. A runtime that
|
|
254
|
+
* conflates them either retries a dead grant forever or asks a user to re-authorise because of a blip.
|
|
255
|
+
*/
|
|
256
|
+
export const REFRESH_GRANT_ERRORS = ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied"];
|
|
257
|
+
export const isGrantError = (error) => {
|
|
258
|
+
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
259
|
+
return REFRESH_GRANT_ERRORS.some((code) => text.includes(code));
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Wraps a resolver so an expiring credential is renewed before it is handed out.
|
|
263
|
+
*
|
|
264
|
+
* A wrapper, like `withCredentialAudit`, and for the same reason: the eight shipped toolkits already resolve
|
|
265
|
+
* **per call**, so they pick this up without a line changing. A toolkit that cached a credential at
|
|
266
|
+
* construction would defeat it, which is why `createGitHubToolkit` and every sibling resolve inside `call()`.
|
|
267
|
+
*
|
|
268
|
+
* ## Time-driven, never 401-driven — AC-7
|
|
269
|
+
*
|
|
270
|
+
* The obvious design is to refresh when the vendor returns 401. It is wrong, and worth stating plainly because
|
|
271
|
+
* it is what most integrations do:
|
|
272
|
+
*
|
|
273
|
+
* A 401 is what a vendor returns for an expired token, a **revoked** grant, a token for the wrong tenant, and a
|
|
274
|
+
* scope the grant never had. Refreshing on 401 therefore turns a revoked grant into an infinite refresh loop
|
|
275
|
+
* against the vendor's token endpoint, and turns a missing scope into a refresh that succeeds and a call that
|
|
276
|
+
* fails again identically. Neither is diagnosable from the outside.
|
|
277
|
+
*
|
|
278
|
+
* Time is the only signal that means what it says: a token with an expiry in the past is expired, and nothing
|
|
279
|
+
* else is inferred from it. A 401 on a freshly-refreshed token is a real error and is surfaced as one.
|
|
280
|
+
*
|
|
281
|
+
* ## One refresh, not N — AC-2
|
|
282
|
+
*
|
|
283
|
+
* Twenty concurrent tool calls hitting an expired token must produce **one** refresh. Refresh endpoints rate
|
|
284
|
+
* limit, and — worse — several vendors invalidate the previous refresh token when one is used, so N concurrent
|
|
285
|
+
* refreshes race to invalidate each other and log the deployment out permanently.
|
|
286
|
+
*
|
|
287
|
+
* The in-flight promise is stored *before* the first await, so a second caller entering the function
|
|
288
|
+
* synchronously after the first still finds it.
|
|
289
|
+
*/
|
|
290
|
+
export const withRefreshingCredentials = (resolver, refresher, options = {}) => {
|
|
291
|
+
const skewMs = Math.max(0, options.skewMs ?? DEFAULT_REFRESH_SKEW_MS);
|
|
292
|
+
const now = options.now ?? (() => Date.now());
|
|
293
|
+
/**
|
|
294
|
+
* Keyed by tenant **and** ref — AC-4.
|
|
295
|
+
*
|
|
296
|
+
* Two tenants naming the same credential `"google"` mean different grants, and a cache keyed by ref alone
|
|
297
|
+
* would hand one tenant the other's token after a refresh. A space separates them: a tenant id cannot
|
|
298
|
+
* contain one, so `a` + `b c` and `a b` + `c` cannot collide.
|
|
299
|
+
*/
|
|
300
|
+
const cached = new Map();
|
|
301
|
+
const inFlight = new Map();
|
|
302
|
+
const keyOf = (tenantId, ref) => `${tenantId} ${ref}`;
|
|
303
|
+
const refreshOnce = (key, input) => {
|
|
304
|
+
const existing = inFlight.get(key);
|
|
305
|
+
if (existing !== undefined)
|
|
306
|
+
return existing;
|
|
307
|
+
// Built and stored before the first await, so a synchronous second caller joins this one rather than
|
|
308
|
+
// starting a second refresh.
|
|
309
|
+
const attempt = (async () => {
|
|
310
|
+
try {
|
|
311
|
+
const fresh = await refresher.refresh({ ref: input.ref, context: input.context });
|
|
312
|
+
cached.set(key, fresh);
|
|
313
|
+
options.onRefreshed?.({
|
|
314
|
+
ref: input.ref,
|
|
315
|
+
tenantId: String(input.context.tenantId),
|
|
316
|
+
expiresAt: fresh.expiresAt,
|
|
317
|
+
});
|
|
318
|
+
return fresh;
|
|
319
|
+
}
|
|
320
|
+
catch (thrown) {
|
|
321
|
+
/**
|
|
322
|
+
* Belt-and-braces, and **not** load-bearing — worth saying rather than implying otherwise.
|
|
323
|
+
*
|
|
324
|
+
* A cached credential that is expiring already fails the freshness guard in `resolve`, so the next
|
|
325
|
+
* caller would attempt a refresh whether or not this line ran. Removing it breaks no test, which is
|
|
326
|
+
* exactly what one should expect. It stays because the invariant it states — a credential known to be
|
|
327
|
+
* dead is never held — is one a future edit could otherwise quietly rely on being false.
|
|
328
|
+
*/
|
|
329
|
+
cached.delete(key);
|
|
330
|
+
throw new AgentPlatformError(isGrantError(thrown)
|
|
331
|
+
? {
|
|
332
|
+
code: "unauthorized",
|
|
333
|
+
message: `The stored authorisation for "${input.ref}" is no longer valid and could not be renewed. ` +
|
|
334
|
+
"Someone needs to connect the account again — retrying will not help.",
|
|
335
|
+
retryable: false,
|
|
336
|
+
}
|
|
337
|
+
: {
|
|
338
|
+
code: "provider_unavailable",
|
|
339
|
+
message: `Could not renew the authorisation for "${input.ref}" right now.`,
|
|
340
|
+
retryable: true,
|
|
341
|
+
}, { cause: thrown });
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
inFlight.delete(key);
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
inFlight.set(key, attempt);
|
|
348
|
+
return attempt;
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
async resolve(input) {
|
|
352
|
+
const key = keyOf(String(input.context.tenantId), input.ref);
|
|
353
|
+
const held = cached.get(key);
|
|
354
|
+
if (held !== undefined && !isExpiring(held, skewMs, now()))
|
|
355
|
+
return held;
|
|
356
|
+
// Ask the underlying resolver first: it is the source of truth, and on the common path the credential it
|
|
357
|
+
// returns is a plain one with no expiry, which is passed straight through.
|
|
358
|
+
const resolved = await resolver.resolve(input);
|
|
359
|
+
if (!isRefreshable(resolved))
|
|
360
|
+
return resolved;
|
|
361
|
+
if (!isExpiring(resolved, skewMs, now())) {
|
|
362
|
+
cached.set(key, resolved);
|
|
363
|
+
return resolved;
|
|
364
|
+
}
|
|
365
|
+
return refreshOnce(key, input);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
};
|
|
54
369
|
//# sourceMappingURL=credentials.js.map
|