console: sync the registration wizard and issue real API tokens

Mirror the Console registration feature: native hook catalog with
activation plans, one-command install that embeds both credentials in
exclusively created owner-only files (mktemp on POSIX, icacls on
Windows), sh/PowerShell tabs, never-expiring default token, focus-visible
styles, merged i18n keys, and the matching e2e specs.

The server previously answered /v1/auth/token with the 7-day Better Auth
session token and ignored ttl_seconds. It now issues opaque 32-byte API
tokens stored as SHA-256 hashes (migration 003), honours ttl_seconds with
null meaning no expiry, and resolves bearer values as API tokens before
session validation. Issuance requires a session credential.

Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
雷电芽衣 committed 2026-09-02 09:56:40 -04:00
1 parent 1bb9d4d49d
commit edf9d73c3e
21 files changed
+967 -163

No files matched your search

+3 -2
View File
@@ -56,6 +56,7 @@ This repository publishes the open-source server, console, integration catalog,
- Keep production source files at or below 500 lines. - Keep production source files at or below 500 lines.
- Keep functions focused on one ownership boundary. - Keep functions focused on one ownership boundary.
- Propagate unexpected errors to the CLI or request boundary. - Propagate unexpected errors to the CLI or request boundary.
- Issue API tokens only from a Better Auth session: 32 random bytes returned once and stored as a SHA-256 hash in `api_tokens`, honoring `ttl_seconds` with `null` meaning no expiry. Resolve bearer values as API tokens before session validation.
- Commit the operation and receipt before enqueueing. Log a failed enqueue as `operation.enqueue_failed` and still return the receipt; answer a `UNIQUE(agent_id, seq_no)` conflict with `PREV_HASH_MISMATCH` carrying the stored chain hash. - Commit the operation and receipt before enqueueing. Log a failed enqueue as `operation.enqueue_failed` and still return the receipt; answer a `UNIQUE(agent_id, seq_no)` conflict with `PREV_HASH_MISMATCH` carrying the stored chain hash.
- Keep technical claims executable through tests or generated validation. - Keep technical claims executable through tests or generated validation.
- Keep the mirrored Python package, distribution, and CLI version in `sdks/python/elydora/_version.py`; Setuptools metadata must read that literal dynamically. - Keep the mirrored Python package, distribution, and CLI version in `sdks/python/elydora/_version.py`; Setuptools metadata must read that literal dynamically.
@@ -65,8 +66,8 @@ This repository publishes the open-source server, console, integration catalog,
- Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping. - Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping.
- Avoid compatibility shims without a named public contract. - Avoid compatibility shims without a named public contract.
- Preserve the minimum runtime versions declared by each package. - Preserve the minimum runtime versions declared by each package.
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. - Keep private keys and API tokens out of process arguments. CLIs accept them through hidden terminal input or owner-only credential files.
- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only. - Console instruction generation accepts agent identity, public configuration, and the issued credentials. Native hook commands store both credentials in owner-only files, install through the SDK file flags, and delete the files; direct SDK examples read named runtime environment variables. Credential values also remain available through explicit copy controls.
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error. - Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
- Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads. - Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads.
+3 -3
View File
@@ -259,13 +259,13 @@ Return the current session and user profile. Requires a valid session token or c
#### `POST /v1/auth/token` #### `POST /v1/auth/token`
Issue a long-lived API token. Issue an opaque API token for SDK and hook use. Requires a Better Auth session; the token is returned once and only its SHA-256 hash is stored.
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `ttl_seconds` | integer \| null | no | Token lifetime in seconds. `null` for non-expiring. | | `ttl_seconds` | integer \| null | no | Token lifetime in seconds, from 1 to 31536000. `null` or omitted for non-expiring. |
**Response:** `{ "token": "<api-token>", "expires_at": <ms> | null }` **Response:** `{ "token": "<api-token>", "expires_at": <unix seconds> | null }`
--- ---
+15
View File
@@ -231,6 +231,17 @@ services:
CREATE INDEX IF NOT EXISTS idx_exports_org_status ON exports (org_id, status); CREATE INDEX IF NOT EXISTS idx_exports_org_status ON exports (org_id, status);
CREATE TABLE IF NOT EXISTS api_tokens (
token_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (user_id),
org_id TEXT NOT NULL REFERENCES organizations (org_id),
token_hash TEXT NOT NULL UNIQUE,
issued_at BIGINT NOT NULL,
expires_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id);
INSERT INTO schema_versions (version, applied_at, description) INSERT INTO schema_versions (version, applied_at, description)
VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema') VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
@@ -238,6 +249,10 @@ services:
INSERT INTO schema_versions (version, applied_at, description) INSERT INTO schema_versions (version, applied_at, description)
VALUES (2, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Require supported agent integration type') VALUES (2, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Require supported agent integration type')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO schema_versions (version, applied_at, description)
VALUES (3, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Opaque API tokens')
ON CONFLICT DO NOTHING;
" "
restart: on-failure restart: on-failure
@@ -4,6 +4,8 @@ import {
INTEGRATION_CATALOG, INTEGRATION_CATALOG,
INTEGRATION_TYPES, INTEGRATION_TYPES,
} from '@/features/agent-registration/integrations'; } from '@/features/agent-registration/integrations';
import enRegistration from '@/i18n/en.json';
import zhRegistration from '@/i18n/zh.json';
import { import {
assertRegistrationResponse, assertRegistrationResponse,
RegistrationContractError, RegistrationContractError,
@@ -43,35 +45,161 @@ test('keeps the complete integration catalog in canonical order', () => {
expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS); expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS);
expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS); expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS);
expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19); expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19);
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')).toHaveLength(15); expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')).toHaveLength(15);
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4); expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4);
expect(Object.fromEntries(INTEGRATION_CATALOG.map(({ id, name }) => [id, name])))
.toMatchObject({
codex: 'Codex CLI',
cursor: 'Cursor CLI',
grok: 'Grok Build',
kimi: 'Kimi Code',
});
}); });
test('generates every adapter command with the verified SDK flag contract', () => { test('keeps every native hook activation plan aligned with its provider contract', () => {
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')) { const plans = Object.fromEntries(
INTEGRATION_CATALOG
.filter(({ mode }) => mode === 'hooks')
.map(({ id, postInstall }) => [id, postInstall]),
);
expect(plans).toEqual({
augment: {
kind: 'sequence',
steps: [{ key: 'startSession', command: 'auggie' }],
},
claudecode: {
kind: 'sequence',
steps: [{ key: 'openHooks', command: '/hooks' }],
},
cline: {
kind: 'sequence',
steps: [{ key: 'startTask' }],
},
codex: {
kind: 'sequence',
steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }],
},
copilot: {
kind: 'sequence',
steps: [
{ key: 'restartSession', command: '/restart' },
{ key: 'inspectEnvironment', command: '/env' },
],
},
cursor: {
kind: 'sequence',
steps: [{ key: 'inspectCursorHooks' }],
},
droid: {
kind: 'sequence',
steps: [{ key: 'openHooks', command: '/hooks' }],
},
gemini: {
kind: 'sequence',
steps: [{ key: 'listHooks', command: '/hooks list' }],
},
grok: {
kind: 'sequence',
steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }],
},
kimi: {
kind: 'sequence',
steps: [{ key: 'startSession', command: 'kimi' }],
},
kirocli: {
kind: 'alternatives',
steps: [
{ key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' },
{ key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' },
],
},
kiroide: {
kind: 'sequence',
steps: [{ key: 'inspectKiroIdeHooks' }],
},
letta: {
kind: 'sequence',
steps: [{ key: 'openHooks', command: '/hooks' }],
},
opencode: {
kind: 'sequence',
steps: [{ key: 'startSession', command: 'opencode' }],
},
qwen: {
kind: 'sequence',
steps: [{ key: 'listHooks', command: '/hooks list' }],
},
});
});
test('translates every activation step in English and Chinese', () => {
const stepKeys = [...new Set(
INTEGRATION_CATALOG.flatMap(({ postInstall }) => (
postInstall?.steps.map(({ key }) => key) ?? []
)),
)].sort();
expect(Object.keys(enRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys);
expect(Object.keys(zhRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys);
});
test('generates every native hook command with the verified SDK flag contract', () => {
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) {
for (const language of SDK_LANGUAGES) { for (const language of SDK_LANGUAGES) {
const instructions = buildInstallInstructions(language, { const instructions = buildInstallInstructions(language, {
integration, integration,
identity: credentials, identity: credentials,
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
privateKey: 'private-contract-key',
token: 'token-contract',
}); });
expect(instructions.setup).toContain(`--agent ${integration.id}`); expect(instructions.setup).toContain(`--agent ${integration.id}`);
expect(instructions.setup).toContain("'https://api.example.com'"); expect(instructions.setup).toContain("'https://api.example.com'");
expect(instructions.setup).not.toContain('private-key'); expect(instructions.setup).toContain("printf '%s' 'k=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\"'\\'' EXIT INT TERM && t=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\" \"$t\"'\\'' EXIT INT TERM && ");
expect(instructions.setup).not.toContain('private_key'); expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''private-contract-key'\\'' > \"$k\"");
expect(instructions.setup).not.toContain('--token'); expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''token-contract'\\'' > \"$t\"");
expect(instructions.setup).not.toContain('token-contract'); expect(instructions.setup).not.toContain('chmod');
expect(instructions.secretDelivery).toBe('hidden-prompts'); expect(instructions.setup.endsWith("' | sh")).toBe(true);
expect(instructions.secretDelivery).toBe('embedded');
expect(instructions.postInstall).toEqual(integration.postInstall);
if (language === 'go') { if (language === 'go') {
expect(instructions.setup).toContain("--org-id 'org-contract'"); expect(instructions.setup).toContain("--org-id '\\''org-contract'\\''");
expect(instructions.setup).toContain("--agent-id 'agent-contract'"); expect(instructions.setup).toContain("--agent-id '\\''agent-contract'\\''");
expect(instructions.setup).toContain("--base-url 'https://api.example.com'"); expect(instructions.setup).toContain("--base-url '\\''https://api.example.com'\\''");
expect(instructions.setup).toContain('--private-key-file "$k" --token-file "$t"');
} else { } else {
expect(instructions.setup).toContain("--org_id 'org-contract'"); expect(instructions.setup).toContain("--org_id '\\''org-contract'\\''");
expect(instructions.setup).toContain("--agent_id 'agent-contract'"); expect(instructions.setup).toContain("--agent_id '\\''agent-contract'\\''");
expect(instructions.setup).toContain("--base_url 'https://api.example.com'"); expect(instructions.setup).toContain("--base_url '\\''https://api.example.com'\\''");
expect(instructions.setup).toContain('--private_key_file "$k" --token_file "$t"');
}
}
}
});
test('generates PowerShell native hook commands with the same SDK flag contract', () => {
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) {
for (const language of SDK_LANGUAGES) {
const instructions = buildInstallInstructions(language, {
integration,
identity: credentials,
baseUrl: 'https://api.example.com',
privateKey: 'private-contract-key',
token: 'token-contract',
}, 'powershell');
expect(instructions.setup).toContain(`--agent ${integration.id}`);
expect(instructions.setup).toContain("$k = Join-Path $HOME '.elydora-agent-contract.key'; $t = Join-Path $HOME '.elydora-agent-contract.token'; try { New-Item -ItemType File -Force -Path $k, $t | Out-Null; if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw \"chmod failed\" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r \"*$($u):F\" | Out-Null; if ($LASTEXITCODE -ne 0) { throw \"icacls failed for $f\" } } }; Set-Content -LiteralPath $k -Value 'private-contract-key'; Set-Content -LiteralPath $t -Value 'token-contract'; ");
expect(instructions.setup.endsWith('; if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" } } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }')).toBe(true);
expect(instructions.secretDelivery).toBe('embedded');
if (language === 'go') {
expect(instructions.setup).toContain("--org-id 'org-contract' --agent-id 'agent-contract' --kid 'agent-contract-key-1' --base-url 'https://api.example.com' --private-key-file $k --token-file $t;");
} else {
expect(instructions.setup).toContain("--org_id 'org-contract' --agent_id 'agent-contract' --kid 'agent-contract-key-1' --base_url 'https://api.example.com' --private_key_file $k --token_file $t;");
} }
} }
} }
@@ -86,11 +214,13 @@ test('generates direct SDK setup and operation code for each language', () => {
integration: integration!, integration: integration!,
identity: credentials, identity: credentials,
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
privateKey: 'private-contract-key',
token: 'token-contract',
}); });
expect(instructions.setup).toContain('agent-contract'); expect(instructions.setup).toContain('agent-contract');
expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY'); expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY');
expect(instructions.setup).toContain('ELYDORA_API_TOKEN'); expect(instructions.setup).toContain('ELYDORA_API_TOKEN');
expect(instructions.setup).not.toContain('private-key'); expect(instructions.setup).not.toContain('private-contract-key');
expect(instructions.setup).not.toContain('token-contract'); expect(instructions.setup).not.toContain('token-contract');
expect(instructions.usage).toContain('ai.tool_use'); expect(instructions.usage).toContain('ai.tool_use');
expect(instructions.verify).toBeUndefined(); expect(instructions.verify).toBeUndefined();
@@ -110,6 +240,8 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
orgId: '$(Write-Output injected)', orgId: '$(Write-Output injected)',
}, },
baseUrl: 'https://api.example.com/`whoami`', baseUrl: 'https://api.example.com/`whoami`',
privateKey: 'private-contract-key',
token: 'token-contract',
}); });
expect(instructions.setup).toContain("'$(Write-Output injected)'"); expect(instructions.setup).toContain("'$(Write-Output injected)'");
expect(instructions.setup).toContain("'https://api.example.com/`whoami`'"); expect(instructions.setup).toContain("'https://api.example.com/`whoami`'");
@@ -118,7 +250,23 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
integration: integration!, integration: integration!,
identity: { ...credentials, orgId: "org-'unsafe" }, identity: { ...credentials, orgId: "org-'unsafe" },
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
privateKey: 'private-contract-key',
token: 'token-contract',
})).toThrow('Organization ID cannot be represented'); })).toThrow('Organization ID cannot be represented');
expect(() => buildInstallInstructions('node', {
integration: integration!,
identity: credentials,
baseUrl: 'https://api.example.com',
privateKey: "key-'unsafe",
token: 'token-contract',
})).toThrow('Private key cannot be represented');
expect(() => buildInstallInstructions('node', {
integration: integration!,
identity: { ...credentials, agentId: 'agent contract' },
baseUrl: 'https://api.example.com',
privateKey: 'private-contract-key',
token: 'token-contract',
})).toThrow('Agent ID cannot be used as a credential file name.');
}); });
test('classifies malformed successful responses as blocking contract failures', () => { test('classifies malformed successful responses as blocking contract failures', () => {
+109 -20
View File
@@ -110,7 +110,7 @@ async function mockBackend(
}); });
} }
test('registers Grok atomically and renders verified adapter commands', async ({ page }) => { test('registers Grok atomically and renders verified native hook commands', async ({ page }) => {
const observed: ObservedRequest[] = []; const observed: ObservedRequest[] = [];
const unexpected: string[] = []; const unexpected: string[] = [];
const runtimeErrors: string[] = []; const runtimeErrors: string[] = [];
@@ -123,10 +123,26 @@ test('registers Grok atomically and renders verified adapter commands', async ({
await page.goto('/agents'); await page.goto('/agents');
await page.getByRole('button', { name: 'Register Agent' }).click(); await page.getByRole('button', { name: 'Register Agent' }).click();
await waitForRenderedDialog(page); await waitForRenderedDialog(page);
await expect(page.getByRole('button', { name: 'Augment Code' })).toBeVisible(); const dialog = page.getByRole('dialog');
await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible(); await dialog.focus();
await expect(page.getByRole('button', { name: 'Kimi CLI' })).toBeVisible(); await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Qwen Code' })).toBeVisible(); await page.keyboard.press('Tab');
const firstIntegration = page.getByRole('button', { name: 'Augment Code' });
await expect(firstIntegration).toBeFocused();
expect(await firstIntegration.evaluate((element) => {
const style = window.getComputedStyle(element);
return { width: style.outlineWidth, style: style.outlineStyle };
})).toEqual({ width: '2px', style: 'solid' });
const hookGroup = page.getByRole('region', { name: 'Native hooks' });
await expect(hookGroup.getByRole('button')).toHaveCount(15);
await expect(hookGroup.getByText('HOOKS', { exact: true })).toHaveCount(15);
await expect(hookGroup.getByRole('button', { name: 'Codex CLI' })).toBeVisible();
await expect(hookGroup.getByRole('button', { name: 'Kimi Code' })).toBeVisible();
await expect(hookGroup.getByRole('button', { name: 'Grok Build' })).toBeVisible();
await expect(hookGroup.getByRole('button', { name: 'Cursor CLI' })).toBeVisible();
const sdkGroup = page.getByRole('region', { name: 'Custom SDK' });
await expect(sdkGroup.getByRole('button')).toHaveCount(4);
await expect(sdkGroup.getByText('HOOKS', { exact: true })).toHaveCount(0);
await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true }); await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true });
await page.getByRole('button', { name: 'Grok Build' }).click(); await page.getByRole('button', { name: 'Grok Build' }).click();
await page.getByLabel('Display Name').fill('Production Grok'); await page.getByLabel('Display Name').fill('Production Grok');
@@ -151,29 +167,58 @@ test('registers Grok atomically and renders verified adapter commands', async ({
const setup = page.locator('pre'); const setup = page.locator('pre');
await expect(setup).toContainText('npx @elydora/sdk install --agent grok'); await expect(setup).toContainText('npx @elydora/sdk install --agent grok');
await expect(setup).toContainText(`--org_id '${ORG_ID}'`); await expect(setup).toContainText(`--org_id '\\''${ORG_ID}'\\''`);
await expect(setup).not.toContainText(TOKEN); await expect(setup).toContainText(`'${TOKEN}'`);
await expect(setup).not.toContainText(privateKey); await expect(setup).toContainText(`'${privateKey}'`);
await expect(setup).not.toContainText('--token'); await expect(setup).toContainText('mktemp "$HOME/.elydora-');
await expect(setup).not.toContainText('--private_key'); await expect(setup).toContainText('--private_key_file "$k" --token_file "$t"');
await expect(page.getByText('The CLI requests both values with terminal echo disabled.')).toBeVisible(); await expect(page.getByText(
'The command stores both credentials in owner-only files, installs the hook, then deletes them.',
)).toBeVisible();
await expect(page.getByText(
'Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.',
)).toBeVisible();
await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible();
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Copy setup command' }).click(); await page.getByRole('button', { name: 'Copy setup command' }).click();
await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied'); await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied');
const copiedSetup = await page.evaluate(() => navigator.clipboard.readText()); const copiedSetup = await page.evaluate(() => navigator.clipboard.readText());
expect(copiedSetup).not.toContain(TOKEN); expect(copiedSetup).toContain(TOKEN);
expect(copiedSetup).not.toContain(privateKey); expect(copiedSetup).toContain(privateKey);
const activationCopy = page.getByRole('button', {
name: 'Copy activation command: grok inspect --json',
});
await activationCopy.click();
await expect(activationCopy).toHaveText('Copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('grok inspect --json');
await page.getByRole('tab', { name: 'go' }).click(); await page.getByRole('tab', { name: 'go' }).click();
await expect(setup).toContainText('elydora install --agent grok'); await expect(setup).toContainText('elydora install --agent grok');
await expect(setup).toContainText(`--org-id '${ORG_ID}'`); await expect(setup).toContainText(`--org-id '\\''${ORG_ID}'\\''`);
await expect(setup).toContainText("--agent-id 'agent-"); await expect(setup).toContainText("--agent-id '\\''agent-");
await expect(setup).not.toContainText('--private-key'); await expect(setup).toContainText('--private-key-file "$k" --token-file "$t"');
await expect(setup).not.toContainText('--token'); await page.getByRole('tab', { name: 'PowerShell' }).click();
await expect(setup).toContainText("$k = Join-Path $HOME '.elydora-agent-");
await expect(setup).toContainText('--private-key-file $k --token-file $t;');
await page.getByRole('tab', { name: 'sh', exact: true }).click();
await expect(setup).toContainText("printf '%s' 'k=$(mktemp ");
await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true }); await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('dialog').locator(':scope > div').last().evaluate(
(content) => content.scrollTo(0, content.scrollHeight),
);
await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible();
await expect(page.getByRole('button', {
name: 'Copy activation command: grok inspect --json',
})).toBeVisible();
expect(await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
)).toBe(false);
await page.screenshot({ path: 'test-results/agent-registration-grok-mobile.png', fullPage: true });
const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register'); const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register');
expect(registrationRequests).toHaveLength(1); expect(registrationRequests).toHaveLength(1);
expect(registrationRequests[0]?.method).toBe('POST'); expect(registrationRequests[0]?.method).toBe('POST');
@@ -249,6 +294,40 @@ test('renders custom SDK setup with runtime secret references', async ({ page })
expect(runtimeErrors).toEqual([]); expect(runtimeErrors).toEqual([]);
}); });
test('renders Kiro CLI versions as activation alternatives', async ({ page }) => {
const observed: ObservedRequest[] = [];
const unexpected: string[] = [];
const runtimeErrors: string[] = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') runtimeErrors.push(message.text());
});
await mockBackend(page, observed, unexpected);
await page.goto('/agents');
await page.getByRole('button', { name: 'Register Agent' }).click();
await page.getByRole('button', { name: 'Kiro CLI' }).click();
await page.getByLabel('Display Name').fill('Production Kiro');
await page.locator('form').getByRole('button', { name: 'Register Agent' }).click();
await page.getByRole('button', { name: 'Issue API Token' }).click();
await expect(page.getByText(TOKEN)).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
const alternatives = page.getByRole('list', { name: 'Post-install steps' });
await expect(alternatives).toHaveJSProperty('tagName', 'UL');
await expect(alternatives.locator('li')).toHaveCount(2);
await expect(alternatives.getByText('V2', { exact: true })).toBeVisible();
await expect(alternatives.getByText('V3', { exact: true })).toBeVisible();
await expect(alternatives.getByText('kiro-cli --agent elydora-audit', { exact: true })).toBeVisible();
await expect(alternatives.getByText('kiro-cli --v3', { exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/agent-registration-kiro.png', fullPage: true });
expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1);
expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1);
expect(unexpected).toEqual([]);
expect(runtimeErrors).toEqual([]);
});
test('loads the Chinese registration resources', async ({ page }) => { test('loads the Chinese registration resources', async ({ page }) => {
const observed: ObservedRequest[] = []; const observed: ObservedRequest[] = [];
const unexpected: string[] = []; const unexpected: string[] = [];
@@ -263,11 +342,21 @@ test('loads the Chinese registration resources', async ({ page }) => {
await page.goto('/agents'); await page.goto('/agents');
await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); await expect(page.locator('html')).toHaveAttribute('lang', 'zh');
await page.getByRole('button', { name: '注册代理' }).click(); await page.getByRole('button', { name: '注册代理' }).click();
await expect(page.getByText('CLI 适配器')).toBeVisible(); await expect(page.getByText('原生 Hooks')).toBeVisible();
await expect(page.getByText('自定义 SDK')).toBeVisible(); await expect(page.getByText('自定义 SDK')).toBeVisible();
await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Codex CLI' })).toBeVisible();
await page.getByRole('button', { name: 'Kimi Code' }).click();
await page.getByLabel('显示名称').fill('生产 Kimi');
await page.locator('form').getByRole('button', { name: '注册代理' }).click();
await page.getByRole('button', { name: '签发 API 令牌' }).click();
await expect(page.getByText(TOKEN)).toBeVisible();
await page.getByRole('button', { name: '继续' }).click();
await expect(page.getByText('启动新的 Kimi Code 会话。')).toBeVisible();
await expect(page.getByText('kimi', { exact: true })).toBeVisible();
expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(1); expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(2);
expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1);
expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1);
expect(unexpected).toEqual([]); expect(unexpected).toEqual([]);
expect(runtimeErrors).toEqual([]); expect(runtimeErrors).toEqual([]);
}); });
+18
View File
@@ -353,3 +353,21 @@ body {
.check-fail { .check-fail {
color: #dc2626; color: #dc2626;
} }
/* Focus-visible styles for keyboard navigation (WCAG 2.1 AA) */
:focus-visible {
outline: 2px solid var(--ink-color);
outline-offset: 2px;
}
.btn-brutalist:focus-visible,
.btn-ghost:focus-visible {
outline: 2px solid var(--bg-color);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--ink-color);
}
.sidebar-nav-item:focus-visible {
outline: 2px solid #EAEAE5;
outline-offset: -2px;
}
@@ -12,7 +12,7 @@ import Modal from '@/components/ui/Modal';
import Link from 'next/link'; import Link from 'next/link';
import type { AgentKey, Operation } from '@elydora/shared'; import type { AgentKey, Operation } from '@elydora/shared';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ADAPTER_INTEGRATION_IDS } from '@/features/agent-registration/integrations'; import { HOOK_INTEGRATION_IDS } from '@/features/agent-registration/integrations';
export default function AgentDetailClient({ agentId }: { agentId: string }) { export default function AgentDetailClient({ agentId }: { agentId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,7 +37,7 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) {
const agent = agentData?.agent; const agent = agentData?.agent;
const supportsFreeze = agent const supportsFreeze = agent
? ADAPTER_INTEGRATION_IDS.has(agent.integration_type) ? HOOK_INTEGRATION_IDS.has(agent.integration_type)
: false; : false;
const keys = agentData?.keys ?? []; const keys = agentData?.keys ?? [];
const operations = opsData?.operations ?? []; const operations = opsData?.operations ?? [];
@@ -7,7 +7,9 @@ import type { IntegrationCatalogItem } from '@/features/agent-registration/integ
import { import {
buildInstallInstructions, buildInstallInstructions,
SDK_LANGUAGES, SDK_LANGUAGES,
SHELLS,
type SdkLanguage, type SdkLanguage,
type Shell,
} from '@/features/agent-registration/install-instructions'; } from '@/features/agent-registration/install-instructions';
import CopyControl from './CopyControl'; import CopyControl from './CopyControl';
@@ -28,6 +30,7 @@ export default function ConnectStep({
}: ConnectStepProps) { }: ConnectStepProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [language, setLanguage] = useState<SdkLanguage>('node'); const [language, setLanguage] = useState<SdkLanguage>('node');
const [shell, setShell] = useState<Shell>('posix');
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787'; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
const instructions = useMemo( const instructions = useMemo(
() => buildInstallInstructions(language, { () => buildInstallInstructions(language, {
@@ -38,9 +41,12 @@ export default function ConnectStep({
orgId: credentials.orgId, orgId: credentials.orgId,
}, },
baseUrl, baseUrl,
}), privateKey: credentials.privateKey,
[baseUrl, credentials.agentId, credentials.kid, credentials.orgId, integration, language], token,
}, shell),
[baseUrl, credentials, integration, language, shell, token],
); );
const PostInstallList = instructions.postInstall?.kind === 'alternatives' ? 'ul' : 'ol';
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -66,10 +72,29 @@ export default function ConnectStep({
))} ))}
</div> </div>
{integration.mode === 'hooks' && (
<div className="flex border-b border-border" role="tablist" aria-label={t('agentRegistration.shell')}>
{SHELLS.map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={shell === item}
onClick={() => setShell(item)}
className={`px-4 py-2 font-mono text-[10px] tracking-wider border-b-2 -mb-px ${
shell === item ? 'border-ink text-ink' : 'border-transparent text-ink-dim hover:text-ink'
}`}
>
{item === 'posix' ? 'sh' : 'PowerShell'}
</button>
))}
</div>
)}
<section aria-labelledby="setup-command-label"> <section aria-labelledby="setup-command-label">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 id="setup-command-label" className="section-label"> <h3 id="setup-command-label" className="section-label">
{integration.mode === 'adapter' ? t('agentRegistration.install') : t('agentRegistration.configure')} {integration.mode === 'hooks' ? t('agentRegistration.install') : t('agentRegistration.configure')}
</h3> </h3>
<CopyControl <CopyControl
value={instructions.setup} value={instructions.setup}
@@ -138,14 +163,35 @@ export default function ConnectStep({
)} )}
{instructions.postInstall && ( {instructions.postInstall && (
<ol className="border-t border-border" aria-label={t('agentRegistration.postInstall')}> <PostInstallList className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
{instructions.postInstall.map((step, index) => ( {instructions.postInstall.steps.map((step, index) => (
<li key={step} className="grid grid-cols-[2rem_1fr] gap-2 py-2 border-b border-border font-mono text-[11px] text-ink"> <li
<span className="text-ink-dim tabular-nums">{index + 1}</span> key={`${step.key}:${step.command ?? ''}`}
<span>{step}</span> className="group grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 border-b border-border py-2 font-mono text-[11px] text-ink"
>
<span className="text-ink-dim tabular-nums">
{step.variant ?? String(index + 1).padStart(2, '0')}
</span>
<span className="min-w-0">
<span className="block">
{t(`agentRegistration.postInstallSteps.${step.key}`, {
integration: integration.name,
})}
</span>
{step.command && (
<code className="mt-1 block break-all text-ink-dim">{step.command}</code>
)}
</span>
{step.command && (
<CopyControl
value={step.command}
ariaLabel={t('agentRegistration.copyPostInstall', { command: step.command })}
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
/>
)}
</li> </li>
))} ))}
</ol> </PostInstallList>
)} )}
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
@@ -35,6 +35,7 @@ function CredentialRow({ label, value }: { readonly label: string; readonly valu
</div> </div>
); );
} }
export default function CredentialsStep({ export default function CredentialsStep({
credentials, credentials,
token, token,
@@ -44,7 +45,7 @@ export default function CredentialsStep({
onContinue, onContinue,
}: CredentialsStepProps) { }: CredentialsStepProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expiration, setExpiration] = useState<ExpirationId>('24hours'); const [expiration, setExpiration] = useState<ExpirationId>('neverExpire');
const [customDays, setCustomDays] = useState(''); const [customDays, setCustomDays] = useState('');
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
@@ -32,11 +32,18 @@ function IntegrationGroup({
key={integration.id} key={integration.id}
type="button" type="button"
onClick={() => onSelect(integration)} onClick={() => onSelect(integration)}
className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface focus-visible:outline-none" className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface"
> >
<span className="font-mono text-[12px] text-ink">{integration.name}</span> <span className="font-mono text-[12px] text-ink">{integration.name}</span>
<span className="font-mono text-[10px] text-ink-dim opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity" aria-hidden="true"> <span className="flex items-center gap-2" aria-hidden="true">
→ {mode === 'hooks' && (
<span className="font-mono text-[9px] tracking-wider text-ink-dim border border-border px-1.5 py-0.5">
{t('agentRegistration.hookBadge')}
</span>
)}
<span className="font-mono text-[10px] text-ink-dim opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity">
→
</span>
</span> </span>
</button> </button>
))} ))}
@@ -44,10 +51,11 @@ function IntegrationGroup({
</section> </section>
); );
} }
export default function IntegrationStep({ onSelect }: IntegrationStepProps) { export default function IntegrationStep({ onSelect }: IntegrationStepProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<IntegrationGroup mode="adapter" onSelect={onSelect} /> <IntegrationGroup mode="hooks" onSelect={onSelect} />
<IntegrationGroup mode="sdk" onSelect={onSelect} /> <IntegrationGroup mode="sdk" onSelect={onSelect} />
</div> </div>
); );
@@ -1,8 +1,10 @@
import type { IntegrationCatalogItem } from './integrations'; import type { IntegrationCatalogItem, PostInstallPlan } from './integrations';
export const SDK_LANGUAGES = ['node', 'python', 'go'] as const; export const SDK_LANGUAGES = ['node', 'python', 'go'] as const;
export type SdkLanguage = (typeof SDK_LANGUAGES)[number]; export type SdkLanguage = (typeof SDK_LANGUAGES)[number];
type SecretDelivery = 'hidden-prompts' | 'environment'; export const SHELLS = ['posix', 'powershell'] as const;
export type Shell = (typeof SHELLS)[number];
type SecretDelivery = 'embedded' | 'environment';
interface AgentInstallIdentity { interface AgentInstallIdentity {
readonly agentId: string; readonly agentId: string;
@@ -14,13 +16,15 @@ interface InstructionInput {
readonly integration: IntegrationCatalogItem; readonly integration: IntegrationCatalogItem;
readonly identity: AgentInstallIdentity; readonly identity: AgentInstallIdentity;
readonly baseUrl: string; readonly baseUrl: string;
readonly privateKey: string;
readonly token: string;
} }
export interface InstallInstructions { export interface InstallInstructions {
readonly setup: string; readonly setup: string;
readonly usage?: string; readonly usage?: string;
readonly verify?: string; readonly verify?: string;
readonly postInstall?: readonly string[]; readonly postInstall?: PostInstallPlan;
readonly secretDelivery: SecretDelivery; readonly secretDelivery: SecretDelivery;
} }
@@ -31,42 +35,97 @@ function shellQuoted(value: string, label: string): string {
return `'${value}'`; return `'${value}'`;
} }
function adapterCommand( const INSTALLER: Record<SdkLanguage, string> = {
node: 'npx @elydora/sdk install',
python: 'elydora install',
go: 'elydora install',
};
const BOOTSTRAP: Partial<Record<SdkLanguage, string>> = {
python: 'python -m pip install elydora',
go: 'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest',
};
interface CommandValues {
readonly agent: string;
readonly orgId: string;
readonly agentId: string;
readonly kid: string;
readonly baseUrl: string;
}
function credentialFileStem(agentId: string): string {
if (!/^[A-Za-z0-9._-]+$/u.test(agentId)) {
throw new Error('Agent ID cannot be used as a credential file name.');
}
return `.elydora-${agentId}`;
}
function installArguments(
language: SdkLanguage, language: SdkLanguage,
{ integration, identity, baseUrl }: InstructionInput, values: CommandValues,
keyFile: string,
tokenFile: string,
): string { ): string {
const values = { if (language === 'go') {
return `--agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl} --private-key-file ${keyFile} --token-file ${tokenFile}`;
}
return `--agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl} --private_key_file ${keyFile} --token_file ${tokenFile}`;
}
function posixInstall(
language: SdkLanguage,
values: CommandValues,
stem: string,
privateKey: string,
token: string,
): string {
const template = `"$HOME/${stem}.XXXXXX"`;
const script = [
`k=$(mktemp ${template})`,
`trap 'rm -f "$k"' EXIT INT TERM`,
`t=$(mktemp ${template})`,
`trap 'rm -f "$k" "$t"' EXIT INT TERM`,
`printf '%s\\n' ${shellQuoted(privateKey, 'Private key')} > "$k"`,
`printf '%s\\n' ${shellQuoted(token, 'API token')} > "$t"`,
`${INSTALLER[language]} ${installArguments(language, values, '"$k"', '"$t"')}`,
].join(' && ');
return `printf '%s' '${script.replace(/'/gu, "'\\''")}' | sh`;
}
function powershellInstall(
language: SdkLanguage,
values: CommandValues,
stem: string,
privateKey: string,
token: string,
): string {
const paths = `$k = Join-Path $HOME '${stem}.key'; $t = Join-Path $HOME '${stem}.token'`;
const create = 'New-Item -ItemType File -Force -Path $k, $t | Out-Null';
const protect = 'if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw "chmod failed" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r "*$($u):F" | Out-Null; if ($LASTEXITCODE -ne 0) { throw "icacls failed for $f" } } }';
const store = `Set-Content -LiteralPath $k -Value ${shellQuoted(privateKey, 'Private key')}; Set-Content -LiteralPath $t -Value ${shellQuoted(token, 'API token')}`;
const install = `${INSTALLER[language]} ${installArguments(language, values, '$k', '$t')}`;
const status = 'if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" }';
return `${paths}; try { ${create}; ${protect}; ${store}; ${install}; ${status} } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }`;
}
function hookInstallerCommand(
language: SdkLanguage,
{ integration, identity, baseUrl, privateKey, token }: InstructionInput,
shell: Shell,
): string {
const values: CommandValues = {
agent: integration.id, agent: integration.id,
orgId: shellQuoted(identity.orgId, 'Organization ID'), orgId: shellQuoted(identity.orgId, 'Organization ID'),
agentId: shellQuoted(identity.agentId, 'Agent ID'), agentId: shellQuoted(identity.agentId, 'Agent ID'),
kid: shellQuoted(identity.kid, 'Key ID'), kid: shellQuoted(identity.kid, 'Key ID'),
baseUrl: shellQuoted(baseUrl, 'API base URL'), baseUrl: shellQuoted(baseUrl, 'API base URL'),
}; };
const stem = credentialFileStem(identity.agentId);
if (language === 'go') { const command = shell === 'powershell'
return [ ? powershellInstall(language, values, stem, privateKey, token)
'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest', : posixInstall(language, values, stem, privateKey, token);
`elydora install --agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl}`, const bootstrap = BOOTSTRAP[language];
].join('\n'); return bootstrap ? `${bootstrap}\n${command}` : command;
}
const executable = language === 'node'
? 'npx @elydora/sdk install'
: 'python -m pip install elydora\nelydora install';
return `${executable} --agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl}`;
}
function postInstallSteps(integration: IntegrationCatalogItem): readonly string[] | undefined {
if (integration.postInstall === 'review-hooks') {
return ['Open the agent and run /hooks to review the active Elydora hooks.'];
}
if (integration.postInstall === 'start-kiro') {
return [
'Kiro CLI v2: kiro-cli --agent elydora-audit',
'Kiro CLI v3: kiro-cli --v3',
];
}
return undefined;
} }
function sdkSetup(language: SdkLanguage, input: InstructionInput): string { function sdkSetup(language: SdkLanguage, input: InstructionInput): string {
@@ -95,7 +154,7 @@ client.set_kid(${JSON.stringify(identity.kid)})`;
} }
if (language === 'go') { if (language === 'go') {
return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
requireEnv := func(name string) string { requireEnv := func(name string) string {
value, ok := os.LookupEnv(name) value, ok := os.LookupEnv(name)
@@ -176,13 +235,14 @@ await client.submitOperation(operation);`;
export function buildInstallInstructions( export function buildInstallInstructions(
language: SdkLanguage, language: SdkLanguage,
input: InstructionInput, input: InstructionInput,
shell: Shell = 'posix',
): InstallInstructions { ): InstallInstructions {
if (input.integration.mode === 'adapter') { if (input.integration.mode === 'hooks') {
return { return {
setup: adapterCommand(language, input), setup: hookInstallerCommand(language, input, shell),
verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status', verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status',
postInstall: postInstallSteps(input.integration), postInstall: input.integration.postInstall,
secretDelivery: 'hidden-prompts', secretDelivery: 'embedded',
}; };
} }
@@ -5,35 +5,169 @@ import {
export { INTEGRATION_TYPES }; export { INTEGRATION_TYPES };
export type IntegrationMode = 'adapter' | 'sdk'; export type IntegrationMode = 'hooks' | 'sdk';
export type PostInstallAction = 'review-hooks' | 'start-kiro'; export type PostInstallStepKey =
| 'inspectEnvironment'
| 'inspectCursorHooks'
| 'inspectGrokConfig'
| 'inspectKiroIdeHooks'
| 'launchKiroV2'
| 'launchKiroV3'
| 'listHooks'
| 'openHooks'
| 'reviewAndTrustHooks'
| 'restartSession'
| 'startSession'
| 'startTask';
export interface PostInstallStep {
readonly key: PostInstallStepKey;
readonly command?: string;
readonly variant?: string;
}
export interface PostInstallPlan {
readonly kind: 'sequence' | 'alternatives';
readonly steps: readonly PostInstallStep[];
}
export interface IntegrationCatalogItem { export interface IntegrationCatalogItem {
readonly id: IntegrationType; readonly id: IntegrationType;
readonly name: string; readonly name: string;
readonly mode: IntegrationMode; readonly mode: IntegrationMode;
readonly postInstall?: PostInstallAction; readonly postInstall?: PostInstallPlan;
} }
const INTEGRATION_METADATA: Record< const INTEGRATION_METADATA: Record<
IntegrationType, IntegrationType,
Omit<IntegrationCatalogItem, 'id'> Omit<IntegrationCatalogItem, 'id'>
> = { > = {
augment: { name: 'Augment Code', mode: 'adapter' }, augment: {
claudecode: { name: 'Claude Code', mode: 'adapter' }, name: 'Augment Code',
cline: { name: 'Cline', mode: 'adapter' }, mode: 'hooks',
codex: { name: 'OpenAI Codex', mode: 'adapter', postInstall: 'review-hooks' }, postInstall: {
copilot: { name: 'GitHub Copilot CLI', mode: 'adapter' }, kind: 'sequence',
cursor: { name: 'Cursor', mode: 'adapter' }, steps: [{ key: 'startSession', command: 'auggie' }],
droid: { name: 'Factory Droid', mode: 'adapter', postInstall: 'review-hooks' }, },
gemini: { name: 'Gemini CLI', mode: 'adapter' }, },
grok: { name: 'Grok Build', mode: 'adapter' }, claudecode: {
kimi: { name: 'Kimi CLI', mode: 'adapter', postInstall: 'review-hooks' }, name: 'Claude Code',
kirocli: { name: 'Kiro CLI', mode: 'adapter', postInstall: 'start-kiro' }, mode: 'hooks',
kiroide: { name: 'Kiro IDE', mode: 'adapter' }, postInstall: {
letta: { name: 'Letta Code', mode: 'adapter' }, kind: 'sequence',
opencode: { name: 'OpenCode', mode: 'adapter' }, steps: [{ key: 'openHooks', command: '/hooks' }],
qwen: { name: 'Qwen Code', mode: 'adapter', postInstall: 'review-hooks' }, },
},
cline: {
name: 'Cline',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'startTask' }],
},
},
codex: {
name: 'Codex CLI',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }],
},
},
copilot: {
name: 'GitHub Copilot CLI',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [
{ key: 'restartSession', command: '/restart' },
{ key: 'inspectEnvironment', command: '/env' },
],
},
},
cursor: {
name: 'Cursor CLI',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'inspectCursorHooks' }],
},
},
droid: {
name: 'Factory Droid',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'openHooks', command: '/hooks' }],
},
},
gemini: {
name: 'Gemini CLI',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'listHooks', command: '/hooks list' }],
},
},
grok: {
name: 'Grok Build',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }],
},
},
kimi: {
name: 'Kimi Code',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'startSession', command: 'kimi' }],
},
},
kirocli: {
name: 'Kiro CLI',
mode: 'hooks',
postInstall: {
kind: 'alternatives',
steps: [
{ key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' },
{ key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' },
],
},
},
kiroide: {
name: 'Kiro IDE',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'inspectKiroIdeHooks' }],
},
},
letta: {
name: 'Letta Code',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'openHooks', command: '/hooks' }],
},
},
opencode: {
name: 'OpenCode',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'startSession', command: 'opencode' }],
},
},
qwen: {
name: 'Qwen Code',
mode: 'hooks',
postInstall: {
kind: 'sequence',
steps: [{ key: 'listHooks', command: '/hooks list' }],
},
},
enterprise: { name: 'Enterprise Agent', mode: 'sdk' }, enterprise: { name: 'Enterprise Agent', mode: 'sdk' },
gui: { name: 'GUI Agent', mode: 'sdk' }, gui: { name: 'GUI Agent', mode: 'sdk' },
sdk: { name: 'SDK', mode: 'sdk' }, sdk: { name: 'SDK', mode: 'sdk' },
@@ -43,6 +177,6 @@ const INTEGRATION_METADATA: Record<
export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] = export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] =
INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] })); INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] }));
export const ADAPTER_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set( export const HOOK_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter').map(({ id }) => id), INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks').map(({ id }) => id),
); );
+19 -2
View File
@@ -330,13 +330,15 @@
"connect": "Connect" "connect": "Connect"
}, },
"integrationGroups": { "integrationGroups": {
"adapter": "CLI adapters", "hooks": "Native hooks",
"sdk": "Custom SDK" "sdk": "Custom SDK"
}, },
"hookBadge": "HOOKS",
"integration": "Integration", "integration": "Integration",
"copySetup": "Copy setup command", "copySetup": "Copy setup command",
"copyUsage": "Copy operation example", "copyUsage": "Copy operation example",
"copyVerify": "Copy verification command", "copyVerify": "Copy verification command",
"copyPostInstall": "Copy activation command: {{command}}",
"copyPrivateKey": "Copy private key", "copyPrivateKey": "Copy private key",
"copyApiToken": "Copy API token", "copyApiToken": "Copy API token",
"copyFailed": "Copy failed.", "copyFailed": "Copy failed.",
@@ -346,9 +348,24 @@
"record": "Record an operation", "record": "Record an operation",
"verify": "Verify", "verify": "Verify",
"postInstall": "Post-install steps", "postInstall": "Post-install steps",
"postInstallSteps": {
"inspectCursorHooks": "Open Customize → Hooks and confirm Elydora in the Hooks output channel.",
"inspectEnvironment": "Confirm the Elydora hooks in the loaded environment.",
"inspectGrokConfig": "Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.",
"inspectKiroIdeHooks": "Open Agent Hooks in the Kiro panel and confirm Elydora Audit is enabled.",
"launchKiroV2": "Launch the Elydora audit agent in Kiro CLI v2.",
"launchKiroV3": "Start Kiro CLI v3 with the global Elydora hooks.",
"listHooks": "List the loaded Elydora hooks.",
"openHooks": "Open the hook manager and review the Elydora hooks.",
"reviewAndTrustHooks": "Review and trust both Elydora command hooks.",
"restartSession": "Restart the current {{integration}} session.",
"startSession": "Start a new {{integration}} session.",
"startTask": "Start a new {{integration}} task."
},
"sdkLanguage": "SDK language", "sdkLanguage": "SDK language",
"shell": "Shell",
"secretDelivery": { "secretDelivery": {
"hidden-prompts": "The CLI requests both values with terminal echo disabled.", "embedded": "The command stores both credentials in owner-only files, installs the hook, then deletes them.",
"environment": "Set ELYDORA_PRIVATE_KEY and ELYDORA_API_TOKEN in the runtime environment." "environment": "Set ELYDORA_PRIVATE_KEY and ELYDORA_API_TOKEN in the runtime environment."
} }
}, },
+19 -2
View File
@@ -330,13 +330,15 @@
"connect": "连接" "connect": "连接"
}, },
"integrationGroups": { "integrationGroups": {
"adapter": "CLI 适配器", "hooks": "原生 Hooks",
"sdk": "自定义 SDK" "sdk": "自定义 SDK"
}, },
"hookBadge": "HOOKS",
"integration": "集成", "integration": "集成",
"copySetup": "复制安装命令", "copySetup": "复制安装命令",
"copyUsage": "复制操作示例", "copyUsage": "复制操作示例",
"copyVerify": "复制验证命令", "copyVerify": "复制验证命令",
"copyPostInstall": "复制激活命令:{{command}}",
"copyPrivateKey": "复制私钥", "copyPrivateKey": "复制私钥",
"copyApiToken": "复制 API 令牌", "copyApiToken": "复制 API 令牌",
"copyFailed": "复制失败。", "copyFailed": "复制失败。",
@@ -346,9 +348,24 @@
"record": "记录操作", "record": "记录操作",
"verify": "验证", "verify": "验证",
"postInstall": "安装后步骤", "postInstall": "安装后步骤",
"postInstallSteps": {
"inspectCursorHooks": "打开 Customize → Hooks,在 Hooks 输出通道中确认 Elydora。",
"inspectEnvironment": "确认已加载环境包含 Elydora hooks。",
"inspectGrokConfig": "确认 Elydora PreToolUse、PostToolUse 与 PostToolUseFailure hooks。",
"inspectKiroIdeHooks": "打开 Kiro 面板中的 Agent Hooks,确认 Elydora Audit 已启用。",
"launchKiroV2": "在 Kiro CLI v2 中启动 Elydora audit agent。",
"launchKiroV3": "启动 Kiro CLI v3 并加载全局 Elydora hooks。",
"listHooks": "列出已加载的 Elydora hooks。",
"openHooks": "打开 hook 管理器并检查 Elydora hooks。",
"reviewAndTrustHooks": "检查并信任两个 Elydora command hooks。",
"restartSession": "重启当前 {{integration}} 会话。",
"startSession": "启动新的 {{integration}} 会话。",
"startTask": "启动新的 {{integration}} 任务。"
},
"sdkLanguage": "SDK 语言", "sdkLanguage": "SDK 语言",
"shell": "终端",
"secretDelivery": { "secretDelivery": {
"hidden-prompts": "CLI 会在关闭终端回显后请求这两项凭据。", "embedded": "命令会把两项凭据写入仅所有者可读的文件,安装 hook 后立即删除。",
"environment": "请在运行环境中设置 ELYDORA_PRIVATE_KEY 与 ELYDORA_API_TOKEN。" "environment": "请在运行环境中设置 ELYDORA_PRIVATE_KEY 与 ELYDORA_API_TOKEN。"
} }
}, },
@@ -0,0 +1,24 @@
-- Opaque API tokens stored as SHA-256 hashes.
BEGIN;
CREATE TABLE IF NOT EXISTS api_tokens (
token_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (user_id),
org_id TEXT NOT NULL REFERENCES organizations (org_id),
token_hash TEXT NOT NULL UNIQUE,
issued_at BIGINT NOT NULL,
expires_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id);
INSERT INTO schema_versions (version, applied_at, description)
VALUES (
3,
EXTRACT(EPOCH FROM NOW())::BIGINT * 1000,
'Opaque API tokens'
)
ON CONFLICT (version) DO NOTHING;
COMMIT;
+10 -2
View File
@@ -90,8 +90,8 @@ const messages: Record<string, Record<Lang, string>> = {
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a', zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
}, },
'auth.ttlMustBePositive': { 'auth.ttlMustBePositive': {
en: 'ttl_seconds must be a positive number', en: 'ttl_seconds must be a whole number of seconds from 1 to 31536000',
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570', zh: 'ttl_seconds 必须是 1 到 31536000 之间的整数秒',
}, },
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -121,6 +121,14 @@ const messages: Record<string, Record<Lang, string>> = {
en: 'ttl_seconds must be a number or null', en: 'ttl_seconds must be a number or null',
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null', zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
}, },
'auth.issueRequiresSession': {
en: 'API token issuance requires a session credential.',
zh: '签发 API 令牌需要使用会话凭据。',
},
'auth.organizationRequiredForApiToken': {
en: 'Organization setup is required before issuing an API token.',
zh: '签发 API 令牌前需要完成组织设置。',
},
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Agent routes (src/routes/agents.ts) // Agent routes (src/routes/agents.ts)
+20 -15
View File
@@ -1,14 +1,10 @@
/** /**
* Better Auth authentication middleware. * Authentication middleware for opaque API tokens and Better Auth sessions.
* *
* Validates requests via Better Auth sessions (cookie or bearer token). * A bearer value is resolved as a hashed API token first; anything else is
* On success, extracts `org_id`, `role`, and user `id` (actor) from the * validated as a Better Auth session (cookie or bearer session token).
* session and stores them in the Hono context variables for downstream * On success the org_id, role, actor, and credential kind are stored in the
* handlers. * Hono context variables for downstream handlers.
*
* Supports:
* - Session cookies (browser / console)
* - Bearer tokens via Better Auth's bearer plugin (SDK clients)
*/ */
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
@@ -16,13 +12,8 @@ import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js'; import { AppError } from './error-handler.js';
import { createAuth } from '../lib/auth.js'; import { createAuth } from '../lib/auth.js';
import { authenticateApiToken } from '../services/api-token-service.js';
/**
* Authentication middleware.
*
* Uses Better Auth's getSession to validate the request via session
* cookie or bearer token. Sets org_id, role, and actor on the context.
*/
export const authMiddleware: MiddlewareHandler<{ export const authMiddleware: MiddlewareHandler<{
Bindings: Env; Bindings: Env;
Variables: AppVariables; Variables: AppVariables;
@@ -32,6 +23,19 @@ export const authMiddleware: MiddlewareHandler<{
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' }); throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
} }
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
if (bearerToken) {
const apiToken = await authenticateApiToken(c.env.ELYDORA_DB, bearerToken);
if (apiToken) {
c.set('org_id', apiToken.org_id);
c.set('role', apiToken.role);
c.set('actor', apiToken.user_id);
c.set('auth_token_type', 'api');
await next();
return;
}
}
let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>; let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>;
try { try {
@@ -57,6 +61,7 @@ export const authMiddleware: MiddlewareHandler<{
c.set('org_id', user.org_id ?? ''); c.set('org_id', user.org_id ?? '');
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole)); c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
c.set('actor', user.id); c.set('actor', user.id);
c.set('auth_token_type', 'session');
await next(); await next();
}; };
+18 -30
View File
@@ -2,7 +2,7 @@
* Auth routes — registration, login, profile retrieval, and token refresh. * Auth routes — registration, login, profile retrieval, and token refresh.
* *
* /register and /login are public (no auth required). * /register and /login are public (no auth required).
* /me, /refresh, and /token require a valid Better Auth session. * /me and /refresh require a valid Better Auth session or API token; /token requires a session.
* *
* Uses Better Auth for session management while preserving the existing * Uses Better Auth for session management while preserving the existing
* API surface (endpoint paths and response formats). * API surface (endpoint paths and response formats).
@@ -11,6 +11,8 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
import { authMiddleware } from '../middleware/auth.js'; import { authMiddleware } from '../middleware/auth.js';
import { AppError } from '../middleware/error-handler.js';
import { issueApiToken, MAX_TTL_SECONDS } from '../services/api-token-service.js';
import { getMe } from '../services/auth-service.js'; import { getMe } from '../services/auth-service.js';
import { createAuth } from '../lib/auth.js'; import { createAuth } from '../lib/auth.js';
import { getMessage } from '../i18n/messages.js'; import { getMessage } from '../i18n/messages.js';
@@ -169,46 +171,32 @@ auth.post('/refresh', authMiddleware, async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/auth/token — Issue an API token with custom TTL (auth required) // POST /v1/auth/token — Issue an opaque API token (session required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.post('/token', authMiddleware, async (c) => { auth.post('/token', authMiddleware, async (c) => {
if (c.get('auth_token_type') !== 'session') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.issueRequiresSession' });
}
const body = await c.req.json(); const body = await c.req.json();
const { ttl_seconds } = body; const { ttl_seconds } = body;
if (ttl_seconds !== null && ttl_seconds !== undefined) { if (ttl_seconds !== null && ttl_seconds !== undefined) {
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) { if (typeof ttl_seconds !== 'number') {
const lang = c.get('lang') ?? 'en'; throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBeNumber' });
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400); }
if (!Number.isInteger(ttl_seconds) || ttl_seconds <= 0 || ttl_seconds > MAX_TTL_SECONDS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
} }
} }
// Get the current session token — Better Auth sessions have a fixed const orgId = c.get('org_id');
// lifetime configured in the auth instance (7 days by default). if (!orgId) {
// The ttl_seconds parameter is accepted for API compatibility but throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.organizationRequiredForApiToken' });
// session lifetime is governed by Better Auth's session config.
const betterAuthInstance = createAuth(
process.env.DATABASE_URL!,
c.env.BETTER_AUTH_SECRET,
c.env.BETTER_AUTH_URL,
c.env.ALLOWED_ORIGINS,
);
const session = await betterAuthInstance.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
const lang = c.get('lang') ?? 'en';
return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401);
} }
const sessionData = session.session as { token?: string; expiresAt?: Date }; const issued = await issueApiToken(c.env.ELYDORA_DB, c.get('actor'), orgId, ttl_seconds ?? null);
const token = sessionData.token ?? ''; return c.json({ token: issued.token, expires_at: issued.expires_at }, 200);
const expiresAt = sessionData.expiresAt
? Math.floor(new Date(sessionData.expiresAt).getTime() / 1000)
: Math.floor(Date.now() / 1000) + 604800;
return c.json({ token, expires_at: expiresAt }, 200);
}); });
export { auth }; export { auth };
@@ -0,0 +1,87 @@
/**
* Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored.
*/
import { createHash, randomBytes } from 'node:crypto';
import type { Database } from '../adapters/interfaces.js';
import type { RbacRole } from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js';
export const MAX_TTL_SECONDS = 31_536_000;
interface ApiTokenAuthRow {
readonly token_id: string;
readonly user_id: string;
readonly org_id: string;
readonly expires_at: number | null;
readonly user_org_id: string | null;
readonly user_role: RbacRole;
readonly user_status: string;
}
export interface AuthenticatedApiToken {
readonly token_id: string;
readonly user_id: string;
readonly org_id: string;
readonly role: RbacRole;
}
export interface IssuedApiToken {
readonly token: string;
readonly token_id: string;
readonly expires_at: number | null;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function authenticateApiToken(
db: Database,
rawToken: string,
): Promise<AuthenticatedApiToken | null> {
const nowSeconds = Math.floor(Date.now() / 1000);
const row = await db
.prepare(
`SELECT t.token_id, t.user_id, t.org_id, t.expires_at,
u.org_id AS user_org_id, u.role AS user_role, u.status AS user_status
FROM api_tokens AS t
JOIN users AS u ON u.user_id = t.user_id
WHERE t.token_hash = ?`,
)
.bind(hashToken(rawToken))
.first<ApiTokenAuthRow>();
if (!row) return null;
if (row.expires_at !== null && row.expires_at <= nowSeconds) return null;
if (row.user_status !== 'active' || row.user_org_id !== row.org_id) return null;
return {
token_id: row.token_id,
user_id: row.user_id,
org_id: row.org_id,
role: row.user_role,
};
}
export async function issueApiToken(
db: Database,
userId: string,
orgId: string,
ttlSeconds: number | null,
): Promise<IssuedApiToken> {
const nowSeconds = Math.floor(Date.now() / 1000);
const expiresAt = ttlSeconds === null ? null : nowSeconds + ttlSeconds;
const tokenId = generateUUIDv7();
const token = randomBytes(32).toString('base64url');
await db
.prepare(
`INSERT INTO api_tokens (token_id, user_id, org_id, token_hash, issued_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(tokenId, userId, orgId, hashToken(token), nowSeconds, expiresAt)
.run();
return { token, token_id: tokenId, expires_at: expiresAt };
}
+3
View File
@@ -62,6 +62,9 @@ export interface AppVariables {
/** Actor identifier extracted from the verified session */ /** Actor identifier extracted from the verified session */
actor: string; actor: string;
/** Credential kind that authenticated the request */
auth_token_type: 'api' | 'session';
/** Detected language from Accept-Language header (set by i18n middleware) */ /** Detected language from Accept-Language header (set by i18n middleware) */
lang: import('./i18n/messages.js').Lang; lang: import('./i18n/messages.js').Lang;
} }
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { Hono } from 'hono';
import { authMiddleware } from '../dist/middleware/auth.js';
import { auth } from '../dist/routes/auth.js';
import { authenticateApiToken, issueApiToken } from '../dist/services/api-token-service.js';
const NOW = Math.floor(Date.now() / 1000);
class FakeDatabase {
constructor(firstResult = null) {
this.firstResult = firstResult;
this.statements = [];
}
prepare(sql) {
const database = this;
const statement = {
sql,
values: [],
bind(...values) {
statement.values = values;
return statement;
},
async first() {
database.statements.push({ sql, values: statement.values });
return database.firstResult;
},
async run() {
database.statements.push({ sql, values: statement.values });
return { success: true };
},
};
return statement;
}
}
function liveRow(overrides = {}) {
return {
token_id: 'token-1',
user_id: 'user-1',
org_id: 'org-1',
expires_at: null,
user_org_id: 'org-1',
user_role: 'integration_engineer',
user_status: 'active',
...overrides,
};
}
const ENV = { ELYDORA_DB: null, BETTER_AUTH_SECRET: 'secret', BETTER_AUTH_URL: 'http://localhost', ALLOWED_ORIGINS: '' };
test('issues a 32-byte token and stores only its hash', async () => {
const db = new FakeDatabase();
const issued = await issueApiToken(db, 'user-1', 'org-1', 3600);
assert.equal(Buffer.from(issued.token, 'base64url').length, 32);
assert.ok(issued.expires_at >= NOW + 3600);
const [insert] = db.statements;
assert.match(insert.sql, /INSERT INTO api_tokens/);
assert.equal(insert.values[3], createHash('sha256').update(issued.token).digest('hex'));
assert.equal(insert.values[5], issued.expires_at);
assert.ok(!insert.values.includes(issued.token));
});
test('a null ttl issues a token without expiry', async () => {
const db = new FakeDatabase();
const issued = await issueApiToken(db, 'user-1', 'org-1', null);
assert.equal(issued.expires_at, null);
assert.equal(db.statements[0].values[5], null);
});
test('authenticates a live token through its hash', async () => {
const db = new FakeDatabase(liveRow());
const result = await authenticateApiToken(db, 'raw-token');
assert.deepEqual(result, { token_id: 'token-1', user_id: 'user-1', org_id: 'org-1', role: 'integration_engineer' });
assert.equal(db.statements[0].values[0], createHash('sha256').update('raw-token').digest('hex'));
});
test('rejects unknown, expired, suspended, and re-homed tokens', async () => {
for (const row of [
null,
liveRow({ expires_at: NOW - 1 }),
liveRow({ user_status: 'suspended' }),
liveRow({ user_org_id: 'org-2' }),
]) {
assert.equal(await authenticateApiToken(new FakeDatabase(row), 'raw-token'), null);
}
assert.notEqual(await authenticateApiToken(new FakeDatabase(liveRow({ expires_at: NOW + 60 })), 'raw-token'), null);
});
test('the middleware resolves an API bearer token before Better Auth', async () => {
const app = new Hono();
app.get('/whoami', authMiddleware, (c) => c.json({
org_id: c.get('org_id'),
role: c.get('role'),
actor: c.get('actor'),
auth_token_type: c.get('auth_token_type'),
}));
const response = await app.request(
'/whoami',
{ headers: { Authorization: 'Bearer raw-token' } },
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
org_id: 'org-1',
role: 'integration_engineer',
actor: 'user-1',
auth_token_type: 'api',
});
});
test('an API token cannot issue another API token', async () => {
const app = new Hono();
app.onError((error, c) => c.json({ error: { code: error.errorCode, key: error.messageKey } }, error.statusCode));
app.route('/v1/auth', auth);
const response = await app.request(
'/v1/auth/token',
{
method: 'POST',
headers: { Authorization: 'Bearer raw-token', 'Content-Type': 'application/json' },
body: JSON.stringify({ ttl_seconds: null }),
},
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
);
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: { code: 'VALIDATION_ERROR', key: 'auth.issueRequiresSession' } });
});