@blogic-cz/agent-tools 1.15.3 → 1.16.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/dist/api-tool/proxy.d.ts.map +1 -1
- package/dist/api-tool/types.d.ts +30 -4
- package/dist/api-tool/types.d.ts.map +1 -1
- package/docs/rest-api-proxy.md +9 -1
- package/package.json +2 -2
- package/src/api-tool/proxy.ts +239 -50
- package/src/api-tool/types.ts +63 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/api-tool/proxy.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAkB,MAAM,SAAS,CAAC;AAGzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,eAAO,MAAM,gBAAgB,eAAgB,MAAM,KAAG,MACC,CAAC;
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/api-tool/proxy.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAkB,MAAM,SAAS,CAAC;AAGzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,eAAO,MAAM,gBAAgB,eAAgB,MAAM,KAAG,MACC,CAAC;AAmBxD,wBAAgB,WAAW,CAAC,OAAO,EAAE;IACnC,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1C,SAAS,EAAE,SAAS,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;IA6aG,SAAS,eAAe,MAAM,KAAG,IAAI;IAGrC,MAAM,WAAW,MAAM,KAAG,IAAI;IAO9B,OAAO,eAAqB,MAAM,SAAS,OAAO,KAAG,OAAO,CAAC,SAAS,CAAC;IAEvE,iBAAiB,eAAqB,MAAM,SAAS,OAAO,KAAG,OAAO,CAAC,MAAM,CAAC;EAGjF"}
|
package/dist/api-tool/types.d.ts
CHANGED
|
@@ -16,8 +16,21 @@ export declare const ProfileSchema: Schema.Struct<{
|
|
|
16
16
|
}>, Schema.Struct<{
|
|
17
17
|
readonly kind: Schema.Literal<"login">;
|
|
18
18
|
readonly path: Schema.String;
|
|
19
|
-
readonly fields: Schema.$Record<Schema.String, Schema.String
|
|
20
|
-
|
|
19
|
+
readonly fields: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.String, Schema.Struct<{
|
|
20
|
+
readonly secretRef: Schema.String;
|
|
21
|
+
}>, Schema.Struct<{
|
|
22
|
+
readonly literal: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
23
|
+
}>]>>;
|
|
24
|
+
readonly tokenField: Schema.optionalKey<Schema.String>;
|
|
25
|
+
readonly tokenPath: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
26
|
+
readonly tokenReuseMs: Schema.optionalKey<Schema.Number>;
|
|
27
|
+
}>, Schema.Struct<{
|
|
28
|
+
readonly kind: Schema.Literal<"bodyFields">;
|
|
29
|
+
readonly fields: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.Struct<{
|
|
30
|
+
readonly secretRef: Schema.String;
|
|
31
|
+
}>, Schema.Struct<{
|
|
32
|
+
readonly literal: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
33
|
+
}>]>>;
|
|
21
34
|
}>]>;
|
|
22
35
|
readonly methods: Schema.optionalKey<Schema.$Array<Schema.Literals<readonly ["GET", "POST", "PUT", "PATCH", "DELETE"]>>>;
|
|
23
36
|
readonly pathPrefixes: Schema.$Array<Schema.String>;
|
|
@@ -74,8 +87,21 @@ export declare const OperatorSchema: Schema.Struct<{
|
|
|
74
87
|
}>, Schema.Struct<{
|
|
75
88
|
readonly kind: Schema.Literal<"login">;
|
|
76
89
|
readonly path: Schema.String;
|
|
77
|
-
readonly fields: Schema.$Record<Schema.String, Schema.String
|
|
78
|
-
|
|
90
|
+
readonly fields: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.String, Schema.Struct<{
|
|
91
|
+
readonly secretRef: Schema.String;
|
|
92
|
+
}>, Schema.Struct<{
|
|
93
|
+
readonly literal: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
94
|
+
}>]>>;
|
|
95
|
+
readonly tokenField: Schema.optionalKey<Schema.String>;
|
|
96
|
+
readonly tokenPath: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
97
|
+
readonly tokenReuseMs: Schema.optionalKey<Schema.Number>;
|
|
98
|
+
}>, Schema.Struct<{
|
|
99
|
+
readonly kind: Schema.Literal<"bodyFields">;
|
|
100
|
+
readonly fields: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.Struct<{
|
|
101
|
+
readonly secretRef: Schema.String;
|
|
102
|
+
}>, Schema.Struct<{
|
|
103
|
+
readonly literal: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
104
|
+
}>]>>;
|
|
79
105
|
}>]>;
|
|
80
106
|
readonly methods: Schema.optionalKey<Schema.$Array<Schema.Literals<readonly ["GET", "POST", "PUT", "PATCH", "DELETE"]>>>;
|
|
81
107
|
readonly pathPrefixes: Schema.$Array<Schema.String>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/api-tool/types.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,eAAO,MAAM,MAAM,qEAA6D,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/api-tool/types.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,eAAO,MAAM,MAAM,qEAA6D,CAAC;AAmCjF,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAcxB,CAAC;AACH,QAAA,MAAM,WAAW;;;;;;;;;EASf,CAAC;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqBzB,CAAC;AACH,eAAO,MAAM,YAAY;;;;EAIvB,CAAC;AACH,eAAO,MAAM,aAAa;;;;;;EAMxB,CAAC;AACH,eAAO,MAAM,YAAY;;;EAA8D,CAAC;AACxF,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC,IAAI,CAAC;AACxD,MAAM,MAAM,YAAY,GAAG,OAAO,YAAY,CAAC,IAAI,CAAC;AACpD,MAAM,MAAM,UAAU,GAAG,OAAO,aAAa,CAAC,IAAI,CAAC;AACnD,MAAM,MAAM,SAAS,GAAG,OAAO,YAAY,CAAC,IAAI,CAAC;AACjD,MAAM,MAAM,OAAO,GAAG,OAAO,aAAa,CAAC,IAAI,CAAC;AAChD,MAAM,MAAM,KAAK,GAAG,OAAO,WAAW,CAAC,IAAI,CAAC;AAE5C,wBAAgB,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,iBAAiB,CAAC,OAAO,CAAC,EAChE,MAAM,EAAE,CAAC,EACT,KAAK,EAAE,OAAO,GACb,CAAC,CAAC,MAAM,CAAC,CAMX;AACD,eAAO,MAAM,cAAc,QAAS,CAAC;AACrC,eAAO,MAAM,yBAAyB,WAAa,CAAC;AACpD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CASjD;AACD,eAAO,MAAM,aAAa,SAAU,MAAM,UAAU,MAAM,KAAG,OAG5D,CAAC;AACF,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,mBAAmB,UAAQ,GAAG,GAAG,CAgB1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CA8G/D"}
|
package/docs/rest-api-proxy.md
CHANGED
|
@@ -121,9 +121,17 @@ Supported profile auth shapes:
|
|
|
121
121
|
{ "kind": "apiKey", "header": "x-api-key", "secretRef": "api-key" }
|
|
122
122
|
{ "kind": "basic", "usernameRef": "username", "passwordRef": "password" }
|
|
123
123
|
{ "kind": "login", "path": "/auth/login", "fields": { "username": "username", "password": "password" }, "tokenField": "access_token" }
|
|
124
|
+
{ "kind": "login", "path": "/auth/login", "fields": { "clientId": { "literal": "sample-client" }, "password": { "secretRef": "password" } }, "tokenPath": ["userInfo", "token"], "tokenReuseMs": 60000 }
|
|
125
|
+
{ "kind": "bodyFields", "fields": { "apiKey": { "secretRef": "api-key" }, "clientId": { "literal": "sample-client" } } }
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
Login is one configured JSON POST at the exact profile origin.
|
|
128
|
+
Login is one configured JSON POST at the exact profile origin. String field values are secret-store references. Object values use either `{ "secretRef": "name" }` or `{ "literal": <JSON value> }`. At least one field must use a secret reference. `tokenField` selects a top-level token and remains the shorthand form. `tokenPath` selects a token through one to eight nested plain-object keys and cannot appear with `tokenField`. A missing key, array traversal or non-string token refuses the operation. Login output never goes to callers, and the login path and descendants are denied to client requests. Login and API requests share a single deadline; the budget reserves both requests' maximum bytes. Failed login refuses the operation.
|
|
129
|
+
|
|
130
|
+
An optional `tokenReuseMs` reuses the last minted token for that profile during the configured window. It must be a positive integer of at most 24 hours. Concurrent first requests share one login. A 401 clears the cached token and returns the upstream status and JSON body; the proxy does not retry the API request. Tokens remain in the echo-protection set after cache expiry.
|
|
131
|
+
|
|
132
|
+
`bodyFields` adds configured fields to each client JSON object body. Its profile must list methods and must exclude `GET`. Field names must be unique without regard to letter case, and at least one field must reference a secret. The client must supply an object body and cannot supply any configured field name, even with different letter case. The proxy adds the configured values after checking for collisions and checks the merged body against the request size limit. Secret values stay in echo protection. Literal values do not enter the secret store or echo protection.
|
|
133
|
+
|
|
134
|
+
Unsupported cookie, OAuth browser, multipart and executable authentication flows refuse configuration instead of executing custom code.
|
|
127
135
|
|
|
128
136
|
Choose `disclosure: { "kind": "fields", "fields": [...] }` to project top-level object fields. Choose `disclosure: { "kind": "raw" }` only when the operator authorizes the service's complete JSON response. Neither mode promises that application data are nonsensitive. Exact stored-secret and minted-token echoes, including JSON-escaped, URI-component, form URL-encoded and base64 variants, refuse disclosure. URL percent escapes match without regard to their hexadecimal letter case; credential letters remain case-sensitive. This does not detect arbitrary transformed secrets or establish universal privacy. At most 128 distinct stored secrets, deterministic Basic credentials and retained auth tokens are allowed per runtime. Pending logins reserve retention slots before dispatch, and reaching the retention bound refuses new logins. Failed exchanges release their slots; a token already known releases its extra reservation without evicting any credential. An aborted exchange keeps its slot until the transport settles, and any valid token it returns is retained before the operation can continue. Tokens are never evicted from echo checks.
|
|
129
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure platform, Azure DevOps, logs, sessions, and audit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
"vitest": "5.0.4"
|
|
178
178
|
},
|
|
179
179
|
"peerDependencies": {
|
|
180
|
-
"effect": "
|
|
180
|
+
"effect": "~4.0.5"
|
|
181
181
|
},
|
|
182
182
|
"engines": {
|
|
183
183
|
"bun": ">=1.3.0"
|
package/src/api-tool/proxy.ts
CHANGED
|
@@ -24,6 +24,16 @@ type Budget = {
|
|
|
24
24
|
active: number;
|
|
25
25
|
startedAt: number;
|
|
26
26
|
revoked: boolean;
|
|
27
|
+
loginWaiters: Set<() => void>;
|
|
28
|
+
};
|
|
29
|
+
type LoginToken = { value: string; expiresAt: number };
|
|
30
|
+
type LoginFlight = {
|
|
31
|
+
promise: Promise<string>;
|
|
32
|
+
waiters: number;
|
|
33
|
+
settled: boolean;
|
|
34
|
+
ownerRequest: object;
|
|
35
|
+
releaseOwnerSlot?: () => void;
|
|
36
|
+
abort: () => void;
|
|
27
37
|
};
|
|
28
38
|
export function createProxy(options: {
|
|
29
39
|
config: unknown;
|
|
@@ -33,10 +43,18 @@ export function createProxy(options: {
|
|
|
33
43
|
}) {
|
|
34
44
|
const config = validateOperator(options.config);
|
|
35
45
|
const now = options.now ?? Date.now;
|
|
36
|
-
const budgets = new Map(
|
|
37
|
-
config.grants.map((grant) => [
|
|
46
|
+
const budgets = new Map<string, Budget>(
|
|
47
|
+
config.grants.map((grant): [string, Budget] => [
|
|
38
48
|
grant.digest,
|
|
39
|
-
{
|
|
49
|
+
{
|
|
50
|
+
grant,
|
|
51
|
+
requests: 0,
|
|
52
|
+
bytes: 0,
|
|
53
|
+
active: 0,
|
|
54
|
+
startedAt: now(),
|
|
55
|
+
revoked: grant.revoked,
|
|
56
|
+
loginWaiters: new Set<() => void>(),
|
|
57
|
+
},
|
|
40
58
|
]),
|
|
41
59
|
);
|
|
42
60
|
let active = 0;
|
|
@@ -61,13 +79,20 @@ export function createProxy(options: {
|
|
|
61
79
|
case "basic":
|
|
62
80
|
return [profile.auth.usernameRef, profile.auth.passwordRef];
|
|
63
81
|
case "login":
|
|
64
|
-
return Object.values(profile.auth.fields)
|
|
82
|
+
return Object.values(profile.auth.fields).flatMap((value) =>
|
|
83
|
+
typeof value === "string" ? [value] : "secretRef" in value ? [value.secretRef] : [],
|
|
84
|
+
);
|
|
85
|
+
case "bodyFields":
|
|
86
|
+
return Object.values(profile.auth.fields).flatMap((value) =>
|
|
87
|
+
"secretRef" in value ? [value.secretRef] : [],
|
|
88
|
+
);
|
|
65
89
|
}
|
|
66
90
|
};
|
|
67
91
|
if (Object.keys(options.secrets).length > 128) throw apiError("CONFIG_INVALID");
|
|
68
92
|
const initial = Object.keys(options.secrets).map(secret);
|
|
69
93
|
const basicCredentials = new Map<Profile, string>();
|
|
70
94
|
const loginBodies = new Map<Profile, string>();
|
|
95
|
+
const bodyFields = new Map<Profile, Record<string, unknown>>();
|
|
71
96
|
for (const profile of Object.values(config.profiles)) {
|
|
72
97
|
for (const ref of references(profile)) secret(ref);
|
|
73
98
|
if (profile.auth.kind === "basic") {
|
|
@@ -80,14 +105,34 @@ export function createProxy(options: {
|
|
|
80
105
|
if (profile.auth.kind === "login") {
|
|
81
106
|
const body = JSON.stringify(
|
|
82
107
|
Object.fromEntries(
|
|
83
|
-
Object.entries(profile.auth.fields).map(([field,
|
|
108
|
+
Object.entries(profile.auth.fields).map(([field, value]) => [
|
|
109
|
+
field,
|
|
110
|
+
typeof value === "string"
|
|
111
|
+
? secret(value)
|
|
112
|
+
: "secretRef" in value
|
|
113
|
+
? secret(value.secretRef)
|
|
114
|
+
: value.literal,
|
|
115
|
+
]),
|
|
84
116
|
),
|
|
85
117
|
);
|
|
86
118
|
if (Buffer.byteLength(profile.auth.path) + Buffer.byteLength(body) > profile.maxRequestBytes)
|
|
87
119
|
throw apiError("CONFIG_INVALID");
|
|
88
120
|
loginBodies.set(profile, body);
|
|
89
121
|
}
|
|
122
|
+
if (profile.auth.kind === "bodyFields") {
|
|
123
|
+
bodyFields.set(
|
|
124
|
+
profile,
|
|
125
|
+
Object.fromEntries(
|
|
126
|
+
Object.entries(profile.auth.fields).map(([field, value]) => [
|
|
127
|
+
field,
|
|
128
|
+
"secretRef" in value ? secret(value.secretRef) : value.literal,
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
90
133
|
}
|
|
134
|
+
const loginTokens = new Map<Profile, LoginToken>();
|
|
135
|
+
const pendingLogins = new Map<Profile, LoginFlight>();
|
|
91
136
|
const retention = createCredentialRetention(initial);
|
|
92
137
|
const protectedOutput = (result: ApiResult): string => {
|
|
93
138
|
const serialized = JSON.stringify(result);
|
|
@@ -124,6 +169,8 @@ export function createProxy(options: {
|
|
|
124
169
|
input: unknown,
|
|
125
170
|
): Promise<{ result: ApiResult; serialized: string }> => {
|
|
126
171
|
const budget = authorize(capability);
|
|
172
|
+
const requestId = {};
|
|
173
|
+
let loginFlight: LoginFlight | undefined;
|
|
127
174
|
if (
|
|
128
175
|
typeof input === "object" &&
|
|
129
176
|
input !== null &&
|
|
@@ -150,7 +197,23 @@ export function createProxy(options: {
|
|
|
150
197
|
(request.method === "GET" && request.body !== undefined)
|
|
151
198
|
)
|
|
152
199
|
throw apiError("POLICY_DENIED");
|
|
153
|
-
|
|
200
|
+
let bodyValue: unknown = request.body;
|
|
201
|
+
if (profile.auth.kind === "bodyFields") {
|
|
202
|
+
if (
|
|
203
|
+
typeof bodyValue !== "object" ||
|
|
204
|
+
bodyValue === null ||
|
|
205
|
+
Array.isArray(bodyValue) ||
|
|
206
|
+
Object.getPrototypeOf(bodyValue) !== Object.prototype
|
|
207
|
+
)
|
|
208
|
+
throw apiError("POLICY_DENIED");
|
|
209
|
+
const injected = bodyFields.get(profile);
|
|
210
|
+
if (injected === undefined) throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
211
|
+
const names = new Set(Object.keys(injected).map((name) => name.toLowerCase()));
|
|
212
|
+
if (Object.keys(bodyValue).some((name) => names.has(name.toLowerCase())))
|
|
213
|
+
throw apiError("POLICY_DENIED");
|
|
214
|
+
bodyValue = { ...bodyValue, ...injected };
|
|
215
|
+
}
|
|
216
|
+
const body = bodyValue === undefined ? "" : (JSON.stringify(bodyValue) ?? "");
|
|
154
217
|
const url = new URL(request.path, profile.origin);
|
|
155
218
|
for (const [key, value] of Object.entries(request.query ?? {}))
|
|
156
219
|
url.searchParams.append(key, value);
|
|
@@ -174,8 +237,15 @@ export function createProxy(options: {
|
|
|
174
237
|
if (budget.revoked || now() >= budget.grant.expiresAt) throw apiError("UNAUTHORIZED");
|
|
175
238
|
if (now() - budget.startedAt >= budget.grant.lifetimeMs) throw apiError("BUDGET_EXHAUSTED");
|
|
176
239
|
};
|
|
177
|
-
const send = (
|
|
178
|
-
|
|
240
|
+
const send = (
|
|
241
|
+
target: URL,
|
|
242
|
+
method: string,
|
|
243
|
+
headers: Record<string, string>,
|
|
244
|
+
data: string,
|
|
245
|
+
signal = controller.signal,
|
|
246
|
+
check = checkGrant,
|
|
247
|
+
) => {
|
|
248
|
+
check();
|
|
179
249
|
return options.transport({
|
|
180
250
|
url: target,
|
|
181
251
|
method,
|
|
@@ -186,11 +256,12 @@ export function createProxy(options: {
|
|
|
186
256
|
},
|
|
187
257
|
body: data,
|
|
188
258
|
maxBytes: profile.maxResponseBytes,
|
|
189
|
-
signal
|
|
259
|
+
signal,
|
|
190
260
|
allowedPrivateAddresses: profile.allowedPrivateAddresses,
|
|
191
261
|
});
|
|
192
262
|
};
|
|
193
263
|
const headers: Record<string, string> = {};
|
|
264
|
+
let usedLoginToken: string | undefined;
|
|
194
265
|
switch (profile.auth.kind) {
|
|
195
266
|
case "bearer":
|
|
196
267
|
headers.authorization = `Bearer ${secret(profile.auth.secretRef)}`;
|
|
@@ -204,47 +275,146 @@ export function createProxy(options: {
|
|
|
204
275
|
headers.authorization = `Basic ${encoded}`;
|
|
205
276
|
break;
|
|
206
277
|
}
|
|
278
|
+
case "bodyFields":
|
|
279
|
+
break;
|
|
207
280
|
case "login": {
|
|
208
281
|
const auth = profile.auth;
|
|
209
282
|
const loginBody = loginBodies.get(profile);
|
|
210
283
|
if (loginBody === undefined) throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
284
|
+
const waitForLogin = (flight: LoginFlight): Promise<string> => {
|
|
285
|
+
loginFlight = flight;
|
|
286
|
+
flight.waiters++;
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
let waiting = true;
|
|
289
|
+
const leave = (): boolean => {
|
|
290
|
+
if (!waiting) return false;
|
|
291
|
+
waiting = false;
|
|
292
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
293
|
+
budget.loginWaiters.delete(onRevoke);
|
|
294
|
+
flight.waiters--;
|
|
295
|
+
if (flight.waiters === 0 && !flight.settled) {
|
|
296
|
+
if (pendingLogins.get(profile) === flight) pendingLogins.delete(profile);
|
|
297
|
+
flight.abort();
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
};
|
|
301
|
+
const onAbort = () => {
|
|
302
|
+
if (leave()) reject(apiError("DEADLINE_EXCEEDED"));
|
|
303
|
+
};
|
|
304
|
+
const onRevoke = () => {
|
|
305
|
+
if (leave()) reject(apiError("UNAUTHORIZED"));
|
|
306
|
+
};
|
|
307
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
308
|
+
budget.loginWaiters.add(onRevoke);
|
|
309
|
+
if (controller.signal.aborted) onAbort();
|
|
310
|
+
else if (budget.revoked) onRevoke();
|
|
311
|
+
flight.promise.then(
|
|
312
|
+
(token) => {
|
|
313
|
+
if (leave()) resolve(token);
|
|
314
|
+
return undefined;
|
|
315
|
+
},
|
|
316
|
+
(error: unknown) => {
|
|
317
|
+
if (leave()) reject(error);
|
|
318
|
+
return undefined;
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const getToken = (): Promise<string> => {
|
|
324
|
+
const reuseMs = auth.tokenReuseMs;
|
|
325
|
+
if (reuseMs !== undefined) {
|
|
326
|
+
const cached = loginTokens.get(profile);
|
|
327
|
+
if (cached && now() < cached.expiresAt) return Promise.resolve(cached.value);
|
|
328
|
+
const pending = pendingLogins.get(profile);
|
|
329
|
+
if (pending) return waitForLogin(pending);
|
|
330
|
+
}
|
|
331
|
+
const reservation = retention.reserve();
|
|
332
|
+
const loginController = new AbortController();
|
|
333
|
+
const loginTimer = setTimeout(() => loginController.abort(), profile.timeoutMs);
|
|
334
|
+
let flight: LoginFlight;
|
|
335
|
+
let operation: Promise<string>;
|
|
336
|
+
try {
|
|
337
|
+
operation = send(
|
|
338
|
+
new URL(auth.path, profile.origin),
|
|
339
|
+
"POST",
|
|
340
|
+
{},
|
|
341
|
+
loginBody,
|
|
342
|
+
loginController.signal,
|
|
343
|
+
() => undefined,
|
|
344
|
+
)
|
|
345
|
+
.then((result) => {
|
|
346
|
+
if (
|
|
347
|
+
result.status < 200 ||
|
|
348
|
+
result.status >= 300 ||
|
|
349
|
+
Buffer.byteLength(result.body) > profile.maxResponseBytes
|
|
350
|
+
)
|
|
351
|
+
throw apiError("UPSTREAM_FAILED");
|
|
352
|
+
const parsed = decode(Schema.Json, JSON.parse(result.body));
|
|
353
|
+
let token: unknown = parsed;
|
|
354
|
+
const tokenPath =
|
|
355
|
+
auth.tokenPath ?? (auth.tokenField === undefined ? [] : [auth.tokenField]);
|
|
356
|
+
for (const segment of tokenPath) {
|
|
357
|
+
if (
|
|
358
|
+
typeof token !== "object" ||
|
|
359
|
+
token === null ||
|
|
360
|
+
Array.isArray(token) ||
|
|
361
|
+
Object.getPrototypeOf(token) !== Object.prototype ||
|
|
362
|
+
!Object.hasOwn(token, segment)
|
|
363
|
+
) {
|
|
364
|
+
token = undefined;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
token = (token as Record<string, unknown>)[segment];
|
|
368
|
+
}
|
|
369
|
+
if (
|
|
370
|
+
typeof token !== "string" ||
|
|
371
|
+
!token ||
|
|
372
|
+
token.length > 16384 ||
|
|
373
|
+
/[\uD800-\uDFFF]/u.test(token) ||
|
|
374
|
+
token.includes("\r") ||
|
|
375
|
+
token.includes("\n") ||
|
|
376
|
+
token.includes(String.fromCharCode(0))
|
|
377
|
+
)
|
|
378
|
+
throw apiError("UPSTREAM_FAILED");
|
|
379
|
+
reservation.retain(token);
|
|
380
|
+
if (reuseMs !== undefined && flight.waiters > 0)
|
|
381
|
+
loginTokens.set(profile, { value: token, expiresAt: now() + reuseMs });
|
|
382
|
+
return token;
|
|
383
|
+
})
|
|
384
|
+
.finally(() => {
|
|
385
|
+
clearTimeout(loginTimer);
|
|
386
|
+
reservation.release();
|
|
387
|
+
flight.settled = true;
|
|
388
|
+
if (pendingLogins.get(profile) === flight) pendingLogins.delete(profile);
|
|
389
|
+
flight.releaseOwnerSlot?.();
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
clearTimeout(loginTimer);
|
|
393
|
+
reservation.release();
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
flight = {
|
|
397
|
+
promise: operation,
|
|
398
|
+
waiters: 0,
|
|
399
|
+
settled: false,
|
|
400
|
+
ownerRequest: requestId,
|
|
401
|
+
abort: () => {
|
|
402
|
+
clearTimeout(loginTimer);
|
|
403
|
+
loginController.abort();
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
if (reuseMs !== undefined) {
|
|
407
|
+
pendingLogins.set(profile, flight);
|
|
408
|
+
const clearPending = () => {
|
|
409
|
+
if (pendingLogins.get(profile) === flight) pendingLogins.delete(profile);
|
|
410
|
+
};
|
|
411
|
+
void flight.promise.then(clearPending, clearPending);
|
|
412
|
+
}
|
|
413
|
+
return waitForLogin(flight);
|
|
414
|
+
};
|
|
415
|
+
const token = await getToken();
|
|
416
|
+
headers.authorization = `Bearer ${token}`;
|
|
417
|
+
usedLoginToken = token;
|
|
248
418
|
break;
|
|
249
419
|
}
|
|
250
420
|
}
|
|
@@ -252,7 +422,13 @@ export function createProxy(options: {
|
|
|
252
422
|
send(url, request.method, headers, body),
|
|
253
423
|
controller.signal,
|
|
254
424
|
);
|
|
255
|
-
|
|
425
|
+
const unauthorizedLogin = profile.auth.kind === "login" && response.status === 401;
|
|
426
|
+
if (unauthorizedLogin && usedLoginToken !== undefined) {
|
|
427
|
+
const cached = loginTokens.get(profile);
|
|
428
|
+
if (cached?.value === usedLoginToken) loginTokens.delete(profile);
|
|
429
|
+
}
|
|
430
|
+
if (!unauthorizedLogin && (response.status < 200 || response.status >= 300))
|
|
431
|
+
throw apiError("UPSTREAM_FAILED");
|
|
256
432
|
if (Buffer.byteLength(response.body) > profile.maxResponseBytes)
|
|
257
433
|
throw apiError("LIMIT_EXCEEDED");
|
|
258
434
|
if (controller.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
@@ -278,8 +454,18 @@ export function createProxy(options: {
|
|
|
278
454
|
throw safeError(error);
|
|
279
455
|
} finally {
|
|
280
456
|
clearTimeout(timer);
|
|
281
|
-
|
|
282
|
-
|
|
457
|
+
if (loginFlight?.ownerRequest === requestId && !loginFlight.settled) {
|
|
458
|
+
let released = false;
|
|
459
|
+
loginFlight.releaseOwnerSlot = () => {
|
|
460
|
+
if (released) return;
|
|
461
|
+
released = true;
|
|
462
|
+
budget.active--;
|
|
463
|
+
active--;
|
|
464
|
+
};
|
|
465
|
+
} else {
|
|
466
|
+
budget.active--;
|
|
467
|
+
active--;
|
|
468
|
+
}
|
|
283
469
|
}
|
|
284
470
|
};
|
|
285
471
|
return {
|
|
@@ -288,7 +474,10 @@ export function createProxy(options: {
|
|
|
288
474
|
},
|
|
289
475
|
revoke: (digest: string): void => {
|
|
290
476
|
const budget = budgets.get(digest);
|
|
291
|
-
if (budget)
|
|
477
|
+
if (budget) {
|
|
478
|
+
budget.revoked = true;
|
|
479
|
+
for (const leave of budget.loginWaiters) leave();
|
|
480
|
+
}
|
|
292
481
|
},
|
|
293
482
|
request: async (capability: string, input: unknown): Promise<ApiResult> =>
|
|
294
483
|
(await execute(capability, input)).result,
|
package/src/api-tool/types.ts
CHANGED
|
@@ -4,6 +4,15 @@ import { Schema } from "effect";
|
|
|
4
4
|
import { apiError } from "./errors";
|
|
5
5
|
|
|
6
6
|
export const Method = Schema.Literals(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
+
const SecretField = Schema.Union([
|
|
8
|
+
Schema.String,
|
|
9
|
+
Schema.Struct({ secretRef: Schema.String }),
|
|
10
|
+
Schema.Struct({ literal: Schema.Json }),
|
|
11
|
+
]);
|
|
12
|
+
const ConfiguredField = Schema.Union([
|
|
13
|
+
Schema.Struct({ secretRef: Schema.String }),
|
|
14
|
+
Schema.Struct({ literal: Schema.Json }),
|
|
15
|
+
]);
|
|
7
16
|
const Auth = Schema.Union([
|
|
8
17
|
Schema.Struct({ kind: Schema.Literal("bearer"), secretRef: Schema.String }),
|
|
9
18
|
Schema.Struct({
|
|
@@ -19,8 +28,14 @@ const Auth = Schema.Union([
|
|
|
19
28
|
Schema.Struct({
|
|
20
29
|
kind: Schema.Literal("login"),
|
|
21
30
|
path: Schema.String,
|
|
22
|
-
fields: Schema.Record(Schema.String,
|
|
23
|
-
tokenField: Schema.String,
|
|
31
|
+
fields: Schema.Record(Schema.String, SecretField),
|
|
32
|
+
tokenField: Schema.optionalKey(Schema.String),
|
|
33
|
+
tokenPath: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
34
|
+
tokenReuseMs: Schema.optionalKey(Schema.Number),
|
|
35
|
+
}),
|
|
36
|
+
Schema.Struct({
|
|
37
|
+
kind: Schema.Literal("bodyFields"),
|
|
38
|
+
fields: Schema.Record(Schema.String, ConfiguredField),
|
|
24
39
|
}),
|
|
25
40
|
]);
|
|
26
41
|
export const ProfileSchema = Schema.Struct({
|
|
@@ -188,8 +203,31 @@ export function validateOperator(value: unknown): OperatorConfig {
|
|
|
188
203
|
throw apiError("CONFIG_INVALID");
|
|
189
204
|
if (profile.auth.kind === "login") {
|
|
190
205
|
relativePath(profile.auth.path);
|
|
206
|
+
const fields = Object.values(profile.auth.fields);
|
|
207
|
+
if (
|
|
208
|
+
!fields.length ||
|
|
209
|
+
!fields.some((fieldValue) => typeof fieldValue === "string" || "secretRef" in fieldValue) ||
|
|
210
|
+
(profile.auth.tokenField !== undefined && profile.auth.tokenPath !== undefined) ||
|
|
211
|
+
(profile.auth.tokenField === undefined && profile.auth.tokenPath === undefined) ||
|
|
212
|
+
(profile.auth.tokenField !== undefined && !profile.auth.tokenField) ||
|
|
213
|
+
(profile.auth.tokenPath !== undefined &&
|
|
214
|
+
(profile.auth.tokenPath.length < 1 ||
|
|
215
|
+
profile.auth.tokenPath.length > 8 ||
|
|
216
|
+
profile.auth.tokenPath.some((segment) => !segment))) ||
|
|
217
|
+
(profile.auth.tokenReuseMs !== undefined && !bounded(profile.auth.tokenReuseMs, 86400000))
|
|
218
|
+
)
|
|
219
|
+
throw apiError("CONFIG_INVALID");
|
|
191
220
|
const minimumBody = JSON.stringify(
|
|
192
|
-
Object.fromEntries(
|
|
221
|
+
Object.fromEntries(
|
|
222
|
+
Object.entries(profile.auth.fields).map(([field, fieldValue]) => [
|
|
223
|
+
field,
|
|
224
|
+
typeof fieldValue === "string"
|
|
225
|
+
? ""
|
|
226
|
+
: "secretRef" in fieldValue
|
|
227
|
+
? ""
|
|
228
|
+
: fieldValue.literal,
|
|
229
|
+
]),
|
|
230
|
+
),
|
|
193
231
|
);
|
|
194
232
|
if (
|
|
195
233
|
Buffer.byteLength(profile.auth.path) + Buffer.byteLength(minimumBody) >
|
|
@@ -197,6 +235,28 @@ export function validateOperator(value: unknown): OperatorConfig {
|
|
|
197
235
|
)
|
|
198
236
|
throw apiError("CONFIG_INVALID");
|
|
199
237
|
}
|
|
238
|
+
if (profile.auth.kind === "bodyFields") {
|
|
239
|
+
const fields = Object.entries(profile.auth.fields);
|
|
240
|
+
const normalized = fields.map(([field]) => field.toLowerCase());
|
|
241
|
+
if (
|
|
242
|
+
!fields.length ||
|
|
243
|
+
!fields.some(([, fieldValue]) => "secretRef" in fieldValue) ||
|
|
244
|
+
new Set(normalized).size !== normalized.length ||
|
|
245
|
+
profile.methods === undefined ||
|
|
246
|
+
profile.methods.includes("GET")
|
|
247
|
+
)
|
|
248
|
+
throw apiError("CONFIG_INVALID");
|
|
249
|
+
const minimumBody = JSON.stringify(
|
|
250
|
+
Object.fromEntries(
|
|
251
|
+
fields.map(([field, fieldValue]) => [
|
|
252
|
+
field,
|
|
253
|
+
"secretRef" in fieldValue ? "" : fieldValue.literal,
|
|
254
|
+
]),
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
if (Buffer.byteLength(minimumBody) > profile.maxRequestBytes)
|
|
258
|
+
throw apiError("CONFIG_INVALID");
|
|
259
|
+
}
|
|
200
260
|
}
|
|
201
261
|
return config;
|
|
202
262
|
}
|