@agentage/extension 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.
Files changed (87) hide show
  1. package/dist/agcid/scope.d.ts +0 -1
  2. package/dist/agcid/scope.js +0 -1
  3. package/dist/auth/part-auth.d.ts +18 -0
  4. package/dist/auth/part-auth.js +61 -0
  5. package/dist/auth/revoked.d.ts +9 -0
  6. package/dist/auth/revoked.js +34 -0
  7. package/dist/auth/route-access.d.ts +2 -0
  8. package/dist/auth/route-access.js +21 -0
  9. package/dist/auth/token-check.d.ts +13 -0
  10. package/dist/auth/token-check.js +26 -0
  11. package/dist/auth/verify-token.d.ts +18 -0
  12. package/dist/auth/verify-token.js +68 -0
  13. package/dist/command/check-cli.js +2 -7
  14. package/dist/command/run-command.d.ts +1 -0
  15. package/dist/command/run-command.js +5 -15
  16. package/dist/context/context.d.ts +4 -0
  17. package/dist/context/context.js +3 -1
  18. package/dist/context/test-context.js +23 -17
  19. package/dist/discovery/discovery.d.ts +8 -2
  20. package/dist/discovery/discovery.schema.d.ts +14 -2
  21. package/dist/discovery/discovery.schema.js +7 -3
  22. package/dist/envelope/envelope.d.ts +2 -0
  23. package/dist/envelope/envelope.js +1 -0
  24. package/dist/manifest/entry.d.ts +5 -0
  25. package/dist/manifest/entry.js +29 -0
  26. package/dist/manifest/parts.d.ts +1 -0
  27. package/dist/manifest/parts.js +1 -0
  28. package/dist/mcp/mcp-app.js +31 -7
  29. package/dist/mcp/server.js +20 -6
  30. package/dist/mcp/tools.d.ts +2 -0
  31. package/dist/mcp/tools.js +4 -0
  32. package/dist/runtime/part-runners.d.ts +6 -5
  33. package/dist/runtime/part-runners.js +20 -27
  34. package/dist/runtime/run-part.d.ts +2 -2
  35. package/dist/runtime/run-part.js +55 -28
  36. package/dist/server/app.d.ts +3 -0
  37. package/dist/server/app.js +15 -1
  38. package/dist/server/errors.js +2 -0
  39. package/dist/store/migrate.js +7 -3
  40. package/dist/store/migrations.d.ts +2 -0
  41. package/dist/store/migrations.js +32 -6
  42. package/dist/store/store.d.ts +7 -1
  43. package/dist/store/store.js +37 -7
  44. package/dist/tool/build.d.ts +0 -2
  45. package/dist/tool/build.js +5 -3
  46. package/dist/tool/check.d.ts +16 -0
  47. package/dist/tool/check.js +30 -0
  48. package/dist/tool/docker.js +3 -2
  49. package/dist/tool/exec.d.ts +1 -0
  50. package/dist/tool/exec.js +43 -16
  51. package/dist/tool/main.js +23 -24
  52. package/dist/tool/record-extras.d.ts +4 -1
  53. package/dist/tool/record-extras.js +16 -15
  54. package/dist/tool/stage.d.ts +1 -0
  55. package/dist/tool/stage.js +8 -10
  56. package/dist/tool/timeouts.d.ts +10 -0
  57. package/dist/tool/timeouts.js +19 -0
  58. package/dist/tool/up/compose-run.d.ts +2 -1
  59. package/dist/tool/up/compose-run.js +3 -2
  60. package/dist/tool/up/compose.d.ts +2 -1
  61. package/dist/tool/up/compose.js +32 -23
  62. package/dist/tool/up/dev-issuer-app.d.ts +9 -3
  63. package/dist/tool/up/dev-issuer-app.js +53 -24
  64. package/dist/tool/up/dev-issuer.js +2 -1
  65. package/dist/tool/up/down.d.ts +1 -0
  66. package/dist/tool/up/down.js +39 -30
  67. package/dist/tool/up/gateway.d.ts +9 -0
  68. package/dist/tool/up/gateway.js +11 -17
  69. package/dist/tool/up/lines.js +1 -1
  70. package/dist/tool/up/local-record.d.ts +4 -1
  71. package/dist/tool/up/ports.d.ts +2 -0
  72. package/dist/tool/up/ports.js +22 -0
  73. package/dist/tool/up/project.d.ts +4 -0
  74. package/dist/tool/up/project.js +20 -0
  75. package/dist/tool/up/store-line.d.ts +11 -0
  76. package/dist/tool/up/store-line.js +36 -0
  77. package/dist/tool/up/token.js +9 -2
  78. package/dist/tool/up/up.d.ts +3 -6
  79. package/dist/tool/up/up.js +49 -46
  80. package/dist/tool/up/wait.js +4 -2
  81. package/dist/ui/ui-app.d.ts +2 -0
  82. package/dist/ui/ui-app.js +18 -5
  83. package/dist/worker/runs.js +11 -11
  84. package/dist/worker/scheduler.js +14 -5
  85. package/package.json +2 -1
  86. package/schemas/extension-discovery.schema.json +17 -3
  87. package/schemas/extension-record.schema.json +17 -3
@@ -3,15 +3,17 @@ import { pipeline } from 'node:stream/promises';
3
3
  import cors from 'cors';
4
4
  import express, {} from 'express';
5
5
  import { createMcpHandler } from '@modelcontextprotocol/server';
6
+ import { strongest } from '../auth/token-check.js';
6
7
  import { fail } from '../envelope/envelope.js';
7
8
  import { BODY_LIMIT, createPartApp, PUBLIC_CORS } from '../server/app.js';
8
9
  import { serverFactory } from './server.js';
9
- import {} from './tools.js';
10
+ import { prefixed } from './tools.js';
10
11
  const MCP_HEADERS = ['Mcp-Protocol-Version', 'Mcp-Method', 'Mcp-Name', 'Accept'];
11
12
  const MCP_CORS = {
12
13
  ...PUBLIC_CORS,
13
14
  allowedHeaders: [...PUBLIC_CORS.allowedHeaders, ...MCP_HEADERS],
14
15
  };
16
+ const PARSE_ERROR = { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null };
15
17
  const parsed = (body) => {
16
18
  try {
17
19
  return JSON.parse(body.toString('utf8'));
@@ -20,7 +22,28 @@ const parsed = (body) => {
20
22
  return undefined;
21
23
  }
22
24
  };
23
- const isListing = (body) => (Array.isArray(body) ? body : [body]).some((message) => message?.method === 'tools/list');
25
+ const messagesOf = (body) => (Array.isArray(body) ? body : [body]);
26
+ const isListing = (body) => messagesOf(body).some((message) => message?.method === 'tools/list');
27
+ // Each tools/call needs its tool's declared scope (read when it declares none).
28
+ const callAccess = (options, body) => messagesOf(body)
29
+ .filter((message) => message?.method === 'tools/call')
30
+ .map((message) => {
31
+ const id = options.pkg.agentage.id;
32
+ const entry = options.tools.find((t) => prefixed(id, t.name) === message?.params?.name);
33
+ return entry?.spec.scope ?? 'read';
34
+ });
35
+ // Tokens off: calls stay anonymous and a closed listing refuses every tools/list (nothing can verify).
36
+ const guard = async (options, req, body) => {
37
+ const listing = options.listing === 'token' && isListing(body) ? ['read'] : [];
38
+ const needed = [...listing, ...callAccess(options, body)];
39
+ if (!options.tokenCheck) {
40
+ if (listing.length > 0)
41
+ throw fail('UNAUTHORIZED', 'tools/list needs a token');
42
+ return;
43
+ }
44
+ if (needed.length > 0)
45
+ await options.tokenCheck(req.get('authorization'), strongest(needed));
46
+ };
24
47
  const webRequest = (req, body) => {
25
48
  const headers = new Headers();
26
49
  for (const [key, value] of Object.entries(req.headers)) {
@@ -40,12 +63,13 @@ const send = async (response, res) => {
40
63
  return void res.end();
41
64
  await pipeline(Readable.fromWeb(response.body), res);
42
65
  };
43
- // The kit verifies no token yet, so a closed listing refuses every tools/list.
44
- const handle = (handler, listing) => async (req, res) => {
66
+ const handle = (handler, options) => async (req, res) => {
45
67
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
46
68
  const message = parsed(body);
47
- if (listing === 'token' && isListing(message))
48
- throw fail('UNAUTHORIZED', 'tools/list needs a token');
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);
72
+ await guard(options, req, message);
49
73
  await send(await handler.fetch(webRequest(req, body), { parsedBody: message }), res);
50
74
  };
51
75
  const methodNotAllowed = (_req, res) => {
@@ -67,7 +91,7 @@ export const createMcpApp = (options) => {
67
91
  part: 'mcp',
68
92
  mount: (app) => {
69
93
  app.use('/mcp', cors(MCP_CORS));
70
- app.post('/mcp', raw, handle(handler, options.listing));
94
+ app.post('/mcp', raw, handle(handler, options));
71
95
  app.all('/mcp', methodNotAllowed);
72
96
  },
73
97
  });
@@ -1,21 +1,35 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
+ import { agcidOf } from '../agcid/agcid.js';
2
3
  import { currentAgcid } from '../agcid/current.js';
3
4
  import { fail, isFailure } from '../envelope/envelope.js';
4
5
  import {} from '../log/log.js';
5
6
  import { prefixed } from './tools.js';
6
- const text = (value) => [{ type: 'text', text: JSON.stringify(value) }];
7
+ const text = (value) => [{ type: 'text', text: value }];
7
8
  // A failed tool is isError with the REST error shape; anything unexpected is a generic INTERNAL.
9
+ // Outside a request (no agcid in scope) the error still carries a valid one, minted here.
8
10
  const errorResult = (err, log) => {
9
11
  const failure = isFailure(err) ? err : fail('INTERNAL', 'Internal error');
10
12
  if (failure.status >= 500)
11
13
  log.error(err);
12
- const error = { code: failure.code, message: failure.message, agcid: currentAgcid() ?? '' };
13
- return { isError: true, content: text({ error }), structuredContent: { error } };
14
+ const error = { code: failure.code, message: failure.message, agcid: agcidOf(currentAgcid()) };
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(', ')}`);
14
27
  };
15
28
  const callback = (entry, log) => async (args) => {
16
29
  try {
17
- const output = entry.spec.output.parse(await entry.handler(args));
18
- return { content: text(output), structuredContent: output };
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 };
19
33
  }
20
34
  catch (err) {
21
35
  return errorResult(err, log);
@@ -27,7 +41,7 @@ export const serverFactory = (identity, tools, log) => () => {
27
41
  for (const entry of tools) {
28
42
  const config = {
29
43
  description: entry.spec.description,
30
- inputSchema: entry.spec.input,
44
+ inputSchema: listedOnly(entry.spec.input),
31
45
  outputSchema: entry.spec.output,
32
46
  };
33
47
  server.registerTool(prefixed(identity.id, entry.name), config, callback(entry, log));
@@ -1,10 +1,12 @@
1
1
  import { type z } from 'zod';
2
+ import { type Access } from '../auth/token-check.js';
2
3
  import { type PartContext } from '../context/context.js';
3
4
  type ObjectSchema = z.ZodObject;
4
5
  export interface ToolSpec<I extends ObjectSchema, O extends ObjectSchema> {
5
6
  description: string;
6
7
  input: I;
7
8
  output: O;
9
+ scope?: Access;
8
10
  }
9
11
  export type ToolHandler<I extends ObjectSchema, O extends ObjectSchema> = (args: z.output<I>) => z.input<O> | Promise<z.input<O>>;
10
12
  export type ToolRegistrar = <I extends ObjectSchema, O extends ObjectSchema>(name: string, spec: ToolSpec<I, O>, handler: ToolHandler<I, O>) => void;
package/dist/mcp/tools.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {} from 'zod';
2
+ import {} from '../auth/token-check.js';
2
3
  import {} from '../context/context.js';
3
4
  const TOOL_NAME = /^[a-z][a-z0-9_-]*$/;
5
+ const SCOPES = ['public', 'read', 'write', 'admin'];
4
6
  export const createToolRegistry = () => {
5
7
  const tools = [];
6
8
  const tool = (name, spec, handler) => {
@@ -10,6 +12,8 @@ export const createToolRegistry = () => {
10
12
  throw new Error(`tool ${name} is registered twice`);
11
13
  if (!spec.description)
12
14
  throw new Error(`tool ${name} needs a description: it routes the model`);
15
+ if (spec.scope !== undefined && !SCOPES.includes(spec.scope))
16
+ throw new Error(`tool ${name} scope ${String(spec.scope)}: one of ${SCOPES.join(', ')}`);
13
17
  tools.push({ name, spec, handler: handler });
14
18
  };
15
19
  return { tool, tools };
@@ -1,20 +1,21 @@
1
1
  import { type Express } from 'express';
2
2
  import { type PartContext } from '../context/context.js';
3
3
  import { type ExtensionPackage } from '../manifest/manifest.schema.js';
4
+ import { type ImportModule } from '../manifest/entry.js';
4
5
  import { type ImagePartKind } from '../manifest/primitives.schema.js';
5
6
  import { type PartAppBase } from '../server/app.js';
7
+ import { type Env } from './build-info.js';
6
8
  export interface RunnerInput {
7
9
  dir: string;
8
10
  pkg: ExtensionPackage;
9
- ctx: PartContext;
11
+ env: Env;
12
+ ctx: () => PartContext;
10
13
  base: PartAppBase;
11
- importModule?: (file: string) => Promise<unknown>;
14
+ openapi: Record<string, unknown> | null;
15
+ importModule?: ImportModule;
12
16
  }
13
17
  export interface Running {
14
18
  app: Express;
15
19
  stop?: () => void;
16
20
  }
17
- type PartCreate = (ctx: never) => unknown;
18
- export declare const createOf: (mod: unknown, file: string) => PartCreate;
19
21
  export declare const RUNNERS: Record<ImagePartKind, (input: RunnerInput) => Promise<Running>>;
20
- export {};
@@ -1,57 +1,50 @@
1
1
  import { join } from 'node:path';
2
- import { pathToFileURL } from 'node:url';
3
2
  import {} from 'express';
4
3
  import {} from '../context/context.js';
5
- import { readOpenapi } from '../discovery/openapi.js';
6
4
  import {} from '../manifest/manifest.schema.js';
7
- import { entryOf, normalizeParts } from '../manifest/parts.js';
5
+ import { loadCreate } from '../manifest/entry.js';
6
+ import { hasPostgres } from '../manifest/parts.js';
8
7
  import {} from '../manifest/primitives.schema.js';
9
8
  import { createMcpApp } from '../mcp/mcp-app.js';
10
9
  import { createToolRegistry } from '../mcp/tools.js';
11
10
  import { createPagesApp } from '../pages/pages-app.js';
12
11
  import { createApiApp, createPartApp } from '../server/app.js';
13
12
  import {} from '../server/part.types.js';
13
+ import {} from './build-info.js';
14
14
  import { createUiApp } from '../ui/ui-app.js';
15
15
  import { runLog } from '../worker/run-log.js';
16
16
  import { memoryRuns, storeRuns } from '../worker/runs.js';
17
17
  import { createScheduler } from '../worker/scheduler.js';
18
- const importFile = (file) => import(pathToFileURL(file).href);
19
- export const createOf = (mod, file) => {
20
- const create = mod?.create;
21
- if (typeof create !== 'function')
22
- throw new Error(`${file} does not export create(ctx)`);
23
- return create;
24
- };
25
- // Imports the part's entry and calls its create(ctx); ui has no code to run.
26
- const callCreate = async (input, part, ctx) => {
27
- const file = join(input.dir, entryOf(input.pkg, part));
28
- const mod = await (input.importModule ?? importFile)(file);
29
- return createOf(mod, file)(ctx);
30
- };
18
+ // ui has no code to run; every other part is its create(ctx).
19
+ const callCreate = async (input, part, ctx) => (await loadCreate(input.dir, input.pkg, part, input.importModule))(ctx);
31
20
  const api = async (input) => {
32
- const router = (await callCreate(input, 'api', input.ctx));
33
- const openapi = await readOpenapi(input.dir);
34
- return { app: createApiApp({ ...input.base, router, openapi }) };
21
+ const router = (await callCreate(input, 'api', input.ctx()));
22
+ return { app: createApiApp({ ...input.base, router, openapi: input.openapi }) };
35
23
  };
36
24
  const mcp = async (input) => {
37
25
  const registry = createToolRegistry();
38
- await callCreate(input, 'mcp', { ...input.ctx, tool: registry.tool });
39
- const listing = input.ctx.settings['mcp.listing'] === 'token' ? 'token' : 'open';
26
+ const ctx = input.ctx();
27
+ await callCreate(input, 'mcp', { ...ctx, tool: registry.tool });
28
+ const listing = ctx.settings['mcp.listing'] === 'token' ? 'token' : 'open';
40
29
  return { app: createMcpApp({ ...input.base, tools: registry.tools, listing }) };
41
30
  };
42
31
  const pages = async (input) => {
43
- const site = (await callCreate(input, 'pages', input.ctx));
32
+ const site = (await callCreate(input, 'pages', input.ctx()));
44
33
  return { app: createPagesApp({ ...input.base, site }) };
45
34
  };
46
35
  const ui = async (input) => ({
47
- app: createUiApp({ ...input.base, dir: join(input.dir, 'dist', 'ui') }),
36
+ app: createUiApp({
37
+ ...input.base,
38
+ dir: join(input.dir, 'dist', 'ui'),
39
+ devIssuer: input.env['AGENTAGE_DEV_ISSUER'],
40
+ }),
48
41
  });
49
42
  const worker = async (input) => {
50
- const log = runLog(input.ctx.log);
51
- const hasStore = normalizeParts(input.pkg.agentage.parts).store === 'postgres';
52
- const runs = hasStore ? storeRuns(input.ctx.store) : memoryRuns();
43
+ const ctx = input.ctx();
44
+ const log = runLog(ctx.log);
45
+ const runs = hasPostgres(input.pkg) ? storeRuns(ctx.store) : memoryRuns();
53
46
  const scheduler = createScheduler({ runs, log });
54
- await callCreate(input, 'worker', { ...input.ctx, log, schedule: scheduler.schedule });
47
+ await callCreate(input, 'worker', { ...ctx, log, schedule: scheduler.schedule });
55
48
  const checks = { ...input.base.checks, ...scheduler.checks() };
56
49
  scheduler.start();
57
50
  return {
@@ -1,14 +1,14 @@
1
1
  import { type Server } from 'node:http';
2
+ import { type ImportModule } from '../manifest/entry.js';
2
3
  import { type ExtensionPackage } from '../manifest/manifest.schema.js';
3
4
  import { type ImagePartKind } from '../manifest/primitives.schema.js';
4
5
  import { type ServeOptions } from '../server/serve.js';
5
6
  import { type Env } from './build-info.js';
6
- export { createOf } from './part-runners.js';
7
7
  export interface RunPartOptions {
8
8
  dir: string;
9
9
  part: string;
10
10
  env: Env;
11
- importModule?: (file: string) => Promise<unknown>;
11
+ importModule?: ImportModule;
12
12
  serve?: Pick<ServeOptions, 'signals' | 'exit'>;
13
13
  }
14
14
  export declare const partOf: (pkg: ExtensionPackage, part: string) => ImagePartKind;
@@ -2,17 +2,21 @@ 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 '../health/health.js';
5
+ import {} from '../discovery/discovery.schema.js';
6
+ import { declaredScopes, partAuth } from '../auth/part-auth.js';
7
+ import { readOpenapi } from '../discovery/openapi.js';
6
8
  import { createLog } from '../log/log.js';
9
+ import {} from '../manifest/entry.js';
7
10
  import {} from '../manifest/manifest.schema.js';
8
- import { imageParts, normalizeParts } from '../manifest/parts.js';
11
+ import { hasPostgres, imageParts } from '../manifest/parts.js';
9
12
  import {} from '../manifest/primitives.schema.js';
10
13
  import { readManifest } from '../manifest/read-manifest.js';
14
+ import {} from '../server/app.js';
11
15
  import { serve } from '../server/serve.js';
12
16
  import { createStore, storeCheck } from '../store/store.js';
17
+ import {} from '../store/store.types.js';
13
18
  import { buildInfoFromEnv, portFromEnv } from './build-info.js';
14
19
  import { RUNNERS } from './part-runners.js';
15
- export { createOf } from './part-runners.js';
16
20
  const recordFor = async (env, log) => {
17
21
  const file = env['AGENTAGE_RECORD_FILE'] || DEFAULT_RECORD_FILE;
18
22
  const record = await loadRecord(file);
@@ -27,34 +31,56 @@ export const partOf = (pkg, part) => {
27
31
  return part;
28
32
  };
29
33
  // ui serves files and gets no ctx; every other part gets the store when the manifest has postgres.
30
- const storeFor = (pkg, part, env, log) => part !== 'ui' && normalizeParts(pkg.agentage.parts).store === 'postgres'
31
- ? createStore(env, log)
32
- : undefined;
33
- const inputFor = async (options, pkg, part, log) => {
34
- const { env } = options;
35
- const origin = env['AGENTAGE_ORIGIN'] || undefined;
36
- const build = buildInfoFromEnv(env);
37
- const store = storeFor(pkg, part, env, log);
38
- const settings = settingsFromEnv(pkg, env, log);
39
- const ctxOrigin = origin ?? `http://127.0.0.1:${portFromEnv(env)}`;
40
- const ctxPart = (part === 'ui' ? 'pages' : part);
41
- const ctx = createContext({
34
+ const storeFor = ({ pkg, part, env, log }) => part !== 'ui' && hasPostgres(pkg) ? createStore(env, log) : undefined;
35
+ const contextFor = (start, part, store) => createContext({
36
+ pkg: start.pkg,
37
+ part,
38
+ origin: start.env['AGENTAGE_ORIGIN'] || `http://127.0.0.1:${portFromEnv(start.env)}`,
39
+ commit: buildInfoFromEnv(start.env).commit,
40
+ log: start.log,
41
+ settings: settingsFromEnv(start.pkg, start.env, start.log),
42
+ store,
43
+ });
44
+ // Built on first use, so the ui runner, which never asks, has no ctx at all.
45
+ const lazyContext = (start, store) => {
46
+ let ctx;
47
+ return () => {
48
+ if (start.part === 'ui')
49
+ throw new Error('the ui part gets no ctx');
50
+ return (ctx ??= contextFor(start, start.part, store));
51
+ };
52
+ };
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) => {
60
+ const { pkg, env, log } = start;
61
+ const record = await recordFor(env, log);
62
+ const guard = await guardFor(start, openapi, record);
63
+ return {
42
64
  pkg,
43
- part: ctxPart,
44
- origin: ctxOrigin,
45
- commit: build.commit,
46
65
  log,
47
- settings,
48
- store,
49
- });
50
- const record = await recordFor(env, log);
51
- const checks = store ? { store: storeCheck(store) } : {};
52
- const base = { pkg, build, log, record, origin, ownImage: env['AGENTAGE_IMAGE'], checks };
66
+ record,
67
+ ...guard,
68
+ checks: { ...(store ? { store: storeCheck(store) } : {}), ...guard.checks },
69
+ build: buildInfoFromEnv(env),
70
+ origin: env['AGENTAGE_ORIGIN'] || undefined,
71
+ ownImage: env['AGENTAGE_IMAGE'],
72
+ };
73
+ };
74
+ const inputFor = async (options, start) => {
75
+ const store = storeFor(start);
76
+ const openapi = await readOpenapi(options.dir);
53
77
  return {
54
78
  dir: options.dir,
55
- pkg,
56
- ctx,
57
- base,
79
+ pkg: start.pkg,
80
+ env: start.env,
81
+ ctx: lazyContext(start, store),
82
+ base: await baseFor(start, store, openapi),
83
+ openapi,
58
84
  importModule: options.importModule,
59
85
  };
60
86
  };
@@ -62,7 +88,8 @@ export const runPart = async (options) => {
62
88
  const pkg = await readManifest(options.dir);
63
89
  const part = partOf(pkg, options.part);
64
90
  const log = createLog({ extension: pkg.agentage.id, part });
65
- const { app, stop } = await RUNNERS[part](await inputFor(options, pkg, part, log));
91
+ const start = { pkg, part, env: options.env, log };
92
+ const { app, stop } = await RUNNERS[part](await inputFor(options, start));
66
93
  if (stop)
67
94
  for (const signal of options.serve?.signals ?? ['SIGTERM', 'SIGINT'])
68
95
  process.once(signal, stop);
@@ -1,6 +1,7 @@
1
1
  import { type CorsOptions } from 'cors';
2
2
  import { type Express } from 'express';
3
3
  import { type HelmetOptions } from 'helmet';
4
+ import { type TokenCheck } from '../auth/token-check.js';
4
5
  import { type BuildInfo } from '../discovery/discovery.js';
5
6
  import { type ExtensionRecord } from '../discovery/discovery.schema.js';
6
7
  import { type HealthCheck } from '../health/health.js';
@@ -16,6 +17,8 @@ export interface PartAppBase {
16
17
  ownImage?: string;
17
18
  origin?: string;
18
19
  checks?: Record<string, HealthCheck>;
20
+ tokenCheck?: TokenCheck;
21
+ resource?: Record<string, unknown>;
19
22
  }
20
23
  export interface PartAppOptions extends PartAppBase {
21
24
  part: ImagePartKind;
@@ -2,6 +2,8 @@ import cors, {} from 'cors';
2
2
  import express, {} from 'express';
3
3
  import helmet, {} from 'helmet';
4
4
  import { agcidMiddleware } from '../agcid/scope.js';
5
+ import { routeAccess } from '../auth/route-access.js';
6
+ import { setCaller } from '../auth/token-check.js';
5
7
  import { discoveryDocument, recordOf, } from '../discovery/discovery.js';
6
8
  import {} from '../discovery/discovery.schema.js';
7
9
  import { createHealthHandler } from '../health/health.js';
@@ -43,6 +45,9 @@ export const createPartApp = (options) => {
43
45
  if (options.part !== 'worker') {
44
46
  app.use('/.well-known', cors(PUBLIC_CORS));
45
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));
46
51
  }
47
52
  options.mount(app);
48
53
  app.use(notFound);
@@ -51,6 +56,14 @@ export const createPartApp = (options) => {
51
56
  };
52
57
  // The committed OpenAPI document, bare JSON and anonymous like discovery (api-standards section 1).
53
58
  const openapiHandler = (doc) => (_req, res) => void res.json(doc);
59
+ // Before the body is read: each request checked for the access its OpenAPI operation declares.
60
+ const routeGuard = (options) => async (req, _res, next) => {
61
+ if (!options.tokenCheck)
62
+ return next();
63
+ const access = routeAccess(options.openapi, req.method, `/api/v1${req.path}`);
64
+ setCaller(req, await options.tokenCheck(req.get('authorization'), access));
65
+ next();
66
+ };
54
67
  export const createApiApp = (options) => createPartApp({
55
68
  ...options,
56
69
  part: 'api',
@@ -58,6 +71,7 @@ export const createApiApp = (options) => createPartApp({
58
71
  app.use('/api/v1', cors(PUBLIC_CORS));
59
72
  if (options.openapi)
60
73
  app.get('/api/v1/openapi.json', openapiHandler(options.openapi));
61
- app.use('/api/v1', express.json({ limit: BODY_LIMIT }), options.router);
74
+ const json = express.json({ limit: BODY_LIMIT });
75
+ app.use('/api/v1', routeGuard(options), json, options.router);
62
76
  },
63
77
  });
@@ -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
  };
@@ -3,12 +3,16 @@ import { createLog } from '../log/log.js';
3
3
  import { readManifest } from '../manifest/read-manifest.js';
4
4
  import { migrate } from './migrations.js';
5
5
  import { createStore } from './store.js';
6
+ // A migration may rewrite a table: minutes, not a request's seconds; the job itself has no retry.
7
+ const MIGRATE_TIMEOUTS = { connectMs: 10_000, statementMs: 300_000 };
6
8
  // The api image's migrate entry (`node dist/store/migrate.js` in /app), run as a job before a rollout.
7
- const log = createLog({ extension: 'unknown', part: 'migrate' });
9
+ // Until the manifest is read the id is unknown; every line after it carries the extension.
10
+ let log = createLog({ extension: 'unknown', part: 'migrate' });
8
11
  try {
9
12
  const pkg = await readManifest(process.cwd());
10
- const store = createStore(process.env, log);
11
- const result = await migrate(store, process.cwd(), createLog({ extension: pkg.agentage.id, part: 'migrate' }));
13
+ log = createLog({ extension: pkg.agentage.id, part: 'migrate' });
14
+ const store = createStore(process.env, log, MIGRATE_TIMEOUTS);
15
+ const result = await migrate(store, process.cwd(), log);
12
16
  process.stdout.write(`${JSON.stringify(result)}\n`);
13
17
  await store.end();
14
18
  }
@@ -5,6 +5,8 @@ export interface MigrateResult {
5
5
  migrations: number;
6
6
  applied: number;
7
7
  seeds: number;
8
+ seedsBefore: number;
8
9
  }
9
10
  export declare const sqlFiles: (dir: string) => Promise<string[]>;
11
+ export declare const SEEDS_OWED = "seeds:owed";
10
12
  export declare const migrate: (store: Store, dir: string, log: Log) => Promise<MigrateResult>;
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import {} from '../log/log.js';
3
+ import { messageOf } from '../log/log.js';
4
4
  import {} from './store.types.js';
5
5
  export const SQL_FILE = /^[0-9]{3}-[a-z0-9-]+\.sql$/;
6
6
  // Filename order; anything else in the folder is refused, so a typo never silently skips a file.
@@ -23,6 +23,14 @@ const LEDGER = `create table if not exists _migrations (
23
23
  name text primary key,
24
24
  applied_at timestamptz not null default now()
25
25
  )`;
26
+ const SEED = 'seed:';
27
+ // Written by the first run on an empty store, removed once the seeds ran: a failed first run still seeds later.
28
+ export const SEEDS_OWED = 'seeds:owed';
29
+ // Tables other than the kit's own: a store holding any (a restored dump) never gets the seeds.
30
+ const USER_TABLES = `select count(*)::int as n from information_schema.tables
31
+ where table_schema not in ('pg_catalog', 'information_schema')
32
+ and table_name not in ('_migrations', 'agentage_job_runs')`;
33
+ const isEmpty = async (store, done) => done.size === 0 && ((await store.query(USER_TABLES)).rows[0]?.n ?? 0) === 0;
26
34
  const appliedNames = async (store) => {
27
35
  await store.query(LEDGER);
28
36
  const { rows } = await store.query('select name from _migrations');
@@ -31,7 +39,14 @@ const appliedNames = async (store) => {
31
39
  // One string, one round trip: the file and its ledger row commit together or not at all.
32
40
  const applyFile = async (store, file, name) => {
33
41
  const sql = await readFile(file, 'utf8');
34
- await store.query(`begin;\n${sql}\n;insert into _migrations (name) values ('${name}');\ncommit;`);
42
+ try {
43
+ await store.query(`begin;\n${sql}\n;insert into _migrations (name) values ('${name}');\ncommit;`);
44
+ }
45
+ catch (err) {
46
+ await store.query('rollback').catch(() => undefined);
47
+ const kind = name.startsWith(SEED) ? 'seed' : 'migration';
48
+ throw new Error(`${kind} ${name.replace(SEED, '')} failed: ${messageOf(err)}`, { cause: err });
49
+ }
35
50
  };
36
51
  const applyAll = async (store, dir, prefix, done) => {
37
52
  let count = 0;
@@ -44,12 +59,23 @@ const applyAll = async (store, dir, prefix, done) => {
44
59
  }
45
60
  return count;
46
61
  };
47
- // Migrations in order, each once; seeds once, only when the store had no ledger row before.
62
+ const seedOnce = async (store, dir, done) => {
63
+ const seeds = await applyAll(store, join(dir, 'seeds'), SEED, done);
64
+ await store.query(`delete from _migrations where name = '${SEEDS_OWED}'`);
65
+ return seeds;
66
+ };
67
+ // Migrations in order, each once; seeds once, only on a store with no ledger row and no user table.
48
68
  export const migrate = async (store, dir, log) => {
49
69
  const done = await appliedNames(store);
70
+ const empty = await isEmpty(store, done);
71
+ if (empty)
72
+ await store.query(`insert into _migrations (name) values ('${SEEDS_OWED}')`);
73
+ const owed = empty || done.has(SEEDS_OWED);
50
74
  const migrations = (await sqlFiles(join(dir, 'migrations'))).length;
51
75
  const applied = await applyAll(store, join(dir, 'migrations'), '', done);
52
- const seeds = done.size === 0 ? await applyAll(store, join(dir, 'seeds'), 'seed:', done) : 0;
53
- log.info('migrated', { migrations, applied, seeds });
54
- return { migrations, applied, seeds };
76
+ const seeds = owed ? await seedOnce(store, dir, done) : 0;
77
+ const seedsBefore = [...done].filter((name) => name.startsWith(SEED)).length;
78
+ const result = { migrations, applied, seeds, seedsBefore };
79
+ log.info('migrated', { ...result });
80
+ return result;
55
81
  };
@@ -5,6 +5,12 @@ import { type Store } from './store.types.js';
5
5
  export interface PoolStore extends Store {
6
6
  end(): Promise<void>;
7
7
  }
8
+ export interface StoreTimeouts {
9
+ connectMs: number;
10
+ statementMs: number;
11
+ }
12
+ export declare const PART_TIMEOUTS: StoreTimeouts;
8
13
  export declare const storeUrl: (env: Env, read?: (file: string) => string) => string | undefined;
9
- export declare const createStore: (env: Env, log: Log) => PoolStore;
14
+ export declare const isOutage: (err: unknown) => boolean;
15
+ export declare const createStore: (env: Env, log: Log, timeouts?: StoreTimeouts) => PoolStore;
10
16
  export declare const storeCheck: (store: Store) => HealthCheck;
@@ -1,9 +1,13 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import pg from 'pg';
3
+ import { fail } from '../envelope/envelope.js';
3
4
  import {} from '../health/health.js';
4
- import {} from '../log/log.js';
5
+ import { messageOf } from '../log/log.js';
5
6
  import {} from '../runtime/build-info.js';
6
7
  import {} from './store.types.js';
8
+ // A part's query is bounded by the server and, if the network itself hangs, by the client too.
9
+ export const PART_TIMEOUTS = { connectMs: 5_000, statementMs: 10_000 };
10
+ const CLIENT_SLACK_MS = 2_000;
7
11
  // The host sets the connection: AGENTAGE_STORE_URL, a secret file named by _FILE, else libpq PG* env.
8
12
  export const storeUrl = (env, read = (file) => readFileSync(file, 'utf8')) => {
9
13
  const file = env['AGENTAGE_STORE_URL_FILE'];
@@ -11,13 +15,39 @@ export const storeUrl = (env, read = (file) => readFileSync(file, 'utf8')) => {
11
15
  return read(file).trim();
12
16
  return env['AGENTAGE_STORE_URL'] || undefined;
13
17
  };
14
- export const createStore = (env, log) => {
15
- const pool = new pg.Pool({ connectionString: storeUrl(env), max: 5, idleTimeoutMillis: 30_000 });
18
+ // Connection classes 08 and 57P01-03 (shutdown), 57014 (statement timeout), socket errors, pg's own timeouts.
19
+ const OUTAGE_SQLSTATE = /^(08|57P0[1-3]$|57014$)/;
20
+ const OUTAGE_SOCKET = /^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|EPIPE)$/;
21
+ const OUTAGE_MESSAGE = /timeout exceeded when trying to connect|Query read timeout|Connection terminated/;
22
+ export const isOutage = (err) => {
23
+ const code = err?.code;
24
+ if (typeof code === 'string' && (OUTAGE_SQLSTATE.test(code) || OUTAGE_SOCKET.test(code)))
25
+ return true;
26
+ return OUTAGE_MESSAGE.test(messageOf(err));
27
+ };
28
+ // A store that cannot answer is UNAVAILABLE (503, Retry-After), never an INTERNAL 500; no retry here.
29
+ const guarded = (pool, log) => async function query(text, params) {
30
+ try {
31
+ return await pool.query(text, params);
32
+ }
33
+ catch (err) {
34
+ if (!isOutage(err))
35
+ throw err;
36
+ log.warn('store unavailable', { reason: messageOf(err) });
37
+ throw fail('UNAVAILABLE', 'The store is unavailable');
38
+ }
39
+ };
40
+ export const createStore = (env, log, timeouts = PART_TIMEOUTS) => {
41
+ const pool = new pg.Pool({
42
+ connectionString: storeUrl(env),
43
+ max: 5,
44
+ idleTimeoutMillis: 30_000,
45
+ connectionTimeoutMillis: timeouts.connectMs,
46
+ statement_timeout: timeouts.statementMs,
47
+ query_timeout: timeouts.statementMs + CLIENT_SLACK_MS,
48
+ });
16
49
  pool.on('error', (err) => log.error(err, { store: 'pool' }));
17
- return {
18
- query: async (text, params) => pool.query(text, params),
19
- end: () => pool.end(),
20
- };
50
+ return { query: guarded(pool, log), end: () => pool.end() };
21
51
  };
22
52
  export const storeCheck = (store) => async () => {
23
53
  await store.query('select 1');