@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/advanced.d.ts +1 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -2
- package/dist/client.d.ts +57 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +514 -30
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +13 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +31 -1
- package/dist/context.js.map +1 -1
- package/dist/coordinator.d.ts +10 -5
- package/dist/coordinator.d.ts.map +1 -1
- package/dist/coordinator.js +12 -11
- package/dist/coordinator.js.map +1 -1
- package/dist/envelope.d.ts +2 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +12 -2
- package/dist/envelope.js.map +1 -1
- package/dist/http.d.ts +3 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +5 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json.d.ts +7 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +8 -0
- package/dist/json.js.map +1 -0
- package/dist/primitives.d.ts +7 -4
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +18 -10
- package/dist/primitives.js.map +1 -1
- package/package.json +12 -6
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
216
|
+
subjectToken: this.rootTokenSync(),
|
|
115
217
|
hop: 0,
|
|
116
218
|
});
|
|
117
219
|
}
|
|
118
220
|
return toHeaders(toEnvelope(ctx));
|
|
119
221
|
}
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
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
|
|
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
|