@caracalai/sdk 0.1.2 → 0.1.4-rc.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/client.js CHANGED
@@ -5,34 +5,88 @@
5
5
  * Caracal: drop-in bound client wrapping zone, application, subject token, and coordinator.
6
6
  */
7
7
  import { bind, fromEnvelope, toEnvelope, current } from "./context.js";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { homedir, platform } from "node:os";
10
+ import { join } from "node:path";
11
+ import { parse } from "smol-toml";
8
12
  import { decodeEnvelope, toHeaders, } from "./envelope.js";
9
13
  import { spawn as spawnPrimitive, delegate as delegatePrimitive, delegateToSpawn as delegateToSpawnPrimitive, } from "./primitives.js";
10
14
  import { AgentKind } from "./coordinator.js";
15
+ import { OAuthClient } from "@caracalai/oauth";
16
+ const DEFAULT_STS_URL = "http://localhost:8080";
17
+ const DEFAULT_COORDINATOR_URL = "http://localhost:4000";
18
+ const DEFAULT_GATEWAY_URL = "http://localhost:8081";
11
19
  export class Caracal {
12
20
  config;
13
21
  agentStartHooks = [];
14
22
  agentEndHooks = [];
15
23
  constructor(config) {
16
24
  this.config = config;
25
+ if ((config.subjectToken === undefined) === (config.tokenSource === undefined)) {
26
+ throw new Error("CaracalConfig requires exactly one of subjectToken or tokenSource");
27
+ }
17
28
  if (config.resources && config.resources.length > 1) {
18
29
  this.config = { ...config, resources: sortBindingsLongestFirst(config.resources) };
19
30
  }
20
31
  }
32
+ /**
33
+ * Builds a Caracal client from explicit values, a generated profile, or env.
34
+ */
35
+ static connect(opts = {}) {
36
+ if (opts.clientSecret && Object.keys(opts.clientSecret).length > 0) {
37
+ const cs = opts.clientSecret;
38
+ const missing = [
39
+ ["coordinatorUrl", cs.coordinatorUrl],
40
+ ["stsUrl", cs.stsUrl],
41
+ ["zoneId", cs.zoneId],
42
+ ["applicationId", cs.applicationId],
43
+ ["clientSecret", cs.clientSecret],
44
+ ["resources", cs.resources?.length ? "set" : undefined],
45
+ ].filter(([, v]) => !v).map(([k]) => k);
46
+ if (missing.length) {
47
+ throw new Error(`Caracal.connect: clientSecret missing ${missing.join(", ")}`);
48
+ }
49
+ return Caracal.fromClientSecret(cs);
50
+ }
51
+ const env = opts.env ?? process.env;
52
+ if (opts.configPath)
53
+ return Caracal.fromConfig(opts.configPath, env);
54
+ const path = resolveProfilePath(env);
55
+ if (path)
56
+ return Caracal.fromConfig(path, env);
57
+ return Caracal.fromEnv(env);
58
+ }
21
59
  static fromEnv(env = process.env) {
22
- const url = env.CARACAL_COORDINATOR_URL;
60
+ const url = serviceUrl(env, "CARACAL_COORDINATOR_URL", DEFAULT_COORDINATOR_URL);
23
61
  const zoneId = env.CARACAL_ZONE_ID;
24
62
  const applicationId = env.CARACAL_APPLICATION_ID;
25
63
  const subjectToken = env.CARACAL_SUBJECT_TOKEN;
26
- const gatewayUrl = env.CARACAL_GATEWAY_URL;
64
+ const stsUrl = stsUrlFromEnv(env);
65
+ const gatewayUrl = serviceUrl(env, "CARACAL_GATEWAY_URL", DEFAULT_GATEWAY_URL);
27
66
  const missing = [
28
- ["CARACAL_COORDINATOR_URL", url],
29
67
  ["CARACAL_ZONE_ID", zoneId],
30
68
  ["CARACAL_APPLICATION_ID", applicationId],
31
- ["CARACAL_SUBJECT_TOKEN", subjectToken],
32
69
  ].filter(([, v]) => !v).map(([k]) => k);
33
70
  if (missing.length) {
34
71
  throw new Error(`Caracal.fromEnv: missing ${missing.join(", ")}`);
35
72
  }
73
+ const clientSecret = clientSecretFromEnv(env, zoneId, applicationId);
74
+ const profileResources = resourcesFromEnv(env, zoneId, applicationId);
75
+ const resources = profileResources.bindings;
76
+ if (clientSecret) {
77
+ return Caracal.fromClientSecret({
78
+ coordinatorUrl: url,
79
+ stsUrl,
80
+ zoneId: zoneId,
81
+ applicationId: applicationId,
82
+ clientSecret,
83
+ resources: resourceIdsFromEnv(env.CARACAL_APP_RESOURCES, profileResources.credentialResources ?? [], profileResources.resources),
84
+ gatewayUrl,
85
+ });
86
+ }
87
+ if (!subjectToken) {
88
+ throw new Error("Caracal.fromEnv: provide CARACAL_APP_CLIENT_SECRET or CARACAL_SUBJECT_TOKEN");
89
+ }
36
90
  validateSubjectToken(subjectToken);
37
91
  return new Caracal({
38
92
  coordinator: { baseUrl: url },
@@ -40,45 +94,90 @@ export class Caracal {
40
94
  applicationId: applicationId,
41
95
  subjectToken: subjectToken,
42
96
  gatewayUrl,
43
- resources: parseResourceBindings(env.CARACAL_RESOURCES),
97
+ resources,
98
+ });
99
+ }
100
+ static fromClientSecret(opts) {
101
+ const resourceIds = opts.resources.map((value) => typeof value === "string" ? value : value.resourceId);
102
+ if (!resourceIds.length)
103
+ throw new Error("Caracal.fromClientSecret requires at least one resource");
104
+ const bindings = opts.resources.filter((value) => typeof value !== "string");
105
+ return new Caracal({
106
+ coordinator: { baseUrl: opts.coordinatorUrl },
107
+ zoneId: opts.zoneId,
108
+ applicationId: opts.applicationId,
109
+ tokenSource: createClientSecretTokenSource(opts.stsUrl, opts.zoneId, opts.applicationId, opts.clientSecret, resourceIds, opts.scope, opts.fetchImpl),
110
+ gatewayUrl: opts.gatewayUrl,
111
+ resources: bindings.length ? bindings : undefined,
112
+ });
113
+ }
114
+ static fromConfig(path = defaultProfilePath(), env = process.env) {
115
+ if (!existsSync(path))
116
+ throw new Error(`Caracal.fromConfig: profile not found: ${path}`);
117
+ assertProfileFileSecure(path);
118
+ const value = parse(readFileSync(path, "utf8"));
119
+ if (!isRecord(value))
120
+ throw new Error("Caracal.fromConfig: profile must be a TOML table");
121
+ const zoneId = requiredString(value, "zone_id", path);
122
+ const applicationId = requiredString(value, "application_id", path);
123
+ const stsUrl = stringValue(value, "sts_url")
124
+ ?? stringValue(value, "zone_url")
125
+ ?? env.CARACAL_STS_URL
126
+ ?? env.CARACAL_ZONE_URL
127
+ ?? serviceUrl(env, "CARACAL_STS_URL", DEFAULT_STS_URL);
128
+ const coordinatorUrl = stringValue(value, "coordinator_url") ?? serviceUrl(env, "CARACAL_COORDINATOR_URL", DEFAULT_COORDINATOR_URL);
129
+ const resources = resourcesFromProfile(value, path, env, zoneId, applicationId);
130
+ if (!resources.resources.length) {
131
+ throw new Error(`Caracal.fromConfig: ${path} requires at least one resource via credentials, CARACAL_RESOURCES, or CARACAL_RESOURCES_FILE`);
132
+ }
133
+ return Caracal.fromClientSecret({
134
+ coordinatorUrl,
135
+ stsUrl,
136
+ zoneId,
137
+ applicationId,
138
+ clientSecret: clientSecretFromProfile(value, path, env, zoneId, applicationId),
139
+ resources: resources.resources,
140
+ gatewayUrl: stringValue(value, "gateway_url") ?? serviceUrl(env, "CARACAL_GATEWAY_URL", DEFAULT_GATEWAY_URL),
44
141
  });
45
142
  }
46
143
  async close() {
47
- // No-op until transport resources need teardown.
48
144
  }
49
- spawn(fn, opts = {}) {
145
+ async spawn(fn, opts = {}) {
50
146
  const input = {
51
147
  coordinator: this.config.coordinator,
52
148
  zoneId: this.config.zoneId,
53
149
  applicationId: this.config.applicationId,
54
- subjectToken: this.config.subjectToken,
150
+ subjectToken: await this.rootToken(),
55
151
  kind: opts.kind ?? this.config.defaultKind ?? AgentKind.Instance,
56
152
  ttlSeconds: opts.ttlSeconds ?? this.config.defaultTtlSeconds,
153
+ subjectSessionId: opts.subjectSessionId,
57
154
  parentId: opts.parentId,
58
155
  metadata: opts.metadata,
59
156
  traceId: opts.traceId,
60
157
  onAgentStart: this.agentStartHooks.length ? (c) => this.fire(this.agentStartHooks, c) : undefined,
61
158
  onAgentEnd: this.agentEndHooks.length ? (c) => this.fire(this.agentEndHooks, c) : undefined,
62
159
  };
63
- return spawnPrimitive(input, fn);
160
+ return await spawnPrimitive(input, fn);
64
161
  }
65
162
  delegate(opts, fn) {
66
163
  const input = {
67
164
  coordinator: this.config.coordinator,
68
165
  toAgentSessionId: opts.to,
69
166
  toApplicationId: opts.toApplicationId,
167
+ resourceId: opts.resourceId,
70
168
  scopes: opts.scopes,
71
169
  constraints: opts.constraints,
72
170
  ttlSeconds: opts.ttlSeconds,
73
171
  };
74
172
  return delegatePrimitive(input, fn);
75
173
  }
76
- delegateToSpawn(opts, fn) {
174
+ async delegateToSpawn(opts, fn) {
77
175
  const input = {
78
176
  coordinator: this.config.coordinator,
79
177
  zoneId: this.config.zoneId,
80
178
  applicationId: this.config.applicationId,
81
- subjectToken: this.config.subjectToken,
179
+ subjectToken: await this.rootToken(),
180
+ resourceId: opts.resourceId,
82
181
  scopes: opts.scopes,
83
182
  constraints: opts.constraints,
84
183
  delegationTtlSeconds: opts.delegationTtlSeconds,
@@ -89,7 +188,7 @@ export class Caracal {
89
188
  onAgentStart: this.agentStartHooks.length ? (c) => this.fire(this.agentStartHooks, c) : undefined,
90
189
  onAgentEnd: this.agentEndHooks.length ? (c) => this.fire(this.agentEndHooks, c) : undefined,
91
190
  };
92
- return delegateToSpawnPrimitive(input, fn);
191
+ return await delegateToSpawnPrimitive(input, fn);
93
192
  }
94
193
  bind(ctx, fn) {
95
194
  return bind(ctx, fn);
@@ -107,17 +206,30 @@ export class Caracal {
107
206
  current() {
108
207
  return current();
109
208
  }
110
- headers() {
209
+ headers(opts = {}) {
111
210
  const ctx = current();
112
211
  if (!ctx) {
212
+ if (!opts.allowRoot) {
213
+ throw new Error("Caracal.headers(): no Caracal context is bound. Pass { allowRoot: true } to use the application subject token.");
214
+ }
113
215
  return toHeaders({
114
- subjectToken: this.config.subjectToken,
216
+ subjectToken: this.rootTokenSync(),
115
217
  hop: 0,
116
218
  });
117
219
  }
118
220
  return toHeaders(toEnvelope(ctx));
119
221
  }
120
- bindFromHeaders(headers, fn) {
222
+ async headersAsync(opts = {}) {
223
+ const ctx = current();
224
+ if (!ctx) {
225
+ if (!opts.allowRoot) {
226
+ throw new Error("Caracal.headersAsync(): no Caracal context is bound. Pass { allowRoot: true } to use the application subject token.");
227
+ }
228
+ return toHeaders({ subjectToken: await this.rootToken(), hop: 0 });
229
+ }
230
+ return toHeaders(toEnvelope(ctx));
231
+ }
232
+ async bindFromHeaders(headers, fn, opts = {}) {
121
233
  const env = typeof headers === "function"
122
234
  ? decodeEnvelope(headers)
123
235
  : decodeEnvelope((n) => {
@@ -130,13 +242,17 @@ export class Caracal {
130
242
  }
131
243
  return undefined;
132
244
  });
133
- if (!env.subjectToken)
134
- env.subjectToken = this.config.subjectToken;
245
+ if (!env.subjectToken) {
246
+ if (!opts.allowRoot) {
247
+ throw new Error("Caracal.bindFromHeaders(): inbound request is missing a bearer token. Pass { allowRoot: true } only for trusted service-root ingress.");
248
+ }
249
+ env.subjectToken = await this.rootToken();
250
+ }
135
251
  const ctx = fromEnvelope(env, {
136
252
  zoneId: this.config.zoneId,
137
253
  clientId: this.config.applicationId,
138
254
  });
139
- return bind(ctx, fn);
255
+ return await bind(ctx, fn);
140
256
  }
141
257
  /**
142
258
  * Returns a fetch-shaped function that injects the Caracal envelope (traceparent
@@ -144,13 +260,15 @@ export class Caracal {
144
260
  * `Authorization` header with the current subject token. Pass to any provider
145
261
  * SDK that accepts a custom fetch.
146
262
  */
147
- transport() {
263
+ transport(opts = {}) {
148
264
  const outer = this;
265
+ const rootAllowed = opts.allowRoot === true;
149
266
  const fn = (async (input, init) => {
150
267
  const ctx = current();
151
- const env = ctx
152
- ? toEnvelope(ctx)
153
- : { subjectToken: outer.config.subjectToken, hop: 0 };
268
+ if (!ctx && !rootAllowed) {
269
+ throw new Error("Caracal.transport(): no Caracal context is bound. Pass { allowRoot: true } to use the application subject token.");
270
+ }
271
+ const env = ctx ? toEnvelope(ctx) : { subjectToken: await outer.rootToken(), hop: 0 };
154
272
  const merged = new Headers(init?.headers ?? {});
155
273
  for (const [k, v] of Object.entries(toHeaders(env))) {
156
274
  if (!merged.has(k))
@@ -161,13 +279,36 @@ export class Caracal {
161
279
  const rewritten = outer.routeThroughGateway(input, explicitResource);
162
280
  if (rewritten) {
163
281
  merged.set("X-Caracal-Resource", rewritten.resourceId);
164
- merged.set("Authorization", `Bearer ${env.subjectToken ?? outer.config.subjectToken}`);
282
+ merged.set("Authorization", `Bearer ${env.subjectToken}`);
165
283
  return fetchImpl(rewritten.url, { ...init, headers: merged });
166
284
  }
167
285
  return fetchImpl(input, { ...init, headers: merged });
168
286
  });
169
287
  return fn;
170
288
  }
289
+ gatewayRequest(resourceId, path = "/") {
290
+ if (!this.config.gatewayUrl)
291
+ throw new Error("Caracal.gatewayRequest(): gatewayUrl is not configured");
292
+ if (!resourceId.trim())
293
+ throw new Error("Caracal.gatewayRequest(): resourceId is required");
294
+ return {
295
+ url: joinGatewayPath(this.config.gatewayUrl, path),
296
+ headers: { "X-Caracal-Resource": resourceId },
297
+ };
298
+ }
299
+ /**
300
+ * One-call happy path: sends `init` to `path` on the given resource through the
301
+ * Gateway with Caracal context and authority injected. Equivalent to building a
302
+ * `gatewayRequest` and calling it with `transport`. The resource header always
303
+ * wins over any caller-supplied `X-Caracal-Resource`.
304
+ */
305
+ fetch(resourceId, path = "/", init = {}, opts = {}) {
306
+ const request = this.gatewayRequest(resourceId, path);
307
+ const headers = new Headers(init.headers ?? {});
308
+ for (const [key, value] of Object.entries(request.headers))
309
+ headers.set(key, value);
310
+ return this.transport(opts)(request.url, { ...init, headers });
311
+ }
171
312
  routeThroughGateway(input, explicitResource) {
172
313
  const gw = this.config.gatewayUrl;
173
314
  if (!gw)
@@ -205,13 +346,305 @@ export class Caracal {
205
346
  const target = base + suffix;
206
347
  return { url: target, resourceId: binding?.resourceId ?? explicitResource };
207
348
  }
208
- middleware() {
349
+ /**
350
+ * Binds Caracal context after a verifier boundary. This does not verify JWT
351
+ * signatures, audience, scopes, token use, or revocation.
352
+ */
353
+ contextMiddleware(opts = {}) {
209
354
  return (req, _res, next) => {
210
355
  this.bindFromHeaders(req.headers, async () => {
211
356
  next();
212
- }).catch(next);
357
+ }, opts).catch(next);
213
358
  };
214
359
  }
360
+ rootTokenSync() {
361
+ if (this.config.subjectToken)
362
+ return this.config.subjectToken;
363
+ throw new Error("Caracal.headers(): this client uses an async token source. Use headersAsync({ allowRoot: true }) for root headers.");
364
+ }
365
+ async rootToken() {
366
+ if (this.config.tokenSource)
367
+ return await this.config.tokenSource();
368
+ if (this.config.subjectToken)
369
+ return this.config.subjectToken;
370
+ throw new Error("Caracal client has no subject token source");
371
+ }
372
+ }
373
+ function serviceUrl(env, key, fallback) {
374
+ const value = env[key];
375
+ if (value)
376
+ return value;
377
+ if (env.NODE_ENV === "production")
378
+ throw new Error(`Caracal SDK: ${key} is required when NODE_ENV=production`);
379
+ return fallback;
380
+ }
381
+ function stsUrlFromEnv(env) {
382
+ return env.CARACAL_STS_URL ?? env.CARACAL_ZONE_URL ?? serviceUrl(env, "CARACAL_STS_URL", DEFAULT_STS_URL);
383
+ }
384
+ function defaultProfilePath(env = process.env) {
385
+ return join(defaultConfigDir(env), "caracal.toml");
386
+ }
387
+ function defaultConfigDir(env = process.env) {
388
+ if (env.CARACAL_CONFIG_HOME)
389
+ return env.CARACAL_CONFIG_HOME;
390
+ if (env.XDG_CONFIG_HOME)
391
+ return join(env.XDG_CONFIG_HOME, "caracal");
392
+ if (platform() === "win32")
393
+ return join(env.APPDATA || env.LOCALAPPDATA || join(homedir(), "AppData", "Roaming"), "Caracal");
394
+ if (platform() === "darwin")
395
+ return join(homedir(), "Library", "Application Support", "Caracal");
396
+ return join(homedir(), ".config", "caracal");
397
+ }
398
+ function defaultCredentialDir(env, zoneId, applicationId) {
399
+ return join(defaultConfigDir(env), "runtime", safePathSegment(zoneId), safePathSegment(applicationId));
400
+ }
401
+ function defaultClientSecretPath(env, zoneId, applicationId) {
402
+ return join(defaultCredentialDir(env, zoneId, applicationId), "client-secret");
403
+ }
404
+ function defaultRunCredentialsPath(env, zoneId, applicationId) {
405
+ return join(defaultCredentialDir(env, zoneId, applicationId), "credentials.json");
406
+ }
407
+ function safePathSegment(value) {
408
+ const segment = value.trim().replace(/[^A-Za-z0-9._-]+/g, "_");
409
+ let start = 0;
410
+ let end = segment.length;
411
+ while (start < end && segment[start] === "_")
412
+ start += 1;
413
+ while (end > start && segment[end - 1] === "_")
414
+ end -= 1;
415
+ return segment.slice(start, end) || "default";
416
+ }
417
+ function existingLocalFile(path, env) {
418
+ if (env.NODE_ENV === "production")
419
+ return undefined;
420
+ return existsSync(path) ? path : undefined;
421
+ }
422
+ function resolveProfilePath(env) {
423
+ if (env.CARACAL_CONFIG) {
424
+ if (!existsSync(env.CARACAL_CONFIG))
425
+ throw new Error(`Caracal.connect: profile not found: ${env.CARACAL_CONFIG}`);
426
+ return env.CARACAL_CONFIG;
427
+ }
428
+ const path = defaultProfilePath(env);
429
+ return existsSync(path) ? path : undefined;
430
+ }
431
+ function assertProfileFileSecure(path) {
432
+ if (process.platform === "win32")
433
+ return;
434
+ const mode = statSync(path).mode & 0o777;
435
+ if ((mode & 0o022) !== 0)
436
+ throw new Error(`Caracal.fromConfig: profile permissions are too broad: ${path}`);
437
+ }
438
+ function assertSecretFileSecure(path) {
439
+ if (process.platform === "win32")
440
+ return;
441
+ const mode = statSync(path).mode & 0o777;
442
+ if ((mode & 0o022) !== 0)
443
+ throw new Error(`Caracal profile secret file permissions are too broad: ${path}`);
444
+ }
445
+ function isRecord(value) {
446
+ return typeof value === "object" && value !== null && !Array.isArray(value);
447
+ }
448
+ function stringValue(record, key) {
449
+ const value = record[key];
450
+ if (value === undefined)
451
+ return undefined;
452
+ if (typeof value !== "string" || value.length === 0)
453
+ throw new Error(`Caracal profile: ${key} must be a non-empty string`);
454
+ return value;
455
+ }
456
+ function requiredString(record, key, source) {
457
+ const value = stringValue(record, key);
458
+ if (!value)
459
+ throw new Error(`${source}: ${key} is required`);
460
+ return value;
461
+ }
462
+ function clientSecretFromProfile(record, source, env, zoneId, applicationId) {
463
+ const inline = stringValue(record, "app_client_secret");
464
+ const file = stringValue(record, "app_client_secret_file");
465
+ if (inline && file)
466
+ throw new Error(`${source}: set only one of app_client_secret or app_client_secret_file`);
467
+ if (inline)
468
+ return inline;
469
+ const localFile = file ?? existingLocalFile(defaultClientSecretPath(env, zoneId, applicationId), env);
470
+ if (!localFile)
471
+ throw new Error(`${source}: client secret is required; local dev/stable auto-detects ${defaultClientSecretPath(env, zoneId, applicationId)} when it exists`);
472
+ return readSecretFile(localFile);
473
+ }
474
+ function clientSecretFromEnv(env, zoneId, applicationId) {
475
+ if (env.CARACAL_APP_CLIENT_SECRET && env.CARACAL_APP_CLIENT_SECRET_FILE) {
476
+ throw new Error("Caracal.fromEnv: set only one of CARACAL_APP_CLIENT_SECRET or CARACAL_APP_CLIENT_SECRET_FILE");
477
+ }
478
+ if (env.CARACAL_APP_CLIENT_SECRET_FILE)
479
+ return readSecretFile(env.CARACAL_APP_CLIENT_SECRET_FILE);
480
+ const localFile = existingLocalFile(defaultClientSecretPath(env, zoneId, applicationId), env);
481
+ if (localFile)
482
+ return readSecretFile(localFile);
483
+ return env.CARACAL_APP_CLIENT_SECRET;
484
+ }
485
+ function readSecretFile(path) {
486
+ if (!existsSync(path))
487
+ throw new Error(`Caracal profile secret file does not exist: ${path}`);
488
+ assertSecretFileSecure(path);
489
+ const secret = readFileSync(path, "utf8").trim();
490
+ if (!secret)
491
+ throw new Error(`Caracal profile secret file is empty: ${path}`);
492
+ return secret;
493
+ }
494
+ function resourcesFromProfile(record, source, env, zoneId, applicationId) {
495
+ const credentials = [
496
+ ...credentialEntries(record.credentials, `${source}.credentials`),
497
+ ...credentialEntries(record.optional_credentials, `${source}.optional_credentials`),
498
+ ...credentialManifestFromEnv(env, zoneId, applicationId),
499
+ ];
500
+ const resources = resourcesFromCredentials(credentials);
501
+ const resolved = resolveProfileResources(resources.resources, resources.bindings ?? [], env);
502
+ return { ...resolved, credentialResources: resources.resources };
503
+ }
504
+ function resourcesFromEnv(env, zoneId, applicationId) {
505
+ const credentials = credentialManifestFromEnv(env, zoneId, applicationId);
506
+ const resources = resourcesFromCredentials(credentials);
507
+ const resolved = resolveProfileResources(resources.resources, resources.bindings ?? [], env);
508
+ return { ...resolved, credentialResources: resources.resources };
509
+ }
510
+ function resolveProfileResources(resources, credentialBindings, env) {
511
+ const envBindings = parseResourceBindings(env.CARACAL_RESOURCES) ?? [];
512
+ const bindings = sortBindingsLongestFirst(mergeResourceBindings(credentialBindings, resourceBindingsFromFile(env.CARACAL_RESOURCES_FILE), envBindings));
513
+ const byResource = new Map();
514
+ for (const item of resources)
515
+ byResource.set(typeof item === "string" ? item : item.resourceId, item);
516
+ for (const binding of bindings)
517
+ byResource.set(binding.resourceId, binding);
518
+ const values = [...byResource.values()];
519
+ return { resources: values, bindings };
520
+ }
521
+ function resourceBindingsFromFile(path) {
522
+ if (!path)
523
+ return [];
524
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
525
+ const errors = [];
526
+ if (Array.isArray(parsed)) {
527
+ const out = [];
528
+ for (const [index, entry] of parsed.entries()) {
529
+ if (!isRecord(entry)) {
530
+ errors.push(`[${index}]: entry must be an object`);
531
+ continue;
532
+ }
533
+ const keys = Object.keys(entry);
534
+ if (keys.length !== 2 || !keys.includes("resource_id") || !keys.includes("upstream_prefix")) {
535
+ errors.push(`[${index}]: expected exactly resource_id and upstream_prefix`);
536
+ continue;
537
+ }
538
+ const resourceId = entry.resource_id;
539
+ const upstreamPrefix = entry.upstream_prefix;
540
+ if (typeof resourceId !== "string" || !resourceId) {
541
+ errors.push(`[${index}]: resource_id must be a non-empty string`);
542
+ continue;
543
+ }
544
+ if (typeof upstreamPrefix !== "string" || !upstreamPrefix) {
545
+ errors.push(`[${index}]: upstream_prefix must be a non-empty string`);
546
+ continue;
547
+ }
548
+ if (!isAbsoluteUrl(upstreamPrefix)) {
549
+ errors.push(`[${index}]: upstream_prefix must be an absolute URL`);
550
+ continue;
551
+ }
552
+ out.push({ resourceId, upstreamPrefix });
553
+ }
554
+ if (errors.length)
555
+ throw new Error(`invalid CARACAL_RESOURCES_FILE:\n- ${errors.join("\n- ")}`);
556
+ return out;
557
+ }
558
+ if (isRecord(parsed)) {
559
+ const out = [];
560
+ for (const [resourceId, upstreamPrefix] of Object.entries(parsed)) {
561
+ if (!resourceId) {
562
+ errors.push("key must be a non-empty string");
563
+ continue;
564
+ }
565
+ if (typeof upstreamPrefix !== "string" || !upstreamPrefix) {
566
+ errors.push(`entry ${JSON.stringify(resourceId)} upstream_prefix must be a non-empty string`);
567
+ continue;
568
+ }
569
+ if (!isAbsoluteUrl(upstreamPrefix)) {
570
+ errors.push(`entry ${JSON.stringify(resourceId)} upstream_prefix must be an absolute URL`);
571
+ continue;
572
+ }
573
+ out.push({ resourceId, upstreamPrefix });
574
+ }
575
+ if (errors.length)
576
+ throw new Error(`invalid CARACAL_RESOURCES_FILE:\n- ${errors.join("\n- ")}`);
577
+ return out;
578
+ }
579
+ throw new Error("CARACAL_RESOURCES_FILE must contain an object or array");
580
+ }
581
+ function mergeResourceBindings(...sources) {
582
+ const order = [];
583
+ const byResource = new Map();
584
+ for (const source of sources) {
585
+ for (const binding of source) {
586
+ if (!byResource.has(binding.resourceId))
587
+ order.push(binding.resourceId);
588
+ byResource.set(binding.resourceId, binding);
589
+ }
590
+ }
591
+ return order.map((resourceId) => byResource.get(resourceId));
592
+ }
593
+ function isAbsoluteUrl(value) {
594
+ try {
595
+ const parsed = new URL(value);
596
+ return Boolean(parsed.protocol && parsed.host);
597
+ }
598
+ catch {
599
+ return false;
600
+ }
601
+ }
602
+ function credentialManifestFromEnv(env, zoneId, applicationId) {
603
+ const file = env.CARACAL_RUN_CREDENTIALS_FILE;
604
+ const inline = env.CARACAL_RUN_CREDENTIALS;
605
+ if (file && inline)
606
+ throw new Error("Caracal.fromEnv: set only one of CARACAL_RUN_CREDENTIALS or CARACAL_RUN_CREDENTIALS_FILE");
607
+ const localFile = !file && !inline ? existingLocalFile(defaultRunCredentialsPath(env, zoneId, applicationId), env) : undefined;
608
+ if (!file && !inline && !localFile)
609
+ return [];
610
+ const raw = file || localFile ? readSecretFile(file ?? localFile) : inline;
611
+ const parsed = JSON.parse(raw);
612
+ const manifest = Array.isArray(parsed) ? { credentials: parsed } : parsed;
613
+ if (!isRecord(manifest))
614
+ throw new Error("Caracal.fromEnv: credential manifest must be an array or object");
615
+ return [
616
+ ...credentialEntries(manifest.credentials, "CARACAL_RUN_CREDENTIALS.credentials"),
617
+ ...credentialEntries(manifest.optional_credentials, "CARACAL_RUN_CREDENTIALS.optional_credentials"),
618
+ ];
619
+ }
620
+ function credentialEntries(value, source) {
621
+ if (value === undefined)
622
+ return [];
623
+ if (!Array.isArray(value))
624
+ throw new Error(`${source} must be an array`);
625
+ return value.map((entry, index) => {
626
+ if (!isRecord(entry))
627
+ throw new Error(`${source}[${index}] must be an object`);
628
+ const resource = stringValue(entry, "resource");
629
+ if (!resource)
630
+ throw new Error(`${source}[${index}].resource is required`);
631
+ const upstreamPrefix = stringValue(entry, "upstream_prefix");
632
+ return upstreamPrefix ? { resource, upstream_prefix: upstreamPrefix } : { resource };
633
+ });
634
+ }
635
+ function resourcesFromCredentials(credentials) {
636
+ const values = [];
637
+ const seen = new Set();
638
+ for (const credential of credentials) {
639
+ if (seen.has(credential.resource))
640
+ continue;
641
+ seen.add(credential.resource);
642
+ values.push(credential.upstream_prefix
643
+ ? { resourceId: credential.resource, upstreamPrefix: credential.upstream_prefix }
644
+ : credential.resource);
645
+ }
646
+ const bindings = values.filter((value) => typeof value !== "string");
647
+ return { resources: values, bindings };
215
648
  }
216
649
  function sameOrigin(a, b) {
217
650
  try {
@@ -222,6 +655,18 @@ function sameOrigin(a, b) {
222
655
  return false;
223
656
  }
224
657
  }
658
+ function joinGatewayPath(gatewayUrl, path) {
659
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) {
660
+ throw new Error("Caracal.gatewayRequest(): path must be relative to the configured gateway");
661
+ }
662
+ const gateway = new URL(gatewayUrl);
663
+ const normalized = path.startsWith("/") ? path : `/${path}`;
664
+ const queryIndex = normalized.indexOf("?");
665
+ const pathname = queryIndex === -1 ? normalized : normalized.slice(0, queryIndex);
666
+ const query = queryIndex === -1 ? "" : normalized.slice(queryIndex + 1);
667
+ const base = gateway.origin + gateway.pathname.replace(/\/$/, "");
668
+ return `${base}${pathname || "/"}${query ? `?${query}` : ""}`;
669
+ }
225
670
  function urlMatchesPrefix(target, prefix) {
226
671
  let p;
227
672
  try {
@@ -242,21 +687,60 @@ function parseResourceBindings(raw) {
242
687
  if (!raw)
243
688
  return undefined;
244
689
  const out = [];
245
- for (const entry of raw.split(",")) {
690
+ const errors = [];
691
+ for (const [index, entry] of raw.split(",").entries()) {
246
692
  const trimmed = entry.trim();
247
693
  if (!trimmed)
248
694
  continue;
249
695
  const idx = trimmed.indexOf("=");
250
- if (idx <= 0)
696
+ if (idx <= 0) {
697
+ errors.push(`entry ${index + 1} must use resourceId=upstreamPrefix`);
251
698
  continue;
699
+ }
252
700
  const resourceId = trimmed.slice(0, idx).trim();
253
701
  const upstreamPrefix = trimmed.slice(idx + 1).trim();
254
- if (resourceId && upstreamPrefix) {
255
- out.push({ resourceId, upstreamPrefix });
702
+ if (!resourceId || !upstreamPrefix) {
703
+ errors.push(`entry ${index + 1} must contain non-empty resourceId and upstreamPrefix`);
704
+ continue;
256
705
  }
706
+ if (!isAbsoluteUrl(upstreamPrefix)) {
707
+ errors.push(`entry ${index + 1} upstreamPrefix must be an absolute URL`);
708
+ continue;
709
+ }
710
+ out.push({ resourceId, upstreamPrefix });
711
+ }
712
+ if (errors.length) {
713
+ throw new Error(`Caracal.fromEnv: invalid CARACAL_RESOURCES:\n- ${errors.join("\n- ")}`);
257
714
  }
258
715
  return out.length ? sortBindingsLongestFirst(out) : undefined;
259
716
  }
717
+ function compactResourceValues(values) {
718
+ const seen = new Set();
719
+ const out = [];
720
+ for (const value of values) {
721
+ const resourceId = typeof value === "string" ? value : value.resourceId;
722
+ if (!resourceId || seen.has(resourceId))
723
+ continue;
724
+ seen.add(resourceId);
725
+ out.push(value);
726
+ }
727
+ return out;
728
+ }
729
+ function resourceIdsFromEnv(raw, first, resources) {
730
+ const explicit = raw?.split(",").map((value) => value.trim()).filter(Boolean);
731
+ if (explicit?.length)
732
+ return compactResourceValues([...first, ...explicit]);
733
+ if (resources?.length)
734
+ return resources;
735
+ throw new Error("Caracal.fromEnv: client-secret mode requires resources via CARACAL_APP_RESOURCES, CARACAL_RUN_CREDENTIALS, CARACAL_RESOURCES, or CARACAL_RESOURCES_FILE");
736
+ }
737
+ function createClientSecretTokenSource(stsUrl, zoneId, applicationId, clientSecret, resources, scope = "agent:lifecycle", fetchImpl) {
738
+ const client = new OAuthClient(stsUrl, zoneId, applicationId, undefined, fetchImpl);
739
+ return async () => {
740
+ const token = await client.exchange("", resources, { clientSecret, scopes: [scope] });
741
+ return token.accessToken;
742
+ };
743
+ }
260
744
  function sortBindingsLongestFirst(bindings) {
261
745
  return [...bindings].sort((a, b) => b.upstreamPrefix.length - a.upstreamPrefix.length);
262
746
  }
@@ -290,7 +774,7 @@ function validateSubjectToken(token) {
290
774
  if (typeof payload.exp !== "number")
291
775
  return;
292
776
  if (payload.exp <= Math.floor(Date.now() / 1000)) {
293
- throw new Error("CARACAL_SUBJECT_TOKEN is expired refresh the bootstrap token before starting the application");
777
+ throw new Error("CARACAL_SUBJECT_TOKEN is expired: refresh the bootstrap token before starting the application");
294
778
  }
295
779
  }
296
780
  //# sourceMappingURL=client.js.map