@agentage/extension 0.2.1 → 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/dist/auth/part-auth.d.ts +18 -0
- package/dist/auth/part-auth.js +61 -0
- package/dist/auth/revoked.d.ts +9 -0
- package/dist/auth/revoked.js +34 -0
- package/dist/auth/route-access.js +13 -13
- package/dist/auth/token-check.d.ts +3 -0
- package/dist/auth/token-check.js +10 -2
- package/dist/auth/verify-token.d.ts +18 -0
- package/dist/auth/verify-token.js +68 -0
- package/dist/context/context.d.ts +4 -0
- package/dist/context/context.js +3 -1
- package/dist/context/test-context.js +4 -1
- package/dist/discovery/discovery.d.ts +8 -2
- package/dist/discovery/discovery.schema.d.ts +9 -2
- package/dist/discovery/discovery.schema.js +6 -2
- package/dist/envelope/envelope.d.ts +2 -0
- package/dist/envelope/envelope.js +1 -0
- package/dist/mcp/mcp-app.js +4 -0
- package/dist/mcp/server.js +17 -5
- package/dist/runtime/part-runners.d.ts +1 -0
- package/dist/runtime/part-runners.js +1 -3
- package/dist/runtime/run-part.d.ts +0 -2
- package/dist/runtime/run-part.js +19 -8
- package/dist/server/app.d.ts +1 -0
- package/dist/server/app.js +3 -0
- package/dist/server/errors.js +2 -0
- package/dist/tool/record-extras.d.ts +4 -1
- package/dist/tool/record-extras.js +9 -4
- package/dist/tool/up/compose.js +21 -15
- package/dist/tool/up/dev-issuer-app.d.ts +9 -3
- package/dist/tool/up/dev-issuer-app.js +53 -24
- package/dist/tool/up/local-record.d.ts +4 -1
- package/package.json +2 -1
- package/schemas/extension-discovery.schema.json +17 -3
- package/schemas/extension-record.schema.json +17 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Log } from '../log/log.js';
|
|
2
|
+
import { type Env } from '../runtime/build-info.js';
|
|
3
|
+
export declare const covers: (audience: string, host: string) => boolean;
|
|
4
|
+
export declare const partAuth: (env: Env, id: string, log: Log, scopes: string[]) => Promise<{
|
|
5
|
+
tokenCheck: import("./token-check.js").TokenCheck;
|
|
6
|
+
resource: {
|
|
7
|
+
resource: string;
|
|
8
|
+
authorization_servers: string[];
|
|
9
|
+
scopes_supported: string[];
|
|
10
|
+
bearer_methods_supported: string[];
|
|
11
|
+
};
|
|
12
|
+
checks: {
|
|
13
|
+
auth: import("../health/health.js").HealthCheck;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
export declare const declaredScopes: (id: string, openapi: Record<string, unknown> | null, tools?: readonly {
|
|
17
|
+
scope: string;
|
|
18
|
+
}[]) => string[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { OriginSchema } from '../discovery/discovery.schema.js';
|
|
3
|
+
import { messageOf } from '../log/log.js';
|
|
4
|
+
import { formatIssues } from '../manifest/read-manifest.js';
|
|
5
|
+
import {} from '../runtime/build-info.js';
|
|
6
|
+
import { createRevokedList, REVOKED_POLL_MS } from './revoked.js';
|
|
7
|
+
import { accessOfSecurity, scopeOf } from './token-check.js';
|
|
8
|
+
import { createTokenCheck, remoteKeys } from './verify-token.js';
|
|
9
|
+
// *.{a domain of two labels or more}, or one host, as a deployment's origin may name it.
|
|
10
|
+
const isAudience = (audience) => {
|
|
11
|
+
const host = audience.startsWith('*.') ? audience.slice(2) : audience;
|
|
12
|
+
return ['https', 'http'].some((scheme) => OriginSchema.safeParse(`${scheme}://${host}`).success);
|
|
13
|
+
};
|
|
14
|
+
const AuthEnvSchema = z.object({
|
|
15
|
+
AGENTAGE_ISSUER: z
|
|
16
|
+
.url()
|
|
17
|
+
.refine((url) => URL.canParse(url) && new URL(url).origin === url, 'an origin, no path'),
|
|
18
|
+
AGENTAGE_AUDIENCE: z.string().refine(isAudience, '*.{domain} or one host'),
|
|
19
|
+
AGENTAGE_ORIGIN: z.url(),
|
|
20
|
+
});
|
|
21
|
+
// *.agentage.io covers the domain and every host under it at any depth; else one exact host.
|
|
22
|
+
export const covers = (audience, host) => audience.startsWith('*.')
|
|
23
|
+
? host === audience.slice(2) || host.endsWith(audience.slice(1))
|
|
24
|
+
: host === audience;
|
|
25
|
+
const settingsOf = (env) => {
|
|
26
|
+
const parsed = AuthEnvSchema.safeParse(env);
|
|
27
|
+
if (!parsed.success)
|
|
28
|
+
throw new Error(`no token check, no start: ${formatIssues(parsed.error.issues)}`);
|
|
29
|
+
const { AGENTAGE_ISSUER: issuer, AGENTAGE_AUDIENCE: audience } = parsed.data;
|
|
30
|
+
const { origin, hostname } = new URL(parsed.data.AGENTAGE_ORIGIN);
|
|
31
|
+
if (!covers(audience, hostname))
|
|
32
|
+
throw new Error(`the host ${hostname} is not under the audience ${audience}`);
|
|
33
|
+
return { issuer, audience, origin };
|
|
34
|
+
};
|
|
35
|
+
// Keys and revoked list read once before the part serves; an issuer down at start is no stop.
|
|
36
|
+
export const partAuth = async (env, id, log, scopes) => {
|
|
37
|
+
const { issuer, audience, origin } = settingsOf(env);
|
|
38
|
+
const { keys, read } = remoteKeys(issuer);
|
|
39
|
+
const revoked = createRevokedList(issuer, log);
|
|
40
|
+
const unread = (err) => log.warn('key set not read', { reason: messageOf(err) });
|
|
41
|
+
await Promise.all([read().catch(unread), revoked.refresh()]);
|
|
42
|
+
setInterval(() => void revoked.refresh(), REVOKED_POLL_MS).unref();
|
|
43
|
+
return {
|
|
44
|
+
tokenCheck: createTokenCheck({ issuer, audience, id, origin, keys, revoked }),
|
|
45
|
+
resource: {
|
|
46
|
+
resource: origin,
|
|
47
|
+
authorization_servers: [issuer],
|
|
48
|
+
scopes_supported: scopes,
|
|
49
|
+
bearer_methods_supported: ['header'],
|
|
50
|
+
},
|
|
51
|
+
checks: { auth: revoked.check },
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export const declaredScopes = (id, openapi, tools = []) => {
|
|
55
|
+
const routes = Object.values(openapi?.['paths'] ?? {})
|
|
56
|
+
.flatMap((item) => Object.values(item))
|
|
57
|
+
.filter((op) => typeof op === 'object' && !Array.isArray(op))
|
|
58
|
+
.map((op) => scopeOf(id, accessOfSecurity(op.security ?? openapi?.['security'])));
|
|
59
|
+
const scopes = new Set([...routes, ...tools.map((tool) => tool.scope)]);
|
|
60
|
+
return [...scopes].filter((scope) => scope !== 'public').sort();
|
|
61
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type HealthCheck } from '../health/health.js';
|
|
2
|
+
import { type Log } from '../log/log.js';
|
|
3
|
+
export declare const REVOKED_POLL_MS = 30000;
|
|
4
|
+
export declare const createRevokedList: (issuer: string, log: Log, now?: () => number) => {
|
|
5
|
+
has: (jti: string) => boolean;
|
|
6
|
+
loaded: () => boolean;
|
|
7
|
+
refresh: () => Promise<void>;
|
|
8
|
+
check: HealthCheck;
|
|
9
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {} from '../health/health.js';
|
|
3
|
+
import { messageOf } from '../log/log.js';
|
|
4
|
+
import { retryOnce } from './token-check.js';
|
|
5
|
+
export const REVOKED_POLL_MS = 30_000;
|
|
6
|
+
const STALE_MS = 5 * 60_000;
|
|
7
|
+
const RevokedSchema = z.object({
|
|
8
|
+
revoked: z.array(z.object({ jti: z.string(), exp: z.number() })),
|
|
9
|
+
generatedAt: z.string(),
|
|
10
|
+
});
|
|
11
|
+
const readList = async (url) => {
|
|
12
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error(`the issuer answered ${res.status}`);
|
|
15
|
+
return new Set(RevokedSchema.parse(await res.json()).revoked.map((entry) => entry.jti));
|
|
16
|
+
};
|
|
17
|
+
// The issuer's revoked ids, read every 30 s so a revoke holds within 60 s (api-standards 5).
|
|
18
|
+
export const createRevokedList = (issuer, log, now = Date.now) => {
|
|
19
|
+
let ids = new Set();
|
|
20
|
+
let readAt;
|
|
21
|
+
const refresh = async () => {
|
|
22
|
+
try {
|
|
23
|
+
ids = await retryOnce(() => readList(`${issuer}/.well-known/agentage-revoked`));
|
|
24
|
+
readAt = now();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
log.warn('revoked list not read', { reason: messageOf(err) });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const loaded = () => readAt !== undefined;
|
|
31
|
+
const fresh = () => readAt !== undefined && now() - readAt <= STALE_MS;
|
|
32
|
+
const check = async () => (fresh() ? 'ok' : 'degraded');
|
|
33
|
+
return { has: (jti) => ids.has(jti), loaded, refresh, check };
|
|
34
|
+
};
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import { accessOfSecurity } from './token-check.js';
|
|
1
|
+
import { accessOfSecurity, strongest } from './token-check.js';
|
|
2
2
|
const PARAM = /^\{.+\}$/;
|
|
3
|
+
// As Express routes: without case, and one trailing slash ignored.
|
|
3
4
|
const sameShape = (template, path) => {
|
|
4
|
-
const want = template.split('/');
|
|
5
|
-
const got = path
|
|
5
|
+
const want = template.toLowerCase().split('/');
|
|
6
|
+
const got = path
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/(.)\/$/, '$1')
|
|
9
|
+
.split('/');
|
|
6
10
|
return want.length === got.length && want.every((seg, i) => PARAM.test(seg) || seg === got[i]);
|
|
7
11
|
};
|
|
8
|
-
|
|
9
|
-
// As OpenAPI matches: a concrete path before a template, then the template with the fewest params.
|
|
10
|
-
const mostSpecific = (templates, path) => templates
|
|
11
|
-
.filter((template) => sameShape(template, path))
|
|
12
|
-
.sort((a, b) => params(a) - params(b))[0];
|
|
13
|
-
// The access the committed OpenAPI declares for a request: its operation's, else the document's.
|
|
12
|
+
// The strongest access any matching operation declares: Express may run another path's handler.
|
|
14
13
|
export const routeAccess = (doc, method, path) => {
|
|
15
14
|
const paths = (doc?.['paths'] ?? {});
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const operations = Object.keys(paths)
|
|
16
|
+
.filter((template) => sameShape(template, path))
|
|
17
|
+
.flatMap((template) => paths[template]?.[method.toLowerCase()] ?? []);
|
|
18
|
+
if (operations.length === 0)
|
|
19
19
|
return 'read';
|
|
20
|
-
return accessOfSecurity(
|
|
20
|
+
return strongest(operations.map((op) => accessOfSecurity(op.security ?? doc?.['security'])));
|
|
21
21
|
};
|
|
@@ -6,5 +6,8 @@ export interface Caller {
|
|
|
6
6
|
export type TokenCheck = (authorization: string | undefined, access: Access) => Promise<Caller | null>;
|
|
7
7
|
export declare const setCaller: (request: object, caller: Caller | null) => void;
|
|
8
8
|
export declare const callerOf: (request: object) => Caller | null;
|
|
9
|
+
export declare const RANK: Record<Access, number>;
|
|
10
|
+
export declare const scopeOf: (id: string, access?: Access) => string;
|
|
9
11
|
export declare const strongest: (needed: readonly Access[]) => Access;
|
|
10
12
|
export declare const accessOfSecurity: (security: unknown) => Access;
|
|
13
|
+
export declare const retryOnce: <T>(read: () => Promise<T>, retries?: (_err: unknown) => boolean) => Promise<T>;
|
package/dist/auth/token-check.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
const callers = new WeakMap();
|
|
3
3
|
export const setCaller = (request, caller) => void callers.set(request, caller);
|
|
4
4
|
export const callerOf = (request) => callers.get(request) ?? null;
|
|
5
|
-
const RANK = { public: 0, read: 1, write: 2, admin: 3 };
|
|
5
|
+
export const RANK = { public: 0, read: 1, write: 2, admin: 3 };
|
|
6
|
+
export const scopeOf = (id, access = 'read') => access === 'public' ? 'public' : `${id}:${access}`;
|
|
6
7
|
export const strongest = (needed) => needed.reduce((top, access) => (RANK[access] > RANK[top] ? access : top), 'public');
|
|
7
8
|
const VERB = /:(read|write|admin)$/;
|
|
8
9
|
// OpenAPI security: [] is public, a scope names the verb; a route that declares nothing needs read.
|
|
@@ -16,3 +17,10 @@ export const accessOfSecurity = (security) => {
|
|
|
16
17
|
const verbs = scopes.map((scope) => VERB.exec(scope)?.[1] ?? 'read');
|
|
17
18
|
return verbs.length === 0 ? 'read' : strongest(verbs);
|
|
18
19
|
};
|
|
20
|
+
// security.md: a failed read of the issuer is tried once more before it counts as an outage.
|
|
21
|
+
export const retryOnce = (read, retries = (_err) => true) => read().catch(async (err) => {
|
|
22
|
+
if (!retries(err))
|
|
23
|
+
throw err;
|
|
24
|
+
await sleep(1000);
|
|
25
|
+
return read();
|
|
26
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from 'jose';
|
|
2
|
+
import { type TokenCheck } from './token-check.js';
|
|
3
|
+
export interface TokenCheckDeps {
|
|
4
|
+
issuer: string;
|
|
5
|
+
audience: string;
|
|
6
|
+
id: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
keys: JWTVerifyGetKey;
|
|
9
|
+
revoked: {
|
|
10
|
+
has(jti: string): boolean;
|
|
11
|
+
loaded(): boolean;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare const remoteKeys: (issuer: string) => {
|
|
15
|
+
keys: JWTVerifyGetKey<Uint8Array<ArrayBufferLike> | import("node:crypto").webcrypto.CryptoKey>;
|
|
16
|
+
read: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare const createTokenCheck: (deps: TokenCheckDeps) => TokenCheck;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRemoteJWKSet, errors, jwtVerify } from 'jose';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { fail, isFailure } from '../envelope/envelope.js';
|
|
4
|
+
import { RANK, retryOnce, scopeOf } from './token-check.js';
|
|
5
|
+
const VERIFY = { algorithms: ['EdDSA'], requiredClaims: ['exp'] };
|
|
6
|
+
const ClaimsSchema = z.object({
|
|
7
|
+
sub: z.string().min(1),
|
|
8
|
+
aud: z.string(),
|
|
9
|
+
scope: z.string(),
|
|
10
|
+
jti: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
const unavailable = (message) => fail('UNAVAILABLE', message, { retryAfter: 5 });
|
|
13
|
+
const invalid = (origin, message = 'Invalid token') => fail('UNAUTHORIZED', message, {
|
|
14
|
+
headers: {
|
|
15
|
+
'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const forbidden = (scope) => fail('FORBIDDEN', `Needs ${scope}`, {
|
|
19
|
+
headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${scope}"` },
|
|
20
|
+
});
|
|
21
|
+
const outage = (err) => !(err instanceof errors.JWKSNoMatchingKey);
|
|
22
|
+
// A key set that cannot be read is an outage, never a 401; a kid it lacks is a bad token.
|
|
23
|
+
const keysOrOutage = (keys) => async (header, token) => {
|
|
24
|
+
if (!header.kid)
|
|
25
|
+
throw new errors.JWKSNoMatchingKey();
|
|
26
|
+
return retryOnce(async () => keys(header, token), outage).catch((err) => {
|
|
27
|
+
throw outage(err) ? unavailable('Token keys unavailable') : err;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
// Read at start and again only for an unknown kid, so a known key outlives an issuer outage.
|
|
31
|
+
export const remoteKeys = (issuer) => {
|
|
32
|
+
const set = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
|
|
33
|
+
timeoutDuration: 3000,
|
|
34
|
+
cacheMaxAge: Infinity,
|
|
35
|
+
});
|
|
36
|
+
return { keys: keysOrOutage(set), read: () => retryOnce(set.reload) };
|
|
37
|
+
};
|
|
38
|
+
const claimsOf = async (authorization, deps) => {
|
|
39
|
+
const [scheme = '', credential = '', ...rest] = authorization.split(' ');
|
|
40
|
+
const bearer = scheme.toLowerCase() === 'bearer' && rest.length === 0;
|
|
41
|
+
const jwt = bearer && credential.startsWith('aga_') ? credential : '';
|
|
42
|
+
const options = { ...VERIFY, issuer: deps.issuer, audience: deps.audience };
|
|
43
|
+
const verified = await jwtVerify(jwt.slice(4), deps.keys, options).catch((err) => {
|
|
44
|
+
throw isFailure(err) ? err : invalid(deps.origin);
|
|
45
|
+
});
|
|
46
|
+
const claims = ClaimsSchema.safeParse(verified.payload);
|
|
47
|
+
if (!claims.success || verified.protectedHeader.typ !== 'at+jwt')
|
|
48
|
+
throw invalid(deps.origin);
|
|
49
|
+
return claims.data;
|
|
50
|
+
};
|
|
51
|
+
const grants = (scope, id, access) => {
|
|
52
|
+
const held = new Set(scope.split(' '));
|
|
53
|
+
return ['read', 'write', 'admin'].some((verb) => RANK[verb] >= RANK[access] && held.has(`${id}:${verb}`));
|
|
54
|
+
};
|
|
55
|
+
export const createTokenCheck = (deps) => async (authorization, access) => {
|
|
56
|
+
if (access === 'public')
|
|
57
|
+
return null;
|
|
58
|
+
if (!authorization)
|
|
59
|
+
throw invalid(deps.origin, 'Missing token');
|
|
60
|
+
const { sub, scope, jti } = await claimsOf(authorization, deps);
|
|
61
|
+
if (jti !== undefined && !deps.revoked.loaded())
|
|
62
|
+
throw unavailable('Revoked list unavailable');
|
|
63
|
+
if (jti !== undefined && deps.revoked.has(jti))
|
|
64
|
+
throw invalid(deps.origin, 'Token revoked');
|
|
65
|
+
if (!grants(scope, deps.id, access))
|
|
66
|
+
throw forbidden(scopeOf(deps.id, access));
|
|
67
|
+
return { sub, scopes: scope.split(' ') };
|
|
68
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Caller } from '../auth/token-check.js';
|
|
1
2
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
2
3
|
import { type Log } from '../log/log.js';
|
|
3
4
|
import { type Store } from '../store/store.types.js';
|
|
@@ -12,6 +13,9 @@ export interface ExtensionInfo {
|
|
|
12
13
|
}
|
|
13
14
|
export interface PartContext {
|
|
14
15
|
readonly extension: Readonly<ExtensionInfo>;
|
|
16
|
+
readonly auth: {
|
|
17
|
+
caller(request: object): Caller | null;
|
|
18
|
+
};
|
|
15
19
|
readonly log: Log;
|
|
16
20
|
readonly settings: Readonly<Record<string, SettingValue>>;
|
|
17
21
|
readonly store: Store;
|
package/dist/context/context.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { callerOf } from '../auth/token-check.js';
|
|
1
2
|
import {} from '../manifest/manifest.schema.js';
|
|
2
3
|
import { createLog } from '../log/log.js';
|
|
3
4
|
import {} from '../store/store.types.js';
|
|
@@ -19,5 +20,6 @@ export const createContext = (input) => {
|
|
|
19
20
|
});
|
|
20
21
|
const log = input.log ?? createLog({ extension: pkg.agentage.id, part });
|
|
21
22
|
const settings = Object.freeze({ ...settingDefaults(pkg), ...input.settings });
|
|
22
|
-
|
|
23
|
+
const auth = Object.freeze({ caller: callerOf });
|
|
24
|
+
return Object.freeze({ extension, auth, log, settings, store: store ?? NO_STORE });
|
|
23
25
|
};
|
|
@@ -40,8 +40,11 @@ const serverContext = (options) => {
|
|
|
40
40
|
const logs = [];
|
|
41
41
|
const log = runLog(recordingLog(logs));
|
|
42
42
|
const store = testStore(options.store, recordingLog([]));
|
|
43
|
+
const extension = { ...TEST_EXTENSION, ...options.extension };
|
|
44
|
+
const caller = { sub: 'usr_test', scopes: [`${extension.id}:read`] };
|
|
43
45
|
return {
|
|
44
|
-
extension
|
|
46
|
+
extension,
|
|
47
|
+
auth: { caller: () => caller },
|
|
45
48
|
log,
|
|
46
49
|
logs,
|
|
47
50
|
settings: { ...options.settings },
|
|
@@ -9,7 +9,10 @@ export type PartImages = Partial<Record<ImagePartKind, string>>;
|
|
|
9
9
|
export declare const DEFAULT_RECORD_FILE = "/run/agentage/agentage-extension.json";
|
|
10
10
|
export interface RecordExtras {
|
|
11
11
|
openapi?: string;
|
|
12
|
-
tools?:
|
|
12
|
+
tools?: {
|
|
13
|
+
name: string;
|
|
14
|
+
scope: string;
|
|
15
|
+
}[];
|
|
13
16
|
widgets?: {
|
|
14
17
|
name: string;
|
|
15
18
|
element: string;
|
|
@@ -43,7 +46,10 @@ export declare const discoveryDocument: (record: ExtensionRecord, host: string)
|
|
|
43
46
|
} | undefined;
|
|
44
47
|
};
|
|
45
48
|
openapi?: string | undefined;
|
|
46
|
-
tools?:
|
|
49
|
+
tools?: {
|
|
50
|
+
name: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
}[] | undefined;
|
|
47
53
|
widgets?: {
|
|
48
54
|
name: string;
|
|
49
55
|
element: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export declare const OriginSchema: z.ZodString;
|
|
2
3
|
export declare const WidgetSchema: z.ZodObject<{
|
|
3
4
|
name: z.ZodString;
|
|
4
5
|
element: z.ZodString;
|
|
@@ -35,7 +36,10 @@ export declare const ExtensionRecordSchema: z.ZodObject<{
|
|
|
35
36
|
}, z.core.$strict>>;
|
|
36
37
|
}, z.core.$strict>;
|
|
37
38
|
openapi: z.ZodOptional<z.ZodString>;
|
|
38
|
-
tools: z.ZodOptional<z.ZodArray<z.
|
|
39
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
40
|
+
name: z.ZodString;
|
|
41
|
+
scope: z.ZodString;
|
|
42
|
+
}, z.core.$strict>>>;
|
|
39
43
|
widgets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
40
44
|
name: z.ZodString;
|
|
41
45
|
element: z.ZodString;
|
|
@@ -75,7 +79,10 @@ export declare const DiscoveryDocumentSchema: z.ZodObject<{
|
|
|
75
79
|
}, z.core.$strict>>;
|
|
76
80
|
}, z.core.$strict>;
|
|
77
81
|
openapi: z.ZodOptional<z.ZodString>;
|
|
78
|
-
tools: z.ZodOptional<z.ZodArray<z.
|
|
82
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
83
|
+
name: z.ZodString;
|
|
84
|
+
scope: z.ZodString;
|
|
85
|
+
}, z.core.$strict>>>;
|
|
79
86
|
widgets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
80
87
|
name: z.ZodString;
|
|
81
88
|
element: z.ZodString;
|
|
@@ -20,7 +20,7 @@ const PartsRecordSchema = z
|
|
|
20
20
|
.refine((parts) => IMAGE_PART_KINDS.some((kind) => parts[kind] !== undefined), {
|
|
21
21
|
message: 'at least one part with an image',
|
|
22
22
|
});
|
|
23
|
-
const OriginSchema = z
|
|
23
|
+
export const OriginSchema = z
|
|
24
24
|
.string()
|
|
25
25
|
.max(270)
|
|
26
26
|
.regex(/^(https:\/\/[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+|http:\/\/(127\.0\.0\.1|localhost))(:[0-9]{1,5})?$/);
|
|
@@ -29,6 +29,10 @@ export const WidgetSchema = z.strictObject({
|
|
|
29
29
|
element: z.string().regex(/^[a-z][a-z0-9]*-[a-z0-9-]+$/),
|
|
30
30
|
module: z.string().regex(/^\/ui\/[^/]+\/[^/]+\.m?js$/),
|
|
31
31
|
});
|
|
32
|
+
const ToolRecordSchema = z.strictObject({
|
|
33
|
+
name: PrefixedToolNameSchema,
|
|
34
|
+
scope: z.string().regex(/^(public|[a-z][a-z0-9-]*:(read|write|admin))$/),
|
|
35
|
+
});
|
|
32
36
|
const recordShape = {
|
|
33
37
|
id: ExtensionIdSchema,
|
|
34
38
|
version: VersionSchema,
|
|
@@ -39,7 +43,7 @@ const recordShape = {
|
|
|
39
43
|
.string()
|
|
40
44
|
.regex(/^\/api\/v[1-9][0-9]*\/openapi\.json$/)
|
|
41
45
|
.optional(),
|
|
42
|
-
tools: z.array(
|
|
46
|
+
tools: z.array(ToolRecordSchema).min(1).optional(),
|
|
43
47
|
widgets: z.array(WidgetSchema).min(1).optional(),
|
|
44
48
|
commands: z.array(CommandNameSchema).min(1).optional(),
|
|
45
49
|
};
|
|
@@ -23,10 +23,12 @@ export interface Failure extends Error {
|
|
|
23
23
|
readonly status: number;
|
|
24
24
|
readonly details?: ErrorDetail[];
|
|
25
25
|
readonly retryAfter?: number;
|
|
26
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
26
27
|
}
|
|
27
28
|
export interface FailOptions {
|
|
28
29
|
details?: ErrorDetail[];
|
|
29
30
|
retryAfter?: number;
|
|
31
|
+
headers?: Record<string, string>;
|
|
30
32
|
}
|
|
31
33
|
export declare const ok: <T>(data: T, meta?: Record<string, unknown>) => OkEnvelope<T>;
|
|
32
34
|
export declare const fail: (code: ErrorCode, message: string, options?: FailOptions) => Failure;
|
|
@@ -14,6 +14,7 @@ export const fail = (code, message, options = {}) => Object.assign(new Error(mes
|
|
|
14
14
|
status: ERROR_STATUS[code],
|
|
15
15
|
details: options.details,
|
|
16
16
|
retryAfter: retryAfterOf(code, options.retryAfter),
|
|
17
|
+
headers: options.headers,
|
|
17
18
|
});
|
|
18
19
|
export const isFailure = (err) => err instanceof Error && err[FAILURE] === true;
|
|
19
20
|
export const errorEnvelope = (failure, agcid) => {
|
package/dist/mcp/mcp-app.js
CHANGED
|
@@ -13,6 +13,7 @@ const MCP_CORS = {
|
|
|
13
13
|
...PUBLIC_CORS,
|
|
14
14
|
allowedHeaders: [...PUBLIC_CORS.allowedHeaders, ...MCP_HEADERS],
|
|
15
15
|
};
|
|
16
|
+
const PARSE_ERROR = { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null };
|
|
16
17
|
const parsed = (body) => {
|
|
17
18
|
try {
|
|
18
19
|
return JSON.parse(body.toString('utf8'));
|
|
@@ -65,6 +66,9 @@ const send = async (response, res) => {
|
|
|
65
66
|
const handle = (handler, options) => async (req, res) => {
|
|
66
67
|
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
67
68
|
const message = parsed(body);
|
|
69
|
+
// The SDK reads a body again when given none (a BOM, say): it must only run what guard read.
|
|
70
|
+
if (message === undefined)
|
|
71
|
+
return void res.status(400).json(PARSE_ERROR);
|
|
68
72
|
await guard(options, req, message);
|
|
69
73
|
await send(await handler.fetch(webRequest(req, body), { parsedBody: message }), res);
|
|
70
74
|
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { currentAgcid } from '../agcid/current.js';
|
|
|
4
4
|
import { fail, isFailure } from '../envelope/envelope.js';
|
|
5
5
|
import {} from '../log/log.js';
|
|
6
6
|
import { prefixed } from './tools.js';
|
|
7
|
-
const text = (value) => [{ type: 'text', text:
|
|
7
|
+
const text = (value) => [{ type: 'text', text: value }];
|
|
8
8
|
// A failed tool is isError with the REST error shape; anything unexpected is a generic INTERNAL.
|
|
9
9
|
// Outside a request (no agcid in scope) the error still carries a valid one, minted here.
|
|
10
10
|
const errorResult = (err, log) => {
|
|
@@ -12,12 +12,24 @@ const errorResult = (err, log) => {
|
|
|
12
12
|
if (failure.status >= 500)
|
|
13
13
|
log.error(err);
|
|
14
14
|
const error = { code: failure.code, message: failure.message, agcid: agcidOf(currentAgcid()) };
|
|
15
|
-
return { isError: true, content: text(
|
|
15
|
+
return { isError: true, content: text(error.message), structuredContent: { error } };
|
|
16
|
+
};
|
|
17
|
+
// The kit, not the SDK, refuses bad arguments (C7): the SDK lists the schema, passes all through.
|
|
18
|
+
const listedOnly = (input) => ({
|
|
19
|
+
'~standard': { ...input['~standard'], validate: (value) => ({ value }) },
|
|
20
|
+
});
|
|
21
|
+
const argsOf = (entry, args) => {
|
|
22
|
+
const parsed = entry.spec.input.safeParse(args);
|
|
23
|
+
if (parsed.success)
|
|
24
|
+
return parsed.data;
|
|
25
|
+
const fields = new Set(parsed.error.issues.map((issue) => issue.path.join('.') || '(root)'));
|
|
26
|
+
throw fail('VALIDATION_ERROR', `Invalid arguments: ${[...fields].join(', ')}`);
|
|
16
27
|
};
|
|
17
28
|
const callback = (entry, log) => async (args) => {
|
|
18
29
|
try {
|
|
19
|
-
const output = entry.spec.output.parse(await entry.handler(args));
|
|
20
|
-
|
|
30
|
+
const output = entry.spec.output.parse(await entry.handler(argsOf(entry, args)));
|
|
31
|
+
const content = text(JSON.stringify(output));
|
|
32
|
+
return { content, structuredContent: output };
|
|
21
33
|
}
|
|
22
34
|
catch (err) {
|
|
23
35
|
return errorResult(err, log);
|
|
@@ -29,7 +41,7 @@ export const serverFactory = (identity, tools, log) => () => {
|
|
|
29
41
|
for (const entry of tools) {
|
|
30
42
|
const config = {
|
|
31
43
|
description: entry.spec.description,
|
|
32
|
-
inputSchema: entry.spec.input,
|
|
44
|
+
inputSchema: listedOnly(entry.spec.input),
|
|
33
45
|
outputSchema: entry.spec.output,
|
|
34
46
|
};
|
|
35
47
|
server.registerTool(prefixed(identity.id, entry.name), config, callback(entry, log));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import {} from 'express';
|
|
3
3
|
import {} from '../context/context.js';
|
|
4
|
-
import { readOpenapi } from '../discovery/openapi.js';
|
|
5
4
|
import {} from '../manifest/manifest.schema.js';
|
|
6
5
|
import { loadCreate } from '../manifest/entry.js';
|
|
7
6
|
import { hasPostgres } from '../manifest/parts.js';
|
|
@@ -20,8 +19,7 @@ import { createScheduler } from '../worker/scheduler.js';
|
|
|
20
19
|
const callCreate = async (input, part, ctx) => (await loadCreate(input.dir, input.pkg, part, input.importModule))(ctx);
|
|
21
20
|
const api = async (input) => {
|
|
22
21
|
const router = (await callCreate(input, 'api', input.ctx()));
|
|
23
|
-
|
|
24
|
-
return { app: createApiApp({ ...input.base, router, openapi }) };
|
|
22
|
+
return { app: createApiApp({ ...input.base, router, openapi: input.openapi }) };
|
|
25
23
|
};
|
|
26
24
|
const mcp = async (input) => {
|
|
27
25
|
const registry = createToolRegistry();
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type TokenCheck } from '../auth/token-check.js';
|
|
3
2
|
import { type ImportModule } from '../manifest/entry.js';
|
|
4
3
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
5
4
|
import { type ImagePartKind } from '../manifest/primitives.schema.js';
|
|
@@ -11,7 +10,6 @@ export interface RunPartOptions {
|
|
|
11
10
|
env: Env;
|
|
12
11
|
importModule?: ImportModule;
|
|
13
12
|
serve?: Pick<ServeOptions, 'signals' | 'exit'>;
|
|
14
|
-
tokenCheck?: TokenCheck;
|
|
15
13
|
}
|
|
16
14
|
export declare const partOf: (pkg: ExtensionPackage, part: string) => ImagePartKind;
|
|
17
15
|
export declare const runPart: (options: RunPartOptions) => Promise<Server>;
|
package/dist/runtime/run-part.js
CHANGED
|
@@ -2,14 +2,16 @@ import {} from 'node:http';
|
|
|
2
2
|
import { createContext } from '../context/context.js';
|
|
3
3
|
import { settingsFromEnv } from '../context/settings.js';
|
|
4
4
|
import { DEFAULT_RECORD_FILE, loadRecord } from '../discovery/discovery.js';
|
|
5
|
-
import {} from '../
|
|
6
|
-
import {} from '../
|
|
5
|
+
import {} from '../discovery/discovery.schema.js';
|
|
6
|
+
import { declaredScopes, partAuth } from '../auth/part-auth.js';
|
|
7
|
+
import { readOpenapi } from '../discovery/openapi.js';
|
|
7
8
|
import { createLog } from '../log/log.js';
|
|
8
9
|
import {} from '../manifest/entry.js';
|
|
9
10
|
import {} from '../manifest/manifest.schema.js';
|
|
10
11
|
import { hasPostgres, imageParts } from '../manifest/parts.js';
|
|
11
12
|
import {} from '../manifest/primitives.schema.js';
|
|
12
13
|
import { readManifest } from '../manifest/read-manifest.js';
|
|
14
|
+
import {} from '../server/app.js';
|
|
13
15
|
import { serve } from '../server/serve.js';
|
|
14
16
|
import { createStore, storeCheck } from '../store/store.js';
|
|
15
17
|
import {} from '../store/store.types.js';
|
|
@@ -48,28 +50,37 @@ const lazyContext = (start, store) => {
|
|
|
48
50
|
return (ctx ??= contextFor(start, start.part, store));
|
|
49
51
|
};
|
|
50
52
|
};
|
|
51
|
-
const
|
|
53
|
+
const guardFor = async (start, openapi, record) => {
|
|
54
|
+
if (start.part !== 'api' && start.part !== 'mcp')
|
|
55
|
+
return { checks: {} };
|
|
56
|
+
const { id } = start.pkg.agentage;
|
|
57
|
+
return partAuth(start.env, id, start.log, declaredScopes(id, openapi, record?.tools));
|
|
58
|
+
};
|
|
59
|
+
const baseFor = async (start, store, openapi) => {
|
|
52
60
|
const { pkg, env, log } = start;
|
|
53
|
-
const
|
|
61
|
+
const record = await recordFor(env, log);
|
|
62
|
+
const guard = await guardFor(start, openapi, record);
|
|
54
63
|
return {
|
|
55
64
|
pkg,
|
|
56
65
|
log,
|
|
57
|
-
|
|
66
|
+
record,
|
|
67
|
+
...guard,
|
|
68
|
+
checks: { ...(store ? { store: storeCheck(store) } : {}), ...guard.checks },
|
|
58
69
|
build: buildInfoFromEnv(env),
|
|
59
|
-
record: await recordFor(env, log),
|
|
60
70
|
origin: env['AGENTAGE_ORIGIN'] || undefined,
|
|
61
71
|
ownImage: env['AGENTAGE_IMAGE'],
|
|
62
|
-
tokenCheck: options.tokenCheck,
|
|
63
72
|
};
|
|
64
73
|
};
|
|
65
74
|
const inputFor = async (options, start) => {
|
|
66
75
|
const store = storeFor(start);
|
|
76
|
+
const openapi = await readOpenapi(options.dir);
|
|
67
77
|
return {
|
|
68
78
|
dir: options.dir,
|
|
69
79
|
pkg: start.pkg,
|
|
70
80
|
env: start.env,
|
|
71
81
|
ctx: lazyContext(start, store),
|
|
72
|
-
base: await baseFor(
|
|
82
|
+
base: await baseFor(start, store, openapi),
|
|
83
|
+
openapi,
|
|
73
84
|
importModule: options.importModule,
|
|
74
85
|
};
|
|
75
86
|
};
|
package/dist/server/app.d.ts
CHANGED
package/dist/server/app.js
CHANGED
|
@@ -45,6 +45,9 @@ export const createPartApp = (options) => {
|
|
|
45
45
|
if (options.part !== 'worker') {
|
|
46
46
|
app.use('/.well-known', cors(PUBLIC_CORS));
|
|
47
47
|
app.get('/.well-known/agentage-extension.json', discoveryHandler(options));
|
|
48
|
+
const { resource } = options;
|
|
49
|
+
if (resource)
|
|
50
|
+
app.get('/.well-known/oauth-protected-resource', (_req, res) => void res.json(resource));
|
|
48
51
|
}
|
|
49
52
|
options.mount(app);
|
|
50
53
|
app.use(notFound);
|
package/dist/server/errors.js
CHANGED
|
@@ -25,5 +25,7 @@ export const errorHandler = (log) => (err, _req, res, _next) => {
|
|
|
25
25
|
log.error(err);
|
|
26
26
|
if (failure.retryAfter !== undefined)
|
|
27
27
|
res.setHeader('Retry-After', String(failure.retryAfter));
|
|
28
|
+
for (const [name, value] of Object.entries(failure.headers ?? {}))
|
|
29
|
+
res.setHeader(name, value);
|
|
28
30
|
res.status(failure.status).json(errorEnvelope(failure, agcidOfResponse(res)));
|
|
29
31
|
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type RecordExtras } from '../discovery/discovery.js';
|
|
2
2
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
3
|
-
export declare const
|
|
3
|
+
export declare const toolsOf: (dir: string, pkg: ExtensionPackage) => Promise<{
|
|
4
|
+
name: string;
|
|
5
|
+
scope: string;
|
|
6
|
+
}[]>;
|
|
4
7
|
export declare const recordExtras: (dir: string, pkg: ExtensionPackage) => Promise<RecordExtras>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { scopeOf } from '../auth/token-check.js';
|
|
3
4
|
import { createContext } from '../context/context.js';
|
|
4
5
|
import {} from '../discovery/discovery.js';
|
|
5
6
|
import { WidgetSchema } from '../discovery/discovery.schema.js';
|
|
@@ -19,8 +20,8 @@ const noStore = {
|
|
|
19
20
|
},
|
|
20
21
|
};
|
|
21
22
|
const silent = createLog({ extension: 'build', part: 'mcp' }, () => undefined);
|
|
22
|
-
// The
|
|
23
|
-
export const
|
|
23
|
+
// The tools the mcp part registers and their scopes, read by running its create(ctx) on a recorder.
|
|
24
|
+
export const toolsOf = async (dir, pkg) => {
|
|
24
25
|
const registry = createToolRegistry();
|
|
25
26
|
const base = createContext({
|
|
26
27
|
pkg,
|
|
@@ -31,7 +32,11 @@ export const toolNames = async (dir, pkg) => {
|
|
|
31
32
|
store: noStore,
|
|
32
33
|
});
|
|
33
34
|
await (await loadCreate(dir, pkg, 'mcp'))({ ...base, tool: registry.tool });
|
|
34
|
-
|
|
35
|
+
const { id } = pkg.agentage;
|
|
36
|
+
return registry.tools.map((tool) => ({
|
|
37
|
+
name: prefixed(id, tool.name),
|
|
38
|
+
scope: scopeOf(id, tool.spec.scope),
|
|
39
|
+
}));
|
|
35
40
|
};
|
|
36
41
|
const widgetsOf = async (dir) => {
|
|
37
42
|
const value = await readJsonIfExists(join(dir, 'dist', 'ui', 'widgets.json'));
|
|
@@ -42,7 +47,7 @@ export const recordExtras = async (dir, pkg) => {
|
|
|
42
47
|
const { code } = normalizeParts(pkg.agentage.parts);
|
|
43
48
|
const extras = {};
|
|
44
49
|
if (code.mcp) {
|
|
45
|
-
const tools = await
|
|
50
|
+
const tools = await toolsOf(dir, pkg);
|
|
46
51
|
if (tools.length > 0)
|
|
47
52
|
extras.tools = tools;
|
|
48
53
|
}
|
package/dist/tool/up/compose.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {} from '../../manifest/manifest.schema.js';
|
|
2
2
|
import { hasPostgres, imageParts } from '../../manifest/parts.js';
|
|
3
3
|
import {} from '../../manifest/primitives.schema.js';
|
|
4
|
-
import { DEV_ISSUER } from './dev-issuer-app.js';
|
|
4
|
+
import { DEV_AUDIENCE, DEV_ISSUER, UP_ISSUER } from './dev-issuer-app.js';
|
|
5
5
|
import { GATEWAY_PORT, ISSUER_PORT, PART_PORT, TRAEFIK_COMMAND, TRAEFIK_IMAGE } from './gateway.js';
|
|
6
6
|
export const STORE_IMAGE = 'postgres:18-alpine';
|
|
7
7
|
const RECORD_TARGET = '/run/agentage/agentage-extension.json';
|
|
@@ -9,32 +9,40 @@ const ISSUER_ENTRY = 'node_modules/@agentage/extension/dist/tool/up/dev-issuer.j
|
|
|
9
9
|
// A local-only credential: the store has no published port, only the parts' network reaches it.
|
|
10
10
|
const STORE_PASSWORD = 'agentage-local';
|
|
11
11
|
const storeUrl = (id) => `postgres://${id}:${STORE_PASSWORD}@store:5432/${id}`;
|
|
12
|
+
const TOKENS = { AGENTAGE_ISSUER: UP_ISSUER, AGENTAGE_AUDIENCE: DEV_AUDIENCE };
|
|
12
13
|
// Only the ui under `up` names the dev issuer: its CSP then lets the harness ask it for a token.
|
|
13
14
|
const partEnv = (pkg, part) => ({
|
|
14
15
|
AGENTAGE_ORIGIN: `http://localhost:${GATEWAY_PORT}`,
|
|
16
|
+
...TOKENS,
|
|
15
17
|
AGENTAGE_RECORD_FILE: RECORD_TARGET,
|
|
16
18
|
...(hasPostgres(pkg) && part !== 'ui' ? { AGENTAGE_STORE_URL: storeUrl(pkg.agentage.id) } : {}),
|
|
17
19
|
...(part === 'ui' ? { AGENTAGE_DEV_ISSUER: DEV_ISSUER } : {}),
|
|
18
20
|
});
|
|
21
|
+
const QUICK_CHECK = { interval: '2s', timeout: '3s', retries: 30 };
|
|
19
22
|
const storeService = (id) => ({
|
|
20
23
|
image: STORE_IMAGE,
|
|
21
24
|
environment: { POSTGRES_USER: id, POSTGRES_PASSWORD: STORE_PASSWORD, POSTGRES_DB: id },
|
|
22
25
|
volumes: ['store:/var/lib/postgresql'],
|
|
23
26
|
healthcheck: {
|
|
24
27
|
test: ['CMD-SHELL', `pg_isready -U ${id} -d ${id}`],
|
|
25
|
-
|
|
26
|
-
timeout: '3s',
|
|
27
|
-
retries: 30,
|
|
28
|
+
...QUICK_CHECK,
|
|
28
29
|
},
|
|
29
30
|
});
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
...(
|
|
35
|
-
? { depends_on: { store: { condition: 'service_healthy' } } }
|
|
36
|
-
: {}),
|
|
31
|
+
const HEALTHY = { condition: 'service_healthy' };
|
|
32
|
+
// api and mcp read the issuer's keys and revoked list before they serve.
|
|
33
|
+
const dependsOn = (pkg, part) => ({
|
|
34
|
+
...(hasPostgres(pkg) && part !== 'ui' ? { store: HEALTHY } : {}),
|
|
35
|
+
...(part === 'api' || part === 'mcp' ? { issuer: HEALTHY } : {}),
|
|
37
36
|
});
|
|
37
|
+
const partService = (input, part) => {
|
|
38
|
+
const depends_on = dependsOn(input.pkg, part);
|
|
39
|
+
return {
|
|
40
|
+
image: input.tags[part],
|
|
41
|
+
environment: partEnv(input.pkg, part),
|
|
42
|
+
volumes: [`${input.recordFile}:${RECORD_TARGET}:ro`],
|
|
43
|
+
...(Object.keys(depends_on).length > 0 ? { depends_on } : {}),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
38
46
|
const fixedServices = (input, anyImage) => ({
|
|
39
47
|
gateway: {
|
|
40
48
|
image: TRAEFIK_IMAGE,
|
|
@@ -43,9 +51,7 @@ const fixedServices = (input, anyImage) => ({
|
|
|
43
51
|
volumes: [`${input.gatewayFile}:/etc/traefik/dynamic.yml:ro`],
|
|
44
52
|
healthcheck: {
|
|
45
53
|
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:8082/ping'],
|
|
46
|
-
|
|
47
|
-
timeout: '3s',
|
|
48
|
-
retries: 30,
|
|
54
|
+
...QUICK_CHECK,
|
|
49
55
|
},
|
|
50
56
|
},
|
|
51
57
|
issuer: {
|
|
@@ -59,7 +65,7 @@ const migrateService = (input) => ({
|
|
|
59
65
|
command: ['node', 'dist/store/migrate.js'],
|
|
60
66
|
environment: partEnv(input.pkg, 'migrate'),
|
|
61
67
|
...(input.seedsDir ? { volumes: [`${input.seedsDir}:/app/seeds:ro`] } : {}),
|
|
62
|
-
depends_on: { store:
|
|
68
|
+
depends_on: { store: HEALTHY },
|
|
63
69
|
profiles: ['jobs'],
|
|
64
70
|
restart: 'no',
|
|
65
71
|
});
|
|
@@ -2,11 +2,17 @@ import { type KeyObject } from 'node:crypto';
|
|
|
2
2
|
import { type Server } from 'node:http';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
export declare const DEV_ISSUER = "http://localhost:4281";
|
|
5
|
-
export declare const
|
|
5
|
+
export declare const UP_ISSUER = "http://issuer:3000";
|
|
6
|
+
export declare const DEV_AUDIENCE = "localhost";
|
|
7
|
+
export type IssuerSettings = {
|
|
8
|
+
issuer: string;
|
|
9
|
+
audience: string;
|
|
10
|
+
};
|
|
6
11
|
declare const TokenRequestSchema: z.ZodObject<{
|
|
7
12
|
scope: z.ZodString;
|
|
8
13
|
sub: z.ZodOptional<z.ZodString>;
|
|
9
14
|
ttlSeconds: z.ZodOptional<z.ZodInt>;
|
|
15
|
+
aud: z.ZodOptional<z.ZodString>;
|
|
10
16
|
}, z.core.$strict>;
|
|
11
17
|
interface Keys {
|
|
12
18
|
kid: string;
|
|
@@ -14,9 +20,9 @@ interface Keys {
|
|
|
14
20
|
jwk: Record<string, unknown>;
|
|
15
21
|
}
|
|
16
22
|
export declare const newKeys: () => Keys;
|
|
17
|
-
export declare const mint: (keys: Keys, request: z.infer<typeof TokenRequestSchema>, now?: number) => {
|
|
23
|
+
export declare const mint: (keys: Keys, request: z.infer<typeof TokenRequestSchema>, settings: IssuerSettings, now?: number) => {
|
|
18
24
|
token: string;
|
|
19
25
|
expiresAt: string;
|
|
20
26
|
};
|
|
21
|
-
export declare const createIssuer: (keys?: Keys) => Server;
|
|
27
|
+
export declare const createIssuer: (settings?: IssuerSettings, keys?: Keys) => Server;
|
|
22
28
|
export {};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { GATEWAY_PORT, PART_PORT } from './gateway.js';
|
|
5
|
+
// Where the host reaches the dev issuer; the parts of `up` reach it, and tokens name it, by service.
|
|
4
6
|
export const DEV_ISSUER = 'http://localhost:4281';
|
|
5
|
-
export const
|
|
7
|
+
export const UP_ISSUER = `http://issuer:${PART_PORT}`;
|
|
8
|
+
// The host of `up` (http://localhost:4280): the one host this audience covers.
|
|
9
|
+
export const DEV_AUDIENCE = 'localhost';
|
|
6
10
|
const TokenRequestSchema = z.strictObject({
|
|
7
11
|
scope: z
|
|
8
12
|
.string()
|
|
@@ -12,8 +16,10 @@ const TokenRequestSchema = z.strictObject({
|
|
|
12
16
|
.string()
|
|
13
17
|
.regex(/^[A-Za-z0-9_-]{1,64}$/)
|
|
14
18
|
.optional(),
|
|
15
|
-
ttlSeconds: z.int().min(
|
|
19
|
+
ttlSeconds: z.int().min(1).max(86_400).optional(),
|
|
20
|
+
aud: z.string().min(1).max(253).optional(),
|
|
16
21
|
});
|
|
22
|
+
const RevokeSchema = z.strictObject({ jti: z.string().regex(/^tok_[0-9a-f-]{36}$/) });
|
|
17
23
|
export const newKeys = () => {
|
|
18
24
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
19
25
|
const kid = `dev-${randomUUID().slice(0, 8)}`;
|
|
@@ -25,18 +31,11 @@ export const newKeys = () => {
|
|
|
25
31
|
};
|
|
26
32
|
const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
27
33
|
// The api-standards token shape: aga_<jwt>, typ at+jwt, EdDSA, scope {id}:{verb}, a jti.
|
|
28
|
-
export const mint = (keys, request, now = Date.now()) => {
|
|
34
|
+
export const mint = (keys, request, settings, now = Date.now()) => {
|
|
29
35
|
const iat = Math.floor(now / 1000);
|
|
30
36
|
const exp = iat + (request.ttlSeconds ?? 3600);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
sub: request.sub ?? 'usr_dev',
|
|
34
|
-
aud: DEV_AUDIENCE,
|
|
35
|
-
scope: request.scope,
|
|
36
|
-
iat,
|
|
37
|
-
exp,
|
|
38
|
-
jti: `tok_${randomUUID()}`,
|
|
39
|
-
};
|
|
37
|
+
const { scope, sub = 'usr_dev', aud = settings.audience } = request;
|
|
38
|
+
const claims = { iss: settings.issuer, sub, aud, scope, iat, exp, jti: `tok_${randomUUID()}` };
|
|
40
39
|
const input = `${b64({ alg: 'EdDSA', typ: 'at+jwt', kid: keys.kid })}.${b64(claims)}`;
|
|
41
40
|
const signature = sign(null, Buffer.from(input), keys.privateKey).toString('base64url');
|
|
42
41
|
return { token: `aga_${input}.${signature}`, expiresAt: new Date(exp * 1000).toISOString() };
|
|
@@ -46,7 +45,7 @@ const reply = (res, status, body) => {
|
|
|
46
45
|
'content-type': 'application/json',
|
|
47
46
|
'cache-control': 'no-store',
|
|
48
47
|
'access-control-allow-origin': '*',
|
|
49
|
-
'access-control-allow-headers': 'content-type',
|
|
48
|
+
'access-control-allow-headers': 'content-type, x-client-type',
|
|
50
49
|
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
51
50
|
});
|
|
52
51
|
res.end(body === undefined ? undefined : JSON.stringify(body));
|
|
@@ -59,22 +58,52 @@ const readBody = async (req) => {
|
|
|
59
58
|
throw new Error('body too large');
|
|
60
59
|
return JSON.parse(text || '{}');
|
|
61
60
|
};
|
|
62
|
-
const tokenRoute = async (
|
|
61
|
+
const tokenRoute = async (issuer, req, res) => {
|
|
63
62
|
const parsed = TokenRequestSchema.safeParse(await readBody(req).catch(() => null));
|
|
64
63
|
if (!parsed.success)
|
|
65
64
|
return reply(res, 400, { error: 'scope must be {id}:{read|write|admin}, space-separated' });
|
|
66
|
-
reply(res, 200, mint(keys, parsed.data));
|
|
65
|
+
reply(res, 200, mint(issuer.keys, parsed.data, issuer.settings));
|
|
66
|
+
};
|
|
67
|
+
// Kept for a day, the longest a dev token lives, so an entry never outlasts its token by more.
|
|
68
|
+
const revokeRoute = async (issuer, req, res) => {
|
|
69
|
+
const parsed = RevokeSchema.safeParse(await readBody(req).catch(() => null));
|
|
70
|
+
if (!parsed.success)
|
|
71
|
+
return reply(res, 400, { error: 'jti must be a tok_ id' });
|
|
72
|
+
issuer.revoked.set(parsed.data.jti, Math.floor(Date.now() / 1000) + 86_400);
|
|
73
|
+
reply(res, 200, { jti: parsed.data.jti, revoked: true });
|
|
74
|
+
};
|
|
75
|
+
const revokedList = ({ revoked }) => {
|
|
76
|
+
const now = Date.now() / 1000;
|
|
77
|
+
const live = [...revoked].filter(([, exp]) => exp > now).map(([jti, exp]) => ({ jti, exp }));
|
|
78
|
+
return { revoked: live, generatedAt: new Date().toISOString() };
|
|
67
79
|
};
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
// security.md, local daemons: its tokens open the parts of `up`, so no other site may mint one.
|
|
81
|
+
const ORIGINS = ['localhost', '127.0.0.1'].map((host) => `http://${host}:${GATEWAY_PORT}`);
|
|
82
|
+
const allowed = ({ settings }, req) => {
|
|
83
|
+
const host = (req.headers.host ?? '').replace(/:[0-9]+$/, '');
|
|
84
|
+
const origin = req.headers.origin;
|
|
85
|
+
const hosts = ['localhost', '127.0.0.1', new URL(settings.issuer).hostname];
|
|
86
|
+
return hosts.includes(host) && (origin === undefined || ORIGINS.includes(origin));
|
|
87
|
+
};
|
|
88
|
+
const route = (issuer, req, res) => {
|
|
89
|
+
const at = `${req.method ?? ''} ${(req.url ?? '/').split('?')[0] ?? ''}`;
|
|
90
|
+
if (!allowed(issuer, req))
|
|
91
|
+
return reply(res, 403, { error: 'host or origin not allowed' });
|
|
71
92
|
if (req.method === 'OPTIONS')
|
|
72
93
|
return reply(res, 204, undefined);
|
|
73
|
-
if (
|
|
94
|
+
if (at === 'GET /health')
|
|
74
95
|
return reply(res, 200, { status: 'ok' });
|
|
75
|
-
if (
|
|
76
|
-
return reply(res, 200, { keys: [keys.jwk] });
|
|
77
|
-
if (
|
|
78
|
-
return
|
|
96
|
+
if (at === 'GET /.well-known/jwks.json')
|
|
97
|
+
return reply(res, 200, { keys: [issuer.keys.jwk] });
|
|
98
|
+
if (at === 'GET /.well-known/agentage-revoked')
|
|
99
|
+
return reply(res, 200, revokedList(issuer));
|
|
100
|
+
if (at === 'POST /token')
|
|
101
|
+
return void tokenRoute(issuer, req, res);
|
|
102
|
+
if (at === 'POST /revoke')
|
|
103
|
+
return void revokeRoute(issuer, req, res);
|
|
79
104
|
reply(res, 404, { error: 'not found' });
|
|
80
|
-
}
|
|
105
|
+
};
|
|
106
|
+
export const createIssuer = (settings = { issuer: UP_ISSUER, audience: DEV_AUDIENCE }, keys = newKeys()) => {
|
|
107
|
+
const issuer = { settings, keys, revoked: new Map() };
|
|
108
|
+
return createServer((req, res) => route(issuer, req, res));
|
|
109
|
+
};
|
|
@@ -33,7 +33,10 @@ export declare const writeLocalRecord: (dir: string, pkg: ExtensionPackage, buil
|
|
|
33
33
|
} | undefined;
|
|
34
34
|
};
|
|
35
35
|
openapi?: string | undefined;
|
|
36
|
-
tools?:
|
|
36
|
+
tools?: {
|
|
37
|
+
name: string;
|
|
38
|
+
scope: string;
|
|
39
|
+
}[] | undefined;
|
|
37
40
|
widgets?: {
|
|
38
41
|
name: string;
|
|
39
42
|
element: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentage/extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The agentage extension kit: create(ctx) per part (pages, ui, api, mcp, worker, cli), the store, /health, discovery, the envelope, and the build tool with up and token",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@modelcontextprotocol/server": "^2.4.0",
|
|
51
51
|
"cors": "^2.8.5",
|
|
52
52
|
"helmet": "^8.1.0",
|
|
53
|
+
"jose": "^6.2.15",
|
|
53
54
|
"pg": "^8.23.1",
|
|
54
55
|
"zod": "^4.2.0"
|
|
55
56
|
},
|
|
@@ -128,9 +128,23 @@
|
|
|
128
128
|
"minItems": 1,
|
|
129
129
|
"type": "array",
|
|
130
130
|
"items": {
|
|
131
|
-
"type": "
|
|
132
|
-
"
|
|
133
|
-
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {
|
|
133
|
+
"name": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"maxLength": 64,
|
|
136
|
+
"pattern": "^[a-z][a-z0-9-]*__[a-z][a-z0-9_-]*$"
|
|
137
|
+
},
|
|
138
|
+
"scope": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"pattern": "^(public|[a-z][a-z0-9-]*:(read|write|admin))$"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"required": [
|
|
144
|
+
"name",
|
|
145
|
+
"scope"
|
|
146
|
+
],
|
|
147
|
+
"additionalProperties": false
|
|
134
148
|
}
|
|
135
149
|
},
|
|
136
150
|
"widgets": {
|
|
@@ -128,9 +128,23 @@
|
|
|
128
128
|
"minItems": 1,
|
|
129
129
|
"type": "array",
|
|
130
130
|
"items": {
|
|
131
|
-
"type": "
|
|
132
|
-
"
|
|
133
|
-
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {
|
|
133
|
+
"name": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"maxLength": 64,
|
|
136
|
+
"pattern": "^[a-z][a-z0-9-]*__[a-z][a-z0-9_-]*$"
|
|
137
|
+
},
|
|
138
|
+
"scope": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"pattern": "^(public|[a-z][a-z0-9-]*:(read|write|admin))$"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"required": [
|
|
144
|
+
"name",
|
|
145
|
+
"scope"
|
|
146
|
+
],
|
|
147
|
+
"additionalProperties": false
|
|
134
148
|
}
|
|
135
149
|
},
|
|
136
150
|
"widgets": {
|