@blogic-cz/agent-tools 1.15.3 → 1.16.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.
@@ -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;AASxD,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;IA6PG,SAAS,eAAe,MAAM,KAAG,IAAI;IAGrC,MAAM,WAAW,MAAM,KAAG,IAAI;IAI9B,OAAO,eAAqB,MAAM,SAAS,OAAO,KAAG,OAAO,CAAC,SAAS,CAAC;IAEvE,iBAAiB,eAAqB,MAAM,SAAS,OAAO,KAAG,OAAO,CAAC,MAAM,CAAC;EAGjF"}
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"}
@@ -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
- readonly tokenField: Schema.String;
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
- readonly tokenField: Schema.String;
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;AAoBjF,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,CAiE/D"}
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"}
@@ -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. Its fixed fields reference the private store. The response's configured top-level string token becomes a Bearer header inside the proxy. 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. Unsupported cookie, OAuth browser, multipart and executable authentication flows refuse configuration instead of executing custom code.
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.15.3",
3
+ "version": "1.16.0",
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",
@@ -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
- { grant, requests: 0, bytes: 0, active: 0, startedAt: now(), revoked: grant.revoked },
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, ref]) => [field, secret(ref)]),
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
- const body = request.body === undefined ? "" : JSON.stringify(request.body);
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 = (target: URL, method: string, headers: Record<string, string>, data: string) => {
178
- checkGrant();
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: controller.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 reservation = retention.reserve();
212
- let operation: Promise<string>;
213
- try {
214
- operation = send(new URL(profile.auth.path, profile.origin), "POST", {}, loginBody)
215
- .then((result) => {
216
- if (
217
- result.status < 200 ||
218
- result.status >= 300 ||
219
- Buffer.byteLength(result.body) > profile.maxResponseBytes
220
- )
221
- throw apiError("UPSTREAM_FAILED");
222
- const parsed = decode(
223
- Schema.Record(Schema.String, Schema.Json),
224
- JSON.parse(result.body),
225
- );
226
- const token = Object.hasOwn(parsed, auth.tokenField)
227
- ? parsed[auth.tokenField]
228
- : undefined;
229
- if (
230
- typeof token !== "string" ||
231
- !token ||
232
- token.length > 16384 ||
233
- /[\uD800-\uDFFF]/u.test(token) ||
234
- token.includes("\r") ||
235
- token.includes("\n") ||
236
- token.includes(String.fromCharCode(0))
237
- )
238
- throw apiError("UPSTREAM_FAILED");
239
- reservation.retain(token);
240
- return token;
241
- })
242
- .finally(reservation.release);
243
- } catch (error) {
244
- reservation.release();
245
- throw error;
246
- }
247
- headers.authorization = `Bearer ${await beforeAbort(operation, controller.signal)}`;
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
- if (response.status < 200 || response.status >= 300) throw apiError("UPSTREAM_FAILED");
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
- budget.active--;
282
- active--;
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) budget.revoked = true;
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,
@@ -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, 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(Object.keys(profile.auth.fields).map((field) => [field, ""])),
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
  }