@graphorin/server 0.5.0 → 0.6.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/CHANGELOG.md +36 -0
- package/README.md +6 -6
- package/dist/app.d.ts +5 -5
- package/dist/app.js +3 -3
- package/dist/app.js.map +1 -1
- package/dist/commentary/audit-bridge.d.ts +2 -2
- package/dist/commentary/audit-bridge.js +3 -3
- package/dist/commentary/audit-bridge.js.map +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/commentary/sanitizer.js +2 -2
- package/dist/commentary/sanitizer.js.map +1 -1
- package/dist/commentary/types.d.ts +5 -5
- package/dist/consolidator/daemon.d.ts +1 -1
- package/dist/consolidator/daemon.js.map +1 -1
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.d.ts +2 -2
- package/dist/health/routes.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/dist/internal/ids.js +1 -1
- package/dist/internal/ids.js.map +1 -1
- package/dist/lifecycle/pre-bind.js.map +1 -1
- package/dist/metrics/catalog.js +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/registry.d.ts +2 -2
- package/dist/metrics/registry.js +1 -1
- package/dist/metrics/registry.js.map +1 -1
- package/dist/middleware/auth.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/cors.js.map +1 -1
- package/dist/middleware/csrf.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +1 -1
- package/dist/middleware/idempotency.js +36 -23
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/rate-limit.js.map +1 -1
- package/dist/middleware/request-state.js.map +1 -1
- package/dist/middleware/scope.js.map +1 -1
- package/dist/registry/index.d.ts +2 -2
- package/dist/registry/index.js +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.js +1 -1
- package/dist/replay/routes.js.map +1 -1
- package/dist/routes/agents.js +1 -1
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/audit.d.ts +1 -1
- package/dist/routes/audit.js.map +1 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.js +42 -8
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/run-state.d.ts +3 -3
- package/dist/runtime/run-state.js +3 -3
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +1 -1
- package/dist/sse/events.js.map +1 -1
- package/dist/triggers/daemon.js.map +1 -1
- package/dist/triggers/routes.js.map +1 -1
- package/dist/ws/dispatcher.d.ts.map +1 -1
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/subjects.d.ts +1 -1
- package/dist/ws/subjects.d.ts.map +1 -1
- package/dist/ws/subjects.js +3 -3
- package/dist/ws/subjects.js.map +1 -1
- package/dist/ws/ticket.d.ts +1 -1
- package/dist/ws/ticket.js +1 -1
- package/dist/ws/ticket.js.map +1 -1
- package/dist/ws/upgrade.js +6 -5
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +17 -17
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","names":["ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope>","headers: Record<string, string>","grantedScopes: ParsedScope[]"],"sources":["../../src/middleware/auth.ts"],"sourcesContent":["/**\n * Bearer-token authentication middleware. Wraps the\n * `@graphorin/security` token verifier so handlers see\n * a stable {@link import('../internal/context.js').AuthState}\n * structure on `c.var.state.auth`.\n *\n * @packageDocumentation\n */\n\nimport {\n type ParsedScope,\n parseScope,\n type TokenVerifier,\n tryParseScope,\n} from '@graphorin/security/auth';\nimport type { Context, MiddlewareHandler, Next } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * IP-13: the scope set granted to every request when `auth.kind = 'none'`.\n * `admin:*` matches every required scope (see `@graphorin/security/auth`\n * `scopeMatches`), so the trusted-loopback operator is uniformly authorized\n * without special-casing each route.\n */\nconst ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope> = Object.freeze([parseScope('admin:*')]);\n\n/**\n * Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps\n * `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the\n * scope middleware, SSE handler and replay routes all treat the request as a\n * fully-authorized principal. This is the documented trusted-loopback /\n * single-operator mode
|
|
1
|
+
{"version":3,"file":"auth.js","names":["ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope>","headers: Record<string, string>","grantedScopes: ParsedScope[]"],"sources":["../../src/middleware/auth.ts"],"sourcesContent":["/**\n * Bearer-token authentication middleware. Wraps the\n * `@graphorin/security` token verifier so handlers see\n * a stable {@link import('../internal/context.js').AuthState}\n * structure on `c.var.state.auth`.\n *\n * @packageDocumentation\n */\n\nimport {\n type ParsedScope,\n parseScope,\n type TokenVerifier,\n tryParseScope,\n} from '@graphorin/security/auth';\nimport type { Context, MiddlewareHandler, Next } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * IP-13: the scope set granted to every request when `auth.kind = 'none'`.\n * `admin:*` matches every required scope (see `@graphorin/security/auth`\n * `scopeMatches`), so the trusted-loopback operator is uniformly authorized\n * without special-casing each route.\n */\nconst ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope> = Object.freeze([parseScope('admin:*')]);\n\n/**\n * Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps\n * `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the\n * scope middleware, SSE handler and replay routes all treat the request as a\n * fully-authorized principal. This is the documented trusted-loopback /\n * single-operator mode - never mount it on a non-loopback deployment without\n * understanding that every endpoint becomes open.\n *\n * @stable\n */\nexport function createAnonymousAuthMiddleware(): MiddlewareHandler<{\n Variables: ServerVariables;\n}> {\n return async (c, next: Next) => {\n c.set('state', {\n ...c.get('state'),\n auth: { kind: 'anonymous' as const, grantedScopes: ANONYMOUS_GRANTED_SCOPES },\n });\n await next();\n };\n}\n\n/**\n * Options accepted by {@link createAuthMiddleware}. Tests inject a\n * stub verifier; production wiring uses the verifier built during the\n * server's pre-bind step.\n *\n * @stable\n */\nexport interface AuthMiddlewareOptions {\n readonly verifier: TokenVerifier;\n /**\n * Whether to allow unauthenticated requests through. Used by\n * `health` and (when explicitly opted-in) by the public read\n * endpoints. When `false` (the default), missing / malformed /\n * invalid tokens short-circuit the request with `401`.\n */\n readonly allowAnonymous?: boolean;\n}\n\nconst HEADER_NAME = 'authorization';\nconst BEARER_PREFIX = 'bearer ';\n\nfunction extractBearer(c: Context): string | undefined {\n const raw = c.req.header(HEADER_NAME);\n if (raw === undefined || raw === null) return undefined;\n const trimmed = raw.trim();\n if (trimmed.length < BEARER_PREFIX.length) return undefined;\n if (trimmed.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return undefined;\n return trimmed.slice(BEARER_PREFIX.length).trim();\n}\n\nfunction reasonToStatus(reason: string): {\n status: number;\n body: { error: string; message: string; hint?: string };\n} {\n switch (reason) {\n case 'malformed':\n return { status: 401, body: { error: 'auth-invalid', message: 'Malformed bearer token.' } };\n case 'unknown-token':\n return { status: 401, body: { error: 'auth-invalid', message: 'Unknown bearer token.' } };\n case 'revoked':\n return {\n status: 401,\n body: { error: 'auth-revoked', message: 'Bearer token has been revoked.' },\n };\n case 'expired':\n return { status: 401, body: { error: 'auth-expired', message: 'Bearer token has expired.' } };\n case 'ip-locked-out':\n return {\n status: 429,\n body: {\n error: 'auth-locked-out',\n message: 'Too many failed authentications from this address.',\n hint: 'Wait for the lockout window to elapse before retrying.',\n },\n };\n case 'token-locked-out':\n return {\n status: 429,\n body: {\n error: 'auth-locked-out',\n message: 'Bearer token is locked out after repeated failures.',\n hint: 'Operator must rotate the token.',\n },\n };\n default:\n return {\n status: 401,\n body: { error: 'auth-invalid', message: `Authentication failed: ${reason}.` },\n };\n }\n}\n\n/**\n * Build the bearer-token middleware. The middleware always sets\n * `c.var.state.auth`, even on the unauthenticated branch, so\n * downstream code can pattern-match the discriminated union without\n * a separate \"is anonymous?\" check.\n *\n * @stable\n */\nexport function createAuthMiddleware(\n options: AuthMiddlewareOptions,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const allowAnonymous = options.allowAnonymous ?? false;\n return async (c, next: Next) => {\n const token = extractBearer(c);\n // IP-11: the request-state middleware is the ONLY IP authority -\n // it resolves the client IP per the configured trustProxy policy\n // (proxy headers when trusted, else the socket address). The old\n // fallback read attacker-controlled X-Real-IP unconditionally,\n // letting header rotation evade per-IP lockout and a spoofed\n // victim IP trigger a lockout against them.\n const ip = c.get('state').clientIp;\n if (token === undefined) {\n if (allowAnonymous) {\n c.set('state', { ...c.get('state'), auth: { kind: 'unauthenticated' as const } });\n await next();\n return;\n }\n return c.json(\n {\n error: 'auth-required',\n message: 'Bearer token required.',\n hint: \"Set 'Authorization: Bearer <token>'.\",\n },\n 401,\n );\n }\n const result = await options.verifier.verify(token, ip !== undefined ? { ip } : {});\n if (!result.ok) {\n const mapped = reasonToStatus(result.reason);\n const headers: Record<string, string> = {};\n if (result.retryAfterMs !== undefined) {\n headers['Retry-After'] = Math.ceil(result.retryAfterMs / 1000).toString();\n }\n return c.json(mapped.body, mapped.status as 401 | 429, headers);\n }\n const grantedScopes: ParsedScope[] = [];\n for (const scope of result.token.scopes) {\n grantedScopes.push(scope);\n }\n // Defensive: any string scopes coming from the store also parse here\n // even though the verifier itself parses them - see scope.ts.\n void tryParseScope;\n c.set('state', {\n ...c.get('state'),\n auth: {\n kind: 'token' as const,\n token: result.token,\n grantedScopes: Object.freeze(grantedScopes),\n },\n });\n // Mirror the verified token onto the documented `c.var.token`\n // slot so route handlers can read it without unwrapping the auth\n // discriminator. The shape is the one called out in the Phase\n // 14a spec: { id, label, scopes, env, expiresAt? }.\n c.set('token', {\n id: result.token.tokenId,\n label: result.token.label,\n scopes: Object.freeze(grantedScopes.slice()),\n env: result.token.env,\n expiresAt: result.token.expiresAt,\n });\n await next();\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cors.js","names":[],"sources":["../../src/middleware/cors.ts"],"sourcesContent":["/**\n * Deny-by-default CORS middleware. The framework refuses to echo back\n * arbitrary origins; allowlist semantics match the threat-model\n * checklist in the runtime architecture.\n *\n * @packageDocumentation\n */\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * @stable\n */\nexport function createCorsMiddleware(\n config: ServerConfigSpec['server']['cors'],\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const allowSet = new Set(config.allowOrigins);\n const allowAny = allowSet.has('*');\n const allowMethods = config.allowMethods.join(', ');\n const allowHeaders = config.allowHeaders.join(', ');\n const maxAge = config.maxAgeSeconds.toString();\n\n return async (c, next) => {\n const origin = c.req.header('origin');\n const isPreflight =\n c.req.method === 'OPTIONS' && c.req.header('access-control-request-method') !== undefined;\n if (origin === undefined) {\n // Same-origin request
|
|
1
|
+
{"version":3,"file":"cors.js","names":[],"sources":["../../src/middleware/cors.ts"],"sourcesContent":["/**\n * Deny-by-default CORS middleware. The framework refuses to echo back\n * arbitrary origins; allowlist semantics match the threat-model\n * checklist in the runtime architecture.\n *\n * @packageDocumentation\n */\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * @stable\n */\nexport function createCorsMiddleware(\n config: ServerConfigSpec['server']['cors'],\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const allowSet = new Set(config.allowOrigins);\n const allowAny = allowSet.has('*');\n const allowMethods = config.allowMethods.join(', ');\n const allowHeaders = config.allowHeaders.join(', ');\n const maxAge = config.maxAgeSeconds.toString();\n\n return async (c, next) => {\n const origin = c.req.header('origin');\n const isPreflight =\n c.req.method === 'OPTIONS' && c.req.header('access-control-request-method') !== undefined;\n if (origin === undefined) {\n // Same-origin request - proceed without CORS headers.\n if (isPreflight) {\n return c.body(null, 204);\n }\n await next();\n return;\n }\n const allowed = allowAny || allowSet.has(origin);\n if (!allowed) {\n if (isPreflight) {\n return c.body(null, 204);\n }\n // Pass the request through but withhold the CORS allow header so\n // the browser blocks it; do NOT short-circuit the request - the\n // server-to-server flow must still receive a normal response.\n await next();\n return;\n }\n\n if (allowAny && config.allowCredentials) {\n // Specs forbid `Access-Control-Allow-Origin: *` with credentials;\n // mirror the request origin instead.\n c.header('Access-Control-Allow-Origin', origin);\n c.header('Vary', 'Origin');\n } else if (allowAny) {\n c.header('Access-Control-Allow-Origin', '*');\n } else {\n c.header('Access-Control-Allow-Origin', origin);\n c.header('Vary', 'Origin');\n }\n if (config.allowCredentials) {\n c.header('Access-Control-Allow-Credentials', 'true');\n }\n if (isPreflight) {\n c.header('Access-Control-Allow-Methods', allowMethods);\n c.header('Access-Control-Allow-Headers', allowHeaders);\n c.header('Access-Control-Max-Age', maxAge);\n return c.body(null, 204);\n }\n await next();\n };\n}\n"],"mappings":";;;;AAgBA,SAAgB,qBACd,QACmD;CACnD,MAAM,WAAW,IAAI,IAAI,OAAO,aAAa;CAC7C,MAAM,WAAW,SAAS,IAAI,IAAI;CAClC,MAAM,eAAe,OAAO,aAAa,KAAK,KAAK;CACnD,MAAM,eAAe,OAAO,aAAa,KAAK,KAAK;CACnD,MAAM,SAAS,OAAO,cAAc,UAAU;AAE9C,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,SAAS,EAAE,IAAI,OAAO,SAAS;EACrC,MAAM,cACJ,EAAE,IAAI,WAAW,aAAa,EAAE,IAAI,OAAO,gCAAgC,KAAK;AAClF,MAAI,WAAW,QAAW;AAExB,OAAI,YACF,QAAO,EAAE,KAAK,MAAM,IAAI;AAE1B,SAAM,MAAM;AACZ;;AAGF,MAAI,EADY,YAAY,SAAS,IAAI,OAAO,GAClC;AACZ,OAAI,YACF,QAAO,EAAE,KAAK,MAAM,IAAI;AAK1B,SAAM,MAAM;AACZ;;AAGF,MAAI,YAAY,OAAO,kBAAkB;AAGvC,KAAE,OAAO,+BAA+B,OAAO;AAC/C,KAAE,OAAO,QAAQ,SAAS;aACjB,SACT,GAAE,OAAO,+BAA+B,IAAI;OACvC;AACL,KAAE,OAAO,+BAA+B,OAAO;AAC/C,KAAE,OAAO,QAAQ,SAAS;;AAE5B,MAAI,OAAO,iBACT,GAAE,OAAO,oCAAoC,OAAO;AAEtD,MAAI,aAAa;AACf,KAAE,OAAO,gCAAgC,aAAa;AACtD,KAAE,OAAO,gCAAgC,aAAa;AACtD,KAAE,OAAO,0BAA0B,OAAO;AAC1C,UAAO,EAAE,KAAK,MAAM,IAAI;;AAE1B,QAAM,MAAM"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"csrf.js","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["/**\n * Double-submit CSRF middleware. Targets browser flows: when a\n * request carries a session-cookie style credential we require the\n * client to echo the value of the `graphorin_csrf` cookie back in\n * the `X-CSRF-Token` header. Bearer-token (Authorization header)\n * requests are exempt because they are not vulnerable to the\n * confused-deputy attack the cookie pattern guards against.\n *\n * @packageDocumentation\n */\n\nimport { Buffer } from 'node:buffer';\nimport { randomBytes, timingSafeEqual } from 'node:crypto';\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * @stable\n */\nexport function createCsrfMiddleware(\n config: ServerConfigSpec['server']['csrf'],\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n if (!config.enabled) {\n return async (_, next) => {\n await next();\n };\n }\n const safeMethods = new Set(config.safeMethods.map((m) => m.toUpperCase()));\n\n return async (c, next) => {\n const method = c.req.method.toUpperCase();\n const cookieHeader = c.req.header('cookie');\n const cookies = parseCookies(cookieHeader);\n const cookieValue = cookies.get(config.cookieName);\n const authHeader = c.req.header('authorization');\n const isBearer = authHeader !== undefined && /^bearer\\s+/i.test(authHeader);\n\n if (safeMethods.has(method) || isBearer) {\n // Always issue a fresh CSRF cookie on browser flows so SPAs\n // can read it on first load. Skip the cookie issuance for\n // bearer-only callers
|
|
1
|
+
{"version":3,"file":"csrf.js","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["/**\n * Double-submit CSRF middleware. Targets browser flows: when a\n * request carries a session-cookie style credential we require the\n * client to echo the value of the `graphorin_csrf` cookie back in\n * the `X-CSRF-Token` header. Bearer-token (Authorization header)\n * requests are exempt because they are not vulnerable to the\n * confused-deputy attack the cookie pattern guards against.\n *\n * @packageDocumentation\n */\n\nimport { Buffer } from 'node:buffer';\nimport { randomBytes, timingSafeEqual } from 'node:crypto';\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * @stable\n */\nexport function createCsrfMiddleware(\n config: ServerConfigSpec['server']['csrf'],\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n if (!config.enabled) {\n return async (_, next) => {\n await next();\n };\n }\n const safeMethods = new Set(config.safeMethods.map((m) => m.toUpperCase()));\n\n return async (c, next) => {\n const method = c.req.method.toUpperCase();\n const cookieHeader = c.req.header('cookie');\n const cookies = parseCookies(cookieHeader);\n const cookieValue = cookies.get(config.cookieName);\n const authHeader = c.req.header('authorization');\n const isBearer = authHeader !== undefined && /^bearer\\s+/i.test(authHeader);\n\n if (safeMethods.has(method) || isBearer) {\n // Always issue a fresh CSRF cookie on browser flows so SPAs\n // can read it on first load. Skip the cookie issuance for\n // bearer-only callers - they don't need it.\n if (!isBearer && cookieValue === undefined) {\n const token = randomBytes(24).toString('base64url');\n // IP-12: the CSRF token must be readable by the SPA for the\n // double-submit echo, so the cookie carries NO HttpOnly attribute -\n // RFC 6265 enables HttpOnly on its mere presence, even `HttpOnly=false`.\n c.header('Set-Cookie', `${config.cookieName}=${token}; Path=/; SameSite=Strict; Secure`);\n }\n await next();\n return;\n }\n\n if (cookieValue === undefined) {\n return c.json(\n { error: 'csrf-denied', message: 'CSRF cookie missing on state-changing request.' },\n 403,\n );\n }\n const headerValue = c.req.header(config.headerName);\n if (headerValue === undefined) {\n return c.json(\n { error: 'csrf-denied', message: `CSRF header '${config.headerName}' missing.` },\n 403,\n );\n }\n if (!constantTimeEqual(cookieValue, headerValue)) {\n return c.json({ error: 'csrf-denied', message: 'CSRF token mismatch.' }, 403);\n }\n await next();\n };\n}\n\nfunction parseCookies(header: string | undefined): Map<string, string> {\n const out = new Map<string, string>();\n if (header === undefined) return out;\n const parts = header.split(';');\n for (const part of parts) {\n const trimmed = part.trim();\n if (trimmed.length === 0) continue;\n const eq = trimmed.indexOf('=');\n if (eq < 0) continue;\n const name = trimmed.slice(0, eq).trim();\n const value = trimmed.slice(eq + 1).trim();\n out.set(name, value);\n }\n return out;\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n const aBuf = Buffer.from(a, 'utf8');\n const bBuf = Buffer.from(b, 'utf8');\n if (aBuf.length !== bBuf.length) return false;\n return timingSafeEqual(aBuf, bBuf);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsBA,SAAgB,qBACd,QACmD;AACnD,KAAI,CAAC,OAAO,QACV,QAAO,OAAO,GAAG,SAAS;AACxB,QAAM,MAAM;;CAGhB,MAAM,cAAc,IAAI,IAAI,OAAO,YAAY,KAAK,MAAM,EAAE,aAAa,CAAC,CAAC;AAE3E,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,SAAS,EAAE,IAAI,OAAO,aAAa;EAGzC,MAAM,cADU,aADK,EAAE,IAAI,OAAO,SAAS,CACD,CACd,IAAI,OAAO,WAAW;EAClD,MAAM,aAAa,EAAE,IAAI,OAAO,gBAAgB;EAChD,MAAM,WAAW,eAAe,UAAa,cAAc,KAAK,WAAW;AAE3E,MAAI,YAAY,IAAI,OAAO,IAAI,UAAU;AAIvC,OAAI,CAAC,YAAY,gBAAgB,QAAW;IAC1C,MAAM,QAAQ,YAAY,GAAG,CAAC,SAAS,YAAY;AAInD,MAAE,OAAO,cAAc,GAAG,OAAO,WAAW,GAAG,MAAM,mCAAmC;;AAE1F,SAAM,MAAM;AACZ;;AAGF,MAAI,gBAAgB,OAClB,QAAO,EAAE,KACP;GAAE,OAAO;GAAe,SAAS;GAAkD,EACnF,IACD;EAEH,MAAM,cAAc,EAAE,IAAI,OAAO,OAAO,WAAW;AACnD,MAAI,gBAAgB,OAClB,QAAO,EAAE,KACP;GAAE,OAAO;GAAe,SAAS,gBAAgB,OAAO,WAAW;GAAa,EAChF,IACD;AAEH,MAAI,CAAC,kBAAkB,aAAa,YAAY,CAC9C,QAAO,EAAE,KAAK;GAAE,OAAO;GAAe,SAAS;GAAwB,EAAE,IAAI;AAE/E,QAAM,MAAM;;;AAIhB,SAAS,aAAa,QAAiD;CACrE,MAAM,sBAAM,IAAI,KAAqB;AACrC,KAAI,WAAW,OAAW,QAAO;CACjC,MAAM,QAAQ,OAAO,MAAM,IAAI;AAC/B,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,UAAU,KAAK,MAAM;AAC3B,MAAI,QAAQ,WAAW,EAAG;EAC1B,MAAM,KAAK,QAAQ,QAAQ,IAAI;AAC/B,MAAI,KAAK,EAAG;EACZ,MAAM,OAAO,QAAQ,MAAM,GAAG,GAAG,CAAC,MAAM;EACxC,MAAM,QAAQ,QAAQ,MAAM,KAAK,EAAE,CAAC,MAAM;AAC1C,MAAI,IAAI,MAAM,MAAM;;AAEtB,QAAO;;AAGT,SAAS,kBAAkB,GAAW,GAAoB;AACxD,KAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;CAClC,MAAM,OAAO,OAAO,KAAK,GAAG,OAAO;CACnC,MAAM,OAAO,OAAO,KAAK,GAAG,OAAO;AACnC,KAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AACxC,QAAO,gBAAgB,MAAM,KAAK"}
|
|
@@ -13,7 +13,7 @@ import { MiddlewareHandler } from "hono";
|
|
|
13
13
|
*/
|
|
14
14
|
interface IdempotencyMiddlewareOptions {
|
|
15
15
|
/**
|
|
16
|
-
* Paths whose responses are NEVER cached or replayed (IP-6)
|
|
16
|
+
* Paths whose responses are NEVER cached or replayed (IP-6) - the
|
|
17
17
|
* middleware passes straight through. Used for secret-bearing
|
|
18
18
|
* endpoints (`POST /v1/tokens` returns a raw token; caching it would
|
|
19
19
|
* persist the secret plaintext in durable SQLite for the TTL).
|
|
@@ -29,6 +29,7 @@ function createIdempotencyMiddleware(options) {
|
|
|
29
29
|
};
|
|
30
30
|
const now = options.now ?? Date.now;
|
|
31
31
|
const lru = new SimpleLru(config.lruCacheSize);
|
|
32
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
32
33
|
const excluded = new Set(options.excludeResponseCachePaths ?? []);
|
|
33
34
|
const registry = options.metricRegistry;
|
|
34
35
|
let hits = 0;
|
|
@@ -110,30 +111,42 @@ function createIdempotencyMiddleware(options) {
|
|
|
110
111
|
lru.delete(key);
|
|
111
112
|
await store.delete(key);
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
key,
|
|
122
|
-
requestHash: fingerprint,
|
|
123
|
-
statusCode: status,
|
|
124
|
-
response: responseBody,
|
|
125
|
-
...responseHeaders !== void 0 ? { responseHeaders } : {},
|
|
126
|
-
scope: principal,
|
|
127
|
-
createdAt: now(),
|
|
128
|
-
expiresAt: now() + config.ttlSeconds * 1e3
|
|
129
|
-
};
|
|
114
|
+
if (inFlight.has(key)) {
|
|
115
|
+
c.header("Retry-After", "1");
|
|
116
|
+
return c.json({
|
|
117
|
+
error: "idempotency-in-flight",
|
|
118
|
+
message: `A request with Idempotency-Key '${key}' is currently being processed.`
|
|
119
|
+
}, 409);
|
|
120
|
+
}
|
|
121
|
+
inFlight.add(key);
|
|
130
122
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
123
|
+
recordCacheOutcome(false);
|
|
124
|
+
await next();
|
|
125
|
+
const status = c.res.status;
|
|
126
|
+
if (NON_CACHEABLE_STATUS.has(status)) return;
|
|
127
|
+
const responseBody = await captureJsonResponse(c);
|
|
128
|
+
if (responseBody === null) return;
|
|
129
|
+
const responseHeaders = collectResponseHeaders(c);
|
|
130
|
+
const record_ = {
|
|
131
|
+
key,
|
|
132
|
+
requestHash: fingerprint,
|
|
133
|
+
statusCode: status,
|
|
134
|
+
response: responseBody,
|
|
135
|
+
...responseHeaders !== void 0 ? { responseHeaders } : {},
|
|
136
|
+
scope: principal,
|
|
137
|
+
createdAt: now(),
|
|
138
|
+
expiresAt: now() + config.ttlSeconds * 1e3
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
await store.put(record_);
|
|
142
|
+
lru.set(key, {
|
|
143
|
+
record: record_,
|
|
144
|
+
cachedAt: now()
|
|
145
|
+
});
|
|
146
|
+
} catch {}
|
|
147
|
+
} finally {
|
|
148
|
+
inFlight.delete(key);
|
|
149
|
+
}
|
|
137
150
|
};
|
|
138
151
|
}
|
|
139
152
|
async function computeFingerprint(c) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"idempotency.js","names":["record: IdempotencyRecord | null","record_: IdempotencyRecord","body: unknown","out: Record<string, string>","#capacity","#map"],"sources":["../../src/middleware/idempotency.ts"],"sourcesContent":["/**\n * REST `Idempotency-Key` middleware per IETF\n * draft-ietf-httpapi-idempotency-key-header-07. Layers an LRU\n * read-cache over a durable {@link IdempotencyStore} so:\n *\n * - Replays of a successful request return the cached response body\n * AND the original status code, with a synthetic\n * `Idempotency-Replayed: true` header so clients can distinguish\n * the cached path.\n * - Replays of a key with a different request body return `409\n * Conflict` and a typed error body (per ADR-036 / DEC-142).\n * - 5xx + 408 + 429 + 503 responses are NOT cached so transient\n * errors do not pin the operator into a non-recoverable state.\n *\n * Streaming endpoints (`/v1/agents/:id/stream`,\n * `/v1/workflows/:id/execute` when invoked with the `stream=true`\n * query) cache only the initial 202 + `runId` envelope; the actual\n * event stream is replayed via the WebSocket layer (Phase 14b).\n *\n * @packageDocumentation\n */\n\nimport type { IdempotencyRecord, IdempotencyStore } from '@graphorin/store-sqlite';\nimport type { Context, MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { fingerprintRequest } from '../internal/json.js';\nimport { SERVER_METRIC_NAMES } from '../metrics/catalog.js';\nimport type { MetricRegistry } from '../metrics/registry.js';\n\nconst HEADER_NAME = 'idempotency-key';\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);\n// Status codes the IETF draft + Stripe convention exclude from\n// idempotent caching (always retry-eligible).\nconst NON_CACHEABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);\nconst KEY_RE = /^[A-Za-z0-9_\\-:]{8,255}$/;\n\n/**\n * Options accepted by {@link createIdempotencyMiddleware}.\n *\n * @stable\n */\nexport interface IdempotencyMiddlewareOptions {\n /**\n * Paths whose responses are NEVER cached or replayed (IP-6) — the\n * middleware passes straight through. Used for secret-bearing\n * endpoints (`POST /v1/tokens` returns a raw token; caching it would\n * persist the secret plaintext in durable SQLite for the TTL).\n */\n readonly excludeResponseCachePaths?: ReadonlyArray<string>;\n readonly store: IdempotencyStore;\n readonly config: ServerConfigSpec['server']['idempotency'];\n /** Wall-clock provider; tests inject a deterministic generator. */\n readonly now?: () => number;\n /**\n * IP-15: when supplied, the middleware publishes a live\n * `graphorin_idempotency_cache_hit_ratio` gauge (replays / replays+executes)\n * instead of leaving the registered metric a permanently-empty series.\n */\n readonly metricRegistry?: MetricRegistry;\n}\n\ninterface CacheEntry {\n readonly record: IdempotencyRecord;\n readonly cachedAt: number;\n}\n\n/**\n * @stable\n */\nexport function createIdempotencyMiddleware(\n options: IdempotencyMiddlewareOptions,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const { store, config } = options;\n if (!config.enabled) {\n return async (_, next) => {\n await next();\n };\n }\n const now = options.now ?? Date.now;\n const lru = new SimpleLru<string, CacheEntry>(config.lruCacheSize);\n\n const excluded = new Set(options.excludeResponseCachePaths ?? []);\n\n // IP-15: publish the cache hit ratio as a live gauge. A \"hit\" is a replay\n // served from a stored record; a \"miss\" is a keyed request that executed\n // fresh. Conflicts (409) are abnormal and excluded from the ratio.\n const registry = options.metricRegistry;\n let hits = 0;\n let lookups = 0;\n const recordCacheOutcome = (hit: boolean): void => {\n if (registry === undefined) return;\n lookups += 1;\n if (hit) hits += 1;\n registry.set(SERVER_METRIC_NAMES.idempotencyCacheHitRatio, hits / lookups);\n };\n\n return async (c, next) => {\n if (SAFE_METHODS.has(c.req.method.toUpperCase())) {\n await next();\n return;\n }\n // IP-6: secret-bearing endpoints opt out of response caching\n // entirely — repeats re-execute and no body is ever persisted.\n if (excluded.has(c.req.path)) {\n await next();\n return;\n }\n const key = c.req.header(HEADER_NAME)?.trim();\n if (key === undefined || key.length === 0) {\n if (config.requireKey === 'enforce') {\n return c.json(\n {\n error: 'idempotency-key-required',\n message: 'Idempotency-Key header is required for this endpoint.',\n hint: \"Set 'Idempotency-Key: <uuid>' on the request.\",\n },\n 400,\n );\n }\n // 'warn' / 'off' — pass-through with a hint header.\n if (config.requireKey === 'warn') {\n c.header('Idempotency-Status', 'header-missing');\n }\n await next();\n return;\n }\n if (!KEY_RE.test(key)) {\n return c.json(\n {\n error: 'idempotency-key-required',\n message: 'Idempotency-Key must be 8-255 chars of [A-Za-z0-9_:-].',\n },\n 400,\n );\n }\n c.set('state', { ...c.get('state'), idempotencyKey: key });\n const fingerprint = await computeFingerprint(c);\n const cached = lru.get(key);\n let record: IdempotencyRecord | null;\n if (cached !== undefined && now() - cached.cachedAt < 5 * 60 * 1000) {\n record = cached.record;\n } else {\n record = await store.get(key);\n if (record !== null) {\n lru.set(key, { record, cachedAt: now() });\n }\n }\n // IP-6: the verified principal the record is bound to. Replays are\n // served only to the SAME principal that originally executed the\n // request (who passed the route's scope checks at execute time) —\n // a different token cannot fetch someone else's cached response,\n // closing the scope-bypass the pre-router mount opened.\n const auth = c.get('state').auth;\n const principal = auth.kind === 'token' ? auth.token.tokenId : 'anonymous';\n if (record !== null) {\n if (record.scope !== undefined && record.scope !== principal) {\n return c.json(\n {\n error: 'idempotency-conflict',\n message: `Idempotency-Key '${key}' is bound to a different principal.`,\n },\n 409,\n );\n }\n if (config.checkBodyFingerprint && record.requestHash !== fingerprint) {\n return c.json(\n {\n error: 'idempotency-conflict',\n message: `Idempotency-Key '${key}' was previously used with a different request body.`,\n },\n 409,\n );\n }\n if (record.expiresAt > now()) {\n c.set('state', { ...c.get('state'), idempotencyReplay: true });\n const headers = record.responseHeaders ?? {};\n for (const [name, value] of Object.entries(headers)) {\n c.header(name, value);\n }\n c.header('Idempotency-Replayed', 'true');\n recordCacheOutcome(true);\n return new Response(JSON.stringify(record.response), {\n status: record.statusCode,\n headers: {\n ...headers,\n 'Content-Type': 'application/json',\n 'Idempotency-Replayed': 'true',\n },\n });\n }\n // Expired — fall through to re-execute.\n lru.delete(key);\n await store.delete(key);\n }\n\n // A keyed request that reaches execution is a cache miss.\n recordCacheOutcome(false);\n await next();\n\n const status = c.res.status;\n if (NON_CACHEABLE_STATUS.has(status)) {\n // Retry-eligible — leave nothing in the cache.\n return;\n }\n const responseBody = await captureJsonResponse(c);\n if (responseBody === null) {\n // Non-JSON or empty body — skip caching to keep the schema\n // simple; clients that need response replay can use bodied\n // POSTs.\n return;\n }\n const responseHeaders = collectResponseHeaders(c);\n const record_: IdempotencyRecord = {\n key,\n requestHash: fingerprint,\n statusCode: status,\n response: responseBody,\n ...(responseHeaders !== undefined ? { responseHeaders } : {}),\n // IP-6: bind the record to the executing principal.\n scope: principal,\n createdAt: now(),\n expiresAt: now() + config.ttlSeconds * 1000,\n };\n try {\n await store.put(record_);\n lru.set(key, { record: record_, cachedAt: now() });\n } catch {\n // Best-effort cache. Persistence failures must not break the\n // hot path — operators see them via the audit log + logger.\n }\n };\n}\n\nasync function computeFingerprint(c: Context<{ Variables: ServerVariables }>): Promise<string> {\n let body: unknown = null;\n const ct = c.req.header('content-type');\n if (ct?.toLowerCase().includes('application/json')) {\n try {\n body = await c.req.json();\n } catch {\n body = await c.req.text();\n }\n } else {\n body = await c.req.text();\n }\n return fingerprintRequest(c.req.method, c.req.path, body);\n}\n\nasync function captureJsonResponse(\n c: Context<{ Variables: ServerVariables }>,\n): Promise<unknown | null> {\n if (c.res === undefined) return null;\n const contentType = c.res.headers.get('content-type') ?? '';\n if (!contentType.toLowerCase().includes('application/json')) return null;\n try {\n const cloned = c.res.clone();\n const text = await cloned.text();\n if (text.length === 0) return null;\n return JSON.parse(text);\n } catch {\n return null;\n }\n}\n\nfunction collectResponseHeaders(\n c: Context<{ Variables: ServerVariables }>,\n): Readonly<Record<string, string>> | undefined {\n if (c.res === undefined) return undefined;\n const out: Record<string, string> = {};\n c.res.headers.forEach((value, name) => {\n if (name.toLowerCase() === 'content-length') return;\n out[name] = value;\n });\n return Object.keys(out).length === 0 ? undefined : Object.freeze(out);\n}\n\nclass SimpleLru<K, V> {\n readonly #capacity: number;\n readonly #map: Map<K, V> = new Map();\n\n constructor(capacity: number) {\n this.#capacity = Math.max(1, capacity);\n }\n\n get(key: K): V | undefined {\n const value = this.#map.get(key);\n if (value === undefined) return undefined;\n this.#map.delete(key);\n this.#map.set(key, value);\n return value;\n }\n\n set(key: K, value: V): void {\n if (this.#map.has(key)) this.#map.delete(key);\n this.#map.set(key, value);\n while (this.#map.size > this.#capacity) {\n const oldestKey = this.#map.keys().next().value as K | undefined;\n if (oldestKey === undefined) break;\n this.#map.delete(oldestKey);\n }\n }\n\n delete(key: K): boolean {\n return this.#map.delete(key);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"idempotency.js","names":["record: IdempotencyRecord | null","record_: IdempotencyRecord","body: unknown","out: Record<string, string>","#capacity","#map"],"sources":["../../src/middleware/idempotency.ts"],"sourcesContent":["/**\n * REST `Idempotency-Key` middleware per IETF\n * draft-ietf-httpapi-idempotency-key-header-07. Layers an LRU\n * read-cache over a durable {@link IdempotencyStore} so:\n *\n * - Replays of a successful request return the cached response body\n * AND the original status code, with a synthetic\n * `Idempotency-Replayed: true` header so clients can distinguish\n * the cached path.\n * - Replays of a key with a different request body return `409\n * Conflict` and a typed error body (per ADR-036 / DEC-142).\n * - 5xx + 408 + 429 + 503 responses are NOT cached so transient\n * errors do not pin the operator into a non-recoverable state.\n *\n * Streaming endpoints (`/v1/agents/:id/stream`,\n * `/v1/workflows/:id/execute` when invoked with the `stream=true`\n * query) cache only the initial 202 + `runId` envelope; the actual\n * event stream is replayed via the WebSocket layer (Phase 14b).\n *\n * @packageDocumentation\n */\n\nimport type { IdempotencyRecord, IdempotencyStore } from '@graphorin/store-sqlite';\nimport type { Context, MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { fingerprintRequest } from '../internal/json.js';\nimport { SERVER_METRIC_NAMES } from '../metrics/catalog.js';\nimport type { MetricRegistry } from '../metrics/registry.js';\n\nconst HEADER_NAME = 'idempotency-key';\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);\n// Status codes the IETF draft + Stripe convention exclude from\n// idempotent caching (always retry-eligible).\nconst NON_CACHEABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);\nconst KEY_RE = /^[A-Za-z0-9_\\-:]{8,255}$/;\n\n/**\n * Options accepted by {@link createIdempotencyMiddleware}.\n *\n * @stable\n */\nexport interface IdempotencyMiddlewareOptions {\n /**\n * Paths whose responses are NEVER cached or replayed (IP-6) - the\n * middleware passes straight through. Used for secret-bearing\n * endpoints (`POST /v1/tokens` returns a raw token; caching it would\n * persist the secret plaintext in durable SQLite for the TTL).\n */\n readonly excludeResponseCachePaths?: ReadonlyArray<string>;\n readonly store: IdempotencyStore;\n readonly config: ServerConfigSpec['server']['idempotency'];\n /** Wall-clock provider; tests inject a deterministic generator. */\n readonly now?: () => number;\n /**\n * IP-15: when supplied, the middleware publishes a live\n * `graphorin_idempotency_cache_hit_ratio` gauge (replays / replays+executes)\n * instead of leaving the registered metric a permanently-empty series.\n */\n readonly metricRegistry?: MetricRegistry;\n}\n\ninterface CacheEntry {\n readonly record: IdempotencyRecord;\n readonly cachedAt: number;\n}\n\n/**\n * @stable\n */\nexport function createIdempotencyMiddleware(\n options: IdempotencyMiddlewareOptions,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const { store, config } = options;\n if (!config.enabled) {\n return async (_, next) => {\n await next();\n };\n }\n const now = options.now ?? Date.now;\n const lru = new SimpleLru<string, CacheEntry>(config.lruCacheSize);\n\n // periphery-08: keys whose execution is currently in flight - a\n // concurrent duplicate is rejected (409) instead of double-executing.\n const inFlight = new Set<string>();\n\n const excluded = new Set(options.excludeResponseCachePaths ?? []);\n\n // IP-15: publish the cache hit ratio as a live gauge. A \"hit\" is a replay\n // served from a stored record; a \"miss\" is a keyed request that executed\n // fresh. Conflicts (409) are abnormal and excluded from the ratio.\n const registry = options.metricRegistry;\n let hits = 0;\n let lookups = 0;\n const recordCacheOutcome = (hit: boolean): void => {\n if (registry === undefined) return;\n lookups += 1;\n if (hit) hits += 1;\n registry.set(SERVER_METRIC_NAMES.idempotencyCacheHitRatio, hits / lookups);\n };\n\n return async (c, next) => {\n if (SAFE_METHODS.has(c.req.method.toUpperCase())) {\n await next();\n return;\n }\n // IP-6: secret-bearing endpoints opt out of response caching\n // entirely - repeats re-execute and no body is ever persisted.\n if (excluded.has(c.req.path)) {\n await next();\n return;\n }\n const key = c.req.header(HEADER_NAME)?.trim();\n if (key === undefined || key.length === 0) {\n if (config.requireKey === 'enforce') {\n return c.json(\n {\n error: 'idempotency-key-required',\n message: 'Idempotency-Key header is required for this endpoint.',\n hint: \"Set 'Idempotency-Key: <uuid>' on the request.\",\n },\n 400,\n );\n }\n // 'warn' / 'off' - pass-through with a hint header.\n if (config.requireKey === 'warn') {\n c.header('Idempotency-Status', 'header-missing');\n }\n await next();\n return;\n }\n if (!KEY_RE.test(key)) {\n return c.json(\n {\n error: 'idempotency-key-required',\n message: 'Idempotency-Key must be 8-255 chars of [A-Za-z0-9_:-].',\n },\n 400,\n );\n }\n c.set('state', { ...c.get('state'), idempotencyKey: key });\n const fingerprint = await computeFingerprint(c);\n const cached = lru.get(key);\n let record: IdempotencyRecord | null;\n if (cached !== undefined && now() - cached.cachedAt < 5 * 60 * 1000) {\n record = cached.record;\n } else {\n record = await store.get(key);\n if (record !== null) {\n lru.set(key, { record, cachedAt: now() });\n }\n }\n // IP-6: the verified principal the record is bound to. Replays are\n // served only to the SAME principal that originally executed the\n // request (who passed the route's scope checks at execute time) -\n // a different token cannot fetch someone else's cached response,\n // closing the scope-bypass the pre-router mount opened.\n const auth = c.get('state').auth;\n const principal = auth.kind === 'token' ? auth.token.tokenId : 'anonymous';\n if (record !== null) {\n if (record.scope !== undefined && record.scope !== principal) {\n return c.json(\n {\n error: 'idempotency-conflict',\n message: `Idempotency-Key '${key}' is bound to a different principal.`,\n },\n 409,\n );\n }\n if (config.checkBodyFingerprint && record.requestHash !== fingerprint) {\n return c.json(\n {\n error: 'idempotency-conflict',\n message: `Idempotency-Key '${key}' was previously used with a different request body.`,\n },\n 409,\n );\n }\n if (record.expiresAt > now()) {\n c.set('state', { ...c.get('state'), idempotencyReplay: true });\n const headers = record.responseHeaders ?? {};\n for (const [name, value] of Object.entries(headers)) {\n c.header(name, value);\n }\n c.header('Idempotency-Replayed', 'true');\n recordCacheOutcome(true);\n return new Response(JSON.stringify(record.response), {\n status: record.statusCode,\n headers: {\n ...headers,\n 'Content-Type': 'application/json',\n 'Idempotency-Replayed': 'true',\n },\n });\n }\n // Expired - fall through to re-execute.\n lru.delete(key);\n await store.delete(key);\n }\n\n // periphery-08: the record is only written AFTER next(), so two\n // concurrent requests with the same key would both miss the cache\n // and both execute (a double agent run). Track in-flight keyed\n // executions in-process (the server is single-process by design)\n // and reject concurrent duplicates per\n // draft-ietf-httpapi-idempotency-key-header: 409 + Retry-After.\n // The key stays in-flight until the record is CACHED (or found\n // uncacheable), so a duplicate never slips in between execution\n // and the write.\n if (inFlight.has(key)) {\n c.header('Retry-After', '1');\n return c.json(\n {\n error: 'idempotency-in-flight',\n message: `A request with Idempotency-Key '${key}' is currently being processed.`,\n },\n 409,\n );\n }\n inFlight.add(key);\n try {\n // A keyed request that reaches execution is a cache miss.\n recordCacheOutcome(false);\n await next();\n\n const status = c.res.status;\n if (NON_CACHEABLE_STATUS.has(status)) {\n // Retry-eligible - leave nothing in the cache.\n return;\n }\n const responseBody = await captureJsonResponse(c);\n if (responseBody === null) {\n // Non-JSON or empty body - skip caching to keep the schema\n // simple; clients that need response replay can use bodied\n // POSTs.\n return;\n }\n const responseHeaders = collectResponseHeaders(c);\n const record_: IdempotencyRecord = {\n key,\n requestHash: fingerprint,\n statusCode: status,\n response: responseBody,\n ...(responseHeaders !== undefined ? { responseHeaders } : {}),\n // IP-6: bind the record to the executing principal.\n scope: principal,\n createdAt: now(),\n expiresAt: now() + config.ttlSeconds * 1000,\n };\n try {\n await store.put(record_);\n lru.set(key, { record: record_, cachedAt: now() });\n } catch {\n // Best-effort cache. Persistence failures must not break the\n // hot path - operators see them via the audit log + logger.\n }\n } finally {\n inFlight.delete(key);\n }\n };\n}\n\nasync function computeFingerprint(c: Context<{ Variables: ServerVariables }>): Promise<string> {\n let body: unknown = null;\n const ct = c.req.header('content-type');\n if (ct?.toLowerCase().includes('application/json')) {\n try {\n body = await c.req.json();\n } catch {\n body = await c.req.text();\n }\n } else {\n body = await c.req.text();\n }\n return fingerprintRequest(c.req.method, c.req.path, body);\n}\n\nasync function captureJsonResponse(\n c: Context<{ Variables: ServerVariables }>,\n): Promise<unknown | null> {\n if (c.res === undefined) return null;\n const contentType = c.res.headers.get('content-type') ?? '';\n if (!contentType.toLowerCase().includes('application/json')) return null;\n try {\n const cloned = c.res.clone();\n const text = await cloned.text();\n if (text.length === 0) return null;\n return JSON.parse(text);\n } catch {\n return null;\n }\n}\n\nfunction collectResponseHeaders(\n c: Context<{ Variables: ServerVariables }>,\n): Readonly<Record<string, string>> | undefined {\n if (c.res === undefined) return undefined;\n const out: Record<string, string> = {};\n c.res.headers.forEach((value, name) => {\n if (name.toLowerCase() === 'content-length') return;\n out[name] = value;\n });\n return Object.keys(out).length === 0 ? undefined : Object.freeze(out);\n}\n\nclass SimpleLru<K, V> {\n readonly #capacity: number;\n readonly #map: Map<K, V> = new Map();\n\n constructor(capacity: number) {\n this.#capacity = Math.max(1, capacity);\n }\n\n get(key: K): V | undefined {\n const value = this.#map.get(key);\n if (value === undefined) return undefined;\n this.#map.delete(key);\n this.#map.set(key, value);\n return value;\n }\n\n set(key: K, value: V): void {\n if (this.#map.has(key)) this.#map.delete(key);\n this.#map.set(key, value);\n while (this.#map.size > this.#capacity) {\n const oldestKey = this.#map.keys().next().value as K | undefined;\n if (oldestKey === undefined) break;\n this.#map.delete(oldestKey);\n }\n }\n\n delete(key: K): boolean {\n return this.#map.delete(key);\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.js","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["/**\n * Per-IP sliding-window rate limit. Defends against brute-force\n * authentication probes and naive DoS scenarios. The limit is\n * deliberately conservative
|
|
1
|
+
{"version":3,"file":"rate-limit.js","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["/**\n * Per-IP sliding-window rate limit. Defends against brute-force\n * authentication probes and naive DoS scenarios. The limit is\n * deliberately conservative - operators with multi-tenant traffic\n * should layer a dedicated reverse proxy on top.\n *\n * @packageDocumentation\n */\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerConfigSpec } from '../config.js';\nimport type { ServerVariables } from '../internal/context.js';\n\ninterface Window {\n count: number;\n start: number;\n}\n\n/**\n * @stable\n */\nexport function createRateLimitMiddleware(\n config: ServerConfigSpec['server']['rateLimit'],\n options: { readonly now?: () => number } = {},\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n if (!config.enabled) {\n return async (_, next) => {\n await next();\n };\n }\n const windows = new Map<string, Window>();\n const now = options.now ?? Date.now;\n // IP-10: bound the window map - expired entries are swept once the\n // map crosses the cap so an attacker rotating spoofed XFF values\n // (trustProxy=true deployments) cannot grow it without bound.\n const SWEEP_THRESHOLD = 10_000;\n const sweep = (ts: number): void => {\n if (windows.size < SWEEP_THRESHOLD) return;\n for (const [key, win] of windows) {\n if (ts - win.start >= config.windowMs) windows.delete(key);\n }\n };\n\n return async (c, next) => {\n const ip = c.get('state').clientIp ?? 'anonymous';\n const ts = now();\n sweep(ts);\n const window = windows.get(ip) ?? { count: 0, start: ts };\n if (ts - window.start >= config.windowMs) {\n window.start = ts;\n window.count = 0;\n }\n window.count += 1;\n windows.set(ip, window);\n const remaining = Math.max(0, config.perIpRequests - window.count);\n c.header('X-RateLimit-Limit', config.perIpRequests.toString());\n c.header('X-RateLimit-Remaining', remaining.toString());\n c.header(\n 'X-RateLimit-Reset',\n Math.max(0, Math.ceil((window.start + config.windowMs - ts) / 1000)).toString(),\n );\n if (window.count > config.perIpRequests) {\n const retryAfter = Math.max(0, Math.ceil((window.start + config.windowMs - ts) / 1000));\n return c.json({ error: 'rate-limit-exceeded', message: 'Too many requests.' }, 429, {\n 'Retry-After': retryAfter.toString(),\n });\n }\n await next();\n };\n}\n"],"mappings":";;;;AAsBA,SAAgB,0BACd,QACA,UAA2C,EAAE,EACM;AACnD,KAAI,CAAC,OAAO,QACV,QAAO,OAAO,GAAG,SAAS;AACxB,QAAM,MAAM;;CAGhB,MAAM,0BAAU,IAAI,KAAqB;CACzC,MAAM,MAAM,QAAQ,OAAO,KAAK;CAIhC,MAAM,kBAAkB;CACxB,MAAM,SAAS,OAAqB;AAClC,MAAI,QAAQ,OAAO,gBAAiB;AACpC,OAAK,MAAM,CAAC,KAAK,QAAQ,QACvB,KAAI,KAAK,IAAI,SAAS,OAAO,SAAU,SAAQ,OAAO,IAAI;;AAI9D,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,KAAK,EAAE,IAAI,QAAQ,CAAC,YAAY;EACtC,MAAM,KAAK,KAAK;AAChB,QAAM,GAAG;EACT,MAAM,SAAS,QAAQ,IAAI,GAAG,IAAI;GAAE,OAAO;GAAG,OAAO;GAAI;AACzD,MAAI,KAAK,OAAO,SAAS,OAAO,UAAU;AACxC,UAAO,QAAQ;AACf,UAAO,QAAQ;;AAEjB,SAAO,SAAS;AAChB,UAAQ,IAAI,IAAI,OAAO;EACvB,MAAM,YAAY,KAAK,IAAI,GAAG,OAAO,gBAAgB,OAAO,MAAM;AAClE,IAAE,OAAO,qBAAqB,OAAO,cAAc,UAAU,CAAC;AAC9D,IAAE,OAAO,yBAAyB,UAAU,UAAU,CAAC;AACvD,IAAE,OACA,qBACA,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,QAAQ,OAAO,WAAW,MAAM,IAAK,CAAC,CAAC,UAAU,CAChF;AACD,MAAI,OAAO,QAAQ,OAAO,eAAe;GACvC,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,QAAQ,OAAO,WAAW,MAAM,IAAK,CAAC;AACvF,UAAO,EAAE,KAAK;IAAE,OAAO;IAAuB,SAAS;IAAsB,EAAE,KAAK,EAClF,eAAe,WAAW,UAAU,EACrC,CAAC;;AAEJ,QAAM,MAAM"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-state.js","names":["state: ServerRequestState"],"sources":["../../src/middleware/request-state.ts"],"sourcesContent":["/**\n * Bootstraps the per-request `c.var.state` slot. Every server-owned\n * middleware downstream reads from this object; defaults are kept on\n * a single line per field so the structure is auditable at a glance.\n *\n * @packageDocumentation\n */\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerRequestState, ServerVariables } from '../internal/context.js';\nimport { newRequestId } from '../internal/ids.js';\n\n/**\n * @stable\n */\nexport interface RequestStateMiddlewareOptions {\n /** Override the wall clock; tests inject a fixed value. */\n readonly now?: () => number;\n /** Override the request id generator. */\n readonly newRequestId?: () => string;\n /** Trust `X-Forwarded-For`/`X-Real-IP` for the client IP. Default false. */\n readonly trustProxy?: boolean;\n /** Echo the request id back to the client. Default `'X-Request-Id'`. */\n readonly responseHeader?: string | false;\n}\n\nfunction clientIp(\n headerLookup: (name: string) => string | undefined,\n trustProxy: boolean,\n): string | undefined {\n if (trustProxy) {\n const xff = headerLookup('x-forwarded-for');\n if (xff !== undefined && xff.length > 0) {\n const [first] = xff.split(',');\n if (first !== undefined) return first.trim();\n }\n const xri = headerLookup('x-real-ip');\n if (xri !== undefined && xri.length > 0) return xri.trim();\n }\n return undefined;\n}\n\n/**\n * @stable\n */\nexport function createRequestStateMiddleware(\n options: RequestStateMiddlewareOptions = {},\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const now = options.now ?? Date.now;\n const idFactory = options.newRequestId ?? newRequestId;\n const trustProxy = options.trustProxy ?? false;\n const responseHeader =\n options.responseHeader === undefined ? 'X-Request-Id' : options.responseHeader;\n\n return async (c, next) => {\n const headerLookup = (name: string): string | undefined => c.req.header(name);\n const requestId = headerLookup('x-request-id')?.trim() || idFactory();\n // IP-10: with the default trustProxy=false the proxy headers are\n // ignored
|
|
1
|
+
{"version":3,"file":"request-state.js","names":["state: ServerRequestState"],"sources":["../../src/middleware/request-state.ts"],"sourcesContent":["/**\n * Bootstraps the per-request `c.var.state` slot. Every server-owned\n * middleware downstream reads from this object; defaults are kept on\n * a single line per field so the structure is auditable at a glance.\n *\n * @packageDocumentation\n */\n\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerRequestState, ServerVariables } from '../internal/context.js';\nimport { newRequestId } from '../internal/ids.js';\n\n/**\n * @stable\n */\nexport interface RequestStateMiddlewareOptions {\n /** Override the wall clock; tests inject a fixed value. */\n readonly now?: () => number;\n /** Override the request id generator. */\n readonly newRequestId?: () => string;\n /** Trust `X-Forwarded-For`/`X-Real-IP` for the client IP. Default false. */\n readonly trustProxy?: boolean;\n /** Echo the request id back to the client. Default `'X-Request-Id'`. */\n readonly responseHeader?: string | false;\n}\n\nfunction clientIp(\n headerLookup: (name: string) => string | undefined,\n trustProxy: boolean,\n): string | undefined {\n if (trustProxy) {\n const xff = headerLookup('x-forwarded-for');\n if (xff !== undefined && xff.length > 0) {\n const [first] = xff.split(',');\n if (first !== undefined) return first.trim();\n }\n const xri = headerLookup('x-real-ip');\n if (xri !== undefined && xri.length > 0) return xri.trim();\n }\n return undefined;\n}\n\n/**\n * @stable\n */\nexport function createRequestStateMiddleware(\n options: RequestStateMiddlewareOptions = {},\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const now = options.now ?? Date.now;\n const idFactory = options.newRequestId ?? newRequestId;\n const trustProxy = options.trustProxy ?? false;\n const responseHeader =\n options.responseHeader === undefined ? 'X-Request-Id' : options.responseHeader;\n\n return async (c, next) => {\n const headerLookup = (name: string): string | undefined => c.req.header(name);\n const requestId = headerLookup('x-request-id')?.trim() || idFactory();\n // IP-10: with the default trustProxy=false the proxy headers are\n // ignored - the client IP comes from the SOCKET, so per-IP rate\n // limits and lockouts actually key per client instead of dumping\n // every request into one shared 'anonymous' bucket.\n const socketAddress = (\n c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined\n )?.incoming?.socket?.remoteAddress;\n const ip = clientIp(headerLookup, trustProxy) ?? socketAddress;\n const state: ServerRequestState = {\n requestId,\n receivedAt: now(),\n clientIp: ip,\n auth: { kind: 'unauthenticated' },\n };\n c.set('state', state);\n if (responseHeader !== false) {\n c.header(responseHeader, requestId);\n }\n await next();\n };\n}\n"],"mappings":";;;AA2BA,SAAS,SACP,cACA,YACoB;AACpB,KAAI,YAAY;EACd,MAAM,MAAM,aAAa,kBAAkB;AAC3C,MAAI,QAAQ,UAAa,IAAI,SAAS,GAAG;GACvC,MAAM,CAAC,SAAS,IAAI,MAAM,IAAI;AAC9B,OAAI,UAAU,OAAW,QAAO,MAAM,MAAM;;EAE9C,MAAM,MAAM,aAAa,YAAY;AACrC,MAAI,QAAQ,UAAa,IAAI,SAAS,EAAG,QAAO,IAAI,MAAM;;;;;;AAQ9D,SAAgB,6BACd,UAAyC,EAAE,EACQ;CACnD,MAAM,MAAM,QAAQ,OAAO,KAAK;CAChC,MAAM,YAAY,QAAQ,gBAAgB;CAC1C,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,iBACJ,QAAQ,mBAAmB,SAAY,iBAAiB,QAAQ;AAElE,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,gBAAgB,SAAqC,EAAE,IAAI,OAAO,KAAK;EAC7E,MAAM,YAAY,aAAa,eAAe,EAAE,MAAM,IAAI,WAAW;EAKrE,MAAM,gBACJ,EAAE,KACD,UAAU,QAAQ;EACrB,MAAM,KAAK,SAAS,cAAc,WAAW,IAAI;EACjD,MAAMA,QAA4B;GAChC;GACA,YAAY,KAAK;GACjB,UAAU;GACV,MAAM,EAAE,MAAM,mBAAmB;GAClC;AACD,IAAE,IAAI,SAAS,MAAM;AACrB,MAAI,mBAAmB,MACrB,GAAE,OAAO,gBAAgB,UAAU;AAErC,QAAM,MAAM"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope.js","names":[],"sources":["../../src/middleware/scope.ts"],"sourcesContent":["/**\n * Per-route scope enforcement. Reads the verified token populated by\n * the auth middleware and short-circuits with `403` when the granted\n * set does not match the required scope.\n *\n * @packageDocumentation\n */\n\nimport { type ParsedScope, parseScope, scopeMatches } from '@graphorin/security/auth';\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * Required-scope spec accepted by {@link createScopeMiddleware}. Either\n * a single string (`'agents:invoke'`), a parsed scope, or a function\n * that derives the required scope from the request (e.g. to insert the\n * `:id` segment lazily).\n *\n * @stable\n */\nexport type ScopeRequirement =\n | string\n | ParsedScope\n | ((path: string, params: Record<string, string>) => string | ParsedScope);\n\n/**\n * @stable\n */\nexport function createScopeMiddleware(\n requirement: ScopeRequirement,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c, next) => {\n const auth = c.get('state').auth;\n if (auth.kind === 'anonymous') {\n // IP-13: auth is disabled server-wide (auth.kind='none'). There is no\n // token to scope-check
|
|
1
|
+
{"version":3,"file":"scope.js","names":[],"sources":["../../src/middleware/scope.ts"],"sourcesContent":["/**\n * Per-route scope enforcement. Reads the verified token populated by\n * the auth middleware and short-circuits with `403` when the granted\n * set does not match the required scope.\n *\n * @packageDocumentation\n */\n\nimport { type ParsedScope, parseScope, scopeMatches } from '@graphorin/security/auth';\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * Required-scope spec accepted by {@link createScopeMiddleware}. Either\n * a single string (`'agents:invoke'`), a parsed scope, or a function\n * that derives the required scope from the request (e.g. to insert the\n * `:id` segment lazily).\n *\n * @stable\n */\nexport type ScopeRequirement =\n | string\n | ParsedScope\n | ((path: string, params: Record<string, string>) => string | ParsedScope);\n\n/**\n * @stable\n */\nexport function createScopeMiddleware(\n requirement: ScopeRequirement,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c, next) => {\n const auth = c.get('state').auth;\n if (auth.kind === 'anonymous') {\n // IP-13: auth is disabled server-wide (auth.kind='none'). There is no\n // token to scope-check - the trusted-loopback operator is allowed\n // through every gate.\n await next();\n return;\n }\n if (auth.kind === 'unauthenticated') {\n return c.json(\n {\n error: 'auth-required',\n message: 'Authentication required for this endpoint.',\n },\n 401,\n );\n }\n const required = resolveRequirement(\n requirement,\n c.req.path,\n c.req.param() as Record<string, string>,\n );\n const granted = auth.grantedScopes;\n let allowed = false;\n for (const scope of granted) {\n if (scopeMatches(scope, required)) {\n allowed = true;\n break;\n }\n }\n if (!allowed) {\n return c.json(\n {\n error: 'scope-denied',\n message: `Token lacks required scope '${required.raw}'.`,\n hint: `Mint a token with the '${required.raw}' (or admin:*) scope.`,\n },\n 403,\n );\n }\n await next();\n };\n}\n\nfunction resolveRequirement(\n requirement: ScopeRequirement,\n path: string,\n params: Record<string, string>,\n): ParsedScope {\n if (typeof requirement === 'function') {\n const next = requirement(path, params);\n return typeof next === 'string' ? parseScope(next) : next;\n }\n return typeof requirement === 'string' ? parseScope(requirement) : requirement;\n}\n"],"mappings":";;;;;;;;;;;;;AA6BA,SAAgB,sBACd,aACmD;AACnD,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,OAAO,EAAE,IAAI,QAAQ,CAAC;AAC5B,MAAI,KAAK,SAAS,aAAa;AAI7B,SAAM,MAAM;AACZ;;AAEF,MAAI,KAAK,SAAS,kBAChB,QAAO,EAAE,KACP;GACE,OAAO;GACP,SAAS;GACV,EACD,IACD;EAEH,MAAM,WAAW,mBACf,aACA,EAAE,IAAI,MACN,EAAE,IAAI,OAAO,CACd;EACD,MAAM,UAAU,KAAK;EACrB,IAAI,UAAU;AACd,OAAK,MAAM,SAAS,QAClB,KAAI,aAAa,OAAO,SAAS,EAAE;AACjC,aAAU;AACV;;AAGJ,MAAI,CAAC,QACH,QAAO,EAAE,KACP;GACE,OAAO;GACP,SAAS,+BAA+B,SAAS,IAAI;GACrD,MAAM,0BAA0B,SAAS,IAAI;GAC9C,EACD,IACD;AAEH,QAAM,MAAM;;;AAIhB,SAAS,mBACP,aACA,MACA,QACa;AACb,KAAI,OAAO,gBAAgB,YAAY;EACrC,MAAM,OAAO,YAAY,MAAM,OAAO;AACtC,SAAO,OAAO,SAAS,WAAW,WAAW,KAAK,GAAG;;AAEvD,QAAO,OAAO,gBAAgB,WAAW,WAAW,YAAY,GAAG"}
|
package/dist/registry/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* server bindings without taking a hard peer dependency on every
|
|
6
6
|
* sibling package.
|
|
7
7
|
*
|
|
8
|
-
* Every entry is keyed by string id; lookups never throw
|
|
8
|
+
* Every entry is keyed by string id; lookups never throw - callers
|
|
9
9
|
* receive `undefined` and the route handler decides how to surface
|
|
10
10
|
* the miss (typically a 404 with a typed error body).
|
|
11
11
|
*
|
|
@@ -101,7 +101,7 @@ interface WorkflowRegistration {
|
|
|
101
101
|
* Read/write registry for agents the server should expose. Every
|
|
102
102
|
* mutation is synchronous and the lookup is `O(1)`.
|
|
103
103
|
*
|
|
104
|
-
* The class is intentionally tiny
|
|
104
|
+
* The class is intentionally tiny - extension points (e.g. lazy
|
|
105
105
|
* factory loading, per-tenant scoping) live in higher-level packages
|
|
106
106
|
* and consume this surface as a primitive.
|
|
107
107
|
*
|
package/dist/registry/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Read/write registry for agents the server should expose. Every
|
|
4
4
|
* mutation is synchronous and the lookup is `O(1)`.
|
|
5
5
|
*
|
|
6
|
-
* The class is intentionally tiny
|
|
6
|
+
* The class is intentionally tiny - extension points (e.g. lazy
|
|
7
7
|
* factory loading, per-tenant scoping) live in higher-level packages
|
|
8
8
|
* and consume this surface as a primitive.
|
|
9
9
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["#entries"],"sources":["../../src/registry/index.ts"],"sourcesContent":["/**\n * Registry plumbing that lets the server route handlers locate user-\n * defined agents, workflows, sessions, memory, skills, and MCP\n * server bindings without taking a hard peer dependency on every\n * sibling package.\n *\n * Every entry is keyed by string id; lookups never throw
|
|
1
|
+
{"version":3,"file":"index.js","names":["#entries"],"sources":["../../src/registry/index.ts"],"sourcesContent":["/**\n * Registry plumbing that lets the server route handlers locate user-\n * defined agents, workflows, sessions, memory, skills, and MCP\n * server bindings without taking a hard peer dependency on every\n * sibling package.\n *\n * Every entry is keyed by string id; lookups never throw - callers\n * receive `undefined` and the route handler decides how to surface\n * the miss (typically a 404 with a typed error body).\n *\n * @packageDocumentation\n */\n\n/**\n * Minimal shape the server needs from an `Agent`. Compatible with\n * the `Agent` interface from `@graphorin/agent` but kept\n * structurally so we avoid the peer dependency.\n *\n * @stable\n */\nexport interface ServerAgentLike {\n readonly id: string;\n run(\n input: unknown,\n options?: {\n readonly signal?: AbortSignal;\n readonly sessionId?: string;\n readonly userId?: string;\n },\n ): Promise<unknown>;\n /**\n * Streaming surface (IP-2). `@graphorin/agent` agents satisfy this\n * structurally; `POST /agents/:id/stream` consumes it and emits\n * every event onto the run's WS subject. Optional so plain\n * run-only fixtures keep working (they emit a single terminal frame).\n */\n stream?(\n input: unknown,\n options?: {\n readonly signal?: AbortSignal;\n readonly sessionId?: string;\n readonly userId?: string;\n },\n ): AsyncIterable<unknown>;\n}\n\n/**\n * Minimal shape the server needs from a `Workflow`. Mirrors the\n * `Workflow` surface from `@graphorin/workflow`.\n *\n * @stable\n */\nexport interface ServerWorkflowLike {\n readonly name: string;\n execute(\n input: unknown,\n options?: { readonly signal?: AbortSignal; readonly threadId?: string },\n ): AsyncIterable<unknown>;\n resume?(threadId: string, directive?: { readonly resume?: unknown }): AsyncIterable<unknown>;\n getState?(threadId: string): Promise<unknown>;\n listCheckpoints?(threadId: string): Promise<ReadonlyArray<unknown>>;\n}\n\n/**\n * Snapshot record returned by {@link AgentRegistry.list}.\n *\n * @stable\n */\nexport interface AgentSummary {\n readonly id: string;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * Snapshot record returned by {@link WorkflowRegistry.list}.\n *\n * @stable\n */\nexport interface WorkflowSummary {\n readonly id: string;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * Internal registry entry - pairs the user-supplied object with the\n * metadata routes serve to clients.\n *\n * @internal\n */\ninterface AgentEntry {\n readonly id: string;\n readonly agent: ServerAgentLike;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * @internal\n */\ninterface WorkflowEntry {\n readonly id: string;\n readonly workflow: ServerWorkflowLike;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * Registration descriptor accepted by {@link AgentRegistry.register}.\n *\n * @stable\n */\nexport interface AgentRegistration {\n readonly id: string;\n readonly agent: ServerAgentLike;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * Registration descriptor accepted by {@link WorkflowRegistry.register}.\n *\n * @stable\n */\nexport interface WorkflowRegistration {\n readonly id: string;\n readonly workflow: ServerWorkflowLike;\n readonly description?: string;\n readonly tags?: ReadonlyArray<string>;\n}\n\n/**\n * Read/write registry for agents the server should expose. Every\n * mutation is synchronous and the lookup is `O(1)`.\n *\n * The class is intentionally tiny - extension points (e.g. lazy\n * factory loading, per-tenant scoping) live in higher-level packages\n * and consume this surface as a primitive.\n *\n * @stable\n */\nexport class AgentRegistry {\n readonly #entries: Map<string, AgentEntry> = new Map();\n\n register(entry: AgentRegistration): void {\n this.#entries.set(entry.id, {\n id: entry.id,\n agent: entry.agent,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: Object.freeze(entry.tags.slice()) } : {}),\n });\n }\n\n unregister(id: string): boolean {\n return this.#entries.delete(id);\n }\n\n get(id: string): ServerAgentLike | undefined {\n return this.#entries.get(id)?.agent;\n }\n\n has(id: string): boolean {\n return this.#entries.has(id);\n }\n\n list(): ReadonlyArray<AgentSummary> {\n return Object.freeze(\n [...this.#entries.values()].map((entry) =>\n Object.freeze({\n id: entry.id,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: entry.tags } : {}),\n }),\n ),\n );\n }\n\n describe(id: string): AgentSummary | undefined {\n const entry = this.#entries.get(id);\n if (entry === undefined) return undefined;\n return Object.freeze({\n id: entry.id,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: entry.tags } : {}),\n });\n }\n\n size(): number {\n return this.#entries.size;\n }\n}\n\n/**\n * @stable\n */\nexport class WorkflowRegistry {\n readonly #entries: Map<string, WorkflowEntry> = new Map();\n\n register(entry: WorkflowRegistration): void {\n this.#entries.set(entry.id, {\n id: entry.id,\n workflow: entry.workflow,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: Object.freeze(entry.tags.slice()) } : {}),\n });\n }\n\n unregister(id: string): boolean {\n return this.#entries.delete(id);\n }\n\n get(id: string): ServerWorkflowLike | undefined {\n return this.#entries.get(id)?.workflow;\n }\n\n has(id: string): boolean {\n return this.#entries.has(id);\n }\n\n list(): ReadonlyArray<WorkflowSummary> {\n return Object.freeze(\n [...this.#entries.values()].map((entry) =>\n Object.freeze({\n id: entry.id,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: entry.tags } : {}),\n }),\n ),\n );\n }\n\n describe(id: string): WorkflowSummary | undefined {\n const entry = this.#entries.get(id);\n if (entry === undefined) return undefined;\n return Object.freeze({\n id: entry.id,\n ...(entry.description !== undefined ? { description: entry.description } : {}),\n ...(entry.tags !== undefined ? { tags: entry.tags } : {}),\n });\n }\n\n size(): number {\n return this.#entries.size;\n }\n}\n"],"mappings":";;;;;;;;;;;AA8IA,IAAa,gBAAb,MAA2B;CACzB,CAASA,0BAAoC,IAAI,KAAK;CAEtD,SAAS,OAAgC;AACvC,QAAKA,QAAS,IAAI,MAAM,IAAI;GAC1B,IAAI,MAAM;GACV,OAAO,MAAM;GACb,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,OAAO,OAAO,MAAM,KAAK,OAAO,CAAC,EAAE,GAAG,EAAE;GAChF,CAAC;;CAGJ,WAAW,IAAqB;AAC9B,SAAO,MAAKA,QAAS,OAAO,GAAG;;CAGjC,IAAI,IAAyC;AAC3C,SAAO,MAAKA,QAAS,IAAI,GAAG,EAAE;;CAGhC,IAAI,IAAqB;AACvB,SAAO,MAAKA,QAAS,IAAI,GAAG;;CAG9B,OAAoC;AAClC,SAAO,OAAO,OACZ,CAAC,GAAG,MAAKA,QAAS,QAAQ,CAAC,CAAC,KAAK,UAC/B,OAAO,OAAO;GACZ,IAAI,MAAM;GACV,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,MAAM,GAAG,EAAE;GACzD,CAAC,CACH,CACF;;CAGH,SAAS,IAAsC;EAC7C,MAAM,QAAQ,MAAKA,QAAS,IAAI,GAAG;AACnC,MAAI,UAAU,OAAW,QAAO;AAChC,SAAO,OAAO,OAAO;GACnB,IAAI,MAAM;GACV,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,MAAM,GAAG,EAAE;GACzD,CAAC;;CAGJ,OAAe;AACb,SAAO,MAAKA,QAAS;;;;;;AAOzB,IAAa,mBAAb,MAA8B;CAC5B,CAASA,0BAAuC,IAAI,KAAK;CAEzD,SAAS,OAAmC;AAC1C,QAAKA,QAAS,IAAI,MAAM,IAAI;GAC1B,IAAI,MAAM;GACV,UAAU,MAAM;GAChB,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,OAAO,OAAO,MAAM,KAAK,OAAO,CAAC,EAAE,GAAG,EAAE;GAChF,CAAC;;CAGJ,WAAW,IAAqB;AAC9B,SAAO,MAAKA,QAAS,OAAO,GAAG;;CAGjC,IAAI,IAA4C;AAC9C,SAAO,MAAKA,QAAS,IAAI,GAAG,EAAE;;CAGhC,IAAI,IAAqB;AACvB,SAAO,MAAKA,QAAS,IAAI,GAAG;;CAG9B,OAAuC;AACrC,SAAO,OAAO,OACZ,CAAC,GAAG,MAAKA,QAAS,QAAQ,CAAC,CAAC,KAAK,UAC/B,OAAO,OAAO;GACZ,IAAI,MAAM;GACV,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,MAAM,GAAG,EAAE;GACzD,CAAC,CACH,CACF;;CAGH,SAAS,IAAyC;EAChD,MAAM,QAAQ,MAAKA,QAAS,IAAI,GAAG;AACnC,MAAI,UAAU,OAAW,QAAO;AAChC,SAAO,OAAO,OAAO;GACnB,IAAI,MAAM;GACV,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG,EAAE;GAC7E,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,MAAM,GAAG,EAAE;GACzD,CAAC;;CAGJ,OAAe;AACb,SAAO,MAAKA,QAAS"}
|
package/dist/replay/routes.js
CHANGED
|
@@ -18,7 +18,7 @@ import { parseScope, scopeMatches } from "@graphorin/security/auth";
|
|
|
18
18
|
* entry. The audit write is best-effort: a missing `AuditDb` does not
|
|
19
19
|
* block the replay.
|
|
20
20
|
*
|
|
21
|
-
* The actual replay stream is consumer-supplied
|
|
21
|
+
* The actual replay stream is consumer-supplied - the framework
|
|
22
22
|
* accepts a `ReplayApi` from the operator that knows how to load
|
|
23
23
|
* trace events for a `runId` / `sessionId`. Phase 14c only owns the
|
|
24
24
|
* scope check + audit + transport.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.js","names":["SCOPE_SANITIZED: ParsedScope","SCOPE_RAW: ParsedScope","mode: ReplayMode"],"sources":["../../src/replay/routes.ts"],"sourcesContent":["/**\n * Replay endpoints with scope enforcement.\n *\n * POST /v1/runs/:runId/replay\n * POST /v1/sessions/:id/replay\n *\n * Scope rules:\n * - `raw: false` (default) → require `traces:read:sanitized`.\n * - `raw: true` → require `traces:read:raw` (admin).\n *\n * Every successful invocation appends a `trace.replay.accessed` audit\n * entry. The audit write is best-effort: a missing `AuditDb` does not\n * block the replay.\n *\n * The actual replay stream is consumer-supplied — the framework\n * accepts a `ReplayApi` from the operator that knows how to load\n * trace events for a `runId` / `sessionId`. Phase 14c only owns the\n * scope check + audit + transport.\n *\n * @packageDocumentation\n */\n\nimport { type AuditDb, appendAudit } from '@graphorin/security/audit';\nimport { type ParsedScope, parseScope, scopeMatches } from '@graphorin/security/auth';\nimport { Hono } from 'hono';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\nimport { z } from 'zod';\n\nimport type { AuthState, ServerVariables } from '../internal/context.js';\n\n/** @stable */\nexport type ReplayMode = 'sanitized' | 'raw';\n\n/** @stable */\nexport interface ReplayApi {\n loadRunReplay(input: {\n readonly runId: string;\n readonly mode: ReplayMode;\n readonly fromMessageId?: string;\n readonly provider?: string;\n }): Promise<ReplayResponse>;\n loadSessionReplay(input: {\n readonly sessionId: string;\n readonly mode: ReplayMode;\n readonly fromMessageId?: string;\n readonly provider?: string;\n }): Promise<ReplayResponse>;\n}\n\n/** @stable */\nexport interface ReplayResponse {\n readonly events: ReadonlyArray<unknown>;\n readonly truncated?: boolean;\n readonly nextCursor?: string;\n}\n\nconst SCOPE_SANITIZED: ParsedScope = parseScope('traces:read:sanitized');\nconst SCOPE_RAW: ParsedScope = parseScope('traces:read:raw');\n\nconst ReplayRequestSchema = z\n .object({\n raw: z.boolean().optional(),\n fromMessageId: z.string().min(1).optional(),\n provider: z.string().min(1).optional(),\n })\n .strict()\n .default({});\n\n/**\n * @stable\n */\nexport interface ReplayRoutesDeps {\n readonly replay: ReplayApi;\n readonly auditDb?: AuditDb;\n readonly now?: () => number;\n}\n\n/**\n * @stable\n */\nexport function createReplayRoutes(deps: ReplayRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n const now = deps.now ?? Date.now;\n\n app.post('/runs/:runId/replay', async (c) => {\n const runId = c.req.param('runId');\n const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));\n if (!body.success) {\n return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);\n }\n const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';\n const auth = c.get('state').auth;\n const scopeError = enforceReplayScope(auth, mode);\n if (scopeError !== undefined) {\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:skipped',\n target: `run:${runId}`,\n decision: 'denied',\n ts: now(),\n metadata: { mode, reason: scopeError.code },\n });\n return c.json(scopeError.body, scopeError.status);\n }\n const replay = await deps.replay.loadRunReplay({\n runId,\n mode,\n ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),\n ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),\n });\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:accessed',\n target: `run:${runId}`,\n decision: 'success',\n ts: now(),\n metadata: { mode, eventCount: replay.events.length },\n });\n return c.json(serializeReplay(replay, mode));\n });\n\n app.post('/sessions/:id/replay', async (c) => {\n const sessionId = c.req.param('id');\n const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));\n if (!body.success) {\n return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);\n }\n const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';\n const auth = c.get('state').auth;\n const scopeError = enforceReplayScope(auth, mode);\n if (scopeError !== undefined) {\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:skipped',\n target: `session:${sessionId}`,\n decision: 'denied',\n ts: now(),\n metadata: { mode, reason: scopeError.code },\n });\n return c.json(scopeError.body, scopeError.status);\n }\n const replay = await deps.replay.loadSessionReplay({\n sessionId,\n mode,\n ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),\n ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),\n });\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:accessed',\n target: `session:${sessionId}`,\n decision: 'success',\n ts: now(),\n metadata: { mode, eventCount: replay.events.length },\n });\n return c.json(serializeReplay(replay, mode));\n });\n\n return app;\n}\n\ninterface ScopeRefusal {\n readonly code: 'auth-required' | 'scope-denied';\n readonly status: ContentfulStatusCode;\n readonly body: { readonly error: string; readonly message: string; readonly hint?: string };\n}\n\nfunction enforceReplayScope(auth: AuthState, mode: ReplayMode): ScopeRefusal | undefined {\n // IP-13: both a verified token and the no-auth anonymous principal carry a\n // `grantedScopes` set; only a genuinely unauthenticated request is refused.\n if (auth.kind === 'unauthenticated') {\n return {\n code: 'auth-required',\n status: 401,\n body: {\n error: 'auth-required',\n message: 'Authentication required for replay endpoints.',\n },\n };\n }\n const required = mode === 'raw' ? SCOPE_RAW : SCOPE_SANITIZED;\n for (const scope of auth.grantedScopes) {\n if (scopeMatches(scope, required)) return undefined;\n }\n return {\n code: 'scope-denied',\n status: 403,\n body: {\n error: 'scope-denied',\n message: `Token lacks required scope '${required.raw}'.`,\n hint: `Mint a token with the '${required.raw}' (or admin:*) scope.`,\n },\n };\n}\n\nfunction auditActor(auth: AuthState): {\n readonly kind: 'token' | 'system';\n readonly id: string;\n readonly label?: string;\n} {\n if (auth.kind === 'token') {\n return {\n kind: 'token',\n id: auth.token.tokenId,\n ...(auth.token.label !== undefined ? { label: auth.token.label } : {}),\n };\n }\n // IP-13: attribute no-auth replays to the synthetic 'anonymous' principal.\n return { kind: 'system', id: auth.kind === 'anonymous' ? 'anonymous' : 'unauthenticated' };\n}\n\nasync function maybeAppendAudit(\n auditDb: AuditDb | undefined,\n entry: Parameters<typeof appendAudit>[1],\n): Promise<void> {\n if (auditDb === undefined) return;\n try {\n await appendAudit(auditDb, entry);\n } catch {\n // Best-effort — replay must not be blocked by audit failures.\n }\n}\n\nfunction serializeReplay(replay: ReplayResponse, mode: ReplayMode): Record<string, unknown> {\n return {\n mode,\n events: [...replay.events],\n ...(replay.truncated === true ? { truncated: true } : {}),\n ...(replay.nextCursor !== undefined ? { nextCursor: replay.nextCursor } : {}),\n };\n}\n\nfunction buildValidationError(\n message: string,\n issues: ReadonlyArray<{\n readonly path: ReadonlyArray<string | number>;\n readonly message: string;\n }>,\n): Record<string, unknown> {\n return {\n error: 'config-invalid',\n message,\n issues: issues.map((i) => ({ path: i.path, message: i.message })),\n };\n}\n\nasync function safelyParseJson(c: {\n readonly req: { readonly json: () => Promise<unknown> };\n}): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n return {};\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"routes.js","names":["SCOPE_SANITIZED: ParsedScope","SCOPE_RAW: ParsedScope","mode: ReplayMode"],"sources":["../../src/replay/routes.ts"],"sourcesContent":["/**\n * Replay endpoints with scope enforcement.\n *\n * POST /v1/runs/:runId/replay\n * POST /v1/sessions/:id/replay\n *\n * Scope rules:\n * - `raw: false` (default) → require `traces:read:sanitized`.\n * - `raw: true` → require `traces:read:raw` (admin).\n *\n * Every successful invocation appends a `trace.replay.accessed` audit\n * entry. The audit write is best-effort: a missing `AuditDb` does not\n * block the replay.\n *\n * The actual replay stream is consumer-supplied - the framework\n * accepts a `ReplayApi` from the operator that knows how to load\n * trace events for a `runId` / `sessionId`. Phase 14c only owns the\n * scope check + audit + transport.\n *\n * @packageDocumentation\n */\n\nimport { type AuditDb, appendAudit } from '@graphorin/security/audit';\nimport { type ParsedScope, parseScope, scopeMatches } from '@graphorin/security/auth';\nimport { Hono } from 'hono';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\nimport { z } from 'zod';\n\nimport type { AuthState, ServerVariables } from '../internal/context.js';\n\n/** @stable */\nexport type ReplayMode = 'sanitized' | 'raw';\n\n/** @stable */\nexport interface ReplayApi {\n loadRunReplay(input: {\n readonly runId: string;\n readonly mode: ReplayMode;\n readonly fromMessageId?: string;\n readonly provider?: string;\n }): Promise<ReplayResponse>;\n loadSessionReplay(input: {\n readonly sessionId: string;\n readonly mode: ReplayMode;\n readonly fromMessageId?: string;\n readonly provider?: string;\n }): Promise<ReplayResponse>;\n}\n\n/** @stable */\nexport interface ReplayResponse {\n readonly events: ReadonlyArray<unknown>;\n readonly truncated?: boolean;\n readonly nextCursor?: string;\n}\n\nconst SCOPE_SANITIZED: ParsedScope = parseScope('traces:read:sanitized');\nconst SCOPE_RAW: ParsedScope = parseScope('traces:read:raw');\n\nconst ReplayRequestSchema = z\n .object({\n raw: z.boolean().optional(),\n fromMessageId: z.string().min(1).optional(),\n provider: z.string().min(1).optional(),\n })\n .strict()\n .default({});\n\n/**\n * @stable\n */\nexport interface ReplayRoutesDeps {\n readonly replay: ReplayApi;\n readonly auditDb?: AuditDb;\n readonly now?: () => number;\n}\n\n/**\n * @stable\n */\nexport function createReplayRoutes(deps: ReplayRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n const now = deps.now ?? Date.now;\n\n app.post('/runs/:runId/replay', async (c) => {\n const runId = c.req.param('runId');\n const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));\n if (!body.success) {\n return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);\n }\n const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';\n const auth = c.get('state').auth;\n const scopeError = enforceReplayScope(auth, mode);\n if (scopeError !== undefined) {\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:skipped',\n target: `run:${runId}`,\n decision: 'denied',\n ts: now(),\n metadata: { mode, reason: scopeError.code },\n });\n return c.json(scopeError.body, scopeError.status);\n }\n const replay = await deps.replay.loadRunReplay({\n runId,\n mode,\n ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),\n ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),\n });\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:accessed',\n target: `run:${runId}`,\n decision: 'success',\n ts: now(),\n metadata: { mode, eventCount: replay.events.length },\n });\n return c.json(serializeReplay(replay, mode));\n });\n\n app.post('/sessions/:id/replay', async (c) => {\n const sessionId = c.req.param('id');\n const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));\n if (!body.success) {\n return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);\n }\n const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';\n const auth = c.get('state').auth;\n const scopeError = enforceReplayScope(auth, mode);\n if (scopeError !== undefined) {\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:skipped',\n target: `session:${sessionId}`,\n decision: 'denied',\n ts: now(),\n metadata: { mode, reason: scopeError.code },\n });\n return c.json(scopeError.body, scopeError.status);\n }\n const replay = await deps.replay.loadSessionReplay({\n sessionId,\n mode,\n ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),\n ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),\n });\n await maybeAppendAudit(deps.auditDb, {\n actor: auditActor(auth),\n action: 'replay:accessed',\n target: `session:${sessionId}`,\n decision: 'success',\n ts: now(),\n metadata: { mode, eventCount: replay.events.length },\n });\n return c.json(serializeReplay(replay, mode));\n });\n\n return app;\n}\n\ninterface ScopeRefusal {\n readonly code: 'auth-required' | 'scope-denied';\n readonly status: ContentfulStatusCode;\n readonly body: { readonly error: string; readonly message: string; readonly hint?: string };\n}\n\nfunction enforceReplayScope(auth: AuthState, mode: ReplayMode): ScopeRefusal | undefined {\n // IP-13: both a verified token and the no-auth anonymous principal carry a\n // `grantedScopes` set; only a genuinely unauthenticated request is refused.\n if (auth.kind === 'unauthenticated') {\n return {\n code: 'auth-required',\n status: 401,\n body: {\n error: 'auth-required',\n message: 'Authentication required for replay endpoints.',\n },\n };\n }\n const required = mode === 'raw' ? SCOPE_RAW : SCOPE_SANITIZED;\n for (const scope of auth.grantedScopes) {\n if (scopeMatches(scope, required)) return undefined;\n }\n return {\n code: 'scope-denied',\n status: 403,\n body: {\n error: 'scope-denied',\n message: `Token lacks required scope '${required.raw}'.`,\n hint: `Mint a token with the '${required.raw}' (or admin:*) scope.`,\n },\n };\n}\n\nfunction auditActor(auth: AuthState): {\n readonly kind: 'token' | 'system';\n readonly id: string;\n readonly label?: string;\n} {\n if (auth.kind === 'token') {\n return {\n kind: 'token',\n id: auth.token.tokenId,\n ...(auth.token.label !== undefined ? { label: auth.token.label } : {}),\n };\n }\n // IP-13: attribute no-auth replays to the synthetic 'anonymous' principal.\n return { kind: 'system', id: auth.kind === 'anonymous' ? 'anonymous' : 'unauthenticated' };\n}\n\nasync function maybeAppendAudit(\n auditDb: AuditDb | undefined,\n entry: Parameters<typeof appendAudit>[1],\n): Promise<void> {\n if (auditDb === undefined) return;\n try {\n await appendAudit(auditDb, entry);\n } catch {\n // Best-effort - replay must not be blocked by audit failures.\n }\n}\n\nfunction serializeReplay(replay: ReplayResponse, mode: ReplayMode): Record<string, unknown> {\n return {\n mode,\n events: [...replay.events],\n ...(replay.truncated === true ? { truncated: true } : {}),\n ...(replay.nextCursor !== undefined ? { nextCursor: replay.nextCursor } : {}),\n };\n}\n\nfunction buildValidationError(\n message: string,\n issues: ReadonlyArray<{\n readonly path: ReadonlyArray<string | number>;\n readonly message: string;\n }>,\n): Record<string, unknown> {\n return {\n error: 'config-invalid',\n message,\n issues: issues.map((i) => ({ path: i.path, message: i.message })),\n };\n}\n\nasync function safelyParseJson(c: {\n readonly req: { readonly json: () => Promise<unknown> };\n}): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n return {};\n }\n}\n"],"mappings":"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"}
|
package/dist/routes/agents.js
CHANGED
|
@@ -195,7 +195,7 @@ function createRunRoutes(deps) {
|
|
|
195
195
|
error: "resume-not-implemented",
|
|
196
196
|
runId,
|
|
197
197
|
status: state.status,
|
|
198
|
-
message: "Server-side HITL resume is not implemented yet. Resume library-side: agent.run(result.state, { directive })
|
|
198
|
+
message: "Server-side HITL resume is not implemented yet. Resume library-side: agent.run(result.state, { directive }) - the suspended RunState is on the AgentResult."
|
|
199
199
|
}, 501);
|
|
200
200
|
});
|
|
201
201
|
return app;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agents.js","names":["callOptions: { signal?: AbortSignal; sessionId?: string; userId?: string }"],"sources":["../../src/routes/agents.ts"],"sourcesContent":["/**\n * Agent REST routes.\n *\n * GET /agents (scope `agents:read`)\n * GET /agents/:id (scope `agents:read`)\n * POST /agents/:id/run (idempotent; scope `agents:invoke[:id]`)\n * POST /agents/:id/stream (scope `agents:invoke[:id]`)\n * GET /runs/:runId/state (scope `agents:read`)\n * POST /runs/:runId/abort (scope `agents:invoke`)\n * POST /runs/:runId/resume (idempotent; scope `agents:invoke`)\n *\n * Agents are looked up via {@link AgentRegistry}; missing entries\n * surface a 404 with a typed error body.\n *\n * @packageDocumentation\n */\n\nimport type { Context } from 'hono';\nimport { Hono } from 'hono';\nimport { z } from 'zod';\nimport { AgentNotFoundError } from '../errors/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { newRequestId } from '../internal/ids.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { AgentRegistry } from '../registry/index.js';\nimport type { RunStateTracker } from '../runtime/run-state.js';\n\n/**\n * @stable\n */\nexport interface AgentRoutesDeps {\n readonly agents: AgentRegistry;\n readonly runs: RunStateTracker;\n readonly newRunId?: () => string;\n /**\n * Streaming dispatcher (IP-2). When present, `POST /:id/stream`\n * actually runs the agent and emits every event onto the\n * `agent:<id>/runs/<runId>/events` subject.\n */\n readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;\n}\n\nconst RunBodySchema = z\n .object({\n input: z.unknown().optional(),\n sessionId: z.string().min(1).optional(),\n userId: z.string().min(1).optional(),\n })\n .strict()\n .default({});\n\n/**\n * @stable\n */\nexport function createAgentRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n const newRunId = deps.newRunId ?? newRequestId;\n\n app.get('/', createScopeMiddleware('agents:read'), (c) => c.json({ agents: deps.agents.list() }));\n\n app.get(\n '/:id',\n createScopeMiddleware((_path, params) => `agents:read:${params.id}`),\n (c) => {\n const id = c.req.param('id');\n const summary = deps.agents.describe(id);\n if (summary === undefined) {\n return c.json(\n { error: 'agent-not-found', message: `Agent '${id}' is not registered.` },\n 404,\n );\n }\n return c.json(summary);\n },\n );\n\n app.post(\n '/:id/run',\n createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),\n async (c) => {\n const id = c.req.param('id');\n const agent = deps.agents.get(id);\n if (agent === undefined) {\n const err = new AgentNotFoundError(id);\n return c.json({ error: err.kind, message: err.message }, 404);\n }\n const parsed = RunBodySchema.safeParse(await safelyParseJson(c));\n if (!parsed.success) {\n return c.json(\n {\n error: 'config-invalid',\n message: 'Invalid run body.',\n issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),\n },\n 400,\n );\n }\n const runId = newRunId();\n const sessionId = parsed.data.sessionId;\n const userId = parsed.data.userId;\n const tracker = deps.runs.start(runId, {\n kind: 'agent',\n agentId: id,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n });\n try {\n const callOptions: { signal?: AbortSignal; sessionId?: string; userId?: string } = {\n signal: tracker.signal,\n };\n if (sessionId !== undefined) callOptions.sessionId = sessionId;\n if (userId !== undefined) callOptions.userId = userId;\n const result = await agent.run(parsed.data.input ?? '', callOptions);\n deps.runs.complete(runId, 'completed');\n return c.json({ runId, status: 'completed', result }, 200);\n } catch (err) {\n const aborted = tracker.signal.aborted;\n deps.runs.complete(runId, aborted ? 'aborted' : 'failed', err);\n return c.json(\n {\n runId,\n status: aborted ? 'aborted' : 'failed',\n error: aborted ? 'run-aborted' : 'run-failed',\n message: err instanceof Error ? err.message : String(err),\n },\n aborted ? 408 : 500,\n );\n }\n },\n );\n\n app.post(\n '/:id/stream',\n createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),\n async (c) => {\n const id = c.req.param('id');\n if (!deps.agents.has(id)) {\n const err = new AgentNotFoundError(id);\n return c.json({ error: err.kind, message: err.message }, 404);\n }\n const runId = newRunId();\n const parsed = RunBodySchema.safeParse(await safelyParseJson(c));\n const sessionId = parsed.success ? parsed.data.sessionId : undefined;\n const userId = parsed.success ? parsed.data.userId : undefined;\n const input = parsed.success ? (parsed.data.input ?? '') : '';\n // IP-2: actually run the agent. The old handler parsed the input\n // and threw it away — the run sat 'pending' forever while the 202\n // advertised subjects nothing would ever emit on.\n const tracker = deps.runs.start(runId, {\n kind: 'agent',\n agentId: id,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n });\n const subject = `agent:${id}/runs/${runId}/events`;\n const agent = deps.agents.get(id);\n if (agent !== undefined) {\n backgroundStreamAgent(agent, input, {\n signal: tracker.signal,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n runs: deps.runs,\n runId,\n subject,\n ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}),\n });\n }\n return c.json(\n {\n runId,\n status: 'running',\n subscribe: {\n // The SSE URL previously advertised here pointed at a path\n // that was never mounted — the WebSocket subject is the\n // delivery channel (IP-2).\n websocket: subject,\n },\n },\n 202,\n );\n },\n );\n\n return app;\n}\n\n/**\n * IP-2: consume the agent's event stream in the background and emit\n * every event onto the run subject. Falls back to `run(...)` (a single\n * terminal frame) for registry entries without a `stream` surface.\n */\nfunction backgroundStreamAgent(\n agent: ReturnType<AgentRegistry['get']> & object,\n input: unknown,\n opts: {\n readonly signal: AbortSignal;\n readonly sessionId?: string;\n readonly userId?: string;\n readonly runs: RunStateTracker;\n readonly runId: string;\n readonly subject: string;\n readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;\n },\n): void {\n const emit = (type: string, payload: unknown): void => {\n opts.dispatcher?.emit(opts.subject, { type, payload });\n };\n void (async () => {\n try {\n const callOpts = {\n signal: opts.signal,\n ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),\n ...(opts.userId !== undefined ? { userId: opts.userId } : {}),\n };\n if (typeof agent.stream === 'function') {\n for await (const ev of agent.stream(input, callOpts)) {\n if (opts.signal.aborted) break;\n const type =\n typeof (ev as { type?: unknown }).type === 'string'\n ? (ev as { type: string }).type\n : 'agent.event';\n emit(type, ev);\n }\n } else {\n const output = await agent.run(input, callOpts);\n emit('agent.end', { runId: opts.runId, output });\n }\n opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'completed');\n } catch (err) {\n emit('agent.error', {\n runId: opts.runId,\n message: err instanceof Error ? err.message : String(err),\n });\n opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'failed', err);\n }\n })();\n}\n\n/**\n * Companion router for the `/runs/...` surface. Kept separate so the\n * `createServer` factory can mount it under the top-level base path\n * rather than under `/agents`.\n *\n * @stable\n */\nexport function createRunRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n\n app.get('/:runId/state', createScopeMiddleware('agents:read'), (c) => {\n const runId = c.req.param('runId');\n const state = deps.runs.snapshot(runId);\n if (state === undefined) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n return c.json(state);\n });\n\n app.post('/:runId/abort', createScopeMiddleware('agents:invoke'), (c) => {\n const runId = c.req.param('runId');\n const aborted = deps.runs.abort(runId);\n if (!aborted) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n return c.json({ runId, status: 'aborted' });\n });\n\n app.post('/:runId/resume', createScopeMiddleware('agents:invoke'), async (c) => {\n const runId = c.req.param('runId');\n const state = deps.runs.snapshot(runId);\n if (state === undefined) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n // IP-14: a 202 that persists nothing and resumes nothing is a lie\n // the client SDK was built on. Until the server can rehydrate the\n // RunState and re-enter the agent loop (the persisted-approvals\n // work — Wave 3), the honest answer is 501 with the working\n // library-side path: `agent.run(result.state, { directive })`\n // executes approved tools for real (AG-1/AG-9).\n return c.json(\n {\n error: 'resume-not-implemented',\n runId,\n status: state.status,\n message:\n 'Server-side HITL resume is not implemented yet. Resume library-side: ' +\n 'agent.run(result.state, { directive }) — the suspended RunState is on the AgentResult.',\n },\n 501,\n );\n });\n\n return app;\n}\n\nasync function safelyParseJson(c: Context<{ Variables: ServerVariables }>): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n return {};\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"agents.js","names":["callOptions: { signal?: AbortSignal; sessionId?: string; userId?: string }"],"sources":["../../src/routes/agents.ts"],"sourcesContent":["/**\n * Agent REST routes.\n *\n * GET /agents (scope `agents:read`)\n * GET /agents/:id (scope `agents:read`)\n * POST /agents/:id/run (idempotent; scope `agents:invoke[:id]`)\n * POST /agents/:id/stream (scope `agents:invoke[:id]`)\n * GET /runs/:runId/state (scope `agents:read`)\n * POST /runs/:runId/abort (scope `agents:invoke`)\n * POST /runs/:runId/resume (idempotent; scope `agents:invoke`)\n *\n * Agents are looked up via {@link AgentRegistry}; missing entries\n * surface a 404 with a typed error body.\n *\n * @packageDocumentation\n */\n\nimport type { Context } from 'hono';\nimport { Hono } from 'hono';\nimport { z } from 'zod';\nimport { AgentNotFoundError } from '../errors/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { newRequestId } from '../internal/ids.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { AgentRegistry } from '../registry/index.js';\nimport type { RunStateTracker } from '../runtime/run-state.js';\n\n/**\n * @stable\n */\nexport interface AgentRoutesDeps {\n readonly agents: AgentRegistry;\n readonly runs: RunStateTracker;\n readonly newRunId?: () => string;\n /**\n * Streaming dispatcher (IP-2). When present, `POST /:id/stream`\n * actually runs the agent and emits every event onto the\n * `agent:<id>/runs/<runId>/events` subject.\n */\n readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;\n}\n\nconst RunBodySchema = z\n .object({\n input: z.unknown().optional(),\n sessionId: z.string().min(1).optional(),\n userId: z.string().min(1).optional(),\n })\n .strict()\n .default({});\n\n/**\n * @stable\n */\nexport function createAgentRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n const newRunId = deps.newRunId ?? newRequestId;\n\n app.get('/', createScopeMiddleware('agents:read'), (c) => c.json({ agents: deps.agents.list() }));\n\n app.get(\n '/:id',\n createScopeMiddleware((_path, params) => `agents:read:${params.id}`),\n (c) => {\n const id = c.req.param('id');\n const summary = deps.agents.describe(id);\n if (summary === undefined) {\n return c.json(\n { error: 'agent-not-found', message: `Agent '${id}' is not registered.` },\n 404,\n );\n }\n return c.json(summary);\n },\n );\n\n app.post(\n '/:id/run',\n createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),\n async (c) => {\n const id = c.req.param('id');\n const agent = deps.agents.get(id);\n if (agent === undefined) {\n const err = new AgentNotFoundError(id);\n return c.json({ error: err.kind, message: err.message }, 404);\n }\n const parsed = RunBodySchema.safeParse(await safelyParseJson(c));\n if (!parsed.success) {\n return c.json(\n {\n error: 'config-invalid',\n message: 'Invalid run body.',\n issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),\n },\n 400,\n );\n }\n const runId = newRunId();\n const sessionId = parsed.data.sessionId;\n const userId = parsed.data.userId;\n const tracker = deps.runs.start(runId, {\n kind: 'agent',\n agentId: id,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n });\n try {\n const callOptions: { signal?: AbortSignal; sessionId?: string; userId?: string } = {\n signal: tracker.signal,\n };\n if (sessionId !== undefined) callOptions.sessionId = sessionId;\n if (userId !== undefined) callOptions.userId = userId;\n const result = await agent.run(parsed.data.input ?? '', callOptions);\n deps.runs.complete(runId, 'completed');\n return c.json({ runId, status: 'completed', result }, 200);\n } catch (err) {\n const aborted = tracker.signal.aborted;\n deps.runs.complete(runId, aborted ? 'aborted' : 'failed', err);\n return c.json(\n {\n runId,\n status: aborted ? 'aborted' : 'failed',\n error: aborted ? 'run-aborted' : 'run-failed',\n message: err instanceof Error ? err.message : String(err),\n },\n aborted ? 408 : 500,\n );\n }\n },\n );\n\n app.post(\n '/:id/stream',\n createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),\n async (c) => {\n const id = c.req.param('id');\n if (!deps.agents.has(id)) {\n const err = new AgentNotFoundError(id);\n return c.json({ error: err.kind, message: err.message }, 404);\n }\n const runId = newRunId();\n const parsed = RunBodySchema.safeParse(await safelyParseJson(c));\n const sessionId = parsed.success ? parsed.data.sessionId : undefined;\n const userId = parsed.success ? parsed.data.userId : undefined;\n const input = parsed.success ? (parsed.data.input ?? '') : '';\n // IP-2: actually run the agent. The old handler parsed the input\n // and threw it away - the run sat 'pending' forever while the 202\n // advertised subjects nothing would ever emit on.\n const tracker = deps.runs.start(runId, {\n kind: 'agent',\n agentId: id,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n });\n const subject = `agent:${id}/runs/${runId}/events`;\n const agent = deps.agents.get(id);\n if (agent !== undefined) {\n backgroundStreamAgent(agent, input, {\n signal: tracker.signal,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(userId !== undefined ? { userId } : {}),\n runs: deps.runs,\n runId,\n subject,\n ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}),\n });\n }\n return c.json(\n {\n runId,\n status: 'running',\n subscribe: {\n // The SSE URL previously advertised here pointed at a path\n // that was never mounted - the WebSocket subject is the\n // delivery channel (IP-2).\n websocket: subject,\n },\n },\n 202,\n );\n },\n );\n\n return app;\n}\n\n/**\n * IP-2: consume the agent's event stream in the background and emit\n * every event onto the run subject. Falls back to `run(...)` (a single\n * terminal frame) for registry entries without a `stream` surface.\n */\nfunction backgroundStreamAgent(\n agent: ReturnType<AgentRegistry['get']> & object,\n input: unknown,\n opts: {\n readonly signal: AbortSignal;\n readonly sessionId?: string;\n readonly userId?: string;\n readonly runs: RunStateTracker;\n readonly runId: string;\n readonly subject: string;\n readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;\n },\n): void {\n const emit = (type: string, payload: unknown): void => {\n opts.dispatcher?.emit(opts.subject, { type, payload });\n };\n void (async () => {\n try {\n const callOpts = {\n signal: opts.signal,\n ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),\n ...(opts.userId !== undefined ? { userId: opts.userId } : {}),\n };\n if (typeof agent.stream === 'function') {\n for await (const ev of agent.stream(input, callOpts)) {\n if (opts.signal.aborted) break;\n const type =\n typeof (ev as { type?: unknown }).type === 'string'\n ? (ev as { type: string }).type\n : 'agent.event';\n emit(type, ev);\n }\n } else {\n const output = await agent.run(input, callOpts);\n emit('agent.end', { runId: opts.runId, output });\n }\n opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'completed');\n } catch (err) {\n emit('agent.error', {\n runId: opts.runId,\n message: err instanceof Error ? err.message : String(err),\n });\n opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'failed', err);\n }\n })();\n}\n\n/**\n * Companion router for the `/runs/...` surface. Kept separate so the\n * `createServer` factory can mount it under the top-level base path\n * rather than under `/agents`.\n *\n * @stable\n */\nexport function createRunRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n\n app.get('/:runId/state', createScopeMiddleware('agents:read'), (c) => {\n const runId = c.req.param('runId');\n const state = deps.runs.snapshot(runId);\n if (state === undefined) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n return c.json(state);\n });\n\n app.post('/:runId/abort', createScopeMiddleware('agents:invoke'), (c) => {\n const runId = c.req.param('runId');\n const aborted = deps.runs.abort(runId);\n if (!aborted) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n return c.json({ runId, status: 'aborted' });\n });\n\n app.post('/:runId/resume', createScopeMiddleware('agents:invoke'), async (c) => {\n const runId = c.req.param('runId');\n const state = deps.runs.snapshot(runId);\n if (state === undefined) {\n return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);\n }\n // IP-14: a 202 that persists nothing and resumes nothing is a lie\n // the client SDK was built on. Until the server can rehydrate the\n // RunState and re-enter the agent loop (the persisted-approvals\n // work - Wave 3), the honest answer is 501 with the working\n // library-side path: `agent.run(result.state, { directive })`\n // executes approved tools for real (AG-1/AG-9).\n return c.json(\n {\n error: 'resume-not-implemented',\n runId,\n status: state.status,\n message:\n 'Server-side HITL resume is not implemented yet. Resume library-side: ' +\n 'agent.run(result.state, { directive }) - the suspended RunState is on the AgentResult.',\n },\n 501,\n );\n });\n\n return app;\n}\n\nasync function safelyParseJson(c: Context<{ Variables: ServerVariables }>): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n return {};\n }\n}\n"],"mappings":"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"}
|
package/dist/routes/audit.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ interface AuditApi {
|
|
|
28
28
|
/**
|
|
29
29
|
* Verify the chain integrity of every audit row in the inclusive
|
|
30
30
|
* range. Phase 14c surfaces this through `POST /v1/audit/verify`.
|
|
31
|
-
* Optional
|
|
31
|
+
* Optional - operators that opt out of the audit chain should
|
|
32
32
|
* leave this method off.
|
|
33
33
|
*/
|
|
34
34
|
verify?(opts: {
|