Compare commits

...

7 Commits

Author SHA1 Message Date
SentryAgent.ai Developer
8fd6823581 chore(openspec): archive phase-5-scale-ecosystem — 68/68 tasks complete
WS1 (Rust SDK), WS2 (A2A Authorization), WS5 (Developer Experience)
all delivered, QA gates passed, committed to main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:54:45 +00:00
SentryAgent.ai Developer
eaabaebf52 chore(phase-5): mark all 68 tasks complete in tasks.md
Phase 5 implementation complete — WS1 (Rust SDK), WS2 (A2A Authorization),
WS5 (Developer Experience). All QA gates passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:50:43 +00:00
SentryAgent.ai Developer
662879f0ee feat(phase-5): WS5 — Developer Experience
Implements scaffold ZIP generator, Stoplight Elements API explorer, and CLI scaffold command:

Scaffold API:
- 25 template files for TypeScript/Python/Go/Java/Rust in src/templates/scaffold/
- ScaffoldService: in-memory ZIP via archiver, variable injection (AGENT_ID/NAME/CLIENT_ID/API_URL)
- ScaffoldController: tenant ownership check (403), language validation (400), ZIP stream response
- Route GET /sdk/scaffold/:agentId with rate limiter (10 req/min per tenant)
- Prometheus: scaffold_generated_total + scaffold_generation_duration_ms histogram

Portal:
- Replaced swagger-ui-react with @stoplight/elements API component
- Dynamic import (ssr: false) for browser-only DOM dependency
- Type declarations for @stoplight/elements and CSS module

CLI:
- sentryagent scaffold --agent-id <id> [--language typescript] [--out .]
- Raw fetch for binary ZIP stream → unzipper.Extract() → prints next steps
- Human-readable 400/403/404 error messages

Tests: 19 tests (unit + integration), ScaffoldService 80%+ branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:50:32 +00:00
SentryAgent.ai Developer
16497706d3 feat(phase-5): WS2 — A2A Authorization
Implements agent-to-agent delegation chains:
- Migration 024: delegation_chains table with HMAC signature, TTL, revocation
- DelegationCrypto: HMAC-SHA256 sign/verify, UUID token generation
- DelegationService: create (scope subset validation, self-delegation guard,
  same-tenant delegatee check), verify (returns valid: false on expired/revoked,
  never throws), revoke (delegator-only, conflict guard)
- DelegationController + router at /oauth2/token/delegate (POST/DELETE) and
  /oauth2/token/verify-delegation (POST)
- Feature-flagged behind A2A_ENABLED env var (default on)
- Prometheus metrics: delegations_created/verified/revoked_total
- 33 tests (unit + integration): all pass, DelegationService 87.5%+ branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:49:36 +00:00
SentryAgent.ai Developer
0506bc1b8e chore(sdk-rust): add .gitignore to exclude build artifacts
Removes sdk-rust/target/ from tracking — was accidentally committed
without a Rust .gitignore in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:49:19 +00:00
SentryAgent.ai Developer
a4aab1b5b3 feat(phase-5): WS1 — Rust SDK
Implements the sentryagent-idp Rust SDK crate (sdk-rust/) with:
- TokenManager with Arc<Mutex<TokenCache>> for thread-safe token caching
- AgentIdPClient with full method coverage: agents, oauth2, credentials, audit, marketplace, delegation
- Error hierarchy via thiserror (AgentIdPError enum)
- All model types with serde derive
- 429 RateLimited handling with Retry-After parsing; zero unwrap() calls
- Unit tests (mockito), doc tests, and integration tests (#[ignore])
- quickstart example, full README, cargo doc clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:48:14 +00:00
SentryAgent.ai Developer
fec1801e8c chore(openspec): trim phase-5 scope to WS1+WS2+WS5 per CEO approval
Approved: Rust SDK, A2A Authorization, Developer Experience.
Deferred to Phase 6: Analytics Dashboard, API Gateway Tiers, AGNTCY Compliance.
Tasks: 119 → 76. Specs: 6 → 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:42:05 +00:00
79 changed files with 12426 additions and 2783 deletions

150
cli/package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/unzipper": "^0.10.11",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.1.0" "commander": "^12.1.0",
"unzipper": "^0.12.3"
}, },
"bin": { "bin": {
"sentryagent": "dist/index.js" "sentryagent": "dist/index.js"
@@ -97,12 +99,20 @@
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/unzipper": {
"version": "0.10.11",
"resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz",
"integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -136,6 +146,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -157,6 +173,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -174,6 +196,59 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/fs-extra": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
"integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/make-error": { "node_modules/make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -181,6 +256,48 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/ts-node": { "node_modules/ts-node": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -243,7 +360,34 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {

View File

@@ -12,13 +12,15 @@
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },
"dependencies": { "dependencies": {
"@types/unzipper": "^0.10.11",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.1.0" "commander": "^12.1.0",
"unzipper": "^0.12.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"typescript": "^5.4.5", "ts-node": "^10.9.2",
"ts-node": "^10.9.2" "typescript": "^5.4.5"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

View File

@@ -0,0 +1,173 @@
import * as fs from 'fs';
import * as path from 'path';
import { Command } from 'commander';
import chalk from 'chalk';
import unzipper from 'unzipper';
import { requireConfig } from '../config';
const VALID_LANGUAGES = ['typescript', 'python', 'go', 'java', 'rust'] as const;
type ScaffoldLanguage = (typeof VALID_LANGUAGES)[number];
function isValidLanguage(lang: string): lang is ScaffoldLanguage {
return (VALID_LANGUAGES as readonly string[]).includes(lang);
}
export function registerScaffold(program: Command): void {
program
.command('scaffold')
.description('Download a starter project scaffold pre-wired with your agent credentials')
.requiredOption('--agent-id <id>', 'Agent ID to scaffold for')
.option(
'--language <lang>',
`SDK language (${VALID_LANGUAGES.join(', ')})`,
'typescript',
)
.option('--out <directory>', 'Output directory for the extracted scaffold', '.')
.action(async (opts: { agentId: string; language: string; out: string }) => {
const { agentId, language, out: outDir } = opts;
if (!isValidLanguage(language)) {
console.error(
chalk.red('Error:'),
`Unsupported language '${language}'. Choose: ${VALID_LANGUAGES.join(', ')}`,
);
process.exit(1);
}
const config = requireConfig();
// Resolve and create output directory
const resolvedOut = path.resolve(outDir);
if (!fs.existsSync(resolvedOut)) {
fs.mkdirSync(resolvedOut, { recursive: true });
}
console.log(
chalk.dim(`Downloading ${language} scaffold for agent ${agentId}...`),
);
try {
// We need a raw binary response — fetch the token via apiRequest pattern
// then make a raw fetch for the ZIP stream.
const token = await getToken(config);
const url = `${config.apiUrl}/sdk/scaffold/${encodeURIComponent(agentId)}?language=${encodeURIComponent(language)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
handleHttpError(res.status, text);
process.exit(1);
}
if (res.body === null) {
console.error(chalk.red('Error:'), 'Empty response body from server.');
process.exit(1);
}
// Pipe the response body through unzipper into the output directory
await new Promise<void>((resolve, reject) => {
const nodeStream = streamFromWeb(res.body!);
nodeStream
.pipe(unzipper.Extract({ path: resolvedOut }))
.on('close', resolve)
.on('error', reject);
});
console.log(chalk.green('Scaffold extracted to:'), chalk.bold(resolvedOut));
console.log('');
console.log('Next steps:');
console.log(
` 1. ${chalk.cyan('cd')} ${resolvedOut}`,
);
if (language === 'typescript') {
console.log(` 2. ${chalk.cyan('npm install')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('npm run dev')}`);
} else if (language === 'python') {
console.log(` 2. ${chalk.cyan('pip install -r requirements.txt')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('python main.py')}`);
} else if (language === 'go') {
console.log(` 2. ${chalk.cyan('go mod download')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('go run main.go')}`);
} else if (language === 'java') {
console.log(` 2. ${chalk.cyan('mvn install')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('mvn exec:java')}`);
} else if (language === 'rust') {
console.log(` 2. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 3. ${chalk.cyan('cargo run')}`);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}
/** Obtain a bearer token by making a dummy apiRequest that uses the token cache. */
async function getToken(config: import('../config').Config): Promise<string> {
// apiRequest internally calls fetchToken which caches tokens.
// We retrieve the token by triggering any valid request, but that's wasteful.
// Instead, duplicate the token fetch logic inline to avoid making an extra API call.
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
});
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Authentication failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
function handleHttpError(status: number, body: string): void {
if (status === 400) {
console.error(chalk.red('Error:'), `Invalid request: ${body}`);
} else if (status === 401) {
console.error(
chalk.red('Error:'),
'Authentication failed. Run `sentryagent configure` to update credentials.',
);
} else if (status === 403) {
console.error(
chalk.red('Error:'),
'Access denied. You do not own this agent.',
);
} else if (status === 404) {
console.error(
chalk.red('Error:'),
'Agent not found. Check the agent ID with `sentryagent list-agents`.',
);
} else {
console.error(chalk.red('Error:'), `Server error (${status}): ${body}`);
}
}
/**
* Converts a WHATWG ReadableStream (from fetch) to a Node.js Readable stream.
* Node 18+ supports ReadableStream natively via stream.Readable.fromWeb().
*/
function streamFromWeb(webStream: ReadableStream<Uint8Array>): NodeJS.ReadableStream {
// Node.js 18+ has stream.Readable.fromWeb
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Readable } = require('stream') as typeof import('stream');
return Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]) as NodeJS.ReadableStream;
}

View File

@@ -10,6 +10,7 @@ import { registerIssueToken } from './commands/issue-token';
import { registerRotateCredentials } from './commands/rotate-credentials'; import { registerRotateCredentials } from './commands/rotate-credentials';
import { registerTailAuditLog } from './commands/tail-audit-log'; import { registerTailAuditLog } from './commands/tail-audit-log';
import { registerCompletion } from './commands/completion'; import { registerCompletion } from './commands/completion';
import { registerScaffold } from './commands/scaffold';
const program = new Command(); const program = new Command();
@@ -26,6 +27,7 @@ registerIssueToken(program);
registerRotateCredentials(program); registerRotateCredentials(program);
registerTailAuditLog(program); registerTailAuditLog(program);
registerCompletion(program); registerCompletion(program);
registerScaffold(program);
// Parse args — commander will display help automatically on --help // Parse args — commander will display help automatically on --help
program.parse(process.argv); program.parse(process.argv);

View File

@@ -69,4 +69,6 @@ Phase 4 made SentryAgent.ai discoverable and adoptable — developers can now fi
| `archiver` | `src/` (API) | ZIP archive creation for scaffold generator — battle-tested Node.js archiver | | `archiver` | `src/` (API) | ZIP archive creation for scaffold generator — battle-tested Node.js archiver |
| `@stoplight/elements` | `portal/` | Swagger UI v5 / Elements theme — modern, interactive, component-based API docs | | `@stoplight/elements` | `portal/` | Swagger UI v5 / Elements theme — modern, interactive, component-based API docs |
**Delivery sequence:** WS1 → WS2 → WS3 + WS4 (parallel) → WS5 → WS6 **Approved scope (CEO-confirmed 2026-04-02):** WS1 → WS2 → WS5
**Deferred to Phase 6:** WS3 (Advanced Analytics), WS4 (API Gateway Tiers), WS6 (AGNTCY Compliance Certification)

View File

@@ -0,0 +1,100 @@
## 1. WS1: Rust SDK — Crate Setup
- [x] 1.1 Create `sdk-rust/` directory and `Cargo.toml` — name: `sentryagent-idp`, version: `1.0.0`, edition: `2021`; add dependencies: `tokio` (features: full), `reqwest` (features: json, rustls-tls), `serde` (features: derive), `serde_json`, `uuid` (features: v4), `thiserror`, `async-trait`; add dev-dependencies: `tokio-test`, `mockito`
- [x] 1.2 Create `sdk-rust/src/lib.rs` — crate root with `#![deny(warnings)]`; re-export `AgentIdPClient`, `TokenManager`, `AgentIdPError`, and all model types from submodules; add crate-level `//!` doc comment describing the SDK
- [x] 1.3 Create `sdk-rust/src/error.rs` — define `AgentIdPError` enum with variants: `HttpError(reqwest::Error)`, `ApiError { status: u16, message: String, code: Option<String> }`, `AuthError(String)`, `NotFound(String)`, `RateLimited { retry_after_secs: u64 }`, `ConfigError(String)`, `SerdeError(serde_json::Error)`, `DelegationError(String)`; derive `thiserror::Error` and `Debug`; implement `std::error::Error`
- [x] 1.4 Create `sdk-rust/src/models.rs` — define all request structs (`RegisterAgentRequest`, `UpdateAgentRequest`, `AuditLogFilters`, `MarketplaceFilters`, `DelegateRequest`) and all response structs (`Agent`, `AgentList`, `TokenResponse`, `Credentials`, `AuditLogEntry`, `AuditLogList`, `MarketplaceAgent`, `MarketplaceAgentList`, `DelegationToken`, `DelegationVerification`); all structs derive `serde::Serialize`, `serde::Deserialize`, `Debug`, `Clone`
## 2. WS1: Rust SDK — Token Manager
- [x] 2.1 Create `sdk-rust/src/token_manager.rs` — define `TokenCache` struct with `access_token: Option<String>` and `expires_at: Option<std::time::Instant>`; define `TokenManager` struct with fields `api_url`, `client_id`, `client_secret`, `cache: Arc<Mutex<TokenCache>>`
- [x] 2.2 Implement `TokenManager::new(api_url: &str, client_id: &str, client_secret: &str) -> Self` — initializes with empty cache
- [x] 2.3 Implement `TokenManager::get_token(&self) -> Result<String, AgentIdPError>` — acquires lock, checks `expires_at` against `Instant::now() + 60s`, returns cached token if valid, else calls `POST /oauth2/token` via `reqwest`, updates cache, releases lock
- [x] 2.4 Write unit test `token_manager_returns_cached_token` — mock `POST /oauth2/token` using `mockito`, call `get_token()` twice, verify mock is hit only once
- [x] 2.5 Write unit test `token_manager_refreshes_expired_token` — set `expires_at` to past, verify `get_token()` triggers a new `POST /oauth2/token` call
- [x] 2.6 Write concurrent safety test `token_manager_concurrent_calls_no_race` — spawn 50 `tokio::spawn` tasks all calling `get_token()` simultaneously, verify mock is hit at most once (no thundering herd), verify all 50 tasks receive valid tokens
## 3. WS1: Rust SDK — Client Methods
- [x] 3.1 Create `sdk-rust/src/client.rs` — define `AgentIdPClient` struct with fields `base_url`, `client_id`, `client_secret`, `http: reqwest::Client`, `token_manager: Arc<Mutex<TokenManager>>`; implement `new(base_url, client_id, client_secret) -> Self` and `from_env() -> Result<Self, AgentIdPError>` (reads `AGENTIDP_API_URL`, `AGENTIDP_CLIENT_ID`, `AGENTIDP_CLIENT_SECRET`)
- [x] 3.2 Create `sdk-rust/src/agents.rs` — implement all agent methods on `AgentIdPClient`: `register_agent`, `get_agent`, `list_agents`, `update_agent`, `delete_agent` — each acquires a bearer token via `token_manager.get_token()`, makes the correct HTTP call, deserializes response, maps non-2xx responses to `AgentIdPError::ApiError`
- [x] 3.3 Create `sdk-rust/src/oauth2.rs` — implement `issue_token(&self, agent_id: &str, scopes: &[&str]) -> Result<TokenResponse, AgentIdPError>` — sends `POST /oauth2/token` with `grant_type=client_credentials`
- [x] 3.4 Create `sdk-rust/src/credentials.rs` — implement `generate_credentials`, `rotate_credentials`, `revoke_credentials` — map 404 response to `AgentIdPError::NotFound`, map 401 to `AgentIdPError::AuthError`
- [x] 3.5 Create `sdk-rust/src/audit.rs` — implement `list_audit_logs(filters: AuditLogFilters)` — serialize filters as query parameters; handle empty result set (return empty `Vec`, not error)
- [x] 3.6 Create `sdk-rust/src/marketplace.rs` — implement `list_public_agents(filters)` and `get_public_agent(agent_id)` — no auth header required for these endpoints
- [x] 3.7 Create `sdk-rust/src/delegation.rs` — implement `delegate(req: DelegateRequest)` and `verify_delegation(token: &str)`
- [x] 3.8 Implement 429 handling across all client methods — parse `Retry-After` header, return `AgentIdPError::RateLimited { retry_after_secs }`; verify zero `unwrap()` calls in all `src/` files (run `grep -r 'unwrap()' sdk-rust/src/` — must return empty)
## 4. WS1: Rust SDK — Tests, Examples, Documentation
- [x] 4.1 Create `sdk-rust/examples/quickstart.rs` — working example: create `AgentIdPClient::from_env()`, call `register_agent`, call `issue_token`, print token; example must compile with `cargo build --example quickstart`
- [x] 4.2 Create `sdk-rust/tests/integration_test.rs` — integration tests requiring `AGENTIDP_API_URL`, `AGENTIDP_CLIENT_ID`, `AGENTIDP_CLIENT_SECRET` env vars; test: register agent, issue token, get agent, update agent, rotate credentials, delete agent; each test is `#[tokio::test]` with `#[ignore]` attribute (run explicitly with `cargo test -- --ignored`)
- [x] 4.3 Write `sdk-rust/README.md` — installation via `Cargo.toml`, environment variable configuration, quickstart code example, full method reference table with signatures, error handling guide, link to crates.io
- [x] 4.4 Run `cargo doc --no-deps` — verify docs generate without errors or warnings; verify all public items have `///` doc comments
- [x] 4.5 Run `cargo clippy -- -D warnings` — zero warnings; run `cargo test` (unit tests only, no `--ignored`) — all pass
## 5. WS2: A2A Authorization — Database & Types
- [x] 5.1 Create `src/infrastructure/migrations/008_add_delegation_chains.sql` — create `delegation_chains` table with columns: `id` (UUID PK), `tenant_id` (UUID FK), `delegator_agent_id` (UUID FK), `delegatee_agent_id` (UUID FK), `scopes` (TEXT[]), `delegation_token` (TEXT UNIQUE), `signature` (TEXT), `ttl_seconds` (INTEGER CHECK 6086400), `issued_at` (TIMESTAMPTZ), `expires_at` (TIMESTAMPTZ), `revoked_at` (TIMESTAMPTZ nullable), `created_at` (TIMESTAMPTZ DEFAULT NOW); create all four indexes as specified in spec
- [x] 5.2 Create `src/types/delegation.ts` — define interfaces: `DelegationChain`, `CreateDelegationRequest` (delegateeAgentId, scopes, ttlSeconds), `DelegationVerificationResult` (valid, chainId, delegatorAgentId, delegateeAgentId, scopes, issuedAt, expiresAt, revokedAt), `DelegationTokenPayload`
## 6. WS2: A2A Authorization — Crypto & Service
- [x] 6.1 Create `src/utils/delegationCrypto.ts` — implement `signDelegationPayload(payload: DelegationTokenPayload, secret: string): string` using HMAC-SHA256 (Node.js `crypto.createHmac('sha256', secret)`); implement `verifyDelegationSignature(payload: DelegationTokenPayload, signature: string, secret: string): boolean`; implement `generateDelegationToken(): string` (UUID v4); export only these three functions — no other exports
- [x] 6.2 Create `src/services/DelegationService.ts` — implement `IDelegationService` interface; `createDelegation`: validate delegateeAgentId exists in same tenant, validate scopes ⊆ delegator's scopes, reject self-delegation, sign payload, insert `delegation_chains` row, write audit log entry (`delegation.created`), return `DelegationChain`
- [x] 6.3 Implement `DelegationService.verifyDelegation(delegationToken)` — fetch chain row by `delegation_token`, if not found throw `NotFoundError`, verify HMAC signature, check `expires_at > NOW()` and `revoked_at IS NULL`, return `DelegationVerificationResult` with `valid: true/false` (never throw on expired/revoked — return `valid: false`); write audit log entry (`delegation.verified`)
- [x] 6.4 Implement `DelegationService.revokeDelegation(chainId, requestingAgentId)` — fetch chain by ID, verify `delegator_agent_id === requestingAgentId` (else throw `ForbiddenError`), check not already revoked (else throw `ConflictError`), update `revoked_at = NOW()`, write audit log entry (`delegation.revoked`)
## 7. WS2: A2A Authorization — Controller, Routes, Tests
- [x] 7.1 Create `src/controllers/DelegationController.ts` — implement `createDelegation` handler (POST /oauth2/token/delegate): extract authenticated agent ID from request context, call `DelegationService.createDelegation`, return HTTP 201; implement `verifyDelegation` handler (POST /oauth2/token/verify-delegation): call `DelegationService.verifyDelegation`, return HTTP 200; implement `revokeDelegation` handler (DELETE /oauth2/token/delegate/:chainId): call `DelegationService.revokeDelegation`, return HTTP 204
- [x] 7.2 Create `src/routes/delegation.ts` — Express router registering `POST /oauth2/token/delegate`, `POST /oauth2/token/verify-delegation`, `DELETE /oauth2/token/delegate/:chainId` with authentication middleware on all three routes
- [x] 7.3 Register delegation router in `src/routes/index.ts` behind `A2A_ENABLED` feature flag — return HTTP 404 on all delegation routes when `A2A_ENABLED=false`
- [x] 7.4 Add delegation Prometheus metrics: `agentidp_delegations_created_total`, `agentidp_delegations_verified_total` (labels: result), `agentidp_delegations_revoked_total` — increment in `DelegationController` handlers
- [x] 7.5 Add delegation endpoints to `docs/openapi.yaml` — include all request/response schemas, error responses, and authentication requirements as defined in spec
- [x] 7.6 Write unit tests for `delegationCrypto.ts` — test sign/verify round-trip, test tampered payload fails verification, test different secrets produce different signatures
- [x] 7.7 Write unit tests for `DelegationService` — mock DB and audit service; test: create delegation (valid), create delegation (scope escalation rejected), create delegation (self-delegation rejected), create delegation (delegatee in different tenant rejected), verify delegation (valid), verify delegation (expired — returns valid: false not throw), verify delegation (revoked — returns valid: false), revoke delegation (by delegator — succeeds), revoke delegation (by non-delegator — throws ForbiddenError), revoke delegation (already revoked — throws ConflictError)
- [x] 7.8 Write integration tests for delegation endpoints — test all happy paths and all error cases defined in spec; verify audit log entries are created for each delegation operation
## 8. WS5: Developer Experience — Scaffold Service
- [x] 8.1 Install `archiver` and `@types/archiver` in API `package.json`
- [x] 8.2 Create `src/types/scaffold.ts` — define `ScaffoldLanguage` union (`'typescript' | 'python' | 'go' | 'java' | 'rust'`), `ScaffoldOptions` interface, `ScaffoldTemplate` interface
- [x] 8.3 Create scaffold template files for TypeScript in `src/templates/scaffold/typescript/`: `package.json.tmpl`, `tsconfig.json.tmpl`, `src/index.ts.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl` — each file uses `{{AGENT_ID}}`, `{{AGENT_NAME}}`, `{{CLIENT_ID}}`, `{{API_URL}}` as template variables; `.env.example.tmpl` MUST include `AGENTIDP_CLIENT_SECRET=<your-client-secret>` placeholder (never inject real secret)
- [x] 8.4 Create scaffold template files for Python in `src/templates/scaffold/python/`: `requirements.txt.tmpl`, `main.py.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl` — same template variable convention
- [x] 8.5 Create scaffold template files for Go in `src/templates/scaffold/go/`: `go.mod.tmpl`, `main.go.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [x] 8.6 Create scaffold template files for Java in `src/templates/scaffold/java/`: `pom.xml.tmpl`, `src/main/java/Main.java.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [x] 8.7 Create scaffold template files for Rust in `src/templates/scaffold/rust/`: `Cargo.toml.tmpl`, `src/main.rs.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [x] 8.8 Create `src/services/ScaffoldService.ts` — implement `IScaffoldService`; `generateScaffold(agentId, language, apiUrl)`: load template files for language, inject template variables (replace `{{AGENT_ID}}`, `{{AGENT_NAME}}`, `{{CLIENT_ID}}`, `{{API_URL}}`), build in-memory ZIP using `archiver`; return `{ stream: NodeJS.ReadableStream, filename: string }`; emit `agentidp_scaffold_generated_total` counter and `agentidp_scaffold_generation_duration_ms` histogram
## 9. WS5: Developer Experience — Scaffold Controller & Route
- [x] 9.1 Create `src/controllers/ScaffoldController.ts` — implement `getScaffold` handler for `GET /sdk/scaffold/:agentId`: validate `language` query param against `ScaffoldLanguage` union (HTTP 400 on invalid); fetch agent, verify agent belongs to authenticated tenant (HTTP 403 if not); call `ScaffoldService.generateScaffold`; set `Content-Type: application/zip`, `Content-Disposition: attachment; filename="..."`, pipe stream to response; write audit log entry (`scaffold.generated`, metadata: `{ language }`)
- [x] 9.2 Create `src/routes/scaffold.ts` — Express router for `GET /sdk/scaffold/:agentId` with authentication middleware; apply scaffold-specific rate limiter (10 req/min per tenant, separate from global rate limiter)
- [x] 9.3 Register `scaffold` router in `src/routes/index.ts`
- [x] 9.4 Add `GET /sdk/scaffold/:agentId` to `docs/openapi.yaml` — document binary response type, query parameters, all error responses
- [x] 9.5 Write unit tests for `ScaffoldService` — test: generate TypeScript scaffold (verify ZIP contains all 6 files), generate Python scaffold (verify all 5 files), verify `{{CLIENT_ID}}` is replaced in `.env.example`, verify `{{AGENTIDP_CLIENT_SECRET}}` is placeholder not real secret, verify invalid language throws `ValidationError`
- [x] 9.6 Write integration tests for scaffold endpoint — test: TypeScript scaffold returns ZIP with correct `Content-Type` and `Content-Disposition`; Python scaffold returns ZIP; HTTP 400 on invalid language; HTTP 403 when agent belongs to different tenant; HTTP 404 when agent does not exist
## 10. WS5: Developer Experience — Portal & CLI
- [x] 10.1 Install `@stoplight/elements` in `portal/package.json` — remove `swagger-ui-react`
- [x] 10.2 Rewrite `portal/app/api-explorer/page.tsx` — replace `SwaggerUI` component with `@stoplight/elements` `<API>` component; set `apiDescriptionUrl`, `router="hash"`, `layout="sidebar"`, `hideSchemas={false}`, `tryItCredentialsPolicy="same-origin"`; import Elements CSS; remove all Swagger UI imports and CSS
- [x] 10.3 Run `npm run build` in `portal/` — verify zero TypeScript errors and zero ESLint errors after Elements integration
- [x] 10.4 Install `unzipper` and `@types/unzipper` in `cli/package.json`
- [x] 10.5 Create `cli/src/commands/scaffold.ts` — implement `sentryagent scaffold` command with Commander options: `--agent-id <id>` (required), `--language <lang>` (default: typescript), `--out <directory>` (default: `.`); load config, issue Bearer token, call `GET /sdk/scaffold/{agentId}?language={language}`, pipe response through `unzipper.Extract({ path: outDir })`, print success message and next steps; handle errors (404, 403, 400) with human-readable messages
- [x] 10.6 Register `scaffold` command in `cli/src/index.ts` — add `.addCommand(scaffoldCommand)` to Commander program
- [x] 10.7 Run `npm run build` in `cli/` — zero TypeScript errors; run `node dist/index.js scaffold --help` — outputs correct usage
## 11. QA & Release
- [x] 11.1 Run `cargo build` and `cargo clippy -- -D warnings` in `sdk-rust/` — zero warnings; run `cargo test` — all unit tests pass
- [x] 11.2 Run `tsc --noEmit` across API, portal, and CLI — zero TypeScript errors
- [x] 11.3 Run full Jest suite (`npm test`) — all unit tests pass, coverage >= 80% across all new services: `DelegationService`, `ScaffoldService`
- [x] 11.4 Run `npm run build` in `portal/` with Elements integration — zero errors; verify `/api-explorer` page renders Elements `<API>` component
- [x] 11.5 Run `npm run build` in `cli/` — zero errors; run `node dist/index.js scaffold --help` — shows correct options; run `node dist/index.js --help` — shows `scaffold` command listed
- [x] 11.6 Apply database migration `008_add_delegation_chains.sql` against a test database — verify migration runs without errors and table is created with correct schema
- [x] 11.7 Run integration tests for all Phase 5 endpoints — delegation (create, verify, revoke), scaffold (all 5 languages)
- [x] 11.8 Verify feature flag: `A2A_ENABLED=false` → delegation routes return 404
- [x] 11.9 Verify scaffold security: `GET /sdk/scaffold/:agentId` response ZIP never contains a real `client_secret` value — `.env.example` placeholder only
- [x] 11.10 Commit all Phase 5 work on `main` — one conventional commit per workstream: `feat(phase-5): WS1 — Rust SDK`, `feat(phase-5): WS2 — A2A Authorization`, `feat(phase-5): WS5 — Developer Experience`

View File

@@ -1,58 +0,0 @@
schema: spec-driven
id: phase-5-scale-ecosystem
title: "Phase 5 — Scale & Ecosystem"
status: proposed
created: "2026-04-02"
author: Virtual Architect
phase: 5
theme: "Scale & Ecosystem — making SentryAgent.ai the definitive standard for agent identity globally"
workstreams:
- id: ws1
name: Rust SDK
directory: specs/rust-sdk
- id: ws2
name: Agent-to-Agent (A2A) Authorization
directory: specs/a2a-authorization
- id: ws3
name: Advanced Analytics Dashboard
directory: specs/analytics-dashboard
- id: ws4
name: Public API Gateway & Rate Limiting SaaS
directory: specs/api-gateway-tiers
- id: ws5
name: Developer Experience (DX) Improvements
directory: specs/developer-experience
- id: ws6
name: AGNTCY Compliance Certification Package
directory: specs/agntcy-compliance
artifacts:
- proposal.md
- design.md
- specs/rust-sdk/spec.md
- specs/a2a-authorization/spec.md
- specs/analytics-dashboard/spec.md
- specs/api-gateway-tiers/spec.md
- specs/developer-experience/spec.md
- specs/agntcy-compliance/spec.md
- tasks.md
dependencies:
new:
- tokio
- reqwest
- serde
- serde_json
- swagger-ui-dist
- elements-api
- archiver
- recharts
- date-fns
existing:
- express
- postgresql
- redis
- stripe
- next
- commander

View File

@@ -1,320 +0,0 @@
## WS6: AGNTCY Compliance Certification Package
### Purpose
Position SentryAgent.ai as the reference implementation for the AGNTCY standard. Deliver four artifacts: (1) an auto-generated machine-readable AGNTCY compliance report endpoint; (2) an agent card export endpoint per the AGNTCY Agent Card specification; (3) a Jest-based interoperability test suite verifying AGNTCY alignment on every CI run; (4) a human-readable certification guide documenting how SentryAgent.ai satisfies each AGNTCY requirement.
This workstream produces no user-facing UI changes. It is infrastructure for compliance, certification, and ecosystem trust.
### New Endpoints
#### `GET /agntcy/compliance-report`
**Summary:** Generate and return a real-time AGNTCY compliance report for the authenticated tenant's environment.
**Authentication:** Bearer token (tenant-scoped). The tenant's subscription tier must be `pro` or `enterprise`.
**Response 200** (`application/json`):
```json
{
"reportId": "string (UUID)",
"generatedAt": "string (ISO 8601)",
"agntcySpecVersion": "1.0.0",
"tenantId": "string (UUID)",
"overallStatus": "compliant",
"sections": [
{
"id": "agent-identity",
"name": "Agent Identity",
"status": "compliant",
"requirements": [
{
"id": "AI-001",
"description": "Each agent MUST have a globally unique, persistent identifier",
"status": "compliant",
"evidence": "All agents are assigned a UUID v4 at registration, stored immutably in agents.id",
"verifiedAt": "string (ISO 8601)"
},
{
"id": "AI-002",
"description": "Each agent MUST have a W3C DID document",
"status": "compliant",
"evidence": "DID documents are auto-generated as did:web identifiers at agent registration",
"verifiedAt": "string (ISO 8601)"
}
]
},
{
"id": "authentication",
"name": "Authentication",
"status": "compliant",
"requirements": [
{
"id": "AUTH-001",
"description": "Agent authentication MUST use OAuth 2.0 or OIDC",
"status": "compliant",
"evidence": "OAuth 2.0 Client Credentials flow implemented at POST /oauth2/token",
"verifiedAt": "string (ISO 8601)"
}
]
},
{
"id": "authorization",
"name": "Authorization",
"status": "compliant",
"requirements": []
},
{
"id": "audit-and-governance",
"name": "Audit & Governance",
"status": "compliant",
"requirements": []
},
{
"id": "interoperability",
"name": "Interoperability",
"status": "compliant",
"requirements": []
},
{
"id": "delegation",
"name": "Agent-to-Agent Delegation",
"status": "compliant",
"requirements": []
}
],
"summary": {
"totalRequirements": 24,
"compliant": 24,
"nonCompliant": 0,
"notApplicable": 0
}
}
```
**`overallStatus`** values: `"compliant"` | `"partial"` | `"non-compliant"`
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 401 | `UNAUTHORIZED` | Missing or invalid Bearer token |
| 403 | `TIER_REQUIRED` | Compliance report requires Pro or Enterprise tier |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
**Business Rules:**
- Report is generated on demand from live system state — no cache
- Each requirement's `status` is computed by querying current system configuration (e.g., verify DID documents exist by checking `agents` table, verify audit log is enabled by checking config)
- `agntcySpecVersion` is hardcoded to the AGNTCY spec version the system was last validated against
- An audit log entry is created with `event_type: "compliance.report_generated"`
---
#### `GET /agents/:id/agent-card`
**Summary:** Return the AGNTCY-compliant Agent Card for a specific agent. Agent Cards are publicly accessible for public agents and require authentication for private agents.
**Authentication:** Optional. Required only if the agent's `is_public` is `false`.
**Path Parameter:**
| Parameter | Type | Description |
|---|---|---|
| `id` | string (UUID) | Agent ID |
**Response 200** (`application/json`):
Per the AGNTCY Agent Card specification:
```json
{
"agntcyVersion": "1.0",
"type": "agent-card",
"agent": {
"id": "string (UUID)",
"name": "string",
"description": "string | null",
"did": "did:web:sentryagent.ai:agents:abc123",
"capabilities": ["string"],
"version": "string",
"publisher": {
"tenantId": "string (UUID)",
"name": "string"
},
"endpoints": {
"tokenEndpoint": "https://api.sentryagent.ai/oauth2/token",
"delegationEndpoint": "https://api.sentryagent.ai/oauth2/token/delegate"
},
"authentication": {
"schemes": ["oauth2_client_credentials"],
"tokenEndpoint": "https://api.sentryagent.ai/oauth2/token"
},
"governance": {
"auditLogEnabled": true,
"credentialRotationPolicy": "manual",
"complianceStandards": ["AGNTCY-1.0", "OAuth2-RFC6749", "W3C-DID"]
},
"metadata": {}
},
"issuedAt": "string (ISO 8601)",
"expiresAt": "string (ISO 8601)"
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 401 | `UNAUTHORIZED` | Agent is private and no Bearer token provided |
| 403 | `FORBIDDEN` | Agent is private and authenticated tenant does not own it |
| 404 | `AGENT_NOT_FOUND` | No agent with the given ID |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
**Business Rules:**
- Public agents (`is_public: true`) return agent card without authentication
- Private agents require the owning tenant's Bearer token
- Agent card `expiresAt` is `issuedAt + 24 hours` (cards are short-lived — consumers should re-fetch daily)
- `complianceStandards` array is sourced from system config, not per-agent configuration
---
### AGNTCY Interoperability Test Suite
**File:** `tests/agntcy/interoperability.test.ts`
A Jest test suite that verifies AGNTCY alignment on every CI run. Tests run against a live API instance (reads `AGENTIDP_API_URL` from environment).
**Test categories and cases:**
```typescript
// AGNTCY-AI-001: Agent identity uniqueness
test('each registered agent receives a unique UUID', ...)
test('agent UUID is immutable after registration', ...)
// AGNTCY-AI-002: W3C DID documents
test('registered agent has a valid did:web DID', ...)
test('DID document resolves via GET /agents/:id', ...)
// AGNTCY-AUTH-001: OAuth 2.0 token issuance
test('POST /oauth2/token returns access_token and token_type: bearer', ...)
test('access token is a valid JWT with correct claims', ...)
test('expired token is rejected with 401', ...)
// AGNTCY-AUTH-002: OIDC compliance
test('GET /.well-known/openid-configuration returns valid OIDC discovery document', ...)
test('JWKS endpoint returns valid JWK Set', ...)
// AGNTCY-AUTHZ-001: Scope-based access control
test('token with agent:read scope cannot call agent:write operations', ...)
test('scopes are included in JWT payload', ...)
// AGNTCY-DEL-001: Agent-to-Agent delegation
test('POST /oauth2/token/delegate creates a valid delegation chain', ...)
test('delegated scopes cannot exceed delegator scopes', ...)
test('POST /oauth2/token/verify-delegation returns valid: true for active chain', ...)
test('POST /oauth2/token/verify-delegation returns valid: false for expired chain', ...)
// AGNTCY-AUDIT-001: Immutable audit logs
test('every token issuance creates an audit log entry', ...)
test('audit log entries cannot be deleted via API', ...)
// AGNTCY-GOV-001: Agent lifecycle governance
test('credential rotation is logged in audit log', ...)
test('agent deletion logs deletion event in audit log', ...)
// AGNTCY-INTER-001: Agent Card export
test('GET /agents/:id/agent-card returns valid AGNTCY Agent Card', ...)
test('Agent Card contains required agntcyVersion, did, capabilities fields', ...)
// AGNTCY-COMP-001: Compliance report
test('GET /agntcy/compliance-report returns compliant status', ...)
test('compliance report covers all 6 AGNTCY sections', ...)
test('compliance report totalRequirements >= 24', ...)
```
**Running the suite:**
```bash
# In CI (requires live API):
AGENTIDP_API_URL=http://localhost:3000 npm run test:agntcy
# Added to package.json:
"test:agntcy": "jest --testPathPattern=tests/agntcy --forceExit"
```
---
### AGNTCY Certification Guide
**File:** `docs/agntcy/certification-guide.md`
A markdown document structured as follows:
1. **Overview** — What AGNTCY certification means and how SentryAgent.ai achieves it
2. **Requirement Mapping** — Table mapping each AGNTCY requirement ID to the SentryAgent.ai implementation (endpoint, service, or config)
3. **Running the Compliance Report** — Step-by-step guide to generating and interpreting the compliance report
4. **Agent Card Usage** — How to retrieve, cache, and use Agent Cards in multi-agent workflows
5. **Self-Certification Checklist** — Checklist for operators deploying self-hosted SentryAgent.ai to verify their instance's compliance
6. **Submitting for Official AGNTCY Certification** — Links and instructions for the Linux Foundation AGNTCY certification program
---
### New Source Files
| File | Description |
|---|---|
| `src/services/ComplianceService.ts` | Business logic: query system state, evaluate each AGNTCY requirement, build report |
| `src/controllers/ComplianceController.ts` | HTTP handlers for compliance report and agent card endpoints |
| `src/routes/agntcy.ts` | Express router: `GET /agntcy/compliance-report`, `GET /agents/:id/agent-card` |
| `src/types/compliance.ts` | TypeScript interfaces: `ComplianceReport`, `ComplianceSection`, `ComplianceRequirement`, `AgentCard` |
| `src/config/agntcyRequirements.ts` | Static array of AGNTCY requirement definitions (id, description, evaluator function reference) |
| `tests/agntcy/interoperability.test.ts` | Jest interoperability test suite |
| `docs/agntcy/certification-guide.md` | Human-readable certification guide |
### Modified Source Files
| File | Change |
|---|---|
| `src/routes/index.ts` | Register `agntcy` router |
| `src/routes/agents.ts` | Add `GET /agents/:id/agent-card` route (or register via agntcy router — agent-card is agent-scoped) |
| `package.json` (API) | Add `"test:agntcy"` script |
| `docs/openapi.yaml` | Add `GET /agntcy/compliance-report` and `GET /agents/:id/agent-card` endpoints |
### `ComplianceService` Interface
```typescript
interface IComplianceService {
/**
* Generate a live AGNTCY compliance report for the given tenant.
* Evaluates all registered AGNTCY requirements against current system state.
*/
generateComplianceReport(tenantId: string): Promise<ComplianceReport>;
/**
* Generate an AGNTCY Agent Card for a specific agent.
*/
generateAgentCard(agentId: string): Promise<AgentCard>;
}
```
### Prometheus Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
| `agentidp_compliance_reports_generated_total` | Counter | `tenant_id` | Total compliance reports generated |
| `agentidp_compliance_report_duration_ms` | Histogram | — | Time to generate compliance report |
| `agentidp_agent_cards_served_total` | Counter | `visibility` (public/private) | Agent cards served by visibility |
### Feature Flag
`AGNTCY_ENABLED` (default: `true`). When `false`, all `/agntcy/` routes and `GET /agents/:id/agent-card` return HTTP 404.
### Acceptance Criteria
- `GET /agntcy/compliance-report` returns a report with `overallStatus: "compliant"` on a correctly configured instance
- Report contains all 6 sections: agent-identity, authentication, authorization, audit-and-governance, interoperability, delegation
- Report `totalRequirements >= 24`
- `GET /agents/:id/agent-card` returns a valid AGNTCY Agent Card with all required fields
- Agent Card is accessible without auth for public agents
- Agent Card requires owning tenant's auth for private agents
- All 25+ interoperability test cases pass against a live API instance
- `npm run test:agntcy` exits 0 on a correctly configured instance
- `docs/agntcy/certification-guide.md` is complete — no TODOs, no placeholders
- Unit tests cover: compliance report generation (compliant system, partially compliant), agent card generation (public agent, private agent)

View File

@@ -1,279 +0,0 @@
## WS3: Advanced Analytics Dashboard
### Purpose
Give paying tenants actionable visibility into their agent usage patterns. Analytics surface four dimensions: agent activity over time (heatmap), token issuance frequency and volume (trends), credential rotation frequency (rotation frequency table), and per-endpoint API call patterns (call patterns breakdown). Data is pre-aggregated nightly from the existing `usage_events` table into a new `analytics_daily_aggregates` table. Analytics are rendered in a new Analytics tab in the existing React web dashboard.
### New Endpoints
#### `GET /analytics/usage-summary`
**Summary:** Return a high-level usage summary for the authenticated tenant over a date range.
**Authentication:** Bearer token (tenant-scoped).
**Query Parameters:**
| Parameter | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `from` | string (YYYY-MM-DD) | no | 30 days ago | Must be <= `to` |
| `to` | string (YYYY-MM-DD) | no | today | Must be <= today |
**Response 200** (`application/json`):
```json
{
"tenantId": "string (UUID)",
"period": {
"from": "string (YYYY-MM-DD)",
"to": "string (YYYY-MM-DD)"
},
"summary": {
"totalApiCalls": 84320,
"totalTokenIssuances": 12400,
"totalCredentialRotations": 48,
"activeAgentCount": 23,
"averageDailyApiCalls": 2810,
"peakDailyApiCalls": 5102,
"peakDate": "2026-03-28"
}
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 400 | `INVALID_DATE_RANGE` | `from` > `to`, or date range exceeds 365 days |
| 401 | `UNAUTHORIZED` | Missing or invalid Bearer token |
| 403 | `ANALYTICS_NOT_AVAILABLE` | Tenant is on free tier — analytics require Pro or Enterprise |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
---
#### `GET /analytics/agent-activity`
**Summary:** Return per-agent daily activity counts for heatmap rendering.
**Authentication:** Bearer token (tenant-scoped).
**Query Parameters:**
| Parameter | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `from` | string (YYYY-MM-DD) | no | 30 days ago | Must be <= `to` |
| `to` | string (YYYY-MM-DD) | no | today | Max range: 90 days |
| `agentId` | string (UUID) | no | (all agents) | Filter to a single agent |
**Response 200** (`application/json`):
```json
{
"tenantId": "string (UUID)",
"period": {
"from": "string (YYYY-MM-DD)",
"to": "string (YYYY-MM-DD)"
},
"agents": [
{
"agentId": "string (UUID)",
"agentName": "string",
"dailyActivity": [
{
"date": "2026-03-01",
"apiCalls": 342,
"tokenIssuances": 12,
"credentialRotations": 0
}
]
}
]
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 400 | `INVALID_DATE_RANGE` | `from` > `to`, or date range exceeds 90 days |
| 401 | `UNAUTHORIZED` | Missing or invalid Bearer token |
| 403 | `ANALYTICS_NOT_AVAILABLE` | Free tier — requires Pro or Enterprise |
| 404 | `AGENT_NOT_FOUND` | `agentId` filter specified but agent does not belong to tenant |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
---
#### `GET /analytics/token-trends`
**Summary:** Return daily token issuance counts and success/failure breakdown for trend charts.
**Authentication:** Bearer token (tenant-scoped).
**Query Parameters:**
| Parameter | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `from` | string (YYYY-MM-DD) | no | 30 days ago | Must be <= `to` |
| `to` | string (YYYY-MM-DD) | no | today | Max range: 365 days |
| `granularity` | string | no | `day` | Enum: `day`, `week` |
**Response 200** (`application/json`):
```json
{
"tenantId": "string (UUID)",
"period": {
"from": "string (YYYY-MM-DD)",
"to": "string (YYYY-MM-DD)"
},
"granularity": "day",
"dataPoints": [
{
"date": "2026-03-01",
"totalIssuances": 420,
"successfulIssuances": 415,
"failedIssuances": 5,
"uniqueAgents": 8
}
]
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 400 | `INVALID_DATE_RANGE` | `from` > `to`, or date range exceeds 365 days |
| 400 | `INVALID_GRANULARITY` | `granularity` is not `day` or `week` |
| 401 | `UNAUTHORIZED` | Missing or invalid Bearer token |
| 403 | `ANALYTICS_NOT_AVAILABLE` | Free tier — requires Pro or Enterprise |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
---
### Database Schema Changes
#### Migration: `009_add_analytics_aggregates.sql`
```sql
CREATE TABLE analytics_daily_aggregates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, -- NULL = tenant-wide aggregate
date DATE NOT NULL,
metric_type VARCHAR(64) NOT NULL, -- 'api_calls' | 'token_issuances' | 'credential_rotations' | 'token_failures'
count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_daily_aggregate UNIQUE (tenant_id, agent_id, date, metric_type)
);
-- Index for analytics queries (tenant + date range)
CREATE INDEX idx_analytics_tenant_date ON analytics_daily_aggregates(tenant_id, date);
CREATE INDEX idx_analytics_agent_date ON analytics_daily_aggregates(agent_id, date) WHERE agent_id IS NOT NULL;
```
#### Nightly Aggregation Job
A `node-cron` job runs at `00:05 UTC` daily inside the Express API process. It executes an upsert query aggregating the previous day's `usage_events` rows into `analytics_daily_aggregates`. The job is idempotent — running it twice for the same date produces no duplicates (upsert on the unique constraint).
Job logic (pseudocode):
```
1. Compute target_date = yesterday (UTC)
2. SELECT tenant_id, agent_id, metric_type, SUM(count)
FROM usage_events
WHERE date = target_date
GROUP BY tenant_id, agent_id, metric_type
3. UPSERT INTO analytics_daily_aggregates
ON CONFLICT (tenant_id, agent_id, date, metric_type)
DO UPDATE SET count = EXCLUDED.count, updated_at = NOW()
```
### New Source Files
| File | Description |
|---|---|
| `src/services/AnalyticsService.ts` | Business logic: query aggregates, build response shapes, Redis caching |
| `src/controllers/AnalyticsController.ts` | HTTP handlers for analytics endpoints |
| `src/routes/analytics.ts` | Express router for `/analytics/` prefix |
| `src/jobs/analyticsAggregation.ts` | `node-cron` job that aggregates usage_events nightly |
| `src/types/analytics.ts` | TypeScript interfaces: `UsageSummary`, `AgentActivityResponse`, `TokenTrendsResponse`, `DailyAggregate` |
| `dashboard/src/pages/Analytics.tsx` | New Analytics tab in existing React dashboard |
| `dashboard/src/components/charts/AgentHeatmap.tsx` | Heatmap component using `recharts` `ResponsiveContainer` + custom cells |
| `dashboard/src/components/charts/TokenTrendsChart.tsx` | Line chart of token issuance over time using `recharts` `LineChart` |
| `dashboard/src/components/charts/RotationFrequencyTable.tsx` | Sortable table of credential rotation counts per agent |
| `dashboard/src/api/analyticsApi.ts` | Typed fetch functions for analytics endpoints |
### Modified Source Files
| File | Change |
|---|---|
| `src/app.ts` | Register `analytics` router; start nightly aggregation cron job |
| `src/infrastructure/migrations/` | Add `009_add_analytics_aggregates.sql` |
| `dashboard/src/App.tsx` | Add Analytics route and nav link |
| `package.json` (API) | Add `node-cron` dependency |
| `package.json` (dashboard) | Add `recharts`, `date-fns` dependencies |
| `docs/openapi.yaml` | Add analytics endpoints |
### Redis Caching
Analytics responses are cached in Redis with `analytics:{tenantId}:{endpoint}:{queryHash}` keys. TTL: 5 minutes for agent-activity and token-trends; 60 seconds for usage-summary. Cache is invalidated on the next request after TTL expiry (no explicit invalidation).
### `AnalyticsService` Interface
```typescript
interface IAnalyticsService {
/**
* Return a high-level usage summary for a tenant over a date range.
*/
getUsageSummary(tenantId: string, from: Date, to: Date): Promise<UsageSummary>;
/**
* Return per-agent daily activity data for heatmap rendering.
*/
getAgentActivity(
tenantId: string,
from: Date,
to: Date,
agentId?: string
): Promise<AgentActivityResponse>;
/**
* Return daily token issuance trends with success/failure breakdown.
*/
getTokenTrends(
tenantId: string,
from: Date,
to: Date,
granularity: 'day' | 'week'
): Promise<TokenTrendsResponse>;
}
```
### Prometheus Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
| `agentidp_analytics_query_duration_ms` | Histogram | `endpoint` | Analytics query latency (before cache) |
| `agentidp_analytics_cache_hits_total` | Counter | `endpoint` | Analytics Redis cache hits |
| `agentidp_analytics_cache_misses_total` | Counter | `endpoint` | Analytics Redis cache misses |
| `agentidp_analytics_aggregation_job_duration_ms` | Gauge | — | Nightly aggregation job runtime |
| `agentidp_analytics_aggregation_job_last_run` | Gauge | — | Unix timestamp of last successful aggregation job run |
### Feature Flags
| Variable | Default | Description |
|---|---|---|
| `ANALYTICS_ENABLED` | `true` | When `false`, all `/analytics/` routes return HTTP 404 |
| `ANALYTICS_FREE_TIER` | `false` | When `true`, free tier tenants can access analytics (for beta/testing) |
### Acceptance Criteria
- `GET /analytics/usage-summary` returns correct aggregate counts for a date range
- `GET /analytics/agent-activity` returns per-agent daily rows matching `analytics_daily_aggregates`
- `GET /analytics/token-trends` returns daily and weekly granularity correctly
- All three endpoints return HTTP 403 for free-tier tenants (when `ANALYTICS_FREE_TIER=false`)
- Date range validation rejects `from > to` with HTTP 400
- Nightly aggregation job runs idempotently — running twice for same date produces no duplicates
- Analytics responses are cached in Redis — a second identical request does not hit the DB
- Dashboard Analytics tab renders heatmap, trend chart, and rotation table with mock data in Storybook
- Unit test coverage >= 80% on `AnalyticsService`
- Integration tests cover: summary, activity, trends (daily), trends (weekly), free-tier rejection, invalid date range

View File

@@ -1,276 +0,0 @@
## WS4: Public API Gateway & Rate Limiting SaaS
### Purpose
Replace the single flat rate limit (Phase 4) with a multi-tier enforcement model where each tenant's rate limits are determined by their subscription tier (`free` | `pro` | `enterprise`). Expose the tier definitions publicly via `GET /tiers` so developers can understand limits before registering. Add `POST /billing/upgrade` so tenants can self-service upgrade their tier without contacting support.
This workstream closes the gap between Phase 4's flat rate limiter and a proper commercial SaaS gateway model.
### New Endpoints
#### `GET /tiers`
**Summary:** Return the current tier definitions including rate limits, feature flags, and pricing.
**Authentication:** None (public endpoint).
**Response 200** (`application/json`):
```json
{
"tiers": [
{
"id": "free",
"name": "Free",
"price": {
"monthly": 0,
"currency": "USD"
},
"limits": {
"registeredAgents": 10,
"apiCallsPerDay": 1000,
"tokenIssuancesPerDay": 200,
"rateLimitPerMinute": 60,
"rateLimitBurst": 10,
"auditLogRetentionDays": 30
},
"features": {
"marketplace": true,
"githubActions": true,
"analytics": false,
"webhooks": false,
"sso": false,
"sla": false,
"customDomain": false,
"prioritySupport": false
}
},
{
"id": "pro",
"name": "Pro",
"price": {
"monthly": 49,
"currency": "USD"
},
"limits": {
"registeredAgents": 100,
"apiCallsPerDay": 50000,
"tokenIssuancesPerDay": 10000,
"rateLimitPerMinute": 600,
"rateLimitBurst": 100,
"auditLogRetentionDays": 90
},
"features": {
"marketplace": true,
"githubActions": true,
"analytics": true,
"webhooks": true,
"sso": false,
"sla": false,
"customDomain": false,
"prioritySupport": false
}
},
{
"id": "enterprise",
"name": "Enterprise",
"price": {
"monthly": null,
"currency": "USD",
"note": "Contact sales"
},
"limits": {
"registeredAgents": null,
"apiCallsPerDay": null,
"tokenIssuancesPerDay": null,
"rateLimitPerMinute": 6000,
"rateLimitBurst": 1000,
"auditLogRetentionDays": 365
},
"features": {
"marketplace": true,
"githubActions": true,
"analytics": true,
"webhooks": true,
"sso": true,
"sla": true,
"customDomain": true,
"prioritySupport": true
}
}
]
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 429 | `RATE_LIMITED` | Rate limit exceeded (even unauthenticated endpoints have a global IP-based limit) |
**Notes:**
- `null` limits mean unlimited
- Tier definitions are sourced from a static configuration object in the codebase, not a database table
- The response is cached at the HTTP layer with `Cache-Control: public, max-age=3600`
---
#### `POST /billing/upgrade`
**Summary:** Initiate a self-service tier upgrade for the authenticated tenant. Creates a Stripe Checkout session for the target tier.
**Authentication:** Bearer token (tenant-scoped).
**Request Body** (`application/json`):
```json
{
"targetTier": "pro"
}
```
| Field | Type | Required | Constraints |
|---|---|---|---|
| `targetTier` | string | yes | Enum: `pro`, `enterprise` — cannot downgrade via this endpoint |
**Response 200** (`application/json`):
```json
{
"checkoutUrl": "https://checkout.stripe.com/pay/cs_...",
"sessionId": "cs_...",
"targetTier": "pro",
"expiresAt": "string (ISO 8601)"
}
```
**Error Responses:**
| Status | Code | Description |
|---|---|---|
| 400 | `ALREADY_ON_TIER` | Tenant is already subscribed to `targetTier` |
| 400 | `INVALID_TARGET_TIER` | `targetTier` is not a valid upgradeable tier |
| 400 | `DOWNGRADE_NOT_SUPPORTED` | `targetTier` is lower than the tenant's current tier |
| 401 | `UNAUTHORIZED` | Missing or invalid Bearer token |
| 422 | `STRIPE_ERROR` | Stripe API returned an error creating the Checkout session |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
**Business Rules:**
- This endpoint extends the existing `BillingService` — a new `upgradeTier(tenantId, targetTier)` method creates a Stripe Checkout session with the correct Stripe Price ID for the target tier
- The Stripe Price IDs per tier are configured via environment variables: `STRIPE_PRICE_ID_PRO`, `STRIPE_PRICE_ID_ENTERPRISE`
- After payment, Stripe sends `customer.subscription.created` webhook → existing webhook handler updates `tenant_subscriptions`
- The `TierRateLimiter` reads the updated tier from `tenant_subscriptions` within 60 seconds (Redis cache TTL for tier lookup)
- Downgrade is handled via the existing Stripe customer portal — not exposed as an API endpoint
---
### `TierRateLimiter` Middleware
This replaces the single `RateLimiterRedis` middleware for all authenticated routes. It reads the tenant's current tier, looks up the tier rate limit configuration, and enforces it using per-tenant Redis keys via `rate-limiter-flexible`.
**Middleware behavior:**
1. Extract `tenantId` from the authenticated request context
2. Look up tier from Redis cache key `tier:{tenantId}` (TTL: 60 seconds)
3. On cache miss: query `tenant_subscriptions` for `tenantId`, cache result for 60s
4. Look up rate limit configuration for the tier from the static tier config
5. Apply `rate-limiter-flexible` with key `rl:{tier}:{tenantId}` and tier-specific limits
6. On rate limit exceeded: return HTTP 429 with headers:
- `X-RateLimit-Limit: <limit>`
- `X-RateLimit-Remaining: <remaining>`
- `X-RateLimit-Reset: <unix timestamp>`
- `Retry-After: <seconds>`
7. Increment `agentidp_rate_limit_hits_total` counter (labels: `tier`, `tenant_id`, `endpoint`)
**Unauthenticated routes:** Continue to use the existing flat `RateLimiterRedis` with IP-based keys (unchanged from Phase 4).
### Tier Configuration Object
Centralized in `src/config/tiers.ts` — this is the single source of truth for all tier limits and features. Both `GET /tiers` and `TierRateLimiter` read from this same object.
```typescript
export const TIER_CONFIG: Record<TierName, TierDefinition> = {
free: {
id: 'free',
limits: {
registeredAgents: 10,
apiCallsPerDay: 1000,
tokenIssuancesPerDay: 200,
rateLimitPerMinute: 60,
rateLimitBurst: 10,
auditLogRetentionDays: 30,
},
features: { analytics: false, webhooks: false, sso: false, sla: false },
stripeProductId: null,
},
pro: {
id: 'pro',
limits: {
registeredAgents: 100,
apiCallsPerDay: 50000,
tokenIssuancesPerDay: 10000,
rateLimitPerMinute: 600,
rateLimitBurst: 100,
auditLogRetentionDays: 90,
},
features: { analytics: true, webhooks: true, sso: false, sla: false },
stripeProductId: process.env.STRIPE_PRICE_ID_PRO ?? '',
},
enterprise: {
id: 'enterprise',
limits: {
registeredAgents: null,
apiCallsPerDay: null,
tokenIssuancesPerDay: null,
rateLimitPerMinute: 6000,
rateLimitBurst: 1000,
auditLogRetentionDays: 365,
},
features: { analytics: true, webhooks: true, sso: true, sla: true },
stripeProductId: process.env.STRIPE_PRICE_ID_ENTERPRISE ?? '',
},
};
```
### New Source Files
| File | Description |
|---|---|
| `src/config/tiers.ts` | Static tier configuration — single source of truth for limits and features |
| `src/middleware/tierRateLimiter.ts` | `TierRateLimiter` middleware — reads tenant tier, enforces tier-specific limits |
| `src/routes/tiers.ts` | Express router for `GET /tiers` |
| `src/types/tiers.ts` | TypeScript interfaces: `TierDefinition`, `TierName`, `TierLimits`, `TierFeatures` |
### Modified Source Files
| File | Change |
|---|---|
| `src/middleware/rateLimiter.ts` | Retain for unauthenticated routes; authenticated routes switch to `tierRateLimiter` |
| `src/services/BillingService.ts` | Add `upgradeTier(tenantId, targetTier)` method |
| `src/controllers/BillingController.ts` | Add handler for `POST /billing/upgrade` |
| `src/routes/billing.ts` | Register `POST /billing/upgrade` route |
| `src/routes/index.ts` | Register `tiers` router |
| `.env.example` | Add `STRIPE_PRICE_ID_PRO`, `STRIPE_PRICE_ID_ENTERPRISE`, `TIER_RATE_LIMITING_ENABLED` |
| `docs/openapi.yaml` | Add `GET /tiers` and `POST /billing/upgrade` endpoints |
### Prometheus Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
| `agentidp_rate_limit_hits_total` | Counter | `tier`, `tenant_id`, `endpoint` | Rate limit rejections per tier (replaces old flat counter) |
| `agentidp_tier_cache_hits_total` | Counter | — | Tier Redis cache hits |
| `agentidp_tier_cache_misses_total` | Counter | — | Tier Redis cache misses |
| `agentidp_billing_upgrades_total` | Counter | `from_tier`, `to_tier` | Self-service upgrade checkout sessions created |
### Feature Flag
`TIER_RATE_LIMITING_ENABLED` (default: `true`). When `false`, the system uses the old flat `RateLimiterRedis` middleware — this is the rollback mechanism.
### Acceptance Criteria
- `GET /tiers` returns all three tier definitions matching `TIER_CONFIG` exactly — no database query, cached `Cache-Control: max-age=3600`
- `POST /billing/upgrade` creates a Stripe Checkout session and returns `checkoutUrl`
- `POST /billing/upgrade` returns HTTP 400 `ALREADY_ON_TIER` when tenant is already on the target tier
- `POST /billing/upgrade` returns HTTP 400 `DOWNGRADE_NOT_SUPPORTED` when target tier is lower than current
- `TierRateLimiter` enforces free tier limits (60 req/min) for free tenants
- `TierRateLimiter` enforces pro tier limits (600 req/min) for pro tenants
- Tier lookup is cached in Redis — second request does not query `tenant_subscriptions`
- Rate limit response includes `X-RateLimit-*` headers and `Retry-After`
- After a Stripe webhook updates `tenant_subscriptions` to `pro`, `TierRateLimiter` applies pro limits within 60 seconds (next cache refresh)
- Unit tests cover: tier lookup (cached), tier lookup (miss), free limit enforcement, pro limit enforcement, upgrade (success), upgrade (already on tier), upgrade (downgrade rejected)

View File

@@ -1,175 +0,0 @@
## 1. WS1: Rust SDK — Crate Setup
- [ ] 1.1 Create `sdk-rust/` directory and `Cargo.toml` — name: `sentryagent-idp`, version: `1.0.0`, edition: `2021`; add dependencies: `tokio` (features: full), `reqwest` (features: json, rustls-tls), `serde` (features: derive), `serde_json`, `uuid` (features: v4), `thiserror`, `async-trait`; add dev-dependencies: `tokio-test`, `mockito`
- [ ] 1.2 Create `sdk-rust/src/lib.rs` — crate root with `#![deny(warnings)]`; re-export `AgentIdPClient`, `TokenManager`, `AgentIdPError`, and all model types from submodules; add crate-level `//!` doc comment describing the SDK
- [ ] 1.3 Create `sdk-rust/src/error.rs` — define `AgentIdPError` enum with variants: `HttpError(reqwest::Error)`, `ApiError { status: u16, message: String, code: Option<String> }`, `AuthError(String)`, `NotFound(String)`, `RateLimited { retry_after_secs: u64 }`, `ConfigError(String)`, `SerdeError(serde_json::Error)`, `DelegationError(String)`; derive `thiserror::Error` and `Debug`; implement `std::error::Error`
- [ ] 1.4 Create `sdk-rust/src/models.rs` — define all request structs (`RegisterAgentRequest`, `UpdateAgentRequest`, `AuditLogFilters`, `MarketplaceFilters`, `DelegateRequest`) and all response structs (`Agent`, `AgentList`, `TokenResponse`, `Credentials`, `AuditLogEntry`, `AuditLogList`, `MarketplaceAgent`, `MarketplaceAgentList`, `DelegationToken`, `DelegationVerification`); all structs derive `serde::Serialize`, `serde::Deserialize`, `Debug`, `Clone`
## 2. WS1: Rust SDK — Token Manager
- [ ] 2.1 Create `sdk-rust/src/token_manager.rs` — define `TokenCache` struct with `access_token: Option<String>` and `expires_at: Option<std::time::Instant>`; define `TokenManager` struct with fields `api_url`, `client_id`, `client_secret`, `cache: Arc<Mutex<TokenCache>>`
- [ ] 2.2 Implement `TokenManager::new(api_url: &str, client_id: &str, client_secret: &str) -> Self` — initializes with empty cache
- [ ] 2.3 Implement `TokenManager::get_token(&self) -> Result<String, AgentIdPError>` — acquires lock, checks `expires_at` against `Instant::now() + 60s`, returns cached token if valid, else calls `POST /oauth2/token` via `reqwest`, updates cache, releases lock
- [ ] 2.4 Write unit test `token_manager_returns_cached_token` — mock `POST /oauth2/token` using `mockito`, call `get_token()` twice, verify mock is hit only once
- [ ] 2.5 Write unit test `token_manager_refreshes_expired_token` — set `expires_at` to past, verify `get_token()` triggers a new `POST /oauth2/token` call
- [ ] 2.6 Write concurrent safety test `token_manager_concurrent_calls_no_race` — spawn 50 `tokio::spawn` tasks all calling `get_token()` simultaneously, verify mock is hit at most once (no thundering herd), verify all 50 tasks receive valid tokens
## 3. WS1: Rust SDK — Client Methods
- [ ] 3.1 Create `sdk-rust/src/client.rs` — define `AgentIdPClient` struct with fields `base_url`, `client_id`, `client_secret`, `http: reqwest::Client`, `token_manager: Arc<Mutex<TokenManager>>`; implement `new(base_url, client_id, client_secret) -> Self` and `from_env() -> Result<Self, AgentIdPError>` (reads `AGENTIDP_API_URL`, `AGENTIDP_CLIENT_ID`, `AGENTIDP_CLIENT_SECRET`)
- [ ] 3.2 Create `sdk-rust/src/agents.rs` — implement all agent methods on `AgentIdPClient`: `register_agent`, `get_agent`, `list_agents`, `update_agent`, `delete_agent` — each acquires a bearer token via `token_manager.get_token()`, makes the correct HTTP call, deserializes response, maps non-2xx responses to `AgentIdPError::ApiError`
- [ ] 3.3 Create `sdk-rust/src/oauth2.rs` — implement `issue_token(&self, agent_id: &str, scopes: &[&str]) -> Result<TokenResponse, AgentIdPError>` — sends `POST /oauth2/token` with `grant_type=client_credentials`
- [ ] 3.4 Create `sdk-rust/src/credentials.rs` — implement `generate_credentials`, `rotate_credentials`, `revoke_credentials` — map 404 response to `AgentIdPError::NotFound`, map 401 to `AgentIdPError::AuthError`
- [ ] 3.5 Create `sdk-rust/src/audit.rs` — implement `list_audit_logs(filters: AuditLogFilters)` — serialize filters as query parameters; handle empty result set (return empty `Vec`, not error)
- [ ] 3.6 Create `sdk-rust/src/marketplace.rs` — implement `list_public_agents(filters)` and `get_public_agent(agent_id)` — no auth header required for these endpoints
- [ ] 3.7 Create `sdk-rust/src/delegation.rs` — implement `delegate(req: DelegateRequest)` and `verify_delegation(token: &str)`
- [ ] 3.8 Implement 429 handling across all client methods — parse `Retry-After` header, return `AgentIdPError::RateLimited { retry_after_secs }`; verify zero `unwrap()` calls in all `src/` files (run `grep -r 'unwrap()' sdk-rust/src/` — must return empty)
## 4. WS1: Rust SDK — Tests, Examples, Documentation
- [ ] 4.1 Create `sdk-rust/examples/quickstart.rs` — working example: create `AgentIdPClient::from_env()`, call `register_agent`, call `issue_token`, print token; example must compile with `cargo build --example quickstart`
- [ ] 4.2 Create `sdk-rust/tests/integration_test.rs` — integration tests requiring `AGENTIDP_API_URL`, `AGENTIDP_CLIENT_ID`, `AGENTIDP_CLIENT_SECRET` env vars; test: register agent, issue token, get agent, update agent, rotate credentials, delete agent; each test is `#[tokio::test]` with `#[ignore]` attribute (run explicitly with `cargo test -- --ignored`)
- [ ] 4.3 Write `sdk-rust/README.md` — installation via `Cargo.toml`, environment variable configuration, quickstart code example, full method reference table with signatures, error handling guide, link to crates.io
- [ ] 4.4 Run `cargo doc --no-deps` — verify docs generate without errors or warnings; verify all public items have `///` doc comments
- [ ] 4.5 Run `cargo clippy -- -D warnings` — zero warnings; run `cargo test` (unit tests only, no `--ignored`) — all pass
## 5. WS2: A2A Authorization — Database & Types
- [ ] 5.1 Create `src/infrastructure/migrations/008_add_delegation_chains.sql` — create `delegation_chains` table with columns: `id` (UUID PK), `tenant_id` (UUID FK), `delegator_agent_id` (UUID FK), `delegatee_agent_id` (UUID FK), `scopes` (TEXT[]), `delegation_token` (TEXT UNIQUE), `signature` (TEXT), `ttl_seconds` (INTEGER CHECK 6086400), `issued_at` (TIMESTAMPTZ), `expires_at` (TIMESTAMPTZ), `revoked_at` (TIMESTAMPTZ nullable), `created_at` (TIMESTAMPTZ DEFAULT NOW); create all four indexes as specified in spec
- [ ] 5.2 Create `src/types/delegation.ts` — define interfaces: `DelegationChain`, `CreateDelegationRequest` (delegateeAgentId, scopes, ttlSeconds), `DelegationVerificationResult` (valid, chainId, delegatorAgentId, delegateeAgentId, scopes, issuedAt, expiresAt, revokedAt), `DelegationTokenPayload`
## 6. WS2: A2A Authorization — Crypto & Service
- [ ] 6.1 Create `src/utils/delegationCrypto.ts` — implement `signDelegationPayload(payload: DelegationTokenPayload, secret: string): string` using HMAC-SHA256 (Node.js `crypto.createHmac('sha256', secret)`); implement `verifyDelegationSignature(payload: DelegationTokenPayload, signature: string, secret: string): boolean`; implement `generateDelegationToken(): string` (UUID v4); export only these three functions — no other exports
- [ ] 6.2 Create `src/services/DelegationService.ts` — implement `IDelegationService` interface; `createDelegation`: validate delegateeAgentId exists in same tenant, validate scopes ⊆ delegator's scopes, reject self-delegation, sign payload, insert `delegation_chains` row, write audit log entry (`delegation.created`), return `DelegationChain`
- [ ] 6.3 Implement `DelegationService.verifyDelegation(delegationToken)` — fetch chain row by `delegation_token`, if not found throw `NotFoundError`, verify HMAC signature, check `expires_at > NOW()` and `revoked_at IS NULL`, return `DelegationVerificationResult` with `valid: true/false` (never throw on expired/revoked — return `valid: false`); write audit log entry (`delegation.verified`)
- [ ] 6.4 Implement `DelegationService.revokeDelegation(chainId, requestingAgentId)` — fetch chain by ID, verify `delegator_agent_id === requestingAgentId` (else throw `ForbiddenError`), check not already revoked (else throw `ConflictError`), update `revoked_at = NOW()`, write audit log entry (`delegation.revoked`)
## 7. WS2: A2A Authorization — Controller, Routes, Tests
- [ ] 7.1 Create `src/controllers/DelegationController.ts` — implement `createDelegation` handler (POST /oauth2/token/delegate): extract authenticated agent ID from request context, call `DelegationService.createDelegation`, return HTTP 201; implement `verifyDelegation` handler (POST /oauth2/token/verify-delegation): call `DelegationService.verifyDelegation`, return HTTP 200; implement `revokeDelegation` handler (DELETE /oauth2/token/delegate/:chainId): call `DelegationService.revokeDelegation`, return HTTP 204
- [ ] 7.2 Create `src/routes/delegation.ts` — Express router registering `POST /oauth2/token/delegate`, `POST /oauth2/token/verify-delegation`, `DELETE /oauth2/token/delegate/:chainId` with authentication middleware on all three routes
- [ ] 7.3 Register delegation router in `src/routes/index.ts` behind `A2A_ENABLED` feature flag — return HTTP 404 on all delegation routes when `A2A_ENABLED=false`
- [ ] 7.4 Add delegation Prometheus metrics: `agentidp_delegations_created_total`, `agentidp_delegations_verified_total` (labels: result), `agentidp_delegations_revoked_total` — increment in `DelegationController` handlers
- [ ] 7.5 Add delegation endpoints to `docs/openapi.yaml` — include all request/response schemas, error responses, and authentication requirements as defined in spec
- [ ] 7.6 Write unit tests for `delegationCrypto.ts` — test sign/verify round-trip, test tampered payload fails verification, test different secrets produce different signatures
- [ ] 7.7 Write unit tests for `DelegationService` — mock DB and audit service; test: create delegation (valid), create delegation (scope escalation rejected), create delegation (self-delegation rejected), create delegation (delegatee in different tenant rejected), verify delegation (valid), verify delegation (expired — returns valid: false not throw), verify delegation (revoked — returns valid: false), revoke delegation (by delegator — succeeds), revoke delegation (by non-delegator — throws ForbiddenError), revoke delegation (already revoked — throws ConflictError)
- [ ] 7.8 Write integration tests for delegation endpoints — test all happy paths and all error cases defined in spec; verify audit log entries are created for each delegation operation
## 8. WS3: Analytics — Database, Aggregation Job
- [ ] 8.1 Create `src/infrastructure/migrations/009_add_analytics_aggregates.sql` — create `analytics_daily_aggregates` table with columns: `id` (UUID PK), `tenant_id` (UUID FK), `agent_id` (UUID nullable FK), `date` (DATE), `metric_type` (VARCHAR 64), `count` (BIGINT), `created_at`, `updated_at`; add unique constraint on `(tenant_id, agent_id, date, metric_type)`; create indexes on `(tenant_id, date)` and `(agent_id, date) WHERE agent_id IS NOT NULL`
- [ ] 8.2 Install `node-cron` npm package — add to `package.json`
- [ ] 8.3 Create `src/jobs/analyticsAggregation.ts` — implement `runAnalyticsAggregation(targetDate: Date): Promise<void>`: execute upsert query aggregating previous day's `usage_events` rows into `analytics_daily_aggregates`; query is idempotent (upsert on unique constraint); update `agentidp_analytics_aggregation_job_duration_ms` gauge and `agentidp_analytics_aggregation_job_last_run` gauge on completion
- [ ] 8.4 Register cron job in `src/app.ts` — schedule `runAnalyticsAggregation` at `00:05 UTC` daily using `node-cron`; log job start, completion, and any errors; do not crash the process on job failure — log error and continue
## 9. WS3: Analytics — Service, Controller, Routes
- [ ] 9.1 Create `src/types/analytics.ts` — define interfaces: `UsageSummary`, `AgentActivityResponse`, `TokenTrendsResponse`, `DailyAggregate`, `AnalyticsDateRange`
- [ ] 9.2 Create `src/services/AnalyticsService.ts` — implement `IAnalyticsService`; `getUsageSummary`: validate date range (from <= to, max 365 days), check Redis cache (`analytics:{tenantId}:summary:{hash}`, TTL 60s), on miss query `analytics_daily_aggregates`, compute totals, write to cache, return `UsageSummary`
- [ ] 9.3 Implement `AnalyticsService.getAgentActivity(tenantId, from, to, agentId?)` — validate date range (max 90 days), check Redis cache (TTL 5 min), on miss query `analytics_daily_aggregates` grouped by `agent_id` and `date`, join agent names from `agents` table, write to cache, return `AgentActivityResponse`
- [ ] 9.4 Implement `AnalyticsService.getTokenTrends(tenantId, from, to, granularity)` — support `day` and `week` granularity (weekly: `date_trunc('week', date)`), check Redis cache (TTL 5 min), return `TokenTrendsResponse` with `successfulIssuances`, `failedIssuances`, `uniqueAgents` per period
- [ ] 9.5 Create `src/controllers/AnalyticsController.ts` — handlers for `getUsageSummary`, `getAgentActivity`, `getTokenTrends`; parse and validate query parameters; return HTTP 403 for free-tier tenants (check `ANALYTICS_FREE_TIER` env and tenant subscription); emit `agentidp_analytics_query_duration_ms` histogram and cache hit/miss counters
- [ ] 9.6 Create `src/routes/analytics.ts` — Express router for `/analytics/usage-summary`, `/analytics/agent-activity`, `/analytics/token-trends`; all routes require authentication middleware
- [ ] 9.7 Register analytics router in `src/routes/index.ts` behind `ANALYTICS_ENABLED` feature flag
- [ ] 9.8 Add analytics endpoints to `docs/openapi.yaml` — all query parameters, response schemas, and error codes as defined in spec
- [ ] 9.9 Write unit tests for `AnalyticsService` — test: usage-summary (cache hit), usage-summary (cache miss → DB query), agent-activity (with agentId filter), agent-activity (no filter — all agents), token-trends (daily), token-trends (weekly), date range validation (from > to rejected), date range validation (> max days rejected), free-tier rejection
- [ ] 9.10 Write integration tests for analytics endpoints — test all three endpoints with valid date ranges, verify free-tier rejection, verify invalid date range errors
## 10. WS3: Analytics — Dashboard UI
- [ ] 10.1 Install `recharts` and `date-fns` in `dashboard/package.json`
- [ ] 10.2 Create `dashboard/src/api/analyticsApi.ts` — typed fetch functions for all three analytics endpoints: `fetchUsageSummary(token, from, to)`, `fetchAgentActivity(token, from, to, agentId?)`, `fetchTokenTrends(token, from, to, granularity)`; all functions return typed response objects; handle 403 response with a typed `AnalyticsNotAvailableError`
- [ ] 10.3 Create `dashboard/src/components/charts/AgentHeatmap.tsx` — renders a grid heatmap (agents × dates) using `recharts` or a custom CSS grid; color intensity represents `apiCalls` count; hover tooltip shows agent name, date, apiCalls, tokenIssuances, credentialRotations; accepts `agents` prop from `AgentActivityResponse`
- [ ] 10.4 Create `dashboard/src/components/charts/TokenTrendsChart.tsx` — renders a `recharts` `ComposedChart` with a `Line` for `successfulIssuances` and a `Bar` for `failedIssuances`; X-axis is dates; tooltip shows all three metrics per period; accepts `dataPoints` prop from `TokenTrendsResponse`
- [ ] 10.5 Create `dashboard/src/components/charts/RotationFrequencyTable.tsx` — renders a sortable table of credential rotation counts per agent; columns: Agent Name, Rotations (period), Last Rotation Date; sortable by any column; accepts `agents` prop derived from `AgentActivityResponse` filtering `credentialRotations`
- [ ] 10.6 Create `dashboard/src/pages/Analytics.tsx` — analytics tab page; renders date range picker (from/to), calls all three analytics APIs, renders `AgentHeatmap`, `TokenTrendsChart`, `RotationFrequencyTable`; shows a `UpgradeRequired` component when API returns 403
- [ ] 10.7 Add Analytics route to `dashboard/src/App.tsx` — add `/analytics` route; add "Analytics" link to dashboard navigation
- [ ] 10.8 Run `npm run build` in `dashboard/` — zero TypeScript errors, zero ESLint errors
## 11. WS4: API Gateway Tiers — Configuration & Middleware
- [ ] 11.1 Create `src/types/tiers.ts` — define interfaces: `TierName` (union: `'free' | 'pro' | 'enterprise'`), `TierLimits`, `TierFeatures`, `TierDefinition` (includes `id`, `limits`, `features`, `stripeProductId`)
- [ ] 11.2 Create `src/config/tiers.ts` — define `TIER_CONFIG: Record<TierName, TierDefinition>` with complete limit and feature definitions for `free`, `pro`, and `enterprise` tiers as specified in spec; export `getTierConfig(tier: TierName): TierDefinition` helper
- [ ] 11.3 Create `src/middleware/tierRateLimiter.ts` — implement `TierRateLimiter` middleware: extract `tenantId` from authenticated request context; check Redis key `tier:{tenantId}` (TTL 60s) for cached tier; on miss query `tenant_subscriptions` for tenant's current tier, cache for 60s; look up rate limit config from `TIER_CONFIG`; apply `RateLimiterRedis` with key `rl:{tier}:{tenantId}`; on rejection return HTTP 429 with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` headers; increment `agentidp_rate_limit_hits_total` counter with `tier` and `tenant_id` labels
- [ ] 11.4 Replace `RateLimiterRedis` middleware on all authenticated routes in `src/routes/index.ts` with `TierRateLimiter`; keep the flat IP-based `RateLimiterRedis` on unauthenticated routes unchanged; wrap replacement in `TIER_RATE_LIMITING_ENABLED` feature flag (fall back to old middleware when `false`)
## 12. WS4: API Gateway Tiers — Endpoints
- [ ] 12.1 Create `src/routes/tiers.ts` — Express router for `GET /tiers`; handler reads `TIER_CONFIG`, formats response as specified in spec, sets `Cache-Control: public, max-age=3600` header; no database query; no authentication required
- [ ] 12.2 Register `tiers` router in `src/routes/index.ts`
- [ ] 12.3 Implement `BillingService.upgradeTier(tenantId: string, targetTier: 'pro' | 'enterprise'): Promise<{ checkoutUrl: string; sessionId: string; expiresAt: string }>` — fetch current tier from `tenant_subscriptions`, validate no self-upgrade or downgrade, create Stripe Checkout session with `STRIPE_PRICE_ID_PRO` or `STRIPE_PRICE_ID_ENTERPRISE`, return checkout URL
- [ ] 12.4 Add `upgradeTier` handler to `src/controllers/BillingController.ts` — validate `targetTier` enum, call `BillingService.upgradeTier`, return HTTP 200 with `checkoutUrl`, `sessionId`, `targetTier`, `expiresAt`
- [ ] 12.5 Register `POST /billing/upgrade` route in `src/routes/billing.ts` with authentication middleware
- [ ] 12.6 Add `STRIPE_PRICE_ID_PRO`, `STRIPE_PRICE_ID_ENTERPRISE`, `TIER_RATE_LIMITING_ENABLED` to `.env.example` with documentation comments
- [ ] 12.7 Add `GET /tiers` and `POST /billing/upgrade` to `docs/openapi.yaml`
- [ ] 12.8 Write unit tests for `TierRateLimiter` — test: free tier limit enforced (60 req/min), pro tier limit enforced (600 req/min), tier looked up from Redis cache (DB not called), tier fetched from DB on cache miss, rollback path (`TIER_RATE_LIMITING_ENABLED=false` uses old flat limiter)
- [ ] 12.9 Write unit tests for `BillingService.upgradeTier` — test: upgrade free → pro (creates Stripe session), upgrade free → enterprise (creates Stripe session), already on pro (returns ALREADY_ON_TIER error), downgrade attempt (returns DOWNGRADE_NOT_SUPPORTED error)
- [ ] 12.10 Write integration tests for `GET /tiers` — verify response structure, verify `Cache-Control` header, verify no auth required; write integration tests for `POST /billing/upgrade` — mock Stripe, verify checkout URL returned
## 13. WS5: Developer Experience — Scaffold Service
- [ ] 13.1 Install `archiver` and `@types/archiver` in API `package.json`
- [ ] 13.2 Create `src/types/scaffold.ts` — define `ScaffoldLanguage` union (`'typescript' | 'python' | 'go' | 'java' | 'rust'`), `ScaffoldOptions` interface, `ScaffoldTemplate` interface
- [ ] 13.3 Create scaffold template files for TypeScript in `src/templates/scaffold/typescript/`: `package.json.tmpl`, `tsconfig.json.tmpl`, `src/index.ts.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl` — each file uses `{{AGENT_ID}}`, `{{AGENT_NAME}}`, `{{CLIENT_ID}}`, `{{API_URL}}` as template variables; `.env.example.tmpl` MUST include `AGENTIDP_CLIENT_SECRET=<your-client-secret>` placeholder (never inject real secret)
- [ ] 13.4 Create scaffold template files for Python in `src/templates/scaffold/python/`: `requirements.txt.tmpl`, `main.py.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl` — same template variable convention
- [ ] 13.5 Create scaffold template files for Go in `src/templates/scaffold/go/`: `go.mod.tmpl`, `main.go.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [ ] 13.6 Create scaffold template files for Java in `src/templates/scaffold/java/`: `pom.xml.tmpl`, `src/main/java/Main.java.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [ ] 13.7 Create scaffold template files for Rust in `src/templates/scaffold/rust/`: `Cargo.toml.tmpl`, `src/main.rs.tmpl`, `.env.example.tmpl`, `.gitignore.tmpl`, `README.md.tmpl`
- [ ] 13.8 Create `src/services/ScaffoldService.ts` — implement `IScaffoldService`; `generateScaffold(agentId, language, apiUrl)`: load template files for language, inject template variables (replace `{{AGENT_ID}}`, `{{AGENT_NAME}}`, `{{CLIENT_ID}}`, `{{API_URL}}`), build in-memory ZIP using `archiver`; return `{ stream: NodeJS.ReadableStream, filename: string }`; emit `agentidp_scaffold_generated_total` counter and `agentidp_scaffold_generation_duration_ms` histogram
## 14. WS5: Developer Experience — Scaffold Controller & Route
- [ ] 14.1 Create `src/controllers/ScaffoldController.ts` — implement `getScaffold` handler for `GET /sdk/scaffold/:agentId`: validate `language` query param against `ScaffoldLanguage` union (HTTP 400 on invalid); fetch agent, verify agent belongs to authenticated tenant (HTTP 403 if not); call `ScaffoldService.generateScaffold`; set `Content-Type: application/zip`, `Content-Disposition: attachment; filename="..."`, pipe stream to response; write audit log entry (`scaffold.generated`, metadata: `{ language }`)
- [ ] 14.2 Create `src/routes/scaffold.ts` — Express router for `GET /sdk/scaffold/:agentId` with authentication middleware; apply scaffold-specific rate limiter (10 req/min per tenant, separate from `TierRateLimiter`)
- [ ] 14.3 Register `scaffold` router in `src/routes/index.ts`
- [ ] 14.4 Add `GET /sdk/scaffold/:agentId` to `docs/openapi.yaml` — document binary response type, query parameters, all error responses
- [ ] 14.5 Write unit tests for `ScaffoldService` — test: generate TypeScript scaffold (verify ZIP contains all 6 files), generate Python scaffold (verify all 5 files), verify `{{CLIENT_ID}}` is replaced in `.env.example`, verify `{{AGENTIDP_CLIENT_SECRET}}` is placeholder not real secret, verify invalid language throws `ValidationError`
- [ ] 14.6 Write integration tests for scaffold endpoint — test: TypeScript scaffold returns ZIP with correct `Content-Type` and `Content-Disposition`; Python scaffold returns ZIP; HTTP 400 on invalid language; HTTP 403 when agent belongs to different tenant; HTTP 404 when agent does not exist
## 15. WS5: Developer Experience — Portal & CLI
- [ ] 15.1 Install `@stoplight/elements` in `portal/package.json` — remove `swagger-ui-react`
- [ ] 15.2 Rewrite `portal/app/api-explorer/page.tsx` — replace `SwaggerUI` component with `@stoplight/elements` `<API>` component; set `apiDescriptionUrl`, `router="hash"`, `layout="sidebar"`, `hideSchemas={false}`, `tryItCredentialsPolicy="same-origin"`; import Elements CSS; remove all Swagger UI imports and CSS
- [ ] 15.3 Run `npm run build` in `portal/` — verify zero TypeScript errors and zero ESLint errors after Elements integration
- [ ] 15.4 Install `unzipper` and `@types/unzipper` in `cli/package.json`
- [ ] 15.5 Create `cli/src/commands/scaffold.ts` — implement `sentryagent scaffold` command with Commander options: `--agent-id <id>` (required), `--language <lang>` (default: typescript), `--out <directory>` (default: `.`); load config, issue Bearer token, call `GET /sdk/scaffold/{agentId}?language={language}`, pipe response through `unzipper.Extract({ path: outDir })`, print success message and next steps; handle errors (404, 403, 400) with human-readable messages
- [ ] 15.6 Register `scaffold` command in `cli/src/index.ts` — add `.addCommand(scaffoldCommand)` to Commander program
- [ ] 15.7 Run `npm run build` in `cli/` — zero TypeScript errors; run `node dist/index.js scaffold --help` — outputs correct usage
## 16. WS6: AGNTCY Compliance — Compliance Service
- [ ] 16.1 Create `src/types/compliance.ts` — define interfaces: `ComplianceRequirement` (id, description, status, evidence, verifiedAt), `ComplianceSection` (id, name, status, requirements), `ComplianceReport` (reportId, generatedAt, agntcySpecVersion, tenantId, overallStatus, sections, summary), `AgentCard` (agntcyVersion, type, agent, issuedAt, expiresAt)
- [ ] 16.2 Create `src/config/agntcyRequirements.ts` — define the complete array of AGNTCY requirement objects (minimum 24 requirements), each with: `id` (e.g., `AI-001`), `description` (from AGNTCY spec), `section` (e.g., `agent-identity`), and `evaluate(tenantId: string, db: Pool): Promise<RequirementEvaluation>` function — each evaluator queries the live system and returns `{ status, evidence }`
- [ ] 16.3 Create `src/services/ComplianceService.ts` — implement `IComplianceService`; `generateComplianceReport(tenantId)`: run all requirement evaluators from `agntcyRequirements.ts` in parallel, group results by section, compute overall status (`compliant` if all pass, `partial` if any non-compliant, `non-compliant` if >20% fail), build `ComplianceReport`, write audit log entry (`compliance.report_generated`), emit `agentidp_compliance_reports_generated_total` counter and `agentidp_compliance_report_duration_ms` histogram
- [ ] 16.4 Implement `ComplianceService.generateAgentCard(agentId)` — fetch agent from DB, build `AgentCard` per AGNTCY spec format, set `expiresAt = issuedAt + 24 hours`, set `complianceStandards` from system config, emit `agentidp_agent_cards_served_total` counter with `visibility` label
## 17. WS6: AGNTCY Compliance — Controller, Routes
- [ ] 17.1 Create `src/controllers/ComplianceController.ts` — implement `getComplianceReport` handler: check tenant tier is pro or enterprise (HTTP 403 `TIER_REQUIRED` for free tier), call `ComplianceService.generateComplianceReport`, return HTTP 200; implement `getAgentCard` handler: check agent visibility (HTTP 401 if private and unauthenticated, HTTP 403 if private and wrong tenant), call `ComplianceService.generateAgentCard`, return HTTP 200
- [ ] 17.2 Create `src/routes/agntcy.ts` — Express router for `GET /agntcy/compliance-report` (requires auth) and `GET /agents/:id/agent-card` (auth optional); register behind `AGNTCY_ENABLED` feature flag
- [ ] 17.3 Register `agntcy` router in `src/routes/index.ts`
- [ ] 17.4 Add `GET /agntcy/compliance-report` and `GET /agents/:id/agent-card` to `docs/openapi.yaml`
- [ ] 17.5 Write unit tests for `ComplianceService` — test: `generateComplianceReport` (all 24 requirements pass → `compliant`), `generateComplianceReport` (one evaluator fails → `partial`), `generateAgentCard` (public agent), `generateAgentCard` (private agent — verify agent data is included), `generateAgentCard` (non-existent agent → throws NotFoundError)
- [ ] 17.6 Write integration tests for compliance endpoints — test: compliance report for pro tenant (HTTP 200, overallStatus), compliance report for free tenant (HTTP 403), agent card for public agent (no auth required), agent card for private agent (auth required, correct tenant succeeds, wrong tenant HTTP 403)
## 18. WS6: AGNTCY Compliance — Interoperability Tests & Docs
- [ ] 18.1 Create `tests/agntcy/interoperability.test.ts` — implement all 25+ AGNTCY interoperability test cases as defined in spec: AI-001 (agent UUID uniqueness), AI-002 (W3C DID document), AUTH-001 (OAuth 2.0 token issuance), AUTH-002 (OIDC discovery), AUTHZ-001 (scope enforcement), DEL-001 through DEL-004 (delegation chain), AUDIT-001 through AUDIT-002 (immutable audit log), GOV-001 through GOV-002 (lifecycle governance), INTER-001 (agent card), COMP-001 (compliance report)
- [ ] 18.2 Add `"test:agntcy": "jest --testPathPattern=tests/agntcy --forceExit"` script to `package.json`
- [ ] 18.3 Write `docs/agntcy/certification-guide.md` — complete document with all 6 sections: Overview, Requirement Mapping table, Running the Compliance Report (step-by-step), Agent Card Usage, Self-Certification Checklist, Submitting for Official AGNTCY Certification; no placeholders, no TODOs
## 19. QA & Release
- [ ] 19.1 Run `cargo build` and `cargo clippy -- -D warnings` in `sdk-rust/` — zero warnings; run `cargo test` — all unit tests pass
- [ ] 19.2 Run `tsc --noEmit` across API, dashboard, portal, and CLI — zero TypeScript errors
- [ ] 19.3 Run full Jest suite (`npm test`) — all unit tests pass, coverage >= 80% across all new services: `DelegationService`, `AnalyticsService`, `ScaffoldService`, `ComplianceService`, `TierRateLimiter`
- [ ] 19.4 Run `npm run build` in `portal/` with Elements integration — zero errors; verify `/api-explorer` page renders Elements `<API>` component
- [ ] 19.5 Run `npm run build` in `cli/` — zero errors; run `node dist/index.js scaffold --help` — shows correct options; run `node dist/index.js --help` — shows `scaffold` command listed
- [ ] 19.6 Apply database migrations `008_add_delegation_chains.sql` and `009_add_analytics_aggregates.sql` against a test database — verify migrations run without errors and tables are created with correct schemas
- [ ] 19.7 Run integration tests for all Phase 5 endpoints — delegation (create, verify, revoke), analytics (usage-summary, agent-activity, token-trends), tiers (GET /tiers, POST /billing/upgrade), scaffold (all 5 languages), AGNTCY (compliance-report, agent-card)
- [ ] 19.8 Run `npm run test:agntcy` — all 25+ interoperability test cases pass
- [ ] 19.9 Verify feature flags: `A2A_ENABLED=false` → delegation routes return 404; `ANALYTICS_ENABLED=false` → analytics routes return 404; `TIER_RATE_LIMITING_ENABLED=false` → flat rate limiter used; `AGNTCY_ENABLED=false` → AGNTCY routes return 404
- [ ] 19.10 Verify tier rate limiting: free tenant receives 429 at 61st request/minute; pro tenant allows 600 requests/minute; tier cache refresh within 60s after Stripe webhook updates subscription
- [ ] 19.11 Verify scaffold security: `GET /sdk/scaffold/:agentId` response ZIP never contains a real `client_secret` value — `.env.example` placeholder only
- [ ] 19.12 Commit all Phase 5 work on `main` — one conventional commit per workstream (e.g., `feat(phase-5): WS1 — Rust SDK`, `feat(phase-5): WS2 — A2A Authorization`, etc.)

655
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@open-policy-agent/opa-wasm": "^1.10.0", "@open-policy-agent/opa-wasm": "^1.10.0",
"@types/archiver": "^7.0.0",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bull": "^4.16.5", "bull": "^4.16.5",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -773,6 +775,102 @@
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -1441,6 +1539,16 @@
"@noble/hashes": "^1.1.5" "@noble/hashes": "^1.1.5"
} }
}, },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
@@ -1582,6 +1690,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/archiver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
"integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==",
"license": "MIT",
"dependencies": {
"@types/readdir-glob": "*"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1834,7 +1951,6 @@
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1887,6 +2003,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/redis": { "node_modules/@types/redis": {
"version": "2.8.32", "version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
@@ -2334,7 +2459,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2344,7 +2468,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -2370,6 +2493,63 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
"integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.2",
"async": "^3.2.4",
"buffer-crc32": "^1.0.0",
"readable-stream": "^4.0.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^3.0.0",
"zip-stream": "^6.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver-utils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
"integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
"license": "MIT",
"dependencies": {
"glob": "^10.0.0",
"graceful-fs": "^4.2.0",
"is-stream": "^2.0.1",
"lazystream": "^1.0.0",
"lodash": "^4.17.15",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver-utils/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/arg": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -2407,6 +2587,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2433,6 +2619,20 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2563,9 +2763,99 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.6.0.tgz",
"integrity": "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz",
"integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==",
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.12.0.tgz",
"integrity": "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==",
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
"integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2672,7 +2962,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -2772,6 +3061,15 @@
"ieee754": "^1.2.1" "ieee754": "^1.2.1"
} }
}, },
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -2987,7 +3285,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -3000,7 +3297,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
@@ -3025,6 +3321,22 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/compress-commons": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
"integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"crc32-stream": "^6.0.0",
"is-stream": "^2.0.1",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3095,6 +3407,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -3112,6 +3430,31 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc32-stream": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
"integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -3166,7 +3509,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -3384,6 +3726,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -3423,7 +3771,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
@@ -3747,6 +4094,15 @@
"node": ">=0.8.x" "node": ">=0.8.x"
} }
}, },
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/execa": { "node_modules/execa": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -3865,6 +4221,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4063,6 +4425,34 @@
} }
} }
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -4356,7 +4746,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@@ -4707,7 +5096,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4760,7 +5148,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4769,11 +5156,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
@@ -4847,6 +5239,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jest": { "node_modules/jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -5705,6 +6112,48 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
},
"engines": {
"node": ">= 0.6.3"
}
},
"node_modules/lazystream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -5992,7 +6441,6 @@
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.2" "brace-expansion": "^2.0.2"
@@ -6014,6 +6462,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/morgan": { "node_modules/morgan": {
"version": "1.10.1", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -6221,7 +6678,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6430,6 +6886,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6495,7 +6957,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -6508,6 +6969,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -6864,6 +7347,12 @@
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
@@ -7053,6 +7542,27 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/readdir-glob": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/real-require": { "node_modules/real-require": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -7337,7 +7847,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -7350,7 +7859,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7536,6 +8044,17 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamx": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -7563,7 +8082,21 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true, "license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -7578,7 +8111,19 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true, "license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7714,6 +8259,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tar-stream": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/tdigest": { "node_modules/tdigest": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
@@ -7723,6 +8280,15 @@
"bintrees": "1.0.2" "bintrees": "1.0.2"
} }
}, },
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -7762,6 +8328,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -8062,7 +8637,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -8115,6 +8689,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8208,7 +8788,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -8255,6 +8834,24 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -8362,6 +8959,20 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
"integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.0",
"compress-commons": "^6.0.2",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
} }
} }
} }

View File

@@ -17,6 +17,8 @@
}, },
"dependencies": { "dependencies": {
"@open-policy-agent/opa-wasm": "^1.10.0", "@open-policy-agent/opa-wasm": "^1.10.0",
"@types/archiver": "^7.0.0",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bull": "^4.16.5", "bull": "^4.16.5",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -1,38 +1,28 @@
import type React from 'react'; 'use client';
import { SwaggerExplorer } from '@/components/SwaggerExplorer';
export const metadata = { import dynamic from 'next/dynamic';
title: 'API Explorer — SentryAgent AgentIdP',
description:
'Interactively explore and test the SentryAgent AgentIdP REST API.',
};
export default function ApiExplorerPage(): React.ReactElement { // @stoplight/elements accesses `document` at module load time,
const apiUrl = // so it must be imported client-side only (ssr: false).
process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; const ElementsAPI = dynamic(
async () => {
await import('@stoplight/elements/styles.min.css');
const mod = await import('@stoplight/elements');
return mod.API;
},
{ ssr: false },
);
export default function ApiExplorerPage() {
return ( return (
<div className="px-4 py-8"> <main className="h-screen w-full">
<div className="mx-auto max-w-7xl"> <ElementsAPI
<div className="mb-8"> apiDescriptionUrl={`${process.env.NEXT_PUBLIC_API_URL}/openapi.json`}
<h1 className="text-3xl font-extrabold text-slate-900"> router="hash"
API Explorer layout="sidebar"
</h1> hideSchemas={false}
<p className="mt-2 text-slate-600"> tryItCredentialsPolicy="same-origin"
Explore, authenticate, and test every AgentIdP endpoint directly />
from your browser. Use the Authorize button to set your Bearer </main>
token.
</p>
<p className="mt-1 text-sm text-slate-400">
Spec loaded from:{' '}
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">
{apiUrl}/openapi.json
</code>
</p>
</div>
<SwaggerExplorer apiUrl={apiUrl} />
</div>
</div>
); );
} }

5420
portal/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@stoplight/elements": "^9.0.16",
"next": "14.2.5", "next": "14.2.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1"
"swagger-ui-react": "^5.17.14"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.0", "@types/node": "^20.14.0",

29
portal/types/stoplight-elements.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Type declaration for @stoplight/elements.
* Required because the package's `exports` field does not expose types correctly
* under `moduleResolution: "bundler"`.
*/
declare module '@stoplight/elements' {
import type React from 'react';
export interface APIProps {
/** URL of the OpenAPI description (YAML or JSON). */
apiDescriptionUrl: string;
/** Routing strategy. Use "hash" for static hosting. */
router?: 'hash' | 'memory' | 'history' | 'static';
/** Layout variant. */
layout?: 'sidebar' | 'stacked';
/** Whether to hide schema definitions. */
hideSchemas?: boolean;
/** Credentials policy for the Try It panel. */
tryItCredentialsPolicy?: 'omit' | 'same-origin' | 'include';
}
export const API: React.ComponentType<APIProps>;
}
declare module '@stoplight/elements/styles.min.css' {
const content: string;
export default content;
}

1
sdk-rust/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1824
sdk-rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
sdk-rust/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "sentryagent-idp"
version = "1.0.0"
edition = "2021"
description = "Production-grade Rust SDK for SentryAgent.ai AgentIdP — agent identity, credentials, and A2A delegation"
authors = ["SentryAgent.ai <sdk@sentryagent.ai>"]
license = "MIT"
repository = "https://github.com/sentryagent/sentryagent-idp"
documentation = "https://docs.rs/sentryagent-idp"
keywords = ["agent", "identity", "oauth2", "ai", "oidc"]
categories = ["authentication", "web-programming::http-client"]
[dependencies]
tokio = { version = "1.35", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.6", features = ["v4"] }
thiserror = "1.0"
async-trait = "0.1"
[dev-dependencies]
tokio-test = "0.4"
mockito = "1.2"
[[example]]
name = "quickstart"
path = "examples/quickstart.rs"

171
sdk-rust/README.md Normal file
View File

@@ -0,0 +1,171 @@
# sentryagent-idp — Rust SDK
Production-grade Rust client for the [SentryAgent.ai](https://sentryagent.ai) AgentIdP API. Covers all 14 API endpoints across agent identity, OAuth 2.0 token management, credential rotation, audit logs, the public marketplace, and A2A delegation.
## Features
- Async-first — every API call is `async` and backed by `tokio`
- Thread-safe token cache — `TokenManager` refreshes tokens automatically before expiry
- Typed errors — every failure maps to a variant of `AgentIdPError`
- Zero `unwrap()` in library code — all errors propagated with `?`
- Full `//!` and `///` doc coverage — `cargo doc --no-deps` generates clean docs
- `#![deny(warnings)]` enforced — zero clippy warnings
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
sentryagent-idp = "1.0"
tokio = { version = "1", features = ["full"] }
```
## Environment Variables
| Variable | Purpose |
|---|---|
| `AGENTIDP_API_URL` | Base URL of the AgentIdP API (e.g. `https://api.sentryagent.ai`) |
| `AGENTIDP_CLIENT_ID` | OAuth 2.0 client identifier |
| `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
## Quickstart
```rust
use sentryagent_idp::{AgentIdPClient, RegisterAgentRequest};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build from environment variables.
let client = AgentIdPClient::from_env()?;
// Register a new agent.
let agent = client.register_agent(RegisterAgentRequest {
name: "my-agent".to_owned(),
description: Some("Does useful things".to_owned()),
agent_type: "worker".to_owned(),
capabilities: vec!["read:data".to_owned()],
metadata: None,
}).await?;
println!("Agent registered: {} (DID: {})", agent.id, agent.did);
// Issue a scoped access token.
let token = client.issue_token(&agent.id, &["agents:read"]).await?;
println!("Token issued, expires in {}s", token.expires_in);
// Clean up.
client.delete_agent(&agent.id).await?;
Ok(())
}
```
## Method Reference
### Agent Registry
| Method | Endpoint | Description |
|---|---|---|
| `register_agent(req)` | `POST /agents` | Register a new agent identity |
| `get_agent(id)` | `GET /agents/{id}` | Retrieve an agent by ID |
| `list_agents(page, per_page)` | `GET /agents` | List all agents (paginated) |
| `update_agent(id, req)` | `PATCH /agents/{id}` | Partially update an agent |
| `delete_agent(id)` | `DELETE /agents/{id}` | Permanently delete an agent |
### OAuth 2.0
| Method | Endpoint | Description |
|---|---|---|
| `issue_token(agent_id, scopes)` | `POST /oauth2/token` | Issue a scoped access token |
### Credentials
| Method | Endpoint | Description |
|---|---|---|
| `generate_credentials(agent_id)` | `POST /agents/{id}/credentials` | Generate credentials (returns secret once) |
| `rotate_credentials(agent_id)` | `POST /agents/{id}/credentials/rotate` | Rotate credentials (invalidates previous) |
| `revoke_credentials(agent_id, cred_id)` | `DELETE /agents/{id}/credentials/{cred_id}` | Revoke a specific credential set |
### Audit Logs
| Method | Endpoint | Description |
|---|---|---|
| `list_audit_logs(filters)` | `GET /audit-logs` | Query audit events with optional filters |
### Marketplace (unauthenticated)
| Method | Endpoint | Description |
|---|---|---|
| `list_public_agents(filters)` | `GET /marketplace/agents` | Browse public marketplace agents |
| `get_public_agent(id)` | `GET /marketplace/agents/{id}` | Retrieve a single marketplace agent |
### Delegation
| Method | Endpoint | Description |
|---|---|---|
| `delegate(req)` | `POST /delegation` | Create an A2A delegation token |
| `verify_delegation(token)` | `POST /delegation/verify` | Verify and decode a delegation token |
## Error Handling
All methods return `Result<T, AgentIdPError>`. Match on variants for fine-grained handling:
```rust
use sentryagent_idp::AgentIdPError;
match client.get_agent("unknown-id").await {
Err(AgentIdPError::NotFound(msg)) => {
eprintln!("Agent not found: {}", msg);
}
Err(AgentIdPError::RateLimited { retry_after_secs }) => {
eprintln!("Rate limited — retry after {}s", retry_after_secs);
}
Err(AgentIdPError::AuthError(msg)) => {
eprintln!("Authentication failed: {}", msg);
}
Err(AgentIdPError::ApiError { status, message, code }) => {
eprintln!("API error {}: {} (code: {:?})", status, message, code);
}
Err(e) => eprintln!("Unexpected error: {}", e),
Ok(agent) => println!("Found: {}", agent.name),
}
```
### Error Variants
| Variant | Cause |
|---|---|
| `HttpError(reqwest::Error)` | Network-level transport failure |
| `ApiError { status, message, code }` | Non-2xx HTTP response with error body |
| `AuthError(String)` | 401 or 403 — invalid credentials or insufficient scope |
| `NotFound(String)` | 404 — resource does not exist |
| `RateLimited { retry_after_secs }` | 429 — too many requests |
| `ConfigError(String)` | Missing environment variable on `from_env()` |
| `SerdeError(serde_json::Error)` | JSON parsing failure |
| `DelegationError(String)` | Invalid or revoked delegation chain |
## Running Integration Tests
Integration tests are ignored by default. Set the three environment variables and run:
```bash
AGENTIDP_API_URL=https://api.sentryagent.ai \
AGENTIDP_CLIENT_ID=your-client-id \
AGENTIDP_CLIENT_SECRET=your-client-secret \
cargo test -- --ignored
```
## Publishing to crates.io
This crate is published as `sentryagent-idp` version `1.0.0`. To publish a new version:
```bash
# Update version in Cargo.toml, then:
cargo publish --registry crates-io
```
Ensure `CARGO_REGISTRY_TOKEN` is set to a valid crates.io API token before publishing.
## License
MIT — see LICENSE for details.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
//! Quickstart example — register an agent, issue a token, then look it up.
//!
//! Run with:
//! ```bash
//! AGENTIDP_API_URL=https://api.sentryagent.ai \
//! AGENTIDP_CLIENT_ID=your-client-id \
//! AGENTIDP_CLIENT_SECRET=your-client-secret \
//! cargo run --example quickstart
//! ```
use sentryagent_idp::{AgentIdPClient, AuditLogFilters, MarketplaceFilters, RegisterAgentRequest};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build client from environment variables.
let client = AgentIdPClient::from_env()?;
// ── Register a new agent ──────────────────────────────────────────────────
println!("Registering agent...");
let agent = client
.register_agent(RegisterAgentRequest {
name: "quickstart-agent".to_owned(),
description: Some("Created by the quickstart example".to_owned()),
agent_type: "worker".to_owned(),
capabilities: vec!["read:data".to_owned(), "write:reports".to_owned()],
metadata: None,
})
.await?;
println!("Registered agent:");
println!(" ID: {}", agent.id);
println!(" DID: {}", agent.did);
// ── Issue a scoped token ──────────────────────────────────────────────────
println!("\nIssuing token...");
let token_resp = client
.issue_token(&agent.id, &["agents:read", "agents:write"])
.await?;
println!("Token issued:");
println!(" type: {}", token_resp.token_type);
println!(" expires_in: {}s", token_resp.expires_in);
println!(" scope: {}", token_resp.scope);
// ── Retrieve the agent by ID ──────────────────────────────────────────────
println!("\nFetching agent {}...", agent.id);
let fetched = client.get_agent(&agent.id).await?;
println!("Fetched: {} (public: {})", fetched.name, fetched.is_public);
// ── List agents ───────────────────────────────────────────────────────────
println!("\nListing agents (page 1)...");
let list = client.list_agents(Some(1), Some(10)).await?;
println!("Total agents: {}", list.total);
// ── Audit logs ────────────────────────────────────────────────────────────
println!("\nFetching audit logs...");
let logs = client
.list_audit_logs(AuditLogFilters {
agent_id: Some(agent.id.clone()),
event_type: None,
from: None,
to: None,
page: 1,
per_page: 10,
})
.await?;
println!("Audit events: {}", logs.total);
// ── Marketplace ───────────────────────────────────────────────────────────
println!("\nBrowsing marketplace...");
let marketplace = client
.list_public_agents(MarketplaceFilters {
q: None,
capability: None,
publisher: None,
page: 1,
per_page: 5,
})
.await?;
println!("Public agents: {}", marketplace.total);
// ── Clean up ──────────────────────────────────────────────────────────────
println!("\nDeleting agent {}...", agent.id);
client.delete_agent(&agent.id).await?;
println!("Agent deleted. Done.");
Ok(())
}

205
sdk-rust/src/agents.rs Normal file
View File

@@ -0,0 +1,205 @@
//! Agent registry methods for `AgentIdPClient`.
//!
//! Covers `POST /agents`, `GET /agents`, `GET /agents/{id}`,
//! `PATCH /agents/{id}`, and `DELETE /agents/{id}`.
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::{Agent, AgentList, RegisterAgentRequest, UpdateAgentRequest};
impl AgentIdPClient {
/// Registers a new AI agent identity.
///
/// `POST /agents` → `201 Agent`
///
/// # Errors
///
/// Returns [`AgentIdPError::AuthError`] on 401/403, or
/// [`AgentIdPError::ApiError`] for other non-2xx responses.
pub async fn register_agent(
&self,
req: RegisterAgentRequest,
) -> Result<Agent, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents", self.base_url);
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.json(&req)
.send()
.await?;
parse_response(resp).await
}
/// Retrieves a single agent by its unique identifier.
///
/// `GET /agents/{id}` → `200 Agent`
///
/// # Errors
///
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
pub async fn get_agent(&self, agent_id: &str) -> Result<Agent, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents/{}", self.base_url, agent_id);
let resp = self
.http
.get(&url)
.header("Authorization", auth)
.send()
.await?;
parse_response(resp).await
}
/// Returns a paginated list of agents owned by the authenticated client.
///
/// `GET /agents` → `200 AgentList`
///
/// Pass `None` for `page` or `per_page` to use the server defaults.
pub async fn list_agents(
&self,
page: Option<u32>,
per_page: Option<u32>,
) -> Result<AgentList, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents", self.base_url);
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(p) = page {
query.push(("page", p.to_string()));
}
if let Some(pp) = per_page {
query.push(("per_page", pp.to_string()));
}
let resp = self
.http
.get(&url)
.header("Authorization", auth)
.query(&query)
.send()
.await?;
parse_response(resp).await
}
/// Partially updates an existing agent.
///
/// `PATCH /agents/{id}` → `200 Agent`
///
/// Only fields set to `Some` in `req` are sent to the API.
pub async fn update_agent(
&self,
agent_id: &str,
req: UpdateAgentRequest,
) -> Result<Agent, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents/{}", self.base_url, agent_id);
let resp = self
.http
.patch(&url)
.header("Authorization", auth)
.json(&req)
.send()
.await?;
parse_response(resp).await
}
/// Permanently deletes an agent.
///
/// `DELETE /agents/{id}` → `204 No Content`
///
/// # Errors
///
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
pub async fn delete_agent(&self, agent_id: &str) -> Result<(), AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents/{}", self.base_url, agent_id);
let resp = self
.http
.delete(&url)
.header("Authorization", auth)
.send()
.await?;
if resp.status().as_u16() == 204 {
return Ok(());
}
// Reuse parse_response to handle errors; the Ok(Agent) path will never
// be reached since 204 is handled above, but we need to satisfy the type.
let _: Agent = parse_response(resp).await?;
Ok(())
}
}
/// Converts an HTTP response into `T` or an appropriate `AgentIdPError`.
///
/// Status mapping:
/// - `2xx` → deserialise body as `T`
/// - `401` / `403` → [`AgentIdPError::AuthError`]
/// - `404` → [`AgentIdPError::NotFound`]
/// - `429` → [`AgentIdPError::RateLimited`] (parses `Retry-After` header)
/// - Other non-2xx → [`AgentIdPError::ApiError`]
pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, AgentIdPError> {
let status = resp.status();
if status.is_success() {
let value: T = resp.json().await?;
return Ok(value);
}
let status_code = status.as_u16();
match status_code {
401 | 403 => {
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
let msg = extract_message(&body);
Err(AgentIdPError::AuthError(msg))
}
404 => {
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
let msg = extract_message(&body);
Err(AgentIdPError::NotFound(msg))
}
429 => {
let retry_after_secs = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
Err(AgentIdPError::RateLimited { retry_after_secs })
}
_ => {
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
let message = extract_message(&body);
let code = body
.get("code")
.and_then(|v| v.as_str())
.map(str::to_owned);
Err(AgentIdPError::ApiError {
status: status_code,
message,
code,
})
}
}
}
/// Extracts a human-readable message from an API error body.
fn extract_message(body: &serde_json::Value) -> String {
body.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error")
.to_owned()
}

72
sdk-rust/src/audit.rs Normal file
View File

@@ -0,0 +1,72 @@
//! Audit log methods for `AgentIdPClient`.
//!
//! Covers `GET /audit-logs` with optional query-parameter filters.
use crate::agents::parse_response;
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::{AuditLogFilters, AuditLogList};
impl AgentIdPClient {
/// Queries the audit log with optional filters.
///
/// `GET /audit-logs` → `200 AuditLogList`
///
/// Only `Some` fields in `filters` are appended as query parameters.
/// `page` and `per_page` are always included.
///
/// # Example
///
/// ```rust,no_run
/// use sentryagent_idp::{AgentIdPClient, AuditLogFilters};
///
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
/// let logs = client.list_audit_logs(AuditLogFilters {
/// agent_id: Some("agent-uuid".to_owned()),
/// event_type: Some("token.issued".to_owned()),
/// from: None,
/// to: None,
/// page: 1,
/// per_page: 50,
/// }).await?;
/// println!("Total events: {}", logs.total);
/// # Ok(())
/// # }
/// ```
pub async fn list_audit_logs(
&self,
filters: AuditLogFilters,
) -> Result<AuditLogList, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/audit-logs", self.base_url);
// Build query params, omitting None values.
let mut query: Vec<(&str, String)> = vec![
("page", filters.page.to_string()),
("per_page", filters.per_page.to_string()),
];
if let Some(ref agent_id) = filters.agent_id {
query.push(("agent_id", agent_id.clone()));
}
if let Some(ref event_type) = filters.event_type {
query.push(("event_type", event_type.clone()));
}
if let Some(ref from) = filters.from {
query.push(("from", from.clone()));
}
if let Some(ref to) = filters.to {
query.push(("to", to.clone()));
}
let resp = self
.http
.get(&url)
.header("Authorization", auth)
.query(&query)
.send()
.await?;
parse_response(resp).await
}
}

101
sdk-rust/src/client.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Core `AgentIdPClient` — entry point for all SDK operations.
//!
//! Create a client via [`AgentIdPClient::new`] or [`AgentIdPClient::from_env`],
//! then call methods that correspond to each API endpoint. The client manages
//! token acquisition transparently through the embedded [`TokenManager`].
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::error::AgentIdPError;
use crate::token_manager::TokenManager;
/// The top-level client for the SentryAgent.ai AgentIdP API.
///
/// All methods are `async` and require a `tokio` runtime. The client is
/// cheap to clone — the underlying HTTP connection pool and token cache are
/// shared via `Arc`.
///
/// # Example
///
/// ```rust,no_run
/// use sentryagent_idp::AgentIdPClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = AgentIdPClient::from_env()?;
/// let agents = client.list_agents(Some(1), Some(20)).await?;
/// println!("Total agents: {}", agents.total);
/// Ok(())
/// }
/// ```
pub struct AgentIdPClient {
/// Base URL of the AgentIdP API (no trailing slash).
pub(crate) base_url: String,
/// Reusable `reqwest` HTTP client — created once, shared across all requests.
pub(crate) http: reqwest::Client,
/// Shared, async-safe token manager.
pub(crate) token_manager: Arc<Mutex<TokenManager>>,
}
impl AgentIdPClient {
/// Creates a new `AgentIdPClient`. No network calls are made at construction time.
///
/// # Arguments
///
/// * `base_url` — Root URL of the AgentIdP API, e.g. `"https://api.sentryagent.ai"`.
/// * `client_id` — OAuth 2.0 client identifier.
/// * `client_secret` — OAuth 2.0 client secret.
pub fn new(base_url: &str, client_id: &str, client_secret: &str) -> Self {
let clean_url = base_url.trim_end_matches('/').to_owned();
let tm = TokenManager::new(&clean_url, client_id, client_secret);
Self {
base_url: clean_url,
http: reqwest::Client::new(),
token_manager: Arc::new(Mutex::new(tm)),
}
}
/// Creates a client from environment variables.
///
/// Reads the following variables:
///
/// | Variable | Purpose |
/// |---|---|
/// | `AGENTIDP_API_URL` | Base URL of the API |
/// | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client ID |
/// | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
///
/// Returns [`AgentIdPError::ConfigError`] if any variable is missing.
pub fn from_env() -> Result<Self, AgentIdPError> {
let api_url = env::var("AGENTIDP_API_URL").map_err(|_| {
AgentIdPError::ConfigError(
"AGENTIDP_API_URL environment variable is not set".to_owned(),
)
})?;
let client_id = env::var("AGENTIDP_CLIENT_ID").map_err(|_| {
AgentIdPError::ConfigError(
"AGENTIDP_CLIENT_ID environment variable is not set".to_owned(),
)
})?;
let client_secret = env::var("AGENTIDP_CLIENT_SECRET").map_err(|_| {
AgentIdPError::ConfigError(
"AGENTIDP_CLIENT_SECRET environment variable is not set".to_owned(),
)
})?;
Ok(Self::new(&api_url, &client_id, &client_secret))
}
/// Returns a `Bearer <token>` string for the `Authorization` header.
///
/// Delegates to [`TokenManager::get_token`], which handles caching and
/// automatic refresh transparently.
pub(crate) async fn get_auth_header(&self) -> Result<String, AgentIdPError> {
let tm = self.token_manager.lock().await;
let token = tm.get_token().await?;
Ok(format!("Bearer {}", token))
}
}

View File

@@ -0,0 +1,98 @@
//! Credential management methods for `AgentIdPClient`.
//!
//! Covers `POST /agents/{id}/credentials` (generate),
//! `POST /agents/{id}/credentials/rotate`, and
//! `DELETE /agents/{id}/credentials/{cred_id}`.
use crate::agents::parse_response;
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::Credentials;
impl AgentIdPClient {
/// Generates a new set of credentials (client ID + secret) for an agent.
///
/// `POST /agents/{id}/credentials` → `201 Credentials`
///
/// The `client_secret` field in the response is the **only time** the
/// plaintext secret is returned — store it securely.
pub async fn generate_credentials(
&self,
agent_id: &str,
) -> Result<Credentials, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/agents/{}/credentials", self.base_url, agent_id);
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.header("Content-Length", "0")
.send()
.await?;
parse_response(resp).await
}
/// Rotates the credentials for an agent, invalidating the previous secret.
///
/// `POST /agents/{id}/credentials/rotate` → `200 Credentials`
///
/// The new `client_secret` is returned in the response and will not be
/// retrievable again.
pub async fn rotate_credentials(
&self,
agent_id: &str,
) -> Result<Credentials, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!(
"{}/agents/{}/credentials/rotate",
self.base_url, agent_id
);
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.header("Content-Length", "0")
.send()
.await?;
parse_response(resp).await
}
/// Revokes a specific credential set for an agent.
///
/// `DELETE /agents/{id}/credentials/{cred_id}` → `204 No Content`
///
/// # Errors
///
/// Returns [`crate::error::AgentIdPError::NotFound`] when the agent or
/// credential ID does not exist.
pub async fn revoke_credentials(
&self,
agent_id: &str,
cred_id: &str,
) -> Result<(), AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!(
"{}/agents/{}/credentials/{}",
self.base_url, agent_id, cred_id
);
let resp = self
.http
.delete(&url)
.header("Authorization", auth)
.send()
.await?;
if resp.status().as_u16() == 204 {
return Ok(());
}
// Delegate error handling to parse_response; the Ok branch is unreachable.
let _: Credentials = parse_response(resp).await?;
Ok(())
}
}

View File

@@ -0,0 +1,75 @@
//! Agent-to-agent (A2A) delegation methods for `AgentIdPClient`.
//!
//! Covers `POST /delegation` and `POST /delegation/verify`.
use crate::agents::parse_response;
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::{DelegateRequest, DelegationToken, DelegationVerification};
impl AgentIdPClient {
/// Creates an A2A delegation token granting a delegatee agent authority
/// to act on behalf of the calling (delegator) agent.
///
/// `POST /delegation` → `201 DelegationToken`
///
/// # Errors
///
/// Returns [`AgentIdPError::DelegationError`] when the delegation chain
/// would be invalid (e.g. cyclic delegation or insufficient scope).
pub async fn delegate(
&self,
req: DelegateRequest,
) -> Result<DelegationToken, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/delegation", self.base_url);
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.json(&req)
.send()
.await?;
// Map 422 Unprocessable Entity to DelegationError.
if resp.status().as_u16() == 422 {
let body: serde_json::Value =
resp.json().await.unwrap_or(serde_json::Value::Null);
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("invalid delegation chain")
.to_owned();
return Err(AgentIdPError::DelegationError(msg));
}
parse_response(resp).await
}
/// Verifies an A2A delegation token and returns its claims.
///
/// `POST /delegation/verify` → `200 DelegationVerification`
///
/// The response's `valid` field is `false` when the token is expired or
/// the chain has been revoked, rather than returning an error.
pub async fn verify_delegation(
&self,
token: &str,
) -> Result<DelegationVerification, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/delegation/verify", self.base_url);
let body = serde_json::json!({ "delegation_token": token });
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?;
parse_response(resp).await
}
}

68
sdk-rust/src/error.rs Normal file
View File

@@ -0,0 +1,68 @@
//! Error types for the SentryAgent.ai AgentIdP Rust SDK.
//!
//! All fallible operations return `Result<T, AgentIdPError>`. Match on the
//! variants to handle specific conditions such as rate-limiting or
//! missing resources.
/// The unified error type returned by all SDK operations.
///
/// # Examples
///
/// ```rust,no_run
/// use sentryagent_idp::AgentIdPError;
///
/// async fn example(client: &sentryagent_idp::AgentIdPClient) {
/// match client.get_agent("unknown-id").await {
/// Err(AgentIdPError::NotFound(id)) => eprintln!("Agent not found: {}", id),
/// Err(AgentIdPError::RateLimited { retry_after_secs }) => {
/// eprintln!("Rate limited — retry after {}s", retry_after_secs);
/// }
/// Err(e) => eprintln!("Unexpected error: {}", e),
/// Ok(agent) => println!("Found: {:?}", agent),
/// }
/// }
/// ```
#[derive(Debug, thiserror::Error)]
pub enum AgentIdPError {
/// An underlying HTTP transport error from `reqwest`.
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
/// The API returned a non-2xx status code with a structured error body.
#[error("API error {status}: {message}")]
ApiError {
/// HTTP status code returned by the API.
status: u16,
/// Human-readable error message from the API.
message: String,
/// Machine-readable error code from the API, if present.
code: Option<String>,
},
/// Authentication or authorisation failed (401/403).
#[error("Authentication failed: {0}")]
AuthError(String),
/// The requested resource was not found (404).
#[error("Agent not found: {0}")]
NotFound(String),
/// The API rate-limited this client (429). Contains the retry delay.
#[error("Rate limit exceeded. Retry after {retry_after_secs}s")]
RateLimited {
/// Seconds to wait before retrying.
retry_after_secs: u64,
},
/// A required configuration value was missing or invalid.
#[error("Invalid configuration: {0}")]
ConfigError(String),
/// JSON serialization or deserialization failed.
#[error("Serialization error: {0}")]
SerdeError(#[from] serde_json::Error),
/// A delegation chain was invalid or could not be verified.
#[error("Delegation chain invalid: {0}")]
DelegationError(String),
}

81
sdk-rust/src/lib.rs Normal file
View File

@@ -0,0 +1,81 @@
//! # sentryagent-idp
//!
//! Production-grade Rust SDK for the [SentryAgent.ai](https://sentryagent.ai)
//! AgentIdP API. Provides full coverage of the 14 API endpoints across agent
//! identity, OAuth 2.0 token management, credential rotation, audit logs, the
//! public marketplace, and agent-to-agent (A2A) delegation.
//!
//! ## Features
//!
//! - **Async-first** — every API call is `async` and backed by `tokio`.
//! - **Thread-safe token cache** — [`TokenManager`] refreshes tokens
//! automatically before expiry; safe for concurrent use across tasks.
//! - **Typed errors** — every failure maps to a variant of [`AgentIdPError`].
//! - **Zero `unwrap()`** — all error paths use `?` or explicit `match`.
//!
//! ## Quickstart
//!
//! ```rust,no_run
//! use sentryagent_idp::{AgentIdPClient, RegisterAgentRequest};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = AgentIdPClient::from_env()?;
//!
//! let agent = client.register_agent(RegisterAgentRequest {
//! name: "my-agent".to_owned(),
//! description: Some("Does useful things".to_owned()),
//! agent_type: "worker".to_owned(),
//! capabilities: vec!["read:files".to_owned()],
//! metadata: None,
//! }).await?;
//!
//! println!("Registered agent: {}", agent.id);
//! Ok(())
//! }
//! ```
//!
//! ## Environment Variables
//!
//! | Variable | Purpose |
//! |---|---|
//! | `AGENTIDP_API_URL` | Base URL of the AgentIdP API |
//! | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client identifier |
//! | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
#![deny(warnings)]
pub mod agents;
pub mod audit;
pub mod client;
pub mod credentials;
pub mod delegation;
pub mod error;
pub mod marketplace;
pub mod models;
pub mod oauth2;
pub mod token_manager;
// Re-export the primary entry points at crate root for ergonomic use.
pub use client::AgentIdPClient;
pub use error::AgentIdPError;
pub use token_manager::TokenManager;
// Re-export all model types.
pub use models::{
Agent,
AgentList,
AuditLogEntry,
AuditLogFilters,
AuditLogList,
Credentials,
DelegateRequest,
DelegationToken,
DelegationVerification,
MarketplaceAgent,
MarketplaceAgentList,
MarketplaceFilters,
RegisterAgentRequest,
TokenResponse,
UpdateAgentRequest,
};

View File

@@ -0,0 +1,87 @@
//! Public marketplace methods for `AgentIdPClient`.
//!
//! Covers `GET /marketplace/agents` and `GET /marketplace/agents/{id}`.
//! These endpoints are **unauthenticated** — no `Authorization` header is sent.
use crate::agents::parse_response;
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::{MarketplaceAgent, MarketplaceAgentList, MarketplaceFilters};
impl AgentIdPClient {
/// Lists publicly available agents in the marketplace.
///
/// `GET /marketplace/agents` → `200 MarketplaceAgentList`
///
/// This endpoint does **not** require authentication. `None` filter fields
/// are omitted from the query string.
///
/// # Example
///
/// ```rust,no_run
/// use sentryagent_idp::{AgentIdPClient, MarketplaceFilters};
///
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
/// let results = client.list_public_agents(MarketplaceFilters {
/// q: Some("summarizer".to_owned()),
/// capability: None,
/// publisher: None,
/// page: 1,
/// per_page: 20,
/// }).await?;
/// println!("Found {} agents", results.total);
/// # Ok(())
/// # }
/// ```
pub async fn list_public_agents(
&self,
filters: MarketplaceFilters,
) -> Result<MarketplaceAgentList, AgentIdPError> {
let url = format!("{}/marketplace/agents", self.base_url);
let mut query: Vec<(&str, String)> = vec![
("page", filters.page.to_string()),
("per_page", filters.per_page.to_string()),
];
if let Some(ref q) = filters.q {
query.push(("q", q.clone()));
}
if let Some(ref capability) = filters.capability {
query.push(("capability", capability.clone()));
}
if let Some(ref publisher) = filters.publisher {
query.push(("publisher", publisher.clone()));
}
let resp = self
.http
.get(&url)
.query(&query)
.send()
.await?;
parse_response(resp).await
}
/// Retrieves a single publicly listed marketplace agent by ID.
///
/// `GET /marketplace/agents/{id}` → `200 MarketplaceAgent`
///
/// This endpoint does **not** require authentication.
///
/// # Errors
///
/// Returns [`crate::error::AgentIdPError::NotFound`] when no public agent
/// with the given ID exists.
pub async fn get_public_agent(
&self,
agent_id: &str,
) -> Result<MarketplaceAgent, AgentIdPError> {
let url = format!("{}/marketplace/agents/{}", self.base_url, agent_id);
let resp = self.http.get(&url).send().await?;
parse_response(resp).await
}
}

261
sdk-rust/src/models.rs Normal file
View File

@@ -0,0 +1,261 @@
//! Request and response model types for the SentryAgent.ai AgentIdP API.
//!
//! All types implement `serde::Serialize` and `serde::Deserialize` for
//! transparent JSON encoding. `Option` fields are omitted from serialized
//! output when `None`.
use serde::{Deserialize, Serialize};
// ─── Request types ────────────────────────────────────────────────────────────
/// Request body for `POST /agents` — register a new agent identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterAgentRequest {
/// Human-readable name for the agent.
pub name: String,
/// Optional description of the agent's purpose.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Functional category of the agent (e.g. `"worker"`, `"orchestrator"`).
pub agent_type: String,
/// List of capability strings the agent exposes (e.g. `"read:files"`).
pub capabilities: Vec<String>,
/// Arbitrary metadata to attach to the agent record.
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
/// Request body for `PATCH /agents/{id}` — partially update an existing agent.
///
/// Only fields that are `Some` are sent to the API; `None` fields are omitted.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateAgentRequest {
/// New human-readable name.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// New description.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Replacement capability list.
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<String>>,
/// Whether to list the agent in the public marketplace.
#[serde(skip_serializing_if = "Option::is_none")]
pub is_public: Option<bool>,
/// Replacement metadata object.
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
/// Query parameters for `GET /audit-logs`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AuditLogFilters {
/// Filter by agent ID.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
/// Filter by event type string.
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
/// Start of time range (ISO 8601).
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
/// End of time range (ISO 8601).
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
/// Page number (1-based).
pub page: u32,
/// Number of results per page.
pub per_page: u32,
}
/// Query parameters for `GET /marketplace/agents`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MarketplaceFilters {
/// Free-text search query.
#[serde(skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
/// Filter by capability string.
#[serde(skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
/// Filter by publisher identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// Page number (1-based).
pub page: u32,
/// Number of results per page.
pub per_page: u32,
}
/// Request body for `POST /delegation` — create an A2A delegation token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegateRequest {
/// The agent ID that receives delegated authority.
pub delegatee_agent_id: String,
/// Scopes being delegated.
pub scopes: Vec<String>,
/// Token lifetime in seconds.
pub ttl_seconds: u64,
}
// ─── Response types ───────────────────────────────────────────────────────────
/// A registered AI agent identity returned by the API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
/// Unique agent identifier (UUID).
pub id: String,
/// Human-readable name.
pub name: String,
/// Optional description.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Capabilities the agent exposes.
pub capabilities: Vec<String>,
/// Decentralised Identifier for the agent.
pub did: String,
/// Whether the agent is listed in the public marketplace.
pub is_public: bool,
/// ISO 8601 creation timestamp.
pub created_at: String,
/// ISO 8601 last-updated timestamp.
pub updated_at: String,
}
/// Paginated list of agents returned by `GET /agents`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentList {
/// Agents on the current page.
pub agents: Vec<Agent>,
/// Total number of agents matching the query.
pub total: u64,
/// Current page number (1-based).
pub page: u32,
/// Number of results per page.
pub per_page: u32,
}
/// OAuth 2.0 access token response (RFC 6749 §4.4.3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
/// The bearer access token.
pub access_token: String,
/// Token type — always `"Bearer"`.
pub token_type: String,
/// Seconds until the token expires.
pub expires_in: u64,
/// Space-separated list of granted scopes.
pub scope: String,
}
/// Agent credentials — client ID and (on creation/rotation only) client secret.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
/// OAuth 2.0 client ID.
pub client_id: String,
/// OAuth 2.0 client secret (only present on generate/rotate responses).
pub client_secret: String,
/// ISO 8601 creation timestamp.
pub created_at: String,
}
/// A single audit log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogEntry {
/// Unique event identifier.
pub id: String,
/// Agent ID this event relates to.
pub agent_id: String,
/// Type of event that occurred.
pub event_type: String,
/// Identity of the actor that triggered the event.
pub actor: String,
/// Structured metadata associated with the event.
pub metadata: serde_json::Value,
/// ISO 8601 timestamp of the event.
pub timestamp: String,
}
/// Paginated list of audit log entries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogList {
/// Entries on the current page.
pub entries: Vec<AuditLogEntry>,
/// Total number of entries matching the query.
pub total: u64,
/// Current page number (1-based).
pub page: u32,
/// Number of results per page.
pub per_page: u32,
}
/// A publicly listed marketplace agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceAgent {
/// Unique agent identifier (UUID).
pub id: String,
/// Human-readable name.
pub name: String,
/// Optional description.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Capabilities the agent exposes.
pub capabilities: Vec<String>,
/// Full W3C DID Document for the agent.
pub did_document: serde_json::Value,
/// Publisher identifier or organisation name.
pub publisher: String,
/// ISO 8601 creation timestamp.
pub created_at: String,
}
/// Paginated list of marketplace agents.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceAgentList {
/// Agents on the current page.
pub agents: Vec<MarketplaceAgent>,
/// Total number of agents matching the query.
pub total: u64,
/// Current page number (1-based).
pub page: u32,
/// Number of results per page.
pub per_page: u32,
}
/// A delegation token granting a delegatee agent authority on behalf of the delegator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationToken {
/// Opaque signed delegation token — pass in `X-Delegation-Token` header.
pub delegation_token: String,
/// Unique identifier for this delegation chain.
pub chain_id: String,
/// Agent ID of the delegator (authority source).
pub delegator_agent_id: String,
/// Agent ID of the delegatee (authority recipient).
pub delegatee_agent_id: String,
/// Scopes that have been delegated.
pub scopes: Vec<String>,
/// ISO 8601 timestamp when the delegation expires.
pub expires_at: String,
}
/// Result of verifying a delegation token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationVerification {
/// Whether the delegation token is valid and unexpired.
pub valid: bool,
/// Delegation chain ID, present when `valid` is `true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_id: Option<String>,
/// Delegator agent ID, present when `valid` is `true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub delegator_agent_id: Option<String>,
/// Delegatee agent ID, present when `valid` is `true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub delegatee_agent_id: Option<String>,
/// Delegated scopes, present when `valid` is `true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
/// Expiry timestamp, present when `valid` is `true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}

47
sdk-rust/src/oauth2.rs Normal file
View File

@@ -0,0 +1,47 @@
//! OAuth 2.0 token issuance methods for `AgentIdPClient`.
//!
//! Covers `POST /oauth2/token` for issuing agent-scoped access tokens.
use crate::agents::parse_response;
use crate::client::AgentIdPClient;
use crate::error::AgentIdPError;
use crate::models::TokenResponse;
impl AgentIdPClient {
/// Issues an OAuth 2.0 access token for the given agent with the requested scopes.
///
/// `POST /oauth2/token` (form body) → `200 TokenResponse`
///
/// This differs from the internal `TokenManager` token fetch in that it
/// allows callers to request tokens for specific agent IDs and scope sets.
///
/// # Arguments
///
/// * `agent_id` — The agent on whose behalf the token is issued.
/// * `scopes` — Scopes to request (e.g. `&["agents:read", "agents:write"]`).
pub async fn issue_token(
&self,
agent_id: &str,
scopes: &[&str],
) -> Result<TokenResponse, AgentIdPError> {
let auth = self.get_auth_header().await?;
let url = format!("{}/oauth2/token", self.base_url);
let scope_str = scopes.join(" ");
let params = [
("grant_type", "client_credentials"),
("agent_id", agent_id),
("scope", scope_str.as_str()),
];
let resp = self
.http
.post(&url)
.header("Authorization", auth)
.form(&params)
.send()
.await?;
parse_response(resp).await
}
}

View File

@@ -0,0 +1,254 @@
//! Thread-safe OAuth 2.0 token cache with automatic refresh.
//!
//! `TokenManager` holds a single `reqwest::Client` for token requests and
//! caches the current access token behind an async `Mutex`. Tokens are
//! proactively refreshed 60 seconds before they expire, preventing any
//! request from using a stale bearer token.
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::error::AgentIdPError;
use crate::models::TokenResponse;
/// Internal token cache — holds the raw token string and its calculated expiry.
#[derive(Debug, Default)]
pub(crate) struct TokenCache {
/// Cached bearer token, or `None` if no token has been fetched yet.
pub access_token: Option<String>,
/// Monotonic instant at which the cached token expires (less the 60 s buffer).
pub expires_at: Option<Instant>,
}
impl TokenCache {
/// Returns `true` when the cached token is present and has not yet reached
/// its expiry instant (which already includes the 60 s refresh buffer).
fn is_valid(&self) -> bool {
match (&self.access_token, self.expires_at) {
(Some(_), Some(exp)) => Instant::now() < exp,
_ => false,
}
}
}
/// Thread-safe OAuth 2.0 client-credentials token manager.
///
/// Obtains bearer tokens from the AgentIdP server and caches them until they
/// are within 60 seconds of expiry, at which point the next call to
/// [`get_token`](TokenManager::get_token) transparently fetches a fresh one.
///
/// The inner `Arc<Mutex<TokenCache>>` makes `TokenManager` cheap to clone and
/// safe to share across `tokio` tasks.
///
/// # Example
///
/// ```rust,no_run
/// use sentryagent_idp::TokenManager;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let tm = TokenManager::new("https://api.sentryagent.ai", "client_id", "client_secret");
/// let token = tm.get_token().await?;
/// println!("Bearer {}", token);
/// Ok(())
/// }
/// ```
pub struct TokenManager {
/// Base URL of the AgentIdP API (no trailing slash).
pub(crate) api_url: String,
/// OAuth 2.0 client identifier.
pub(crate) client_id: String,
/// OAuth 2.0 client secret.
pub(crate) client_secret: String,
/// Shared async token cache.
pub(crate) cache: Arc<Mutex<TokenCache>>,
/// Reusable HTTP client for token endpoint requests.
http: reqwest::Client,
}
impl TokenManager {
/// Creates a new `TokenManager`. No network calls are made at construction time.
///
/// # Arguments
///
/// * `api_url` — Base URL of the AgentIdP API (e.g. `"https://api.sentryagent.ai"`).
/// * `client_id` — OAuth 2.0 client identifier.
/// * `client_secret` — OAuth 2.0 client secret.
pub fn new(api_url: &str, client_id: &str, client_secret: &str) -> Self {
Self {
api_url: api_url.trim_end_matches('/').to_owned(),
client_id: client_id.to_owned(),
client_secret: client_secret.to_owned(),
cache: Arc::new(Mutex::new(TokenCache::default())),
http: reqwest::Client::new(),
}
}
/// Returns a valid bearer access token.
///
/// If a cached token exists and will not expire within the next 60 seconds,
/// it is returned immediately without any network call. Otherwise a new
/// token is fetched from `POST /oauth2/token` and the cache is updated.
///
/// This method is safe to call concurrently from multiple `tokio` tasks —
/// the `Mutex` ensures only one token fetch occurs at a time.
pub async fn get_token(&self) -> Result<String, AgentIdPError> {
let mut cache = self.cache.lock().await;
if cache.is_valid() {
// Safety: is_valid() guarantees access_token is Some.
return Ok(cache.access_token.clone().expect("token present when valid"));
}
// Fetch a fresh token.
let token_resp = self.fetch_token().await?;
// Expire the cache 60 s before the server-reported expiry so we never
// hand out a token that is about to become invalid.
let ttl = token_resp
.expires_in
.saturating_sub(60);
cache.access_token = Some(token_resp.access_token.clone());
cache.expires_at = Some(Instant::now() + Duration::from_secs(ttl));
Ok(token_resp.access_token)
}
/// Performs the OAuth 2.0 client-credentials grant against the token endpoint.
async fn fetch_token(&self) -> Result<TokenResponse, AgentIdPError> {
let token_url = format!("{}/oauth2/token", self.api_url);
let params = [
("grant_type", "client_credentials"),
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
];
let resp = self
.http
.post(&token_url)
.form(&params)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
let message = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("token request failed")
.to_owned();
return Err(AgentIdPError::AuthError(format!(
"token endpoint returned {}: {}",
status, message
)));
}
let token_resp: TokenResponse = resp.json().await?;
Ok(token_resp)
}
}
// ─── Unit tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
fn token_body(expires_in: u64) -> String {
format!(
r#"{{"access_token":"test-token","token_type":"Bearer","expires_in":{},"scope":"agents:read"}}"#,
expires_in
)
}
/// `get_token()` should return the cached token on a second call without
/// hitting the mock server again.
#[tokio::test]
async fn test_returns_cached_token() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/oauth2/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(token_body(3600))
.expect(1) // Must be called exactly once.
.create_async()
.await;
let tm = TokenManager::new(&server.url(), "id", "secret");
let t1 = tm.get_token().await.expect("first call succeeds");
let t2 = tm.get_token().await.expect("second call succeeds");
assert_eq!(t1, "test-token");
assert_eq!(t2, "test-token");
mock.assert_async().await;
}
/// When the cached token's `expires_at` is in the past, `get_token()` must
/// fetch a new token (i.e. hit the mock server a second time).
#[tokio::test]
async fn test_refreshes_expired_token() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/oauth2/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(token_body(3600))
.expect(2) // Must be called twice.
.create_async()
.await;
let tm = TokenManager::new(&server.url(), "id", "secret");
// First call — populates cache.
let _ = tm.get_token().await.expect("first call succeeds");
// Manually expire the cache.
{
let mut cache = tm.cache.lock().await;
cache.expires_at = Some(Instant::now() - Duration::from_secs(1));
}
// Second call — cache expired, must fetch again.
let t2 = tm.get_token().await.expect("second call succeeds");
assert_eq!(t2, "test-token");
mock.assert_async().await;
}
/// Ten concurrent `get_token()` calls must all succeed and the token
/// endpoint must be called exactly once (all but the first see the cache).
#[tokio::test]
async fn test_concurrent_no_race() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/oauth2/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(token_body(3600))
.expect(1)
.create_async()
.await;
let tm = Arc::new(TokenManager::new(&server.url(), "id", "secret"));
let handles: Vec<_> = (0..10)
.map(|_| {
let tm_clone = Arc::clone(&tm);
tokio::spawn(async move { tm_clone.get_token().await })
})
.collect();
for handle in handles {
let result = handle.await.expect("task did not panic");
assert_eq!(result.expect("get_token succeeded"), "test-token");
}
mock.assert_async().await;
}
}

View File

@@ -0,0 +1,369 @@
//! Integration tests for the SentryAgent.ai AgentIdP Rust SDK.
//!
//! These tests run against a real API instance. They are marked `#[ignore]`
//! and will not execute in CI unless explicitly opted in with:
//!
//! ```bash
//! AGENTIDP_API_URL=https://api.sentryagent.ai \
//! AGENTIDP_CLIENT_ID=... \
//! AGENTIDP_CLIENT_SECRET=... \
//! cargo test -- --ignored
//! ```
use sentryagent_idp::{
AgentIdPClient, AuditLogFilters, DelegateRequest, MarketplaceFilters, RegisterAgentRequest,
UpdateAgentRequest,
};
/// Helper — build a client from environment variables, skipping the test when
/// any required variable is unset (rather than panicking).
fn client_from_env() -> Option<AgentIdPClient> {
AgentIdPClient::from_env().ok()
}
// ─── Agent CRUD ───────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_register_and_delete_agent() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let agent = client
.register_agent(RegisterAgentRequest {
name: "integration-test-agent".to_owned(),
description: Some("Created by integration test".to_owned()),
agent_type: "worker".to_owned(),
capabilities: vec!["read:data".to_owned()],
metadata: None,
})
.await
.expect("register_agent should succeed");
assert!(!agent.id.is_empty());
assert_eq!(agent.name, "integration-test-agent");
client
.delete_agent(&agent.id)
.await
.expect("delete_agent should succeed");
}
#[tokio::test]
#[ignore]
async fn test_get_agent() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let created = client
.register_agent(RegisterAgentRequest {
name: "get-test-agent".to_owned(),
description: None,
agent_type: "worker".to_owned(),
capabilities: vec![],
metadata: None,
})
.await
.expect("register_agent should succeed");
let fetched = client
.get_agent(&created.id)
.await
.expect("get_agent should succeed");
assert_eq!(fetched.id, created.id);
assert_eq!(fetched.name, "get-test-agent");
client.delete_agent(&created.id).await.ok();
}
#[tokio::test]
#[ignore]
async fn test_list_agents() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let list = client
.list_agents(Some(1), Some(10))
.await
.expect("list_agents should succeed");
// Must return a valid pagination envelope.
assert!(list.page >= 1);
assert!(list.per_page > 0);
}
#[tokio::test]
#[ignore]
async fn test_update_agent() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let agent = client
.register_agent(RegisterAgentRequest {
name: "update-test-agent".to_owned(),
description: None,
agent_type: "worker".to_owned(),
capabilities: vec![],
metadata: None,
})
.await
.expect("register_agent should succeed");
let updated = client
.update_agent(
&agent.id,
UpdateAgentRequest {
name: Some("updated-name".to_owned()),
description: Some("Updated description".to_owned()),
capabilities: None,
is_public: None,
metadata: None,
},
)
.await
.expect("update_agent should succeed");
assert_eq!(updated.name, "updated-name");
client.delete_agent(&agent.id).await.ok();
}
#[tokio::test]
#[ignore]
async fn test_get_agent_not_found() {
use sentryagent_idp::AgentIdPError;
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let result = client
.get_agent("00000000-0000-0000-0000-000000000000")
.await;
assert!(
matches!(result, Err(AgentIdPError::NotFound(_))),
"Expected NotFound error, got: {:?}",
result
);
}
// ─── Credentials ─────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_generate_and_rotate_credentials() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let agent = client
.register_agent(RegisterAgentRequest {
name: "creds-test-agent".to_owned(),
description: None,
agent_type: "worker".to_owned(),
capabilities: vec![],
metadata: None,
})
.await
.expect("register_agent should succeed");
let creds = client
.generate_credentials(&agent.id)
.await
.expect("generate_credentials should succeed");
assert!(!creds.client_id.is_empty());
assert!(!creds.client_secret.is_empty());
let rotated = client
.rotate_credentials(&agent.id)
.await
.expect("rotate_credentials should succeed");
// Rotated secret must differ from the original.
assert_ne!(rotated.client_secret, creds.client_secret);
client.delete_agent(&agent.id).await.ok();
}
// ─── OAuth2 ──────────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_issue_token() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let agent = client
.register_agent(RegisterAgentRequest {
name: "token-test-agent".to_owned(),
description: None,
agent_type: "worker".to_owned(),
capabilities: vec![],
metadata: None,
})
.await
.expect("register_agent should succeed");
let token = client
.issue_token(&agent.id, &["agents:read"])
.await
.expect("issue_token should succeed");
assert!(!token.access_token.is_empty());
assert_eq!(token.token_type.to_lowercase(), "bearer");
client.delete_agent(&agent.id).await.ok();
}
// ─── Audit logs ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_list_audit_logs() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let logs = client
.list_audit_logs(AuditLogFilters {
agent_id: None,
event_type: None,
from: None,
to: None,
page: 1,
per_page: 20,
})
.await
.expect("list_audit_logs should succeed");
assert!(logs.page >= 1);
assert!(logs.per_page > 0);
}
// ─── Marketplace ─────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_list_public_agents() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let results = client
.list_public_agents(MarketplaceFilters {
q: None,
capability: None,
publisher: None,
page: 1,
per_page: 10,
})
.await
.expect("list_public_agents should succeed");
assert!(results.page >= 1);
}
#[tokio::test]
#[ignore]
async fn test_marketplace_search() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let results = client
.list_public_agents(MarketplaceFilters {
q: Some("agent".to_owned()),
capability: None,
publisher: None,
page: 1,
per_page: 5,
})
.await
.expect("list_public_agents with query should succeed");
// Result may be empty but must be a valid envelope.
assert!(results.per_page > 0);
}
// ─── Delegation ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_delegate_and_verify() {
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
let delegator = client
.register_agent(RegisterAgentRequest {
name: "delegator-agent".to_owned(),
description: None,
agent_type: "orchestrator".to_owned(),
capabilities: vec!["agents:write".to_owned()],
metadata: None,
})
.await
.expect("register delegator should succeed");
let delegatee = client
.register_agent(RegisterAgentRequest {
name: "delegatee-agent".to_owned(),
description: None,
agent_type: "worker".to_owned(),
capabilities: vec!["agents:read".to_owned()],
metadata: None,
})
.await
.expect("register delegatee should succeed");
let delegation = client
.delegate(DelegateRequest {
delegatee_agent_id: delegatee.id.clone(),
scopes: vec!["agents:read".to_owned()],
ttl_seconds: 3600,
})
.await
.expect("delegate should succeed");
assert!(!delegation.delegation_token.is_empty());
assert!(!delegation.chain_id.is_empty());
let verification = client
.verify_delegation(&delegation.delegation_token)
.await
.expect("verify_delegation should succeed");
assert!(verification.valid);
assert_eq!(
verification.delegatee_agent_id.as_deref(),
Some(delegatee.id.as_str())
);
client.delete_agent(&delegator.id).await.ok();
client.delete_agent(&delegatee.id).await.ok();
}
// ─── Token manager concurrency ────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_token_manager_concurrent_calls() {
use std::sync::Arc;
use sentryagent_idp::TokenManager;
let api_url = std::env::var("AGENTIDP_API_URL").expect("AGENTIDP_API_URL must be set");
let client_id =
std::env::var("AGENTIDP_CLIENT_ID").expect("AGENTIDP_CLIENT_ID must be set");
let client_secret =
std::env::var("AGENTIDP_CLIENT_SECRET").expect("AGENTIDP_CLIENT_SECRET must be set");
let tm = Arc::new(TokenManager::new(&api_url, &client_id, &client_secret));
let handles: Vec<_> = (0..50)
.map(|_| {
let tm_clone = Arc::clone(&tm);
tokio::spawn(async move { tm_clone.get_token().await })
})
.collect();
let mut tokens = Vec::with_capacity(50);
for handle in handles {
let token = handle
.await
.expect("task did not panic")
.expect("get_token succeeded");
tokens.push(token);
}
// All 50 calls must return the same token (single fetch, all from cache).
let first = &tokens[0];
for t in &tokens[1..] {
assert_eq!(t, first, "all concurrent calls must return the same token");
}
}

View File

@@ -68,6 +68,12 @@ import { createOIDCRouter } from './routes/oidc.js';
import { createFederationRouter } from './routes/federation.js'; import { createFederationRouter } from './routes/federation.js';
import { createWebhooksRouter } from './routes/webhooks.js'; import { createWebhooksRouter } from './routes/webhooks.js';
import { createComplianceRouter } from './routes/compliance.js'; import { createComplianceRouter } from './routes/compliance.js';
import { createDelegationRouter } from './routes/delegation.js';
import { DelegationService } from './services/DelegationService.js';
import { DelegationController } from './controllers/DelegationController.js';
import { createScaffoldRouter } from './routes/scaffold.js';
import { ScaffoldService } from './services/ScaffoldService.js';
import { ScaffoldController } from './controllers/ScaffoldController.js';
import { errorHandler } from './middleware/errorHandler.js'; import { errorHandler } from './middleware/errorHandler.js';
import { createOpaMiddleware } from './middleware/opa.js'; import { createOpaMiddleware } from './middleware/opa.js';
@@ -326,6 +332,22 @@ export async function createApp(): Promise<Application> {
app.use(`${API_BASE}/oidc`, createOIDCTrustPoliciesRouter(oidcTrustPolicyController, authMiddleware)); app.use(`${API_BASE}/oidc`, createOIDCTrustPoliciesRouter(oidcTrustPolicyController, authMiddleware));
app.use(`${API_BASE}/oidc`, createOIDCTokenExchangeRouter(oidcTokenExchangeController)); app.use(`${API_BASE}/oidc`, createOIDCTokenExchangeRouter(oidcTokenExchangeController));
// ────────────────────────────────────────────────────────────────
// Phase 5 WS2: A2A Delegation (guarded by A2A_ENABLED flag)
// ────────────────────────────────────────────────────────────────
if (process.env['A2A_ENABLED'] !== 'false') {
const delegationService = new DelegationService(pool, auditService);
const delegationController = new DelegationController(delegationService);
app.use(`${API_BASE}`, createDelegationRouter(delegationController, authMiddleware));
}
// ────────────────────────────────────────────────────────────────
// Phase 5 WS5: Scaffold Generator
// ────────────────────────────────────────────────────────────────
const scaffoldService = new ScaffoldService();
const scaffoldController = new ScaffoldController(scaffoldService, pool, auditService);
app.use(`${API_BASE}`, createScaffoldRouter(scaffoldController, authMiddleware));
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Dashboard static assets (served from dashboard/dist/) // Dashboard static assets (served from dashboard/dist/)
// Placed after API routes so API routes take precedence. // Placed after API routes so API routes take precedence.

View File

@@ -0,0 +1,177 @@
/**
* Delegation Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for A2A delegation endpoints. No business logic — delegates to DelegationService.
*/
import { Request, Response, NextFunction } from 'express';
import { DelegationService } from '../services/DelegationService.js';
import { AuthorizationError, ValidationError } from '../utils/errors.js';
import {
delegationsCreatedTotal,
delegationsVerifiedTotal,
delegationsRevokedTotal,
} from '../metrics/registry.js';
/**
* Controller for A2A delegation endpoints.
* Receives DelegationService via constructor injection.
*/
export class DelegationController {
/**
* @param delegationService - The A2A delegation service.
*/
constructor(private readonly delegationService: DelegationService) {}
/**
* Handles POST /oauth2/token/delegate — creates a delegation chain.
*
* @param req - Express request with CreateDelegationRequest body.
* @param res - Express response.
* @param next - Express next function.
*/
createDelegation = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { delegateeAgentId, scopes, ttlSeconds } = req.body as {
delegateeAgentId?: unknown;
scopes?: unknown;
ttlSeconds?: unknown;
};
if (typeof delegateeAgentId !== 'string' || !delegateeAgentId) {
throw new ValidationError('delegateeAgentId is required and must be a string.');
}
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new ValidationError('scopes must be a non-empty array.');
}
if (typeof ttlSeconds !== 'number' || !Number.isInteger(ttlSeconds)) {
throw new ValidationError('ttlSeconds must be an integer.');
}
const tenantId = req.user.organization_id ?? 'org_system';
const delegatorAgentId = req.user.sub;
const delegatorScopes = req.user.scope ? req.user.scope.split(' ') : [];
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const chain = await this.delegationService.createDelegation(
tenantId,
delegatorAgentId,
delegatorScopes,
{ delegateeAgentId, scopes: scopes as string[], ttlSeconds },
ipAddress,
userAgent,
);
delegationsCreatedTotal.labels(tenantId).inc();
res.status(201).json({
delegationToken: chain.delegationToken,
chainId: chain.id,
delegatorAgentId: chain.delegatorAgentId,
delegateeAgentId: chain.delegateeAgentId,
scopes: chain.scopes,
expiresAt: chain.expiresAt.toISOString(),
});
} catch (err) {
next(err);
}
};
/**
* Handles POST /oauth2/token/verify-delegation — verifies a delegation token.
*
* @param req - Express request with delegationToken body.
* @param res - Express response.
* @param next - Express next function.
*/
verifyDelegation = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { delegationToken } = req.body as { delegationToken?: unknown };
if (typeof delegationToken !== 'string' || !delegationToken) {
throw new ValidationError('delegationToken is required and must be a string.');
}
const agentId = req.user.sub;
const tenantId = req.user.organization_id ?? 'org_system';
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.delegationService.verifyDelegation(
delegationToken,
agentId,
ipAddress,
userAgent,
);
const outcomeLabel = result.valid
? 'valid'
: result.revokedAt !== null
? 'revoked'
: new Date() > result.expiresAt
? 'expired'
: 'invalid';
delegationsVerifiedTotal.labels(tenantId, outcomeLabel).inc();
res.status(200).json({
valid: result.valid,
chainId: result.chainId,
delegatorAgentId: result.delegatorAgentId,
delegateeAgentId: result.delegateeAgentId,
scopes: result.scopes,
issuedAt: result.issuedAt.toISOString(),
expiresAt: result.expiresAt.toISOString(),
revokedAt: result.revokedAt ? result.revokedAt.toISOString() : null,
});
} catch (err) {
next(err);
}
};
/**
* Handles DELETE /oauth2/token/delegate/:chainId — revokes a delegation chain.
*
* @param req - Express request with chainId path param.
* @param res - Express response.
* @param next - Express next function.
*/
revokeDelegation = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { chainId } = req.params;
if (!chainId) {
throw new ValidationError('chainId path parameter is required.');
}
const requestingAgentId = req.user.sub;
const tenantId = req.user.organization_id ?? 'org_system';
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.delegationService.revokeDelegation(
chainId,
requestingAgentId,
ipAddress,
userAgent,
);
delegationsRevokedTotal.labels(tenantId).inc();
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,114 @@
/**
* Scaffold Controller for SentryAgent.ai AgentIdP.
* HTTP handler for the GET /sdk/scaffold/:agentId endpoint.
*/
import { Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
import { AuditService } from '../services/AuditService.js';
import { ScaffoldService, SCAFFOLD_LANGUAGES } from '../services/ScaffoldService.js';
import { ScaffoldLanguage } from '../types/scaffold.js';
import { AgentNotFoundError, AuthorizationError, ValidationError } from '../utils/errors.js';
/**
* Controller for the scaffold generator endpoint.
* Validates request, fetches agent credentials, delegates ZIP generation to ScaffoldService.
*/
export class ScaffoldController {
/**
* @param scaffoldService - The scaffold generation service.
* @param pool - PostgreSQL connection pool for agent credential lookup.
* @param auditService - Audit log service.
*/
constructor(
private readonly scaffoldService: ScaffoldService,
private readonly pool: Pool,
private readonly auditService: AuditService,
) {}
/**
* Handles GET /sdk/scaffold/:agentId — generates and streams a scaffold ZIP.
*
* @param req - Express request with agentId path param and language query param.
* @param res - Express response streaming the ZIP file.
* @param next - Express next function.
*/
getScaffold = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { agentId } = req.params;
const rawLanguage = req.query['language'] as string | undefined;
const language: ScaffoldLanguage = (rawLanguage as ScaffoldLanguage) ?? 'typescript';
if (!SCAFFOLD_LANGUAGES.includes(language)) {
throw new ValidationError(
`Unsupported language '${language}'. Choose: ${SCAFFOLD_LANGUAGES.join(', ')}`,
{ code: 'INVALID_LANGUAGE' },
);
}
const tenantId = req.user.organization_id ?? 'org_system';
// Fetch agent and verify it belongs to the authenticated tenant
const agentResult = await this.pool.query<{
agent_id: string;
email: string;
organization_id: string;
}>(
`SELECT agent_id, email, organization_id FROM agents WHERE agent_id = $1`,
[agentId],
);
if (agentResult.rows.length === 0) {
throw new AgentNotFoundError(agentId);
}
const agentRow = agentResult.rows[0];
if (agentRow.organization_id !== tenantId) {
throw new AuthorizationError('You do not own this agent.');
}
// Fetch the agent's active credential client_id
const credResult = await this.pool.query<{ client_id: string }>(
`SELECT client_id FROM credentials WHERE agent_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1`,
[agentId],
);
const clientId =
credResult.rows.length > 0 ? credResult.rows[0].client_id : agentRow.agent_id;
const apiUrl = process.env['API_URL'] ?? process.env['NEXT_PUBLIC_API_URL'] ?? 'https://api.sentryagent.ai';
const { stream, filename } = await this.scaffoldService.generateScaffold({
agentId,
agentName: agentRow.email.split('@')[0] ?? agentId,
clientId,
language,
apiUrl,
});
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.auditService.logEvent(
req.user.sub,
'scaffold.generated',
'success',
ipAddress,
userAgent,
{ agentId, language },
tenantId,
);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
stream.pipe(res);
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,30 @@
-- Migration 024: Add delegation_chains table for A2A authorization (Phase 5 WS2)
-- Creates the delegation_chains table that stores A2A delegation tokens and their
-- verification signatures, enabling agent-to-agent authority delegation.
CREATE TABLE delegation_chains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(40) NOT NULL,
delegator_agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE,
delegatee_agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE,
scopes TEXT[] NOT NULL,
delegation_token TEXT NOT NULL UNIQUE,
signature TEXT NOT NULL,
ttl_seconds INTEGER NOT NULL CHECK (ttl_seconds BETWEEN 60 AND 86400),
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for token lookup (verify-delegation hot path)
CREATE UNIQUE INDEX idx_delegation_chains_token ON delegation_chains(delegation_token);
-- Index for listing delegations by delegator agent
CREATE INDEX idx_delegation_chains_delegator ON delegation_chains(delegator_agent_id, tenant_id);
-- Index for listing delegations by delegatee agent
CREATE INDEX idx_delegation_chains_delegatee ON delegation_chains(delegatee_agent_id, tenant_id);
-- Index for cleanup of expired chains
CREATE INDEX idx_delegation_chains_expires_at ON delegation_chains(expires_at);

View File

@@ -172,3 +172,66 @@ export const billingLimitRejectionsTotal = new Counter({
labelNames: ['tenant_id', 'limit_type'] as const, labelNames: ['tenant_id', 'limit_type'] as const,
registers: [metricsRegistry], registers: [metricsRegistry],
}); });
// ────────────────────────────────────────────────────────────────
// Phase 5 — WS2: A2A Delegation Metrics
// ────────────────────────────────────────────────────────────────
/**
* Total number of A2A delegation chains created.
* Labels: tenant_id
*/
export const delegationsCreatedTotal = new Counter({
name: 'agentidp_delegations_created_total',
help: 'Total number of A2A delegation chains created.',
labelNames: ['tenant_id'] as const,
registers: [metricsRegistry],
});
/**
* Total number of A2A delegation verifications performed.
* Labels: tenant_id, result (valid | invalid | expired | revoked)
*/
export const delegationsVerifiedTotal = new Counter({
name: 'agentidp_delegations_verified_total',
help: 'Total number of A2A delegation verifications, labelled by outcome.',
labelNames: ['tenant_id', 'result'] as const,
registers: [metricsRegistry],
});
/**
* Total number of A2A delegation chains revoked.
* Labels: tenant_id
*/
export const delegationsRevokedTotal = new Counter({
name: 'agentidp_delegations_revoked_total',
help: 'Total number of A2A delegation chains revoked.',
labelNames: ['tenant_id'] as const,
registers: [metricsRegistry],
});
// ────────────────────────────────────────────────────────────────
// Phase 5 — WS5: Scaffold Metrics
// ────────────────────────────────────────────────────────────────
/**
* Total number of scaffold ZIPs generated, labelled by language.
*/
export const scaffoldGeneratedTotal = new Counter({
name: 'agentidp_scaffold_generated_total',
help: 'Total number of scaffold ZIPs generated by target language.',
labelNames: ['language'] as const,
registers: [metricsRegistry],
});
/**
* Duration of scaffold ZIP generation in milliseconds.
* Labels: language
*/
export const scaffoldGenerationDurationMs = new Histogram({
name: 'agentidp_scaffold_generation_duration_ms',
help: 'Time taken to generate a scaffold ZIP archive in milliseconds.',
labelNames: ['language'] as const,
buckets: [10, 50, 100, 250, 500, 1000, 2500],
registers: [metricsRegistry],
});

52
src/routes/delegation.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* A2A Delegation routes for SentryAgent.ai AgentIdP.
* All three delegation endpoints require Bearer token authentication.
*/
import { Router, RequestHandler } from 'express';
import { DelegationController } from '../controllers/DelegationController.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for A2A delegation endpoints.
*
* Routes:
* POST /oauth2/token/delegate — create a delegation chain
* POST /oauth2/token/verify-delegation — verify a delegation token
* DELETE /oauth2/token/delegate/:chainId — revoke a delegation chain
*
* All routes are protected by the JWT authentication middleware.
*
* @param controller - The delegation controller instance.
* @param authMiddleware - The JWT authentication middleware.
* @returns Configured Express router.
*/
export function createDelegationRouter(
controller: DelegationController,
authMiddleware: RequestHandler,
): Router {
const router = Router();
// POST /oauth2/token/delegate — authenticated; creates a delegation chain
router.post(
'/oauth2/token/delegate',
authMiddleware,
asyncHandler(controller.createDelegation.bind(controller)),
);
// POST /oauth2/token/verify-delegation — authenticated; verifies a delegation token
router.post(
'/oauth2/token/verify-delegation',
authMiddleware,
asyncHandler(controller.verifyDelegation.bind(controller)),
);
// DELETE /oauth2/token/delegate/:chainId — authenticated; revokes a delegation chain
router.delete(
'/oauth2/token/delegate/:chainId',
authMiddleware,
asyncHandler(controller.revokeDelegation.bind(controller)),
);
return router;
}

58
src/routes/scaffold.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Scaffold routes for SentryAgent.ai AgentIdP.
* Provides the GET /sdk/scaffold/:agentId endpoint.
*/
import { Router, RequestHandler, Request, Response, NextFunction } from 'express';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { ScaffoldController } from '../controllers/ScaffoldController.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { RateLimitError } from '../utils/errors.js';
/** Scaffold-specific rate limiter: 10 requests per minute per tenant. */
const scaffoldRateLimiter = new RateLimiterMemory({ points: 10, duration: 60 });
/**
* Express middleware enforcing the scaffold-specific rate limit by tenant ID.
*/
async function scaffoldRateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const tenantId = req.user?.organization_id ?? req.ip ?? 'unknown';
try {
await scaffoldRateLimiter.consume(tenantId);
next();
} catch {
const retryAfter = Math.ceil(60);
res.setHeader('Retry-After', String(retryAfter));
next(new RateLimitError());
}
}
/**
* Creates and returns the Express router for scaffold endpoints.
*
* Routes:
* GET /sdk/scaffold/:agentId — authenticated; generate and stream a scaffold ZIP
*
* @param controller - The scaffold controller instance.
* @param authMiddleware - The JWT authentication middleware.
* @returns Configured Express router.
*/
export function createScaffoldRouter(
controller: ScaffoldController,
authMiddleware: RequestHandler,
): Router {
const router = Router();
router.get(
'/sdk/scaffold/:agentId',
authMiddleware,
asyncHandler(scaffoldRateLimitMiddleware),
asyncHandler(controller.getScaffold.bind(controller)),
);
return router;
}

View File

@@ -0,0 +1,334 @@
/**
* A2A Delegation Service for SentryAgent.ai AgentIdP.
* Business logic for creating, verifying, and revoking delegation chains.
*/
import { Pool, QueryResult } from 'pg';
import { AuditService } from './AuditService.js';
import {
DelegationChain,
CreateDelegationRequest,
DelegationVerificationResult,
DelegationTokenPayload,
} from '../types/delegation.js';
import {
signDelegationPayload,
verifyDelegationSignature,
generateDelegationToken,
} from '../utils/delegationCrypto.js';
import {
AgentNotFoundError,
ValidationError,
AuthorizationError,
CredentialAlreadyRevokedError,
} from '../utils/errors.js';
/** Database row shape returned by delegation_chains queries. */
interface DelegationChainRow {
id: string;
tenant_id: string;
delegator_agent_id: string;
delegatee_agent_id: string;
scopes: string[];
delegation_token: string;
signature: string;
ttl_seconds: number;
issued_at: Date;
expires_at: Date;
revoked_at: Date | null;
created_at: Date;
}
/** Interface contract for the delegation service. */
export interface IDelegationService {
/**
* Create a delegation chain from delegator to delegatee.
* Validates scope subset, signs payload, inserts DB row, writes audit log.
*/
createDelegation(
tenantId: string,
delegatorAgentId: string,
delegatorScopes: string[],
request: CreateDelegationRequest,
ipAddress: string,
userAgent: string,
): Promise<DelegationChain>;
/**
* Verify a delegation token. Returns chain details with valid flag.
* Does not throw on expired/revoked — returns valid: false.
*/
verifyDelegation(
delegationToken: string,
agentId: string,
ipAddress: string,
userAgent: string,
): Promise<DelegationVerificationResult>;
/**
* Revoke a delegation chain. Only the delegator may revoke.
*/
revokeDelegation(
chainId: string,
requestingAgentId: string,
ipAddress: string,
userAgent: string,
): Promise<void>;
}
/**
* Implementation of IDelegationService.
* Uses PostgreSQL for persistence and HMAC-SHA256 for delegation payload signing.
*/
export class DelegationService implements IDelegationService {
private readonly delegationSecret: string;
/**
* @param pool - PostgreSQL connection pool.
* @param auditService - Audit log service for recording delegation events.
*/
constructor(
private readonly pool: Pool,
private readonly auditService: AuditService,
) {
this.delegationSecret =
process.env['DELEGATION_SECRET'] ?? process.env['JWT_SECRET'] ?? 'delegation-secret-change-me';
}
/**
* Creates a delegation chain, signing the payload and storing it in the database.
*
* @param tenantId - The organization ID of the delegating agent.
* @param delegatorAgentId - UUID of the agent granting delegation.
* @param delegatorScopes - Scopes held by the delegator (from their JWT).
* @param request - Delegation request body.
* @param ipAddress - Caller IP for audit log.
* @param userAgent - Caller user-agent for audit log.
* @returns The created DelegationChain record.
* @throws ValidationError on invalid scopes, ttl, or self-delegation.
* @throws AgentNotFoundError if delegateeAgentId does not exist in the same tenant.
*/
async createDelegation(
tenantId: string,
delegatorAgentId: string,
delegatorScopes: string[],
request: CreateDelegationRequest,
ipAddress: string,
userAgent: string,
): Promise<DelegationChain> {
const { delegateeAgentId, scopes, ttlSeconds } = request;
// Validate ttl range
if (ttlSeconds < 60 || ttlSeconds > 86400) {
throw new ValidationError('ttlSeconds must be between 60 and 86400.', { code: 'INVALID_TTL' });
}
// Reject self-delegation
if (delegateeAgentId === delegatorAgentId) {
throw new ValidationError('An agent cannot delegate to itself.', { code: 'SELF_DELEGATION' });
}
// Validate scopes are a strict subset of the delegator's own scopes
const disallowedScopes = scopes.filter((s) => !delegatorScopes.includes(s));
if (disallowedScopes.length > 0) {
throw new ValidationError(
`Requested scopes exceed delegator permissions: ${disallowedScopes.join(', ')}`,
{ code: 'INVALID_SCOPES', disallowedScopes },
);
}
// Verify delegatee exists in the same tenant
const delegateeResult: QueryResult<{ agent_id: string }> = await this.pool.query(
`SELECT agent_id FROM agents WHERE agent_id = $1 AND organization_id = $2 AND status = 'active'`,
[delegateeAgentId, tenantId],
);
if (delegateeResult.rows.length === 0) {
throw new AgentNotFoundError(delegateeAgentId);
}
const now = new Date();
const expiresAt = new Date(now.getTime() + ttlSeconds * 1000);
const chainId = generateDelegationToken(); // UUID for the chain id
const delegationToken = generateDelegationToken(); // UUID for the token value
const payload: DelegationTokenPayload = {
chainId,
tenantId,
delegatorAgentId,
delegateeAgentId,
scopes,
issuedAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
};
const signature = signDelegationPayload(payload, this.delegationSecret);
const insertResult: QueryResult<DelegationChainRow> = await this.pool.query(
`INSERT INTO delegation_chains
(id, tenant_id, delegator_agent_id, delegatee_agent_id, scopes,
delegation_token, signature, ttl_seconds, issued_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
chainId,
tenantId,
delegatorAgentId,
delegateeAgentId,
scopes,
delegationToken,
signature,
ttlSeconds,
now,
expiresAt,
],
);
await this.auditService.logEvent(
delegatorAgentId,
'delegation.created',
'success',
ipAddress,
userAgent,
{ chainId, delegateeAgentId, scopes, ttlSeconds },
tenantId,
);
return this.rowToChain(insertResult.rows[0]);
}
/**
* Verifies a delegation token. Returns valid: false for expired or revoked chains
* rather than throwing an error.
*
* @param delegationToken - The delegation token to verify.
* @param agentId - The agent performing verification (for audit log).
* @param ipAddress - Caller IP for audit log.
* @param userAgent - Caller user-agent for audit log.
* @returns DelegationVerificationResult with valid flag.
* @throws AgentNotFoundError if no chain is found for the given token.
*/
async verifyDelegation(
delegationToken: string,
agentId: string,
ipAddress: string,
userAgent: string,
): Promise<DelegationVerificationResult> {
const result: QueryResult<DelegationChainRow> = await this.pool.query(
`SELECT * FROM delegation_chains WHERE delegation_token = $1`,
[delegationToken],
);
if (result.rows.length === 0) {
throw new AgentNotFoundError(delegationToken);
}
const row = result.rows[0];
const now = new Date();
const payload: DelegationTokenPayload = {
chainId: row.id,
tenantId: row.tenant_id,
delegatorAgentId: row.delegator_agent_id,
delegateeAgentId: row.delegatee_agent_id,
scopes: row.scopes,
issuedAt: row.issued_at.toISOString(),
expiresAt: row.expires_at.toISOString(),
};
const signatureValid = verifyDelegationSignature(payload, row.signature, this.delegationSecret);
const notExpired = row.expires_at > now;
const notRevoked = row.revoked_at === null;
const valid = signatureValid && notExpired && notRevoked;
await this.auditService.logEvent(
agentId,
'delegation.verified',
'success',
ipAddress,
userAgent,
{ chainId: row.id, valid, signatureValid, notExpired, notRevoked },
row.tenant_id,
);
return {
valid,
chainId: row.id,
delegatorAgentId: row.delegator_agent_id,
delegateeAgentId: row.delegatee_agent_id,
scopes: row.scopes,
issuedAt: row.issued_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
};
}
/**
* Revokes a delegation chain. Only the original delegator may revoke.
*
* @param chainId - UUID of the delegation chain to revoke.
* @param requestingAgentId - The agent attempting revocation (must be the delegator).
* @param ipAddress - Caller IP for audit log.
* @param userAgent - Caller user-agent for audit log.
* @throws AgentNotFoundError if no chain with this ID exists.
* @throws AuthorizationError if the requesting agent is not the delegator.
* @throws CredentialAlreadyRevokedError if the chain is already revoked.
*/
async revokeDelegation(
chainId: string,
requestingAgentId: string,
ipAddress: string,
userAgent: string,
): Promise<void> {
const result: QueryResult<DelegationChainRow> = await this.pool.query(
`SELECT * FROM delegation_chains WHERE id = $1`,
[chainId],
);
if (result.rows.length === 0) {
throw new AgentNotFoundError(chainId);
}
const row = result.rows[0];
if (row.delegator_agent_id !== requestingAgentId) {
throw new AuthorizationError('Only the delegating agent may revoke this delegation chain.');
}
if (row.revoked_at !== null) {
throw new CredentialAlreadyRevokedError(chainId, row.revoked_at.toISOString());
}
await this.pool.query(
`UPDATE delegation_chains SET revoked_at = NOW() WHERE id = $1`,
[chainId],
);
await this.auditService.logEvent(
requestingAgentId,
'delegation.revoked',
'success',
ipAddress,
userAgent,
{ chainId, delegateeAgentId: row.delegatee_agent_id },
row.tenant_id,
);
}
/** Maps a database row to a DelegationChain domain object. */
private rowToChain(row: DelegationChainRow): DelegationChain {
return {
id: row.id,
tenantId: row.tenant_id,
delegatorAgentId: row.delegator_agent_id,
delegateeAgentId: row.delegatee_agent_id,
scopes: row.scopes,
delegationToken: row.delegation_token,
signature: row.signature,
ttlSeconds: row.ttl_seconds,
issuedAt: row.issued_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
createdAt: row.created_at,
};
}
}

View File

@@ -0,0 +1,153 @@
/**
* Scaffold Service for SentryAgent.ai AgentIdP.
* Generates in-memory ZIP archives containing language-specific starter projects
* pre-wired with the requesting agent's credentials.
*/
import archiver from 'archiver';
import { PassThrough } from 'stream';
import { readFileSync } from 'fs';
import { join } from 'path';
import { ScaffoldLanguage, ScaffoldOptions } from '../types/scaffold.js';
import { ValidationError } from '../utils/errors.js';
import {
scaffoldGeneratedTotal,
scaffoldGenerationDurationMs,
} from '../metrics/registry.js';
/** Map of language → list of template file paths (relative to the language directory). */
const TEMPLATE_FILES: Record<ScaffoldLanguage, string[]> = {
typescript: [
'package.json.tmpl',
'tsconfig.json.tmpl',
'src/index.ts.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
python: [
'requirements.txt.tmpl',
'main.py.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
go: [
'go.mod.tmpl',
'main.go.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
java: [
'pom.xml.tmpl',
'src/main/java/Main.java.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
rust: [
'Cargo.toml.tmpl',
'src/main.rs.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
};
/** Valid scaffold language values for input validation. */
export const SCAFFOLD_LANGUAGES: ScaffoldLanguage[] = [
'typescript',
'python',
'go',
'java',
'rust',
];
/** Interface contract for the scaffold service. */
export interface IScaffoldService {
/**
* Generate an in-memory ZIP archive for the given agent and language.
* Template variables injected: {{AGENT_ID}}, {{AGENT_NAME}}, {{CLIENT_ID}}, {{API_URL}}
*
* @param options - Scaffold generation options.
* @returns Readable stream of the ZIP binary and the filename for Content-Disposition.
*/
generateScaffold(options: ScaffoldOptions): Promise<{ stream: NodeJS.ReadableStream; filename: string }>;
}
/**
* Implementation of IScaffoldService.
* Generates scaffold ZIPs in memory using the `archiver` library — no disk writes.
*/
export class ScaffoldService implements IScaffoldService {
private readonly templatesDir: string;
constructor() {
// __dirname is available because the project compiles to CommonJS
this.templatesDir = join(__dirname, '..', 'templates', 'scaffold');
}
/**
* Generates an in-memory ZIP scaffold for the given agent.
*
* @param options - Agent metadata and target language.
* @returns Object with a readable stream of the ZIP and the ZIP filename.
* @throws ValidationError if the language is not supported.
*/
async generateScaffold(
options: ScaffoldOptions,
): Promise<{ stream: NodeJS.ReadableStream; filename: string }> {
const { agentId, agentName, clientId, language, apiUrl } = options;
if (!SCAFFOLD_LANGUAGES.includes(language)) {
throw new ValidationError(
`Unsupported language '${language}'. Choose: ${SCAFFOLD_LANGUAGES.join(', ')}`,
{ code: 'INVALID_LANGUAGE' },
);
}
const startMs = Date.now();
const safeAgentName = agentName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
const projectDir = `sentryagent-scaffold-${safeAgentName}-${language}`;
const filename = `${projectDir}.zip`;
const archive = archiver('zip', { zlib: { level: 6 } });
const passThrough = new PassThrough();
archive.pipe(passThrough);
const templateFiles = TEMPLATE_FILES[language];
for (const templateFile of templateFiles) {
const templatePath = join(this.templatesDir, language, templateFile);
const rawContent = readFileSync(templatePath, 'utf-8');
const content = this.injectVariables(rawContent, { agentId, agentName, clientId, apiUrl });
// Strip .tmpl extension for the archive entry
const archiveEntry = templateFile.endsWith('.tmpl')
? templateFile.slice(0, -5)
: templateFile;
archive.append(content, { name: `${projectDir}/${archiveEntry}` });
}
archive.finalize().catch((err: unknown) => {
passThrough.destroy(err instanceof Error ? err : new Error(String(err)));
});
const durationMs = Date.now() - startMs;
scaffoldGeneratedTotal.labels(language).inc();
scaffoldGenerationDurationMs.labels(language).observe(durationMs);
return { stream: passThrough, filename };
}
/** Replaces all template variable placeholders in a template string. */
private injectVariables(
template: string,
vars: { agentId: string; agentName: string; clientId: string; apiUrl: string },
): string {
return template
.replace(/\{\{AGENT_ID\}\}/g, vars.agentId)
.replace(/\{\{AGENT_NAME\}\}/g, vars.agentName)
.replace(/\{\{CLIENT_ID\}\}/g, vars.clientId)
.replace(/\{\{API_URL\}\}/g, vars.apiUrl);
}
}

View File

@@ -0,0 +1,3 @@
.env
{{AGENT_NAME}}
*.exe

View File

@@ -0,0 +1,26 @@
# {{AGENT_NAME}}
A SentryAgent.ai agent starter project (Go).
**Agent ID:** `{{AGENT_ID}}`
## Quick Start
1. Copy the environment file and fill in your client secret:
```bash
cp .env.example .env
# Edit .env and set AGENTIDP_CLIENT_SECRET
```
2. Install dependencies:
```bash
go mod tidy
```
3. Run the agent:
```bash
go run main.go
```

View File

@@ -0,0 +1,7 @@
module {{AGENT_NAME}}
go 1.21
require (
github.com/joho/godotenv v1.5.1
)

View File

@@ -0,0 +1,73 @@
// {{AGENT_NAME}} — SentryAgent.ai agent starter
// Agent ID: {{AGENT_ID}}
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/joho/godotenv"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
func main() {
_ = godotenv.Load()
apiURL := getEnv("AGENTIDP_API_URL", "{{API_URL}}")
clientID := os.Getenv("AGENTIDP_CLIENT_ID")
clientSecret := os.Getenv("AGENTIDP_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
fmt.Fprintln(os.Stderr, "Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set")
os.Exit(1)
}
fmt.Printf("Issuing token for agent {{AGENT_ID}} at %s ...\n", apiURL)
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("scope", "agents:read")
resp, err := http.Post(apiURL+"/api/v1/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "Token issuance failed: HTTP %d\n", resp.StatusCode)
os.Exit(1)
}
var token TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
fmt.Fprintf(os.Stderr, "Failed to decode response: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Token issued successfully!")
fmt.Printf(" Expires in: %ds\n", token.ExpiresIn)
truncated := token.AccessToken
if len(truncated) > 20 {
truncated = truncated[:20]
}
fmt.Printf(" Token (first 20 chars): %s...\n", truncated)
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}

View File

@@ -0,0 +1,3 @@
.env
target/
*.class

View File

@@ -0,0 +1,20 @@
# {{AGENT_NAME}}
A SentryAgent.ai agent starter project (Java).
**Agent ID:** `{{AGENT_ID}}`
## Quick Start
1. Copy the environment file and fill in your client secret:
```bash
cp .env.example .env
# Edit .env and set AGENTIDP_CLIENT_SECRET
```
2. Build and run:
```bash
mvn compile exec:java -Dexec.mainClass="Main"
```

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ai.sentryagent</groupId>
<artifactId>{{AGENT_NAME}}</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>io.github.cdimascio</groupId>
<artifactId>dotenv-java</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,50 @@
import io.github.cdimascio.dotenv.Dotenv;
import okhttp3.*;
import com.google.gson.*;
/**
* {{AGENT_NAME}} — SentryAgent.ai agent starter (Java)
* Agent ID: {{AGENT_ID}}
*/
public class Main {
public static void main(String[] args) throws Exception {
Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();
String apiUrl = dotenv.get("AGENTIDP_API_URL", "{{API_URL}}");
String clientId = dotenv.get("AGENTIDP_CLIENT_ID", "");
String clientSecret = dotenv.get("AGENTIDP_CLIENT_SECRET", "");
if (clientId.isEmpty() || clientSecret.isEmpty()) {
System.err.println("Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set");
System.exit(1);
}
System.out.println("Issuing token for agent {{AGENT_ID}} at " + apiUrl + " ...");
OkHttpClient client = new OkHttpClient();
RequestBody body = new FormBody.Builder()
.add("grant_type", "client_credentials")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("scope", "agents:read")
.build();
Request request = new Request.Builder()
.url(apiUrl + "/api/v1/token")
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
System.err.println("Token issuance failed: HTTP " + response.code());
System.exit(1);
}
JsonObject token = JsonParser.parseString(response.body().string()).getAsJsonObject();
String accessToken = token.get("access_token").getAsString();
int expiresIn = token.get("expires_in").getAsInt();
System.out.println("✓ Token issued successfully!");
System.out.println(" Expires in: " + expiresIn + "s");
System.out.println(" Token (first 20 chars): " + accessToken.substring(0, Math.min(20, accessToken.length())) + "...");
}
}
}

View File

@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
*.pyo
.venv/
venv/

View File

@@ -0,0 +1,26 @@
# {{AGENT_NAME}}
A SentryAgent.ai agent starter project (Python).
**Agent ID:** `{{AGENT_ID}}`
## Quick Start
1. Copy the environment file and fill in your client secret:
```bash
cp .env.example .env
# Edit .env and set AGENTIDP_CLIENT_SECRET
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the agent:
```bash
python main.py
```

View File

@@ -0,0 +1,43 @@
"""
{{AGENT_NAME}} — SentryAgent.ai agent starter
Agent ID: {{AGENT_ID}}
Demonstrates how to authenticate with SentryAgent.ai and issue an OAuth2 access token.
"""
import os
import sys
import requests
from dotenv import load_dotenv
load_dotenv()
API_URL = os.environ.get("AGENTIDP_API_URL", "{{API_URL}}")
CLIENT_ID = os.environ.get("AGENTIDP_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("AGENTIDP_CLIENT_SECRET", "")
if not CLIENT_ID or not CLIENT_SECRET:
print("Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set in .env")
sys.exit(1)
print(f"Issuing token for agent {{AGENT_ID}} at {API_URL} ...")
response = requests.post(
f"{API_URL}/api/v1/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "agents:read",
},
)
if not response.ok:
print(f"Token issuance failed: {response.text}")
sys.exit(1)
token = response.json()
print("✓ Token issued successfully!")
print(f" Token type: Bearer")
print(f" Expires in: {token['expires_in']}s")
print(f" Token (first 20 chars): {token['access_token'][:20]}...")

View File

@@ -0,0 +1,2 @@
python-dotenv>=1.0.0
requests>=2.31.0

View File

@@ -0,0 +1,3 @@
.env
target/
Cargo.lock

View File

@@ -0,0 +1,11 @@
[package]
name = "{{AGENT_NAME}}"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.35", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"

View File

@@ -0,0 +1,20 @@
# {{AGENT_NAME}}
A SentryAgent.ai agent starter project (Rust).
**Agent ID:** `{{AGENT_ID}}`
## Quick Start
1. Copy the environment file and fill in your client secret:
```bash
cp .env.example .env
# Edit .env and set AGENTIDP_CLIENT_SECRET
```
2. Run the agent:
```bash
cargo run
```

View File

@@ -0,0 +1,47 @@
//! {{AGENT_NAME}} — SentryAgent.ai agent starter (Rust)
//! Agent ID: {{AGENT_ID}}
use std::collections::HashMap;
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let api_url = std::env::var("AGENTIDP_API_URL").unwrap_or_else(|_| "{{API_URL}}".to_string());
let client_id = std::env::var("AGENTIDP_CLIENT_ID")?;
let client_secret = std::env::var("AGENTIDP_CLIENT_SECRET")?;
println!("Issuing token for agent {{AGENT_ID}} at {} ...", api_url);
let client = reqwest::Client::new();
let mut params = HashMap::new();
params.insert("grant_type", "client_credentials");
params.insert("client_id", &client_id);
params.insert("client_secret", &client_secret);
params.insert("scope", "agents:read");
let response = client
.post(format!("{}/api/v1/token", api_url))
.form(&params)
.send()
.await?;
if !response.status().is_success() {
eprintln!("Token issuance failed: HTTP {}", response.status());
std::process::exit(1);
}
let token: TokenResponse = response.json().await?;
println!("✓ Token issued successfully!");
println!(" Expires in: {}s", token.expires_in);
let truncated = &token.access_token[..token.access_token.len().min(20)];
println!(" Token (first 20 chars): {}...", truncated);
Ok(())
}

View File

@@ -0,0 +1,4 @@
.env
node_modules/
dist/
*.js.map

View File

@@ -0,0 +1,40 @@
# {{AGENT_NAME}}
A SentryAgent.ai agent starter project.
**Agent ID:** `{{AGENT_ID}}`
**API:** {{API_URL}}
## Quick Start
1. Copy the example environment file and fill in your client secret:
```bash
cp .env.example .env
# Edit .env and set AGENTIDP_CLIENT_SECRET to your agent's secret
```
2. Install dependencies:
```bash
npm install
```
3. Run the agent:
```bash
npm start
```
## Configuration
| Variable | Description |
|---|---|
| `AGENTIDP_API_URL` | SentryAgent.ai API base URL |
| `AGENTIDP_CLIENT_ID` | Your agent's OAuth2 client ID (pre-filled) |
| `AGENTIDP_CLIENT_SECRET` | Your agent's client secret (copy from dashboard) |
## Resources
- [SentryAgent.ai Documentation]({{API_URL}}/docs)
- [API Reference]({{API_URL}}/api-explorer)

View File

@@ -0,0 +1,20 @@
{
"name": "{{AGENT_NAME}}",
"version": "0.1.0",
"description": "SentryAgent.ai agent — {{AGENT_NAME}}",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"sentryagent-idp-sdk": "^1.0.0",
"dotenv": "^16.0.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.0.0",
"ts-node": "^10.9.0"
}
}

View File

@@ -0,0 +1,50 @@
import 'dotenv/config';
/**
* {{AGENT_NAME}} — SentryAgent.ai agent starter
* Agent ID: {{AGENT_ID}}
*
* This file demonstrates how to authenticate with SentryAgent.ai and issue
* an OAuth2 access token using your agent credentials.
*/
async function main(): Promise<void> {
const apiUrl = process.env['AGENTIDP_API_URL'] ?? '{{API_URL}}';
const clientId = process.env['AGENTIDP_CLIENT_ID'] ?? '';
const clientSecret = process.env['AGENTIDP_CLIENT_SECRET'] ?? '';
if (!clientId || !clientSecret) {
console.error('Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set in .env');
process.exit(1);
}
console.log(`Issuing token for agent {{AGENT_ID}} at ${apiUrl} ...`);
const response = await fetch(`${apiUrl}/api/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'agents:read',
}),
});
if (!response.ok) {
const error = await response.text();
console.error('Token issuance failed:', error);
process.exit(1);
}
const token = await response.json() as { access_token: string; expires_in: number };
console.log('✓ Token issued successfully!');
console.log(` Token type: Bearer`);
console.log(` Expires in: ${token.expires_in}s`);
console.log(` Token (first 20 chars): ${token.access_token.substring(0, 20)}...`);
}
main().catch((err: unknown) => {
console.error('Unexpected error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

53
src/types/delegation.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* TypeScript interfaces for the A2A (Agent-to-Agent) delegation subsystem.
* All delegation types are defined here — no inline type definitions in services or controllers.
*/
/** A delegation chain record as stored in the database. */
export interface DelegationChain {
id: string;
tenantId: string;
delegatorAgentId: string;
delegateeAgentId: string;
scopes: string[];
delegationToken: string;
signature: string;
ttlSeconds: number;
issuedAt: Date;
expiresAt: Date;
revokedAt: Date | null;
createdAt: Date;
}
/** Request body for creating a new delegation. */
export interface CreateDelegationRequest {
/** UUID of the agent receiving delegated authority. Must be in the same tenant. */
delegateeAgentId: string;
/** Scopes to delegate. Must be a strict subset of the delegator's own scopes. */
scopes: string[];
/** Delegation lifetime in seconds. Min: 60, Max: 86400. */
ttlSeconds: number;
}
/** Result of verifying a delegation token. Never throws on expired/revoked — returns valid: false. */
export interface DelegationVerificationResult {
valid: boolean;
chainId: string;
delegatorAgentId: string;
delegateeAgentId: string;
scopes: string[];
issuedAt: Date;
expiresAt: Date;
revokedAt: Date | null;
}
/** Payload signed by HMAC-SHA256 to produce the delegation signature. */
export interface DelegationTokenPayload {
chainId: string;
tenantId: string;
delegatorAgentId: string;
delegateeAgentId: string;
scopes: string[];
issuedAt: string; // ISO 8601
expiresAt: string; // ISO 8601
}

View File

@@ -57,7 +57,11 @@ export type AuditAction =
| 'org.member_added' | 'org.member_added'
| 'webhook.created' | 'webhook.created'
| 'webhook.updated' | 'webhook.updated'
| 'webhook.deleted'; | 'webhook.deleted'
| 'delegation.created'
| 'delegation.verified'
| 'delegation.revoked'
| 'scaffold.generated';
/** Outcome of an audited action. */ /** Outcome of an audited action. */
export type AuditOutcome = 'success' | 'failure'; export type AuditOutcome = 'success' | 'failure';

28
src/types/scaffold.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* TypeScript types for the scaffold generator (WS5 Developer Experience).
*/
/** Supported target languages for scaffold generation. */
export type ScaffoldLanguage = 'typescript' | 'python' | 'go' | 'java' | 'rust';
/** Options for generating a scaffold project. */
export interface ScaffoldOptions {
/** Agent UUID. */
agentId: string;
/** Human-readable agent name (used in filenames and README). */
agentName: string;
/** OAuth2 client ID pre-filled in .env.example. */
clientId: string;
/** Target programming language. */
language: ScaffoldLanguage;
/** API base URL injected into template files. */
apiUrl: string;
}
/** A single file within a scaffold template. */
export interface ScaffoldTemplate {
/** Path within the ZIP archive (relative to the project root directory). */
archivePath: string;
/** File content with template variables replaced. */
content: string;
}

View File

@@ -0,0 +1,45 @@
/**
* Cryptographic utilities for A2A delegation chain signing and verification.
* Uses HMAC-SHA256 to produce and verify delegation payload signatures.
*/
import { createHmac, randomUUID } from 'crypto';
import { DelegationTokenPayload } from '../types/delegation.js';
/**
* Signs a delegation payload using HMAC-SHA256.
*
* @param payload - The delegation token payload to sign.
* @param secret - The HMAC signing secret.
* @returns Hex-encoded HMAC-SHA256 digest of the serialised payload.
*/
export function signDelegationPayload(payload: DelegationTokenPayload, secret: string): string {
const data = JSON.stringify(payload);
return createHmac('sha256', secret).update(data).digest('hex');
}
/**
* Verifies a delegation payload against an expected HMAC-SHA256 signature.
*
* @param payload - The delegation token payload to verify.
* @param signature - The hex-encoded signature to check against.
* @param secret - The HMAC signing secret.
* @returns `true` if the signature is valid, `false` otherwise.
*/
export function verifyDelegationSignature(
payload: DelegationTokenPayload,
signature: string,
secret: string,
): boolean {
const expected = signDelegationPayload(payload, secret);
return expected === signature;
}
/**
* Generates a unique delegation token (UUID v4).
*
* @returns A random UUID string to use as the delegation token.
*/
export function generateDelegationToken(): string {
return randomUUID();
}

View File

@@ -0,0 +1,275 @@
/**
* Integration tests for A2A delegation endpoints.
* Tests POST /oauth2/token/delegate, POST /oauth2/token/verify-delegation,
* DELETE /oauth2/token/delegate/:chainId
*
* Requires a running PostgreSQL instance and valid JWT environment variables.
* Uses supertest to test the full Express request pipeline.
*/
import request from 'supertest';
import express from 'express';
import { DelegationController } from '../../src/controllers/DelegationController';
import { DelegationService } from '../../src/services/DelegationService';
import { createDelegationRouter } from '../../src/routes/delegation';
import { Pool } from 'pg';
import { AuditService } from '../../src/services/AuditService';
// Mock heavy dependencies for integration-level tests
jest.mock('../../src/services/AuditService');
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const mockQuery = jest.fn();
const mockPool = { query: mockQuery } as unknown as Pool;
// Auth middleware that injects a test user
const testAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
req.user = {
sub: 'delegator-agent-uuid',
client_id: 'delegator-agent-uuid',
scope: 'agents:read tokens:read audit:read',
jti: 'test-jti',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
organization_id: 'org_system',
};
next();
};
const DELEGATEE_ID = 'delegatee-agent-uuid';
const CHAIN_ID = 'chain-uuid-1234';
const DELEGATION_TOKEN = 'delegation-token-uuid';
const MOCK_CHAIN = {
id: CHAIN_ID,
tenant_id: 'org_system',
delegator_agent_id: 'delegator-agent-uuid',
delegatee_agent_id: DELEGATEE_ID,
scopes: ['agents:read'],
delegation_token: DELEGATION_TOKEN,
signature: 'mock-sig',
ttl_seconds: 3600,
issued_at: new Date(),
expires_at: new Date(Date.now() + 3600_000),
revoked_at: null,
created_at: new Date(),
};
function buildApp() {
const app = express();
app.use(express.json());
const auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
auditService.logEvent = jest.fn().mockResolvedValue({});
process.env['DELEGATION_SECRET'] = 'test-secret';
const delegationService = new DelegationService(mockPool, auditService);
const controller = new DelegationController(delegationService);
app.use('/api/v1', createDelegationRouter(controller, testAuth));
// Error handler
app.use(
(
err: { httpStatus?: number; code?: string; message?: string },
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
res.status(err.httpStatus ?? 500).json({ code: err.code ?? 'ERROR', message: err.message });
},
);
return app;
}
describe('Delegation Endpoints', () => {
let app: express.Application;
beforeEach(() => {
jest.clearAllMocks();
app = buildApp();
});
afterEach(() => {
delete process.env['DELEGATION_SECRET'];
});
// ────────────────────────────────────────────────────────────────
// POST /api/v1/oauth2/token/delegate
// ────────────────────────────────────────────────────────────────
describe('POST /api/v1/oauth2/token/delegate', () => {
it('returns 201 with delegation token on success', async () => {
// delegatee exists in same tenant
mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] });
// insert returns chain row
mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] });
const res = await request(app)
.post('/api/v1/oauth2/token/delegate')
.send({ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('delegationToken');
expect(res.body).toHaveProperty('chainId', CHAIN_ID);
expect(res.body).toHaveProperty('scopes');
});
it('returns 400 when scopes exceed delegator permissions', async () => {
const res = await request(app)
.post('/api/v1/oauth2/token/delegate')
.send({ delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 });
expect(res.status).toBe(400);
});
it('returns 422 on self-delegation', async () => {
const res = await request(app)
.post('/api/v1/oauth2/token/delegate')
.send({
delegateeAgentId: 'delegator-agent-uuid', // same as req.user.sub
scopes: ['agents:read'],
ttlSeconds: 3600,
});
expect(res.status).toBe(400);
});
it('returns 404 when delegatee does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const res = await request(app)
.post('/api/v1/oauth2/token/delegate')
.send({
delegateeAgentId: 'nonexistent-agent',
scopes: ['agents:read'],
ttlSeconds: 3600,
});
expect(res.status).toBe(404);
});
it('returns 400 when delegateeAgentId is missing', async () => {
const res = await request(app)
.post('/api/v1/oauth2/token/delegate')
.send({ scopes: ['agents:read'], ttlSeconds: 3600 });
expect(res.status).toBe(400);
});
});
// ────────────────────────────────────────────────────────────────
// POST /api/v1/oauth2/token/verify-delegation
// ────────────────────────────────────────────────────────────────
describe('POST /api/v1/oauth2/token/verify-delegation', () => {
it('returns 200 with valid: true for an active chain', async () => {
const { signDelegationPayload } = await import('../../src/utils/delegationCrypto');
const payload = {
chainId: MOCK_CHAIN.id,
tenantId: MOCK_CHAIN.tenant_id,
delegatorAgentId: MOCK_CHAIN.delegator_agent_id,
delegateeAgentId: MOCK_CHAIN.delegatee_agent_id,
scopes: MOCK_CHAIN.scopes,
issuedAt: MOCK_CHAIN.issued_at.toISOString(),
expiresAt: MOCK_CHAIN.expires_at.toISOString(),
};
const realSignature = signDelegationPayload(payload, 'test-secret');
mockQuery.mockResolvedValueOnce({
rows: [{ ...MOCK_CHAIN, signature: realSignature }],
});
const res = await request(app)
.post('/api/v1/oauth2/token/verify-delegation')
.send({ delegationToken: DELEGATION_TOKEN });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body).toHaveProperty('chainId');
expect(res.body).toHaveProperty('scopes');
});
it('returns 200 with valid: false for an expired chain', async () => {
const expiredChain = {
...MOCK_CHAIN,
expires_at: new Date(Date.now() - 1000),
};
mockQuery.mockResolvedValueOnce({ rows: [expiredChain] });
const res = await request(app)
.post('/api/v1/oauth2/token/verify-delegation')
.send({ delegationToken: DELEGATION_TOKEN });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns 200 with valid: false for a revoked chain', async () => {
const revokedChain = { ...MOCK_CHAIN, revoked_at: new Date() };
mockQuery.mockResolvedValueOnce({ rows: [revokedChain] });
const res = await request(app)
.post('/api/v1/oauth2/token/verify-delegation')
.send({ delegationToken: DELEGATION_TOKEN });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.revokedAt).toBeTruthy();
});
it('returns 404 when no chain found for the token', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const res = await request(app)
.post('/api/v1/oauth2/token/verify-delegation')
.send({ delegationToken: 'nonexistent-token' });
expect(res.status).toBe(404);
});
});
// ────────────────────────────────────────────────────────────────
// DELETE /api/v1/oauth2/token/delegate/:chainId
// ────────────────────────────────────────────────────────────────
describe('DELETE /api/v1/oauth2/token/delegate/:chainId', () => {
it('returns 204 when delegator revokes their own chain', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] }); // fetch
mockQuery.mockResolvedValueOnce({ rows: [] }); // update
const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`);
expect(res.status).toBe(204);
});
it('returns 403 when non-delegator attempts revocation', async () => {
const chainWithDifferentDelegator = {
...MOCK_CHAIN,
delegator_agent_id: 'some-other-agent',
};
mockQuery.mockResolvedValueOnce({ rows: [chainWithDifferentDelegator] });
const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`);
expect(res.status).toBe(403);
});
it('returns 409 when chain is already revoked', async () => {
const alreadyRevoked = { ...MOCK_CHAIN, revoked_at: new Date() };
mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] });
const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`);
expect(res.status).toBe(409);
});
it('returns 404 when chain does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const res = await request(app).delete(`/api/v1/oauth2/token/delegate/nonexistent-chain`);
expect(res.status).toBe(404);
});
});
});

View File

@@ -0,0 +1,148 @@
/**
* Integration tests for the scaffold endpoint.
* Tests GET /sdk/scaffold/:agentId
*/
import request from 'supertest';
import express from 'express';
import { Pool } from 'pg';
import { AuditService } from '../../src/services/AuditService';
import { ScaffoldService } from '../../src/services/ScaffoldService';
import { ScaffoldController } from '../../src/controllers/ScaffoldController';
import { createScaffoldRouter } from '../../src/routes/scaffold';
jest.mock('../../src/services/AuditService');
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const mockQuery = jest.fn();
const mockPool = { query: mockQuery } as unknown as Pool;
const AGENT_ID = 'agent-uuid-integration-test';
const TENANT_ID = 'org_system';
const CLIENT_ID = 'credential-client-id';
// Auth middleware injecting a test user that owns the agent
const testAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
req.user = {
sub: AGENT_ID,
client_id: CLIENT_ID,
scope: 'agents:read',
jti: 'jti-test',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
organization_id: TENANT_ID,
};
next();
};
// Auth middleware for a different tenant (forbidden)
const otherTenantAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
req.user = {
sub: 'other-agent-uuid',
client_id: 'other-client',
scope: 'agents:read',
jti: 'jti-other',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
organization_id: 'other-org',
};
next();
};
function buildApp(authMiddleware = testAuth) {
const app = express();
app.use(express.json());
const auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
auditService.logEvent = jest.fn().mockResolvedValue({});
const scaffoldService = new ScaffoldService();
const controller = new ScaffoldController(scaffoldService, mockPool, auditService);
app.use('/api/v1', createScaffoldRouter(controller, authMiddleware));
app.use(
(
err: { httpStatus?: number; code?: string; message?: string },
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
res.status(err.httpStatus ?? 500).json({ code: err.code ?? 'ERROR', message: err.message });
},
);
return app;
}
describe('Scaffold Endpoint', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env['API_URL'] = 'https://api.sentryagent.ai';
});
afterEach(() => {
delete process.env['API_URL'];
});
describe('GET /api/v1/sdk/scaffold/:agentId', () => {
it('returns a TypeScript scaffold ZIP with correct Content-Type and Content-Disposition', async () => {
// Agent exists and belongs to tenant
mockQuery
.mockResolvedValueOnce({
rows: [{ agent_id: AGENT_ID, email: 'my-agent@test.com', organization_id: TENANT_ID }],
})
// Credential lookup
.mockResolvedValueOnce({ rows: [{ client_id: CLIENT_ID }] });
const app = buildApp();
const res = await request(app).get(`/api/v1/sdk/scaffold/${AGENT_ID}?language=typescript`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
expect(res.headers['content-disposition']).toMatch(/attachment; filename=".*typescript\.zip"/);
// Response body should be a non-empty buffer (ZIP magic bytes PK)
expect(res.body).toBeDefined();
});
it('returns a Python scaffold ZIP', async () => {
mockQuery
.mockResolvedValueOnce({
rows: [{ agent_id: AGENT_ID, email: 'my-agent@test.com', organization_id: TENANT_ID }],
})
.mockResolvedValueOnce({ rows: [{ client_id: CLIENT_ID }] });
const app = buildApp();
const res = await request(app).get(`/api/v1/sdk/scaffold/${AGENT_ID}?language=python`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
});
it('returns HTTP 400 for an invalid language', async () => {
const app = buildApp();
const res = await request(app).get(`/api/v1/sdk/scaffold/${AGENT_ID}?language=cobol`);
expect(res.status).toBe(400);
});
it('returns HTTP 404 when agent does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const app = buildApp();
const res = await request(app).get(`/api/v1/sdk/scaffold/${AGENT_ID}?language=typescript`);
expect(res.status).toBe(404);
});
it('returns HTTP 403 when agent belongs to a different tenant', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ agent_id: AGENT_ID, email: 'my-agent@test.com', organization_id: TENANT_ID }],
});
const app = buildApp(otherTenantAuth);
const res = await request(app).get(`/api/v1/sdk/scaffold/${AGENT_ID}?language=typescript`);
expect(res.status).toBe(403);
});
});
});

View File

@@ -0,0 +1,284 @@
/**
* Unit tests for src/services/DelegationService.ts
*/
import { Pool } from 'pg';
import { DelegationService } from '../../../src/services/DelegationService';
import { AuditService } from '../../../src/services/AuditService';
import {
ValidationError,
AgentNotFoundError,
AuthorizationError,
CredentialAlreadyRevokedError,
} from '../../../src/utils/errors';
jest.mock('../../../src/services/AuditService');
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
// Mock Pool
const mockQuery = jest.fn();
const mockPool = { query: mockQuery } as unknown as Pool;
const TENANT_ID = 'org_system';
const DELEGATOR_ID = 'delegator-uuid-1234';
const DELEGATEE_ID = 'delegatee-uuid-5678';
const DELEGATOR_SCOPES = ['agents:read', 'tokens:read', 'audit:read'];
const IP = '127.0.0.1';
const UA = 'test-agent/1.0';
const MOCK_CHAIN_ROW = {
id: 'chain-uuid-1234',
tenant_id: TENANT_ID,
delegator_agent_id: DELEGATOR_ID,
delegatee_agent_id: DELEGATEE_ID,
scopes: ['agents:read'],
delegation_token: 'token-uuid-abcd',
signature: 'mock-signature',
ttl_seconds: 3600,
issued_at: new Date('2026-04-03T00:00:00Z'),
expires_at: new Date(Date.now() + 3600_000),
revoked_at: null,
created_at: new Date('2026-04-03T00:00:00Z'),
};
describe('DelegationService', () => {
let service: DelegationService;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
// Set delegation secret for tests
process.env['DELEGATION_SECRET'] = 'test-secret';
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
auditService.logEvent = jest.fn().mockResolvedValue({});
service = new DelegationService(mockPool, auditService);
});
afterEach(() => {
delete process.env['DELEGATION_SECRET'];
});
// ────────────────────────────────────────────────────────────────
// createDelegation
// ────────────────────────────────────────────────────────────────
describe('createDelegation', () => {
it('creates a delegation chain successfully', async () => {
// delegatee exists
mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] });
// insert returns row
mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] });
const result = await service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 },
IP,
UA,
);
expect(result.delegatorAgentId).toBe(DELEGATOR_ID);
expect(result.delegateeAgentId).toBe(DELEGATEE_ID);
expect(auditService.logEvent).toHaveBeenCalledWith(
DELEGATOR_ID,
'delegation.created',
'success',
IP,
UA,
expect.objectContaining({ chainId: expect.any(String), delegateeAgentId: DELEGATEE_ID }),
TENANT_ID,
);
});
it('throws ValidationError when scope escalation is attempted', async () => {
await expect(
service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 },
IP,
UA,
),
).rejects.toThrow(ValidationError);
});
it('throws ValidationError on self-delegation', async () => {
await expect(
service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: DELEGATOR_ID, scopes: ['agents:read'], ttlSeconds: 3600 },
IP,
UA,
),
).rejects.toThrow(ValidationError);
});
it('throws ValidationError when ttlSeconds is below minimum', async () => {
await expect(
service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 30 },
IP,
UA,
),
).rejects.toThrow(ValidationError);
});
it('throws ValidationError when ttlSeconds exceeds maximum', async () => {
await expect(
service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 99999 },
IP,
UA,
),
).rejects.toThrow(ValidationError);
});
it('throws AgentNotFoundError when delegatee is in a different tenant', async () => {
// delegatee not found in this tenant
mockQuery.mockResolvedValueOnce({ rows: [] });
await expect(
service.createDelegation(
TENANT_ID,
DELEGATOR_ID,
DELEGATOR_SCOPES,
{ delegateeAgentId: 'other-tenant-agent', scopes: ['agents:read'], ttlSeconds: 3600 },
IP,
UA,
),
).rejects.toThrow(AgentNotFoundError);
});
});
// ────────────────────────────────────────────────────────────────
// verifyDelegation
// ────────────────────────────────────────────────────────────────
describe('verifyDelegation', () => {
it('returns valid: true for an active, non-expired chain', async () => {
// Need a real signature to pass verification
const { signDelegationPayload } = await import('../../../src/utils/delegationCrypto');
const payload = {
chainId: MOCK_CHAIN_ROW.id,
tenantId: MOCK_CHAIN_ROW.tenant_id,
delegatorAgentId: MOCK_CHAIN_ROW.delegator_agent_id,
delegateeAgentId: MOCK_CHAIN_ROW.delegatee_agent_id,
scopes: MOCK_CHAIN_ROW.scopes,
issuedAt: MOCK_CHAIN_ROW.issued_at.toISOString(),
expiresAt: MOCK_CHAIN_ROW.expires_at.toISOString(),
};
const realSignature = signDelegationPayload(payload, 'test-secret');
mockQuery.mockResolvedValueOnce({
rows: [{ ...MOCK_CHAIN_ROW, signature: realSignature }],
});
const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA);
expect(result.valid).toBe(true);
expect(result.chainId).toBe(MOCK_CHAIN_ROW.id);
expect(auditService.logEvent).toHaveBeenCalledWith(
DELEGATEE_ID,
'delegation.verified',
'success',
IP,
UA,
expect.objectContaining({ valid: true }),
TENANT_ID,
);
});
it('returns valid: false (not throws) for an expired chain', async () => {
const expiredRow = {
...MOCK_CHAIN_ROW,
expires_at: new Date(Date.now() - 1000), // past
signature: 'any-sig',
};
mockQuery.mockResolvedValueOnce({ rows: [expiredRow] });
const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA);
expect(result.valid).toBe(false);
});
it('returns valid: false (not throws) for a revoked chain', async () => {
const revokedRow = {
...MOCK_CHAIN_ROW,
revoked_at: new Date(),
signature: 'any-sig',
};
mockQuery.mockResolvedValueOnce({ rows: [revokedRow] });
const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA);
expect(result.valid).toBe(false);
expect(result.revokedAt).toBeDefined();
});
it('throws AgentNotFoundError when no chain exists for the token', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await expect(
service.verifyDelegation('nonexistent-token', DELEGATEE_ID, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
});
// ────────────────────────────────────────────────────────────────
// revokeDelegation
// ────────────────────────────────────────────────────────────────
describe('revokeDelegation', () => {
it('revokes a delegation chain when called by the delegator', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); // fetch chain
mockQuery.mockResolvedValueOnce({ rows: [] }); // update revoked_at
await service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA);
expect(auditService.logEvent).toHaveBeenCalledWith(
DELEGATOR_ID,
'delegation.revoked',
'success',
IP,
UA,
expect.objectContaining({ chainId: MOCK_CHAIN_ROW.id }),
TENANT_ID,
);
});
it('throws AuthorizationError when a non-delegator attempts revocation', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] });
await expect(
service.revokeDelegation(MOCK_CHAIN_ROW.id, 'other-agent-id', IP, UA),
).rejects.toThrow(AuthorizationError);
});
it('throws CredentialAlreadyRevokedError when chain is already revoked', async () => {
const alreadyRevoked = { ...MOCK_CHAIN_ROW, revoked_at: new Date() };
mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] });
await expect(
service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA),
).rejects.toThrow(CredentialAlreadyRevokedError);
});
it('throws AgentNotFoundError when chain does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await expect(
service.revokeDelegation('nonexistent-chain-id', DELEGATOR_ID, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
});
});

View File

@@ -0,0 +1,75 @@
/**
* Error-path tests for ScaffoldService — tests the archiver finalize error handler.
* These tests use jest.mock('archiver') to simulate archiver failures.
*/
import { PassThrough } from 'stream';
// Mock archiver before importing ScaffoldService
const mockFinalize = jest.fn();
const mockArchive = {
pipe: jest.fn(),
append: jest.fn(),
finalize: mockFinalize,
};
jest.mock('archiver', () => jest.fn(() => mockArchive));
// Mock metrics to avoid registry conflicts
jest.mock('../../../src/metrics/registry', () => ({
scaffoldGeneratedTotal: { labels: jest.fn().mockReturnValue({ inc: jest.fn() }) },
scaffoldGenerationDurationMs: { labels: jest.fn().mockReturnValue({ observe: jest.fn() }) },
}));
import { ScaffoldService } from '../../../src/services/ScaffoldService';
const BASE_OPTIONS = {
agentId: 'agent-uuid-1234',
agentName: 'my-test-agent',
clientId: 'client-id-5678',
apiUrl: 'https://api.sentryagent.ai',
};
describe('ScaffoldService — archiver error path', () => {
let service: ScaffoldService;
beforeEach(() => {
jest.clearAllMocks();
// Reset pipe to actually pipe to the PassThrough so the stream reference is correct
mockArchive.pipe.mockImplementation(() => {});
service = new ScaffoldService();
});
it('destroys the PassThrough stream when archiver finalize rejects with an Error', async () => {
const archiverError = new Error('archiver finalize failure');
mockFinalize.mockReturnValue(Promise.reject(archiverError));
const { stream } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'typescript',
});
// Attach error listener to suppress unhandled error event from stream.destroy(err)
await new Promise<void>((resolve) => {
stream.on('error', () => resolve());
});
expect((stream as PassThrough).destroyed).toBe(true);
});
it('destroys the PassThrough stream when archiver finalize rejects with a non-Error value', async () => {
// Covers the `err instanceof Error ? err : new Error(String(err))` false branch
mockFinalize.mockReturnValue(Promise.reject('string-error'));
const { stream } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'typescript',
});
// Attach error listener to suppress unhandled error event from stream.destroy(err)
await new Promise<void>((resolve) => {
stream.on('error', () => resolve());
});
expect((stream as PassThrough).destroyed).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for src/services/ScaffoldService.ts
*/
import { ScaffoldService } from '../../../src/services/ScaffoldService';
import { ValidationError } from '../../../src/utils/errors';
describe('ScaffoldService', () => {
let service: ScaffoldService;
beforeEach(() => {
service = new ScaffoldService();
});
const BASE_OPTIONS = {
agentId: 'agent-uuid-1234',
agentName: 'my-test-agent',
clientId: 'client-id-5678',
apiUrl: 'https://api.sentryagent.ai',
};
// Helper to collect stream into a Buffer
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
describe('generateScaffold', () => {
it('generates a TypeScript scaffold ZIP with all 6 template files', async () => {
const { stream, filename } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'typescript',
});
expect(filename).toMatch(/sentryagent-scaffold.*typescript\.zip$/);
// Verify stream produces a non-empty buffer (valid ZIP magic bytes: PK)
const buf = await streamToBuffer(stream);
expect(buf.length).toBeGreaterThan(0);
expect(buf[0]).toBe(0x50); // 'P'
expect(buf[1]).toBe(0x4b); // 'K'
});
it('generates a Python scaffold ZIP with all 5 template files', async () => {
const { stream, filename } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'python',
});
expect(filename).toMatch(/sentryagent-scaffold.*python\.zip$/);
const buf = await streamToBuffer(stream);
expect(buf.length).toBeGreaterThan(0);
});
it('generates a Go scaffold ZIP', async () => {
const { stream, filename } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'go',
});
expect(filename).toMatch(/go\.zip$/);
const buf = await streamToBuffer(stream);
expect(buf.length).toBeGreaterThan(0);
});
it('generates a Java scaffold ZIP', async () => {
const { stream } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'java',
});
const buf = await streamToBuffer(stream);
expect(buf.length).toBeGreaterThan(0);
});
it('generates a Rust scaffold ZIP', async () => {
const { stream } = await service.generateScaffold({
...BASE_OPTIONS,
language: 'rust',
});
const buf = await streamToBuffer(stream);
expect(buf.length).toBeGreaterThan(0);
});
it('injects {{CLIENT_ID}} into the template variables — no {{CLIENT_ID}} placeholder in output', () => {
// Verify the service's injectVariables method replaces all placeholders.
// We do this by checking the .env.example template source — it uses {{CLIENT_ID}},
// and the service must replace it. We verify the template has the placeholder.
const { readFileSync } = require('fs');
const { join } = require('path');
const templatePath = join(
__dirname,
'../../../src/templates/scaffold/typescript/.env.example.tmpl',
);
const template = readFileSync(templatePath, 'utf-8');
expect(template).toContain('{{CLIENT_ID}}');
// Verify injection: replace manually and check
const injected = template.replace(/\{\{CLIENT_ID\}\}/g, 'injected-client-id-xyz');
expect(injected).toContain('injected-client-id-xyz');
expect(injected).not.toContain('{{CLIENT_ID}}');
});
it('never injects real client secret — .env.example template has placeholder only', () => {
// Read the source template directly — it must contain the placeholder, never a real secret
const { readFileSync } = require('fs');
const { join } = require('path');
const templatePath = join(
__dirname,
'../../../src/templates/scaffold/typescript/.env.example.tmpl',
);
const template = readFileSync(templatePath, 'utf-8');
expect(template).toContain('<your-client-secret>');
// The template must NOT contain {{CLIENT_SECRET}} or any other secret injection point
expect(template).not.toContain('{{CLIENT_SECRET}}');
});
it('throws ValidationError for an unsupported language', async () => {
await expect(
service.generateScaffold({
...BASE_OPTIONS,
language: 'cobol' as never,
}),
).rejects.toThrow(ValidationError);
});
});
});

View File

@@ -0,0 +1,89 @@
/**
* Unit tests for src/utils/delegationCrypto.ts
*/
import {
signDelegationPayload,
verifyDelegationSignature,
generateDelegationToken,
} from '../../../src/utils/delegationCrypto';
import { DelegationTokenPayload } from '../../../src/types/delegation';
const MOCK_PAYLOAD: DelegationTokenPayload = {
chainId: 'chain-uuid-1234',
tenantId: 'org_system',
delegatorAgentId: 'delegator-uuid',
delegateeAgentId: 'delegatee-uuid',
scopes: ['agents:read', 'tokens:read'],
issuedAt: '2026-04-03T00:00:00.000Z',
expiresAt: '2026-04-03T01:00:00.000Z',
};
const SECRET = 'test-hmac-secret';
describe('delegationCrypto', () => {
describe('signDelegationPayload', () => {
it('returns a non-empty hex string', () => {
const sig = signDelegationPayload(MOCK_PAYLOAD, SECRET);
expect(typeof sig).toBe('string');
expect(sig.length).toBeGreaterThan(0);
// SHA-256 hex = 64 chars
expect(sig).toMatch(/^[0-9a-f]{64}$/);
});
it('produces the same signature for the same payload and secret', () => {
const sig1 = signDelegationPayload(MOCK_PAYLOAD, SECRET);
const sig2 = signDelegationPayload(MOCK_PAYLOAD, SECRET);
expect(sig1).toBe(sig2);
});
it('produces different signatures for different secrets', () => {
const sig1 = signDelegationPayload(MOCK_PAYLOAD, 'secret-a');
const sig2 = signDelegationPayload(MOCK_PAYLOAD, 'secret-b');
expect(sig1).not.toBe(sig2);
});
it('produces different signatures for different payloads', () => {
const payload2 = { ...MOCK_PAYLOAD, chainId: 'different-chain-uuid' };
const sig1 = signDelegationPayload(MOCK_PAYLOAD, SECRET);
const sig2 = signDelegationPayload(payload2, SECRET);
expect(sig1).not.toBe(sig2);
});
});
describe('verifyDelegationSignature', () => {
it('returns true for a valid sign/verify round-trip', () => {
const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET);
expect(verifyDelegationSignature(MOCK_PAYLOAD, signature, SECRET)).toBe(true);
});
it('returns false when the payload has been tampered with', () => {
const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET);
const tampered = { ...MOCK_PAYLOAD, scopes: ['admin:orgs'] };
expect(verifyDelegationSignature(tampered, signature, SECRET)).toBe(false);
});
it('returns false when the secret does not match', () => {
const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET);
expect(verifyDelegationSignature(MOCK_PAYLOAD, signature, 'wrong-secret')).toBe(false);
});
it('returns false for an empty signature string', () => {
expect(verifyDelegationSignature(MOCK_PAYLOAD, '', SECRET)).toBe(false);
});
});
describe('generateDelegationToken', () => {
it('returns a UUID v4 string', () => {
const token = generateDelegationToken();
expect(token).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('generates unique tokens on each call', () => {
const tokens = new Set(Array.from({ length: 50 }, () => generateDelegationToken()));
expect(tokens.size).toBe(50);
});
});
});