@m6d/cortex-cli 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold and operate Cortex servers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,166 @@
1
+ import { z } from "zod";
2
+ import { isTlsOrLoopback } from "../egress/url";
3
+
4
+ export { isTlsOrLoopback };
5
+
6
+ /*
7
+ * A credential is a value Cortex presents to an upstream: one it holds
8
+ * (`static`) or one it acquires (`oauth2-client-credentials`, RFC 6749 §4.4).
9
+ * The definition is the same on both sides; the Control Center stores it,
10
+ * a code config declares it, and each side resolves it to a bearer through
11
+ * the helper and cache below with its own egress-guarded fetch.
12
+ */
13
+
14
+ export const CREDENTIAL_KINDS = ["static", "oauth2-client-credentials"] as const;
15
+ export type CredentialKind = (typeof CREDENTIAL_KINDS)[number];
16
+
17
+ export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
18
+ z.object({
19
+ kind: z.literal("static"),
20
+ value: z.string().min(1),
21
+ }),
22
+ z.object({
23
+ kind: z.literal("oauth2-client-credentials"),
24
+ /** The token endpoint; a client secret travels only over TLS, or to loopback. */
25
+ tokenUrl: z.url().refine(isTlsOrLoopback, "token URL must be https, or http to loopback"),
26
+ clientId: z.string().min(1),
27
+ clientSecret: z.string().min(1),
28
+ scope: z.string().min(1).optional(),
29
+ }),
30
+ ]);
31
+ export type CredentialDefinition = z.infer<typeof credentialDefinitionSchema>;
32
+ export type OAuth2ClientCredentials = Extract<
33
+ CredentialDefinition,
34
+ { kind: "oauth2-client-credentials" }
35
+ >;
36
+
37
+ /** One POST to the token endpoint through the caller's guarded fetch; `null` when the transport failed. */
38
+ export type TokenPost = (
39
+ url: string,
40
+ init: { headers: Record<string, string>; body: string },
41
+ ) => Promise<{ status: number; body: string } | null>;
42
+
43
+ /** A bearer and when it stops being one; `null` when the issuer did not say. */
44
+ export type AcquiredToken = { token: string; expiresAt: number | null };
45
+
46
+ export type AcquireResult =
47
+ | { ok: true; value: AcquiredToken }
48
+ | { ok: false; error: "token_request_failed" | "token_rejected" | "token_response_invalid" };
49
+
50
+ const tokenResponseSchema = z.object({
51
+ access_token: z.string().min(1),
52
+ token_type: z.string().optional(),
53
+ // Some issuers (Azure AD v1 among them) send the lifetime as a string.
54
+ expires_in: z
55
+ .union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
56
+ .pipe(z.number().nonnegative())
57
+ .optional(),
58
+ });
59
+
60
+ /**
61
+ * The client credentials grant: a form-encoded POST with the client
62
+ * authenticated by `client_secret_basic` (RFC 6749 §2.3.1, each half
63
+ * form-encoded before base64). The endpoint's body never leaves this
64
+ * function: a failure is one of three literals, since the body may echo
65
+ * the client secret or carry the issuer's own error detail.
66
+ */
67
+ export async function acquireOAuth2ClientCredentials(
68
+ definition: OAuth2ClientCredentials,
69
+ post: TokenPost,
70
+ ) {
71
+ const basic = Buffer.from(
72
+ `${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
73
+ ).toString("base64");
74
+ const answer = await post(definition.tokenUrl, {
75
+ headers: {
76
+ Authorization: `Basic ${basic}`,
77
+ "Content-Type": "application/x-www-form-urlencoded",
78
+ Accept: "application/json",
79
+ },
80
+ body: new URLSearchParams({
81
+ grant_type: "client_credentials",
82
+ ...(definition.scope ? { scope: definition.scope } : {}),
83
+ }).toString(),
84
+ });
85
+ if (!answer) return { ok: false, error: "token_request_failed" } satisfies AcquireResult;
86
+ if (answer.status < 200 || answer.status >= 300) {
87
+ return { ok: false, error: "token_rejected" } satisfies AcquireResult;
88
+ }
89
+ let parsed: z.ZodSafeParseResult<z.infer<typeof tokenResponseSchema>>;
90
+ try {
91
+ parsed = tokenResponseSchema.safeParse(JSON.parse(answer.body));
92
+ } catch {
93
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
94
+ }
95
+ if (!parsed.success || (parsed.data.token_type ?? "bearer").toLowerCase() !== "bearer") {
96
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
97
+ }
98
+ const issuedAt = Date.now();
99
+ return {
100
+ ok: true as const,
101
+ value: {
102
+ token: parsed.data.access_token,
103
+ expiresAt:
104
+ parsed.data.expires_in === undefined
105
+ ? null
106
+ : issuedAt + parsed.data.expires_in * 1000,
107
+ },
108
+ } satisfies AcquireResult;
109
+ }
110
+
111
+ /** A token is not reused inside this margin of its expiry: the call it is presented on still has to complete. */
112
+ const EXPIRY_MARGIN_MS = 30_000;
113
+
114
+ /** Equal definitions share one key; a rotated secret is another definition and another token. Kept in memory only, like the definitions themselves. */
115
+ function keyOf(definition: CredentialDefinition) {
116
+ return JSON.stringify(definition);
117
+ }
118
+
119
+ /**
120
+ * Acquired tokens by definition, reused until their expiry margin. Concurrent
121
+ * acquisitions of one definition share a single request; a token the issuer
122
+ * gave no expiry for is answered but not kept, since nothing says when it
123
+ * stops working. `invalidate` is for the caller that saw a 401: the next
124
+ * resolve acquires again. Pure, so the console's client bundle may import
125
+ * the schemas beside it.
126
+ */
127
+ export function createCredentialCache(now: () => number = Date.now) {
128
+ const tokens = new Map<string, AcquiredToken>();
129
+ const inFlight = new Map<string, Promise<AcquireResult>>();
130
+
131
+ return {
132
+ async get(definition: CredentialDefinition, acquire: () => Promise<AcquireResult>) {
133
+ const key = keyOf(definition);
134
+ const cached = tokens.get(key);
135
+ if (
136
+ cached &&
137
+ cached.expiresAt !== null &&
138
+ cached.expiresAt - EXPIRY_MARGIN_MS > now()
139
+ ) {
140
+ return { ok: true, value: cached } satisfies AcquireResult;
141
+ }
142
+ tokens.delete(key);
143
+ const pending = inFlight.get(key);
144
+ if (pending) return pending;
145
+ const acquiring = acquire()
146
+ .then((result) => {
147
+ if (
148
+ result.ok &&
149
+ result.value.expiresAt !== null &&
150
+ result.value.expiresAt - EXPIRY_MARGIN_MS > now()
151
+ ) {
152
+ tokens.set(key, result.value);
153
+ }
154
+ return result;
155
+ })
156
+ .finally(() => inFlight.delete(key));
157
+ inFlight.set(key, acquiring);
158
+ return acquiring;
159
+ },
160
+ invalidate(definition: CredentialDefinition) {
161
+ tokens.delete(keyOf(definition));
162
+ },
163
+ };
164
+ }
165
+
166
+ export type CredentialCache = ReturnType<typeof createCredentialCache>;
@@ -63,22 +63,7 @@ export function isBlockedAddress(address: string) {
63
63
  return isIP(address) === 6 ? isBlockedV6(address) : isBlockedV4(address);
64
64
  }
65
65
 
66
- /**
67
- * Whether a credential may travel to this URL: over TLS anywhere, or over
68
- * plain http to loopback alone, for a service on the developer's machine.
69
- */
70
- export function isTlsOrLoopback(url: string) {
71
- try {
72
- const { protocol, hostname } = new URL(url);
73
- if (protocol === "https:") return true;
74
- return (
75
- protocol === "http:" &&
76
- (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
77
- );
78
- } catch {
79
- return false;
80
- }
81
- }
66
+ export { isTlsOrLoopback } from "./url";
82
67
 
83
68
  export function egressError() {
84
69
  const error: NodeJS.ErrnoException = new Error("egress blocked");
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a credential may travel to this URL: over TLS anywhere, or over
3
+ * plain http to loopback alone, for a service on the developer's machine.
4
+ * Pure, so a browser bundle can judge a URL the same way the server does.
5
+ */
6
+ export function isTlsOrLoopback(url: string) {
7
+ try {
8
+ const { protocol, hostname } = new URL(url);
9
+ if (protocol === "https:") return true;
10
+ return (
11
+ protocol === "http:" &&
12
+ (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
13
+ );
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
@@ -27,14 +27,25 @@ export const tokenUsageSchema = z.object({
27
27
  total: tokenCountSchema,
28
28
  }) satisfies z.ZodType<TokenUsage>;
29
29
 
30
+ const componentNamesSchema = z.array(z.string().min(1).max(128)).max(1_000);
31
+
30
32
  /**
31
33
  * What a server offers every agent the Control Center creates for it, sent
32
34
  * whole at boot in `server.declare`: the `{{variables}}` its session loader
33
- * and request context fill, SDK ones included. Agents in the server's own
34
- * code never appear here; the Control Center is blind to them.
35
+ * and request context fill, SDK ones included, and the names of the
36
+ * components its code declares at server level, which reach those agents
37
+ * too and which the Control Center therefore refuses on its own components.
38
+ * Agents in the server's own code never appear here; the Control Center is
39
+ * blind to them.
35
40
  */
36
41
  export const serverDeclarationSchema = z.object({
37
42
  variables: z.array(variableDeclarationSchema),
43
+ components: z.object({
44
+ tool: componentNamesSchema,
45
+ skill: componentNamesSchema,
46
+ trigger: componentNamesSchema,
47
+ dataset_host: componentNamesSchema,
48
+ }),
38
49
  });
39
50
  export type ServerDeclaration = z.infer<typeof serverDeclarationSchema>;
40
51
 
@@ -129,7 +129,8 @@ export type TriggerDeclaration = z.infer<typeof triggerDeclarationSchema>;
129
129
  /**
130
130
  * `POST /api/runtime/agents/:agentSlug/triggers/:name/auth`: a server holding a
131
131
  * bearer for a cc trigger asks cc whether it is the trigger's secret. Only the
132
- * hash travels; a positive answer carries the trigger's credential, if any.
132
+ * hash travels; a positive answer carries the bearer the run acts with, if
133
+ * the trigger has a credential, resolved by cc (acquired, for an OAuth2 one).
133
134
  */
134
135
  export const triggerAuthRequestSchema = z.object({
135
136
  secretHash: sha256HexSchema,
@@ -140,6 +141,8 @@ export const triggerAuthResponseSchema = z.discriminatedUnion("ok", [
140
141
  z.object({
141
142
  ok: z.literal(true),
142
143
  credential: z.string().nullable(),
144
+ /** When the bearer stops working, ISO 8601; null for one that does not expire or has no credential. */
145
+ expiresAt: z.iso.datetime().nullable(),
143
146
  /** The bearer was a single-use test secret, now spent: the server must not cache this verdict. */
144
147
  oneShot: z.boolean(),
145
148
  }),
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
27
27
  // sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
28
28
  // them to that.
29
29
  export const DEPENDENCIES = {
30
- "@m6d/cortex-server": "~0.2.0",
30
+ "@m6d/cortex-server": "~0.3.0",
31
31
  "@tanstack/ai": "^0.55.0",
32
32
  "@tanstack/ai-openai": "^0.22.8",
33
33
  hono: "^4.12.3",
@@ -167,9 +167,10 @@ ${on === "" ? "" : `${on}\n`} agents: {
167
167
  assistant: defineAgent({
168
168
  systemPrompt: "You are a helpful assistant.",
169
169
  // Applications that serve datasets for grouped questions (see the
170
- // README's "Dataset hosts"): the host applies its own access rules,
171
- // the agent only picks which datasets to expose and pin.
172
- // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", pinned: ["orders"] }],
170
+ // README's "Dataset hosts"): the host applies its own access rules
171
+ // under the run's token the list below forwards, the agent only
172
+ // picks which datasets to expose and pin.
173
+ // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", credentials: [{ header: "X-End-User-Token", credential: { kind: "run-token" } }], pinned: ["orders"] }],
173
174
  }),
174
175
  },
175
176