mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add secure Buzz connection diagnostics (#946)
This commit is contained in:
parent
32517e4df6
commit
54417357f9
31 changed files with 4225 additions and 76 deletions
|
|
@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added a reference-only Buzz communication adapter with typed configuration,
|
||||
host-preserving HTTP/WS normalization, SSRF-aware bounded relay probes,
|
||||
NIP-11 software/version/community checks, NIP-98 identity and membership/read
|
||||
diagnostics with built-in hexadecimal/`nsec` signing, stable machine-readable
|
||||
reason codes, evidence invalidation, shell-free optional command discovery,
|
||||
Settings controls, `vk doctor` reporting, and operator/API/security
|
||||
documentation. Buzz message delivery and reply subscription remain
|
||||
fail-closed for their dedicated follow-up issue (#905).
|
||||
- Added transactional, remote-safe worktree lifecycle management with
|
||||
versioned manifests, exact fetched base commits, reasoned offline fallback,
|
||||
task/attempt ownership leases, active-run locks, recovery states, and
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ const baseRoutes: Record<string, Response> = {
|
|||
cli: { installed: true, authenticated: true },
|
||||
recommendations: [],
|
||||
}),
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse(
|
||||
{ error: 'not found' },
|
||||
404
|
||||
),
|
||||
};
|
||||
|
||||
describe('vk doctor', () => {
|
||||
|
|
@ -250,4 +254,121 @@ describe('vk doctor', () => {
|
|||
status: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports exact Buzz health in human and JSON output', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail:
|
||||
'Buzz relay identity, configured signing identity, membership posture, and read capabilities are compatible.',
|
||||
reasonCode: 'ok',
|
||||
buzz: {
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'compatible',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [],
|
||||
evidenceKey: 'safe-evidence',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'pass',
|
||||
details: {
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
testedRelease: '0.4.24',
|
||||
buzz: {
|
||||
checks: {
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
evidenceKey: 'safe-evidence',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(formatDoctorReport(report)).toContain('[PASS] Buzz compatibility');
|
||||
});
|
||||
|
||||
it('fails doctor for an enabled Buzz membership denial with remediation', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'not_member',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz authenticated the identity but denied relay membership.',
|
||||
reasonCode: 'relay_membership_required',
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'fail',
|
||||
details: {
|
||||
status: 'not_member',
|
||||
reasonCode: 'relay_membership_required',
|
||||
},
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { API_BASE, buildApiHeaders } from '../utils/api.js';
|
||||
import type { HarnessSupportStatus } from '@veritas-kanban/shared';
|
||||
import type { CommunicationAdapterHealth, HarnessSupportStatus } from '@veritas-kanban/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CRITICAL_STATUSES = new Set<DoctorCheck['id']>([
|
||||
|
|
@ -16,6 +16,7 @@ const CRITICAL_STATUSES = new Set<DoctorCheck['id']>([
|
|||
'agents',
|
||||
'harness-support',
|
||||
'routing',
|
||||
'buzz',
|
||||
]);
|
||||
|
||||
export type DoctorStatus = 'pass' | 'warn' | 'fail' | 'skip';
|
||||
|
|
@ -540,6 +541,53 @@ function buildCodexCheck(health: CodexHealthResponse | null): DoctorCheck {
|
|||
);
|
||||
}
|
||||
|
||||
function buildBuzzCheck(
|
||||
health: CommunicationAdapterHealth | null,
|
||||
responseStatus: number
|
||||
): DoctorCheck {
|
||||
if (!health) {
|
||||
if (responseStatus === 404) {
|
||||
return check('buzz', 'Buzz compatibility', 'skip', 'Buzz is not configured');
|
||||
}
|
||||
return check(
|
||||
'buzz',
|
||||
'Buzz compatibility',
|
||||
'warn',
|
||||
'Buzz compatibility diagnostics are unavailable',
|
||||
{ status: responseStatus },
|
||||
'Verify settings-read permission and the communication adapter API.'
|
||||
);
|
||||
}
|
||||
|
||||
const details = {
|
||||
status: health.status,
|
||||
reasonCode: health.reasonCode,
|
||||
configured: health.configured,
|
||||
canSend: health.canSend,
|
||||
canReceiveReplies: health.canReceiveReplies,
|
||||
checkedAt: health.checkedAt,
|
||||
expectedCommunity: health.buzz?.expectedCommunity,
|
||||
observedCommunity: health.buzz?.observedCommunity,
|
||||
publicKeyFingerprint: health.buzz?.publicKeyFingerprint,
|
||||
testedRelease: health.buzz?.testedRelease,
|
||||
testedCommit: health.buzz?.testedCommit,
|
||||
probeRevision: health.buzz?.probeRevision,
|
||||
commands: health.buzz?.commands,
|
||||
buzz: health.buzz,
|
||||
};
|
||||
|
||||
if (health.status === 'disabled') {
|
||||
return check('buzz', 'Buzz compatibility', 'skip', health.detail, details, health.remediation);
|
||||
}
|
||||
if (health.status === 'healthy') {
|
||||
return check('buzz', 'Buzz compatibility', 'pass', health.detail, details);
|
||||
}
|
||||
if (health.status === 'degraded' || health.status === 'warning') {
|
||||
return check('buzz', 'Buzz compatibility', 'warn', health.detail, details, health.remediation);
|
||||
}
|
||||
return check('buzz', 'Buzz compatibility', 'fail', health.detail, details, health.remediation);
|
||||
}
|
||||
|
||||
function buildHarnessSupportCheck(statuses: HarnessSupportStatus[] | null): DoctorCheck {
|
||||
if (!statuses) {
|
||||
return check(
|
||||
|
|
@ -809,6 +857,13 @@ export async function runDoctorChecks(
|
|||
'/api/settings/codex/health'
|
||||
);
|
||||
checks.push(buildCodexCheck(codexHealth.ok ? codexHealth.data : null));
|
||||
|
||||
const buzzHealth = await requestJson<CommunicationAdapterHealth>(
|
||||
deps,
|
||||
options,
|
||||
'/api/integrations/communication/adapters/buzz-default/health'
|
||||
);
|
||||
checks.push(buildBuzzCheck(buzzHealth.ok ? buzzHealth.data : null, buzzHealth.status));
|
||||
} else {
|
||||
checks.push(
|
||||
check('api-auth', 'API authentication', 'skip', 'Skipped because API is unreachable')
|
||||
|
|
@ -825,6 +880,7 @@ export async function runDoctorChecks(
|
|||
checks.push(
|
||||
check('harness-support', 'Harness support', 'skip', 'Skipped because API is unreachable')
|
||||
);
|
||||
checks.push(check('buzz', 'Buzz compatibility', 'skip', 'Skipped because API is unreachable'));
|
||||
}
|
||||
|
||||
const agentResult = await buildAgentCheck(deps, agents);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ Fresh v5 installs use OpenAI Codex as the default agent:
|
|||
|
||||
Existing configs keep the user's chosen default agent. Missing built-in profiles are added during config normalization without overwriting customized commands, arguments, or enabled states.
|
||||
|
||||
## Buzz communication harness
|
||||
|
||||
Buzz is integrated as a `buzz` communication adapter, not as an
|
||||
`AgentProvider`. Settings and `vk doctor` can verify its relay/community
|
||||
identity, NIP-98 signing identity, relay membership, read capabilities, tested
|
||||
release contract, and optional local command versions without sending a
|
||||
message. Veritas does not route task execution through Buzz or start
|
||||
`buzz-acp`/`buzz-agent` in this slice. See
|
||||
[Buzz Connection Diagnostics](BUZZ-INTEGRATION.md).
|
||||
|
||||
## Harness Support Profiles And Tiers
|
||||
|
||||
Every configured agent is normalized to a `harness-support-profile/v1` contract.
|
||||
|
|
|
|||
|
|
@ -729,9 +729,11 @@ Search returns redacted snippets only. Mention notifications link back to the sq
|
|||
|
||||
### Communication Adapters
|
||||
|
||||
Bidirectional human reply adapters live under integrations. The first adapter
|
||||
contract is provider-neutral with Microsoft Teams posture fields and a local
|
||||
ingest API. It stores external thread mappings separately from message content.
|
||||
Communication adapters live under integrations. Microsoft Teams provides the
|
||||
existing human reply/send posture. Buzz provides a reference-only connection
|
||||
configuration and read-only relay compatibility probe. It does not send or
|
||||
subscribe to messages in this slice. External thread mappings remain separate
|
||||
from message content.
|
||||
|
||||
```
|
||||
GET /api/integrations/communication/adapters
|
||||
|
|
@ -773,6 +775,41 @@ Responses never return credentials or raw webhook query strings. Redacted
|
|||
posture appears as `webhookUrlConfigured`, `webhookUrlRedacted`, and
|
||||
`hasCredential`.
|
||||
|
||||
**Buzz configure body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "buzz",
|
||||
"displayName": "Buzz",
|
||||
"enabled": true,
|
||||
"relayHttpUrl": "https://community.example.com",
|
||||
"relayWebSocketUrl": "wss://community.example.com",
|
||||
"expectedCommunity": "community.example.com",
|
||||
"publicKey": "<64-hex-public-key>",
|
||||
"credentialRef": "env:BUZZ_PRIVATE_KEY",
|
||||
"authTagRef": "env:BUZZ_AUTH_TAG",
|
||||
"allowLocalhost": false,
|
||||
"allowPrivateNetwork": false,
|
||||
"command": {
|
||||
"executable": "buzz",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Buzz rejects raw credentials, URL userinfo/query/fragment components, and
|
||||
credential-like command arguments. Health returns an exact status and
|
||||
`reasonCode`, redacted configured/resolved endpoints, expected/observed
|
||||
community, public-key fingerprint, tested Buzz release/commit, probe revision,
|
||||
relay contract, independent verification checks, and optional command
|
||||
diagnostics. `POST .../buzz-default/test` runs the same read-only probe;
|
||||
`canSend` and `canReceiveReplies` remain `false`. See
|
||||
[Buzz Connection Diagnostics](BUZZ-INTEGRATION.md).
|
||||
|
||||
Send `null` for `relayWebSocketUrl`, `expectedCommunity`, `authTagRef`, or
|
||||
`command` to clear that optional setting. Omitting one preserves its current
|
||||
value.
|
||||
|
||||
**Reply ingest body**:
|
||||
|
||||
```json
|
||||
|
|
|
|||
173
docs/BUZZ-INTEGRATION.md
Normal file
173
docs/BUZZ-INTEGRATION.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Buzz Connection Diagnostics
|
||||
|
||||
Veritas Kanban can register a Buzz relay as a communication adapter and verify
|
||||
its compatibility without sending a message or changing relay state. This
|
||||
first integration slice covers connection configuration, relay/community
|
||||
identity, NIP-98 authentication, relay membership, channel/message read
|
||||
capability, and optional local command discovery.
|
||||
|
||||
Buzz is a communication harness in this integration. It is not an
|
||||
`AgentProvider`, and Veritas does not start `buzz`, `buzz-acp`, or `buzz-agent`.
|
||||
Message send, reply subscription, persona import, and composed agent execution
|
||||
are separate roadmap capabilities.
|
||||
|
||||
## Supported contract
|
||||
|
||||
The probe is fixture-pinned to:
|
||||
|
||||
- Buzz release `0.4.24`
|
||||
- Buzz commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`
|
||||
- Veritas probe revision `1`
|
||||
- Relay software identity `https://github.com/block/buzz`
|
||||
- Required NIPs `11`, `29`, and `42`
|
||||
- Optional enforced relay membership advertised as NIP `43`
|
||||
|
||||
An unknown Buzz version is reported as `unsupported`. Veritas still reads
|
||||
public NIP-11 metadata, but it does not treat that version as safe evidence for
|
||||
later message delivery.
|
||||
|
||||
## Configure the identity
|
||||
|
||||
Use a dedicated Buzz/Nostr identity. Keep the private key in the Veritas server
|
||||
environment and store only its environment-variable reference in Veritas.
|
||||
|
||||
```dotenv
|
||||
BUZZ_PRIVATE_KEY=<set outside source control>
|
||||
BUZZ_AUTH_TAG=<optional NIP-OA owner attestation>
|
||||
```
|
||||
|
||||
The signing key may be a 64-character hexadecimal private key or its `nsec`
|
||||
encoding and must match the configured 64-character hexadecimal public key.
|
||||
Veritas signs the Buzz-specific NIP-98 event with its pinned `nostr-tools`
|
||||
runtime. `BUZZ_AUTH_TAG` is needed only when an agent identity receives relay
|
||||
membership through a NIP-OA owner. Never put an `nsec`, private-key hex, auth
|
||||
tag, token, or authorization header in the Settings form, API body, task, log,
|
||||
screenshot, or support packet.
|
||||
|
||||
In **Settings -> Notifications -> Buzz Connection**, configure:
|
||||
|
||||
- Relay HTTP URL, such as `https://community.example.com`
|
||||
- Optional matching WebSocket URL; Veritas derives it when omitted
|
||||
- Expected community host and optional non-default port
|
||||
- Public key hex
|
||||
- `env:BUZZ_PRIVATE_KEY`
|
||||
- Optional `env:BUZZ_AUTH_TAG`
|
||||
- Explicit localhost/private-network allowances when required
|
||||
- Optional `buzz`, `buzz-acp`, or `buzz-agent` executable for version
|
||||
diagnostics
|
||||
|
||||
HTTP/WS endpoints must have the same host, port, path, and TLS posture.
|
||||
Credentials, query strings, and fragments are rejected. Veritas preserves a
|
||||
configured path and non-default port because Buzz binds the community to the
|
||||
request host.
|
||||
|
||||
## API setup
|
||||
|
||||
`settings:write` is required to configure, test, disable, or disconnect an
|
||||
adapter. `settings:read` is sufficient to read the adapter and health result.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3001/api/integrations/communication/adapters/buzz-default \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"kind": "buzz",
|
||||
"displayName": "Buzz",
|
||||
"enabled": true,
|
||||
"relayHttpUrl": "https://community.example.com",
|
||||
"expectedCommunity": "community.example.com",
|
||||
"publicKey": "<64-hex-public-key>",
|
||||
"credentialRef": "env:BUZZ_PRIVATE_KEY",
|
||||
"authTagRef": "env:BUZZ_AUTH_TAG"
|
||||
}'
|
||||
```
|
||||
|
||||
The response returns public configuration and reference posture only. It never
|
||||
resolves or returns an environment value.
|
||||
|
||||
Run the read-only probe:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/integrations/communication/adapters/buzz-default/health \
|
||||
-H 'X-API-Key: <veritas-api-key>'
|
||||
|
||||
vk doctor --json
|
||||
```
|
||||
|
||||
`POST .../buzz-default/test` runs the same read-only compatibility probe. It
|
||||
does not use the generic test-send path.
|
||||
|
||||
## Probe sequence
|
||||
|
||||
The probe:
|
||||
|
||||
1. validates and normalizes HTTP/HTTPS and WS/WSS endpoints;
|
||||
2. fetches bounded NIP-11 metadata from `/info` through the SSRF-protected,
|
||||
DNS-pinned outbound client;
|
||||
3. verifies Buzz software, version, relay public identity, NIPs, and the
|
||||
configured/observed community authority;
|
||||
4. resolves the signing key and optional auth tag only for the active probe;
|
||||
5. signs a fresh, host-bound NIP-98 `POST /query` request with the required
|
||||
URL, method, random nonce, and exact request-body hash tags;
|
||||
6. performs separately signed, read-only channel metadata and message filters
|
||||
so each capability has independent evidence;
|
||||
7. classifies authentication, relay membership, and read capability
|
||||
independently; and
|
||||
8. probes optional commands with executable-plus-argv process spawning, a
|
||||
timeout, bounded output, no shell, and a minimal environment that excludes
|
||||
provider and Buzz credentials.
|
||||
|
||||
Configured endpoint strings and separately resolved endpoints are retained in
|
||||
the redacted result. The compatibility evidence key changes when the endpoint,
|
||||
expected community, configured or observed public identity, secret reference,
|
||||
auth-tag reference, network allowance, command configuration/version, Buzz
|
||||
contract, or Veritas probe revision changes. Persisted evidence from an older
|
||||
probe release, commit, or revision is not restored as current.
|
||||
|
||||
## Status and remediation
|
||||
|
||||
| Status | Meaning |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| `healthy` | Relay, identity, membership posture, and reads were verified. |
|
||||
| `degraded` | Public contract is usable, but a bounded diagnostic is partial. |
|
||||
| `unsupported` | Relay software, version, or contract is outside tested support. |
|
||||
| `unauthorized` | Identity proof or read authorization was rejected. |
|
||||
| `not_member` | Identity was authenticated but is not a relay member. |
|
||||
| `misconfigured` | Endpoints, community, identity, or secret reference disagree. |
|
||||
| `unreachable` | Network policy, DNS, TLS, timeout, or relay availability failed. |
|
||||
| `disabled` | Configuration is retained but no probe is run. |
|
||||
|
||||
Machine-readable `reasonCode` values distinguish endpoint mismatch,
|
||||
community mismatch, missing credential reference, malformed NIP-OA auth tags,
|
||||
public-key mismatch, authentication rejection, membership denial,
|
||||
read-capability denial, rate-limiting, oversized/invalid responses, and
|
||||
unsupported builds.
|
||||
|
||||
The probe never claims send/reply verification. `canSend` and
|
||||
`canReceiveReplies` remain `false` for this integration slice.
|
||||
|
||||
## Local and private relays
|
||||
|
||||
Public HTTPS is the default. Plain HTTP requires an explicit localhost or
|
||||
private-network allowance. Localhost and RFC1918/IPv6 ULA private ranges are
|
||||
denied unless the operator enables the matching setting. Link-local, cloud
|
||||
metadata, and CGNAT ranges remain blocked even when private-network access is
|
||||
enabled. Both allowances are explicit because a Buzz relay URL is an outbound
|
||||
request target. DNS is still resolved and pinned for every request, redirects
|
||||
remain disabled, reads are bounded, and the probe has a fixed timeout.
|
||||
|
||||
Enable only the narrow network class required by the relay. Do not use a
|
||||
private-network allowance to reach cloud metadata or unrelated internal
|
||||
services.
|
||||
|
||||
## Upgrade, disable, and rollback
|
||||
|
||||
After a Buzz upgrade, run `vk doctor --json`. A version/build change invalidates
|
||||
the prior evidence and must pass the pinned compatibility fixtures before it is
|
||||
considered supported.
|
||||
|
||||
Use **Disable** or the disconnect endpoint to stop probes. Buzz reference-only
|
||||
configuration is retained so rollback does not destroy operator setup. Remove
|
||||
the environment secrets separately if the identity is being retired. Veritas
|
||||
does not remove relay membership, change communities, or modify Buzz desktop
|
||||
state.
|
||||
|
|
@ -1224,6 +1224,7 @@ Notification and broadcast features provide local visibility and optional delive
|
|||
- **Broadcast messages** — Durable system-wide messages at `/api/broadcasts` with `info`, `action-required`, and `urgent` priorities
|
||||
- **External delivery boundary** — Local notifications, broadcasts, and Squad Chat can work while external webhook delivery is disabled
|
||||
- **Human reply adapter health** — Settings -> Notifications shows Teams reply posture, redacted webhook state, recent delivery audit, test send, and disconnect controls
|
||||
- **Buzz compatibility diagnostics** — Reference-only Buzz relay setup verifies host-derived community identity, NIP-98 authentication, relay membership, channel/message read capability, tested release evidence, and optional command versions without sending a message
|
||||
|
||||
### API Endpoints
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,22 @@ limits are tracked in
|
|||
Compatibility errors and debug bundles must redact tokens, cookies, private
|
||||
keys, local private paths, raw chat content, and task body text.
|
||||
|
||||
Buzz communication diagnostics store environment-variable references and
|
||||
public/redacted metadata only. The Nostr private key and optional NIP-OA auth
|
||||
tag are resolved only for the active read-only probe and are never returned.
|
||||
The built-in signer accepts hexadecimal or `nsec` private-key material,
|
||||
constructs the Buzz-required nonce and exact request-body hash, and clears its
|
||||
decoded key bytes after signing on a best-effort basis.
|
||||
Relay URLs reject userinfo, query strings, and fragments. Outbound requests use
|
||||
scheme validation, explicit plaintext/local/RFC1918/ULA opt-ins, DNS pinning,
|
||||
manual redirects, fixed timeouts, and bounded response reads. Link-local,
|
||||
cloud-metadata, and CGNAT ranges remain blocked under the private-network
|
||||
opt-in. The default probe reads NIP-11 metadata and authenticated query filters
|
||||
only; it does not send a Buzz event. Optional command discovery runs without a
|
||||
shell and receives a minimal environment that excludes provider and Buzz
|
||||
credentials. See
|
||||
[Buzz Connection Diagnostics](BUZZ-INTEGRATION.md).
|
||||
|
||||
## Provider Runtime Capability Enforcement
|
||||
|
||||
Provider runtime manifests are authorization evidence, not display metadata.
|
||||
|
|
|
|||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
|
|
@ -207,6 +207,9 @@ importers:
|
|||
nanoid:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
nostr-tools:
|
||||
specifier: 2.24.1
|
||||
version: 2.24.1(typescript@6.0.3)
|
||||
pino:
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1
|
||||
|
|
@ -1053,6 +1056,14 @@ packages:
|
|||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@1.4.0':
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
|
@ -1061,6 +1072,10 @@ packages:
|
|||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
|
@ -1250,6 +1265,15 @@ packages:
|
|||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@scure/base@2.0.0':
|
||||
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
|
||||
|
||||
'@simple-git/args-pathspec@1.0.3':
|
||||
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
|
||||
|
||||
|
|
@ -3874,6 +3898,17 @@ packages:
|
|||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
nostr-tools@2.24.1:
|
||||
resolution: {integrity: sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
nostr-wasm@0.1.0:
|
||||
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5907,10 +5942,18 @@ snapshots:
|
|||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
|
||||
'@noble/hashes@1.4.0': {}
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@openai/codex-sdk@0.144.3':
|
||||
|
|
@ -6043,6 +6086,19 @@ snapshots:
|
|||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@scure/base@2.0.0': {}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
dependencies:
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@simple-git/args-pathspec@1.0.3': {}
|
||||
|
||||
'@simple-git/argv-parser@1.1.1':
|
||||
|
|
@ -9199,6 +9255,20 @@ snapshots:
|
|||
|
||||
normalize-url@6.1.0: {}
|
||||
|
||||
nostr-tools@2.24.1(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
'@scure/bip32': 2.0.1
|
||||
'@scure/bip39': 2.0.1
|
||||
nostr-wasm: 0.1.0
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
nostr-wasm@0.1.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"mime-types": "^3.0.2",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"nostr-tools": "2.24.1",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
|
|
|
|||
133
server/src/__tests__/buzz-compatibility-integration.test.ts
Normal file
133
server/src/__tests__/buzz-compatibility-integration.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BuzzCompatibilityService } from '../services/buzz-compatibility-service.js';
|
||||
import { CommunicationAdapterService } from '../services/communication-adapter-service.js';
|
||||
|
||||
const PUBLIC_KEY = 'ab'.repeat(32);
|
||||
const FIXTURE_PRIVATE_MATERIAL = 'fixture-private-material';
|
||||
let closeServer: (() => Promise<void>) | undefined;
|
||||
let temporaryDirectory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await closeServer?.();
|
||||
closeServer = undefined;
|
||||
if (temporaryDirectory) {
|
||||
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
temporaryDirectory = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown): void {
|
||||
response.statusCode = 200;
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
describe('Buzz compatibility fake relay', () => {
|
||||
it('proves the authenticated read path without persisting or returning secret material', async () => {
|
||||
const requests: Array<{
|
||||
method?: string;
|
||||
url?: string;
|
||||
authorizationPresent: boolean;
|
||||
}> = [];
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
requests.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
authorizationPresent: Boolean(request.headers.authorization),
|
||||
});
|
||||
if (request.url === '/info') {
|
||||
sendJson(response, {
|
||||
software: 'https://github.com/block/buzz',
|
||||
version: '0.4.24',
|
||||
supported_nips: [1, 11, 29, 42, 43],
|
||||
supported_extensions: ['nip-er'],
|
||||
self: 'cd'.repeat(32),
|
||||
limitation: { auth_required: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.url === '/query' && request.method === 'POST') {
|
||||
sendJson(response, []);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
closeServer = () => new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('fixture relay did not bind');
|
||||
|
||||
const compatibility = new BuzzCompatibilityService({
|
||||
resolveSecret: async () => FIXTURE_PRIVATE_MATERIAL,
|
||||
signer: {
|
||||
async sign() {
|
||||
return { authorization: 'Nostr fixture-signature', publicKey: PUBLIC_KEY };
|
||||
},
|
||||
},
|
||||
runCommand: async () => {
|
||||
throw Object.assign(new Error('not installed'), { code: 'ENOENT' });
|
||||
},
|
||||
});
|
||||
temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'buzz-compatibility-'));
|
||||
const audit = vi.fn().mockResolvedValue(undefined);
|
||||
const adapters = new CommunicationAdapterService({
|
||||
storageDir: temporaryDirectory,
|
||||
persist: true,
|
||||
buzzCompatibility: compatibility,
|
||||
audit,
|
||||
});
|
||||
await adapters.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
enabled: true,
|
||||
relayHttpUrl: `http://127.0.0.1:${address.port}`,
|
||||
expectedCommunity: `127.0.0.1:${address.port}`,
|
||||
publicKey: PUBLIC_KEY,
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
allowLocalhost: true,
|
||||
});
|
||||
const result = await adapters.checkHealth('buzz-default');
|
||||
const publicAdapter = await adapters.getAdapter('buzz-default');
|
||||
const persisted = await fs.readFile(path.join(temporaryDirectory, 'state.json'), 'utf8');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
});
|
||||
expect(result.buzz).toMatchObject({
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
{ method: 'GET', url: '/info', authorizationPresent: false },
|
||||
{ method: 'POST', url: '/query', authorizationPresent: true },
|
||||
{ method: 'POST', url: '/query', authorizationPresent: true },
|
||||
]);
|
||||
const artifacts = JSON.stringify({
|
||||
result,
|
||||
publicAdapter,
|
||||
persisted,
|
||||
audit: audit.mock.calls,
|
||||
requests,
|
||||
});
|
||||
expect(artifacts).not.toContain(FIXTURE_PRIVATE_MATERIAL);
|
||||
expect(artifacts).not.toContain('fixture-signature');
|
||||
expect(publicAdapter).toMatchObject({
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
hasCredential: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
693
server/src/__tests__/buzz-compatibility-service.test.ts
Normal file
693
server/src/__tests__/buzz-compatibility-service.test.ts
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { verifyEvent } from 'nostr-tools';
|
||||
import {
|
||||
BuzzCompatibilityService,
|
||||
buildBuzzCommandEnvironment,
|
||||
normalizeBuzzEndpoints,
|
||||
} from '../services/buzz-compatibility-service.js';
|
||||
|
||||
const PUBLIC_KEY = 'ab'.repeat(32);
|
||||
const SIGNING_KEY = `${'0'.repeat(63)}1`;
|
||||
const SIGNING_PUBLIC_KEY = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||
const AUTH_TAG = JSON.stringify(['auth', 'cd'.repeat(32), 'kind=9', 'ef'.repeat(64)]);
|
||||
const BASE_CONFIG = {
|
||||
enabled: true,
|
||||
relayHttpUrl: 'https://relay.example.test/team',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: PUBLIC_KEY,
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
};
|
||||
|
||||
function relayInfo(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
software: 'https://github.com/block/buzz',
|
||||
version: '0.4.24',
|
||||
supported_nips: [1, 11, 29, 42, 43],
|
||||
supported_extensions: ['nip-er'],
|
||||
self: 'cd'.repeat(32),
|
||||
limitation: { auth_required: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(body: unknown, status = 200): Response {
|
||||
return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe('normalizeBuzzEndpoints', () => {
|
||||
it.each([
|
||||
[
|
||||
'https://relay.example.test',
|
||||
undefined,
|
||||
'https://relay.example.test',
|
||||
'wss://relay.example.test',
|
||||
'relay.example.test',
|
||||
],
|
||||
[
|
||||
'http://127.0.0.1:3000/team/',
|
||||
'ws://127.0.0.1:3000/team',
|
||||
'http://127.0.0.1:3000/team',
|
||||
'ws://127.0.0.1:3000/team',
|
||||
'127.0.0.1:3000',
|
||||
],
|
||||
[
|
||||
'https://[2001:db8::1]:8443/buzz',
|
||||
'wss://[2001:db8::1]:8443/buzz',
|
||||
'https://[2001:db8::1]:8443/buzz',
|
||||
'wss://[2001:db8::1]:8443/buzz',
|
||||
'[2001:db8::1]:8443',
|
||||
],
|
||||
])(
|
||||
'normalizes %s without losing relay identity',
|
||||
(relayHttpUrl, relayWebSocketUrl, httpUrl, webSocketUrl, community) => {
|
||||
expect(normalizeBuzzEndpoints({ relayHttpUrl, relayWebSocketUrl })).toMatchObject({
|
||||
httpUrl,
|
||||
webSocketUrl,
|
||||
community,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
relayWebSocketUrl: 'wss://other.example.test',
|
||||
},
|
||||
{
|
||||
relayHttpUrl: 'https://relay.example.test/a',
|
||||
relayWebSocketUrl: 'wss://relay.example.test/b',
|
||||
},
|
||||
{ relayHttpUrl: 'https://user:secret@relay.example.test' },
|
||||
{ relayHttpUrl: 'ftp://relay.example.test' },
|
||||
])('rejects ambiguous endpoint pairs', (input) => {
|
||||
expect(() => normalizeBuzzEndpoints(input)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBuzzCommandEnvironment', () => {
|
||||
it('passes only process-discovery essentials and strips credentials', () => {
|
||||
expect(
|
||||
buildBuzzCommandEnvironment({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
LANG: 'en_US.UTF-8',
|
||||
BUZZ_PRIVATE_KEY: 'must-not-pass',
|
||||
BUZZ_AUTH_TAG: 'must-not-pass',
|
||||
OPENAI_API_KEY: 'must-not-pass',
|
||||
})
|
||||
).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
LANG: 'en_US.UTF-8',
|
||||
NO_COLOR: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuzzCompatibilityService', () => {
|
||||
it('verifies relay, identity, membership, and read capability without a mutation', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([]));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async (reference) =>
|
||||
reference === 'env:BUZZ_PRIVATE_KEY' ? 'private-secret' : undefined,
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await service.probe(BASE_CONFIG);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test/team',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test/team',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test/team',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: expect.stringMatching(/^[a-f0-9]{12}$/),
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
contract: {
|
||||
software: 'https://github.com/block/buzz',
|
||||
version: '0.4.24',
|
||||
},
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://relay.example.test/team/query',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ kinds: [39000], limit: 1 }]),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://relay.example.test/team/query',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ kinds: [9], limit: 1 }]),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain('private-secret');
|
||||
expect(JSON.stringify(result)).not.toContain('signed-event');
|
||||
});
|
||||
|
||||
it('uses the built-in Buzz signer for the authenticated read-only probe', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([]));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => SIGNING_KEY,
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
const result = await service.probe({
|
||||
...BASE_CONFIG,
|
||||
publicKey: SIGNING_PUBLIC_KEY,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
checks: {
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
});
|
||||
|
||||
const queryCall = fetch.mock.calls[1];
|
||||
const authorization = new Headers(queryCall?.[1]?.headers).get('authorization');
|
||||
expect(authorization).toMatch(/^Nostr [A-Za-z0-9+/]+=*$/);
|
||||
if (!authorization) throw new Error('Expected Buzz NIP-98 authorization');
|
||||
const event = JSON.parse(
|
||||
Buffer.from(authorization.slice('Nostr '.length), 'base64').toString('utf8')
|
||||
);
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
expect(event).toMatchObject({
|
||||
kind: 27_235,
|
||||
pubkey: SIGNING_PUBLIC_KEY,
|
||||
content: '',
|
||||
tags: expect.arrayContaining([
|
||||
['u', 'https://relay.example.test/team/query'],
|
||||
['method', 'POST'],
|
||||
['payload', expect.stringMatching(/^[a-f0-9]{64}$/)],
|
||||
['nonce', expect.stringMatching(/^[a-f0-9-]{36}$/)],
|
||||
]),
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(SIGNING_KEY);
|
||||
expect(JSON.stringify(queryCall)).not.toContain(SIGNING_KEY);
|
||||
});
|
||||
|
||||
it('classifies relay-side authentication rejection without attempting reads', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response({ error: 'invalid_or_expired_auth' }, 401));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unauthorized',
|
||||
reasonCode: 'authentication_rejected',
|
||||
checks: {
|
||||
authentication: 'failed',
|
||||
channelRead: 'unverified',
|
||||
messageRead: 'unverified',
|
||||
},
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('classifies membership denial independently from authentication', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(
|
||||
response(
|
||||
{
|
||||
error: 'relay_membership_required',
|
||||
message: 'You must be a relay member',
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'not_member',
|
||||
reasonCode: 'relay_membership_required',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'unverified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a verified channel read when message reads are denied', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response({ error: 'read_denied' }, 403));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unauthorized',
|
||||
reasonCode: 'read_capability_rejected',
|
||||
checks: {
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fails before network access when the expected community does not match', async () => {
|
||||
const fetch = vi.fn();
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
await service.probe({
|
||||
...BASE_CONFIG,
|
||||
expectedCommunity: 'other.example.test',
|
||||
})
|
||||
).toMatchObject({
|
||||
status: 'misconfigured',
|
||||
reasonCode: 'community_mismatch',
|
||||
checks: { communityBinding: 'failed' },
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates a referenced NIP-OA auth tag before forwarding it', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([]));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async (reference) =>
|
||||
reference === 'env:BUZZ_AUTH_TAG'
|
||||
? JSON.stringify(JSON.parse(AUTH_TAG), null, 2)
|
||||
: 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe({ ...BASE_CONFIG, authTagRef: 'env:BUZZ_AUTH_TAG' })).toMatchObject({
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'x-auth-tag': AUTH_TAG }),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'x-auth-tag': AUTH_TAG }),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'not-json',
|
||||
JSON.stringify(['auth', 'CD'.repeat(32), '', 'ef'.repeat(64)]),
|
||||
JSON.stringify(['auth', 'cd'.repeat(32), 'kind=01', 'ef'.repeat(64)]),
|
||||
JSON.stringify(['auth', 'cd'.repeat(32), '', 'ef'.repeat(64), 'extra']),
|
||||
'x'.repeat(1_025),
|
||||
])('fails closed on an invalid referenced NIP-OA auth tag', async (authTag) => {
|
||||
const fetch = vi.fn().mockResolvedValue(response(relayInfo()));
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async (reference) =>
|
||||
reference === 'env:BUZZ_AUTH_TAG' ? authTag : 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
const result = await service.probe({ ...BASE_CONFIG, authTagRef: 'env:BUZZ_AUTH_TAG' });
|
||||
expect(result).toMatchObject({ status: 'misconfigured', reasonCode: 'auth_tag_invalid' });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(result)).not.toContain(authTag);
|
||||
});
|
||||
|
||||
it('distinguishes an invalid query response from invalid relay metadata', async () => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo()))
|
||||
.mockResolvedValueOnce(response({ unexpected: true })),
|
||||
resolveSecret: async () => 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: PUBLIC_KEY,
|
||||
}),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unsupported',
|
||||
reasonCode: 'query_response_invalid',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'unverified',
|
||||
messageRead: 'unverified',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed on unsupported builds while retaining safe relay evidence', async () => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(response(relayInfo({ version: '0.5.0' }))),
|
||||
resolveSecret: async () => 'must-not-be-read',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unsupported',
|
||||
reasonCode: 'relay_version_unsupported',
|
||||
contract: { version: '0.5.0' },
|
||||
checks: { relayIdentity: 'verified', authentication: 'unverified' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak secrets from signer or relay errors', async () => {
|
||||
const secret = 'nsec1thismustneverappear';
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(response(relayInfo())),
|
||||
resolveSecret: async () => secret,
|
||||
signer: {
|
||||
sign: vi.fn().mockRejectedValue(new Error(`invalid private key ${secret}`)),
|
||||
},
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
const result = await service.probe(BASE_CONFIG);
|
||||
expect(result.status).toBe('unreachable');
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('invalidates evidence when identity, reference, command, or contract changes', async () => {
|
||||
const probe = async (
|
||||
change: Partial<typeof BASE_CONFIG> & { command?: { executable: string } } = {},
|
||||
version = '0.4.24',
|
||||
observedPublicKey = change.publicKey ?? PUBLIC_KEY
|
||||
) => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(relayInfo({ version })))
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([])),
|
||||
resolveSecret: async () => 'private-secret',
|
||||
signer: {
|
||||
sign: vi.fn().mockResolvedValue({
|
||||
authorization: 'Nostr signed-event',
|
||||
publicKey: observedPublicKey,
|
||||
}),
|
||||
},
|
||||
runCommand: vi.fn().mockResolvedValue({ stdout: 'buzz 0.4.24', stderr: '' }),
|
||||
});
|
||||
return service.probe({ ...BASE_CONFIG, ...change });
|
||||
};
|
||||
|
||||
const baseline = await probe();
|
||||
expect((await probe({ publicKey: 'cd'.repeat(32) })).evidenceKey).not.toBe(
|
||||
baseline.evidenceKey
|
||||
);
|
||||
expect((await probe({ credentialRef: 'env:OTHER_KEY' })).evidenceKey).not.toBe(
|
||||
baseline.evidenceKey
|
||||
);
|
||||
expect((await probe({ command: { executable: '/opt/buzz' } })).evidenceKey).not.toBe(
|
||||
baseline.evidenceKey
|
||||
);
|
||||
expect((await probe({}, '0.5.0')).evidenceKey).not.toBe(baseline.evidenceKey);
|
||||
const rotatedIdentity = await probe({}, '0.4.24', 'cd'.repeat(32));
|
||||
expect(rotatedIdentity).toMatchObject({
|
||||
status: 'misconfigured',
|
||||
reasonCode: 'public_key_mismatch',
|
||||
});
|
||||
expect(rotatedIdentity.evidenceKey).not.toBe(baseline.evidenceKey);
|
||||
});
|
||||
|
||||
it('bounds relay response bodies', async () => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(response('x'.repeat(65))),
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
maxResponseBytes: 64,
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unsupported',
|
||||
reasonCode: 'response_too_large',
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces a request timeout without exposing the signing-key reference value', async () => {
|
||||
const fetch = vi.fn(
|
||||
async (_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
||||
);
|
||||
})
|
||||
);
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await service.probe(BASE_CONFIG);
|
||||
expect(result).toMatchObject({
|
||||
status: 'unreachable',
|
||||
reasonCode: 'relay_unreachable',
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('unused');
|
||||
});
|
||||
|
||||
it('enforces the timeout while a relay response body is stalled', async () => {
|
||||
const stalledBody = new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Intentionally never enqueue or close.
|
||||
},
|
||||
});
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(
|
||||
new Response(stalledBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
),
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
expect(await service.probe(BASE_CONFIG)).toMatchObject({
|
||||
status: 'unreachable',
|
||||
reasonCode: 'relay_unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the private-network opt-in reach link-local metadata ranges', async () => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
await service.probe({
|
||||
...BASE_CONFIG,
|
||||
relayHttpUrl: 'http://169.254.170.2',
|
||||
expectedCommunity: '169.254.170.2',
|
||||
allowPrivateNetwork: true,
|
||||
})
|
||||
).toMatchObject({
|
||||
status: 'unreachable',
|
||||
reasonCode: 'network_policy_blocked',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an explicit local or private-network opt-in for plaintext HTTP', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(null);
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch,
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
await service.probe({
|
||||
...BASE_CONFIG,
|
||||
relayHttpUrl: 'http://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
})
|
||||
).toMatchObject({
|
||||
status: 'unreachable',
|
||||
reasonCode: 'network_policy_blocked',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://relay.example.test/info',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ allowHttp: false })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/opt/buzz/bin/buzz-agent', ['--profile', 'linux']],
|
||||
['/Applications/Buzz.app/Contents/MacOS/Buzz', ['--profile', 'macOS']],
|
||||
['C:\\Program Files\\Buzz\\buzz.exe', ['--profile', 'Local Profile']],
|
||||
])(
|
||||
'passes cross-platform executable path %s and argv without a shell',
|
||||
async (executable, args) => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: 'buzz 0.4.24', stderr: '' });
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(response(relayInfo({ version: '0.5.0' }))),
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand,
|
||||
});
|
||||
|
||||
await service.probe({
|
||||
...BASE_CONFIG,
|
||||
command: {
|
||||
executable,
|
||||
args,
|
||||
},
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith(executable, [...args, '--version']);
|
||||
}
|
||||
);
|
||||
|
||||
it('strips terminal controls from optional command diagnostics', async () => {
|
||||
const service = new BuzzCompatibilityService({
|
||||
fetch: vi.fn().mockResolvedValue(response(relayInfo({ version: '0.5.0' }))),
|
||||
resolveSecret: async () => 'unused',
|
||||
runCommand: vi.fn().mockResolvedValue({ stdout: '\u001b[2Jbuzz 0.4.24', stderr: '' }),
|
||||
});
|
||||
|
||||
const result = await service.probe(BASE_CONFIG);
|
||||
expect(result.commands[0]).toMatchObject({
|
||||
available: true,
|
||||
version: 'buzz 0.4.24',
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('\u001b');
|
||||
});
|
||||
});
|
||||
82
server/src/__tests__/buzz-nip98-signer.test.ts
Normal file
82
server/src/__tests__/buzz-nip98-signer.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { verifyEvent, type VerifiedEvent } from 'nostr-tools';
|
||||
import { NostrToolsBuzzNip98Signer } from '../services/buzz-nip98-signer.js';
|
||||
|
||||
const PRIVATE_KEY_HEX = `${'0'.repeat(63)}1`;
|
||||
const PRIVATE_KEY_NSEC = 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl';
|
||||
const PUBLIC_KEY_HEX = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||
const QUERY_BODY = '[{"kinds":[39000],"limit":1},{"kinds":[9],"limit":1}]';
|
||||
const QUERY_BODY_SHA256 = '3cf7aa0af5a4e941e20c0e001869ee85e8c5888566b30c03696c1d52fdbb2355';
|
||||
const NONCE = '03ef8155-d4c5-4f67-9cb8-bfcf94ed702c';
|
||||
|
||||
function decodeAuthorization(value: string): VerifiedEvent {
|
||||
expect(value.startsWith('Nostr ')).toBe(true);
|
||||
return JSON.parse(Buffer.from(value.slice('Nostr '.length), 'base64').toString('utf8'));
|
||||
}
|
||||
|
||||
function signer(): NostrToolsBuzzNip98Signer {
|
||||
return new NostrToolsBuzzNip98Signer({
|
||||
now: () => 1_770_000_000_000,
|
||||
createNonce: () => NONCE,
|
||||
});
|
||||
}
|
||||
|
||||
describe('NostrToolsBuzzNip98Signer', () => {
|
||||
it('creates the exact Buzz NIP-98 proof for a hexadecimal private key', async () => {
|
||||
const result = await signer().sign({
|
||||
privateKey: PRIVATE_KEY_HEX,
|
||||
method: 'POST',
|
||||
url: 'https://relay.example.test/team/query',
|
||||
body: QUERY_BODY,
|
||||
});
|
||||
const event = decodeAuthorization(result.authorization);
|
||||
|
||||
expect(result.publicKey).toBe(PUBLIC_KEY_HEX);
|
||||
expect(event).toMatchObject({
|
||||
kind: 27_235,
|
||||
created_at: 1_770_000_000,
|
||||
content: '',
|
||||
pubkey: PUBLIC_KEY_HEX,
|
||||
tags: [
|
||||
['u', 'https://relay.example.test/team/query'],
|
||||
['method', 'POST'],
|
||||
['nonce', NONCE],
|
||||
['payload', QUERY_BODY_SHA256],
|
||||
],
|
||||
});
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the equivalent nsec identity without changing the public contract', async () => {
|
||||
const result = await signer().sign({
|
||||
privateKey: PRIVATE_KEY_NSEC,
|
||||
method: 'POST',
|
||||
url: 'https://relay.example.test/query',
|
||||
body: '[]',
|
||||
});
|
||||
|
||||
expect(result.publicKey).toBe(PUBLIC_KEY_HEX);
|
||||
expect(verifyEvent(decodeAuthorization(result.authorization))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['not-a-private-key', '1'.repeat(63), '0'.repeat(64), `nsec1${'secret'.repeat(20)}`])(
|
||||
'rejects invalid private material without echoing it',
|
||||
async (privateKey) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
await signer().sign({
|
||||
privateKey,
|
||||
method: 'POST',
|
||||
url: 'https://relay.example.test/query',
|
||||
body: '[]',
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('Invalid Buzz private key format');
|
||||
expect((error as Error).message).not.toContain(privateKey);
|
||||
}
|
||||
);
|
||||
});
|
||||
107
server/src/__tests__/communication-adapter-schemas.test.ts
Normal file
107
server/src/__tests__/communication-adapter-schemas.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buzzAdapterConfigSchema,
|
||||
communicationAdapterConfigSchema,
|
||||
} from '../schemas/communication-adapter-schemas.js';
|
||||
|
||||
describe('communication adapter schemas', () => {
|
||||
it('accepts a reference-only Buzz configuration', () => {
|
||||
expect(
|
||||
buzzAdapterConfigSchema.parse({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test/team',
|
||||
relayWebSocketUrl: 'wss://relay.example.test/team',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagRef: 'env:BUZZ_AUTH_TAG',
|
||||
command: {
|
||||
executable: 'C:\\Program Files\\Buzz\\buzz.exe',
|
||||
args: ['--json'],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'buzz',
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts explicit nulls to clear optional Buzz configuration', () => {
|
||||
expect(
|
||||
buzzAdapterConfigSchema.parse({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
relayWebSocketUrl: null,
|
||||
expectedCommunity: null,
|
||||
authTagRef: null,
|
||||
command: null,
|
||||
})
|
||||
).toMatchObject({
|
||||
relayWebSocketUrl: null,
|
||||
expectedCommunity: null,
|
||||
authTagRef: null,
|
||||
command: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['raw secret field', { credential: 'secret' }],
|
||||
['URL userinfo', { relayHttpUrl: 'https://user:secret@relay.example.test' }],
|
||||
['URL query', { relayHttpUrl: 'https://relay.example.test?token=secret' }],
|
||||
['ambiguous secret reference', { credentialRef: 'BUZZ_PRIVATE_KEY' }],
|
||||
['invalid public key', { publicKey: 'npub-not-accepted-here' }],
|
||||
['unknown field', { apiTokenRef: 'env:BUZZ_TOKEN' }],
|
||||
[
|
||||
'credential-bearing command argument',
|
||||
{ command: { executable: 'buzz', args: ['--private-key', 'nsec1secret'] } },
|
||||
],
|
||||
[
|
||||
'credential-bearing executable path',
|
||||
{ command: { executable: '/tmp/nsec1secret/buzz', args: [] } },
|
||||
],
|
||||
[
|
||||
'bare hexadecimal private key argument',
|
||||
{ command: { executable: 'buzz', args: ['ab'.repeat(32)] } },
|
||||
],
|
||||
[
|
||||
'serialized NIP-OA auth tag argument',
|
||||
{
|
||||
command: {
|
||||
executable: 'buzz',
|
||||
args: [JSON.stringify(['auth', 'ab'.repeat(32), 'kind=9', 'cd'.repeat(64)])],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'shell executable with user-controlled arguments',
|
||||
{ command: { executable: '/bin/sh', args: ['-c', 'echo unsafe'] } },
|
||||
],
|
||||
['command control characters', { command: { executable: 'buzz\u001b[31m', args: [] } }],
|
||||
['relay URL control characters', { relayHttpUrl: 'https://relay.example.test/\u001b' }],
|
||||
])('rejects %s', (_name, change) => {
|
||||
const result = buzzAdapterConfigSchema.safeParse({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
...change,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the existing Microsoft Teams input compatible', () => {
|
||||
expect(
|
||||
communicationAdapterConfigSchema.parse({
|
||||
displayName: 'Microsoft Teams',
|
||||
deliveryMode: 'webhook',
|
||||
webhookUrl: 'https://example.test/hook',
|
||||
credential: 'write-only-secret',
|
||||
})
|
||||
).toMatchObject({
|
||||
displayName: 'Microsoft Teams',
|
||||
deliveryMode: 'webhook',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
|||
import type { ChatService } from '../services/chat-service.js';
|
||||
import type { OutboundIntegrationService } from '../services/outbound-integration-service.js';
|
||||
import { CommunicationAdapterService } from '../services/communication-adapter-service.js';
|
||||
import type { BuzzCompatibilityService } from '../services/buzz-compatibility-service.js';
|
||||
|
||||
describe('CommunicationAdapterService', () => {
|
||||
let tmpDir: string;
|
||||
|
|
@ -180,4 +181,222 @@ describe('CommunicationAdapterService', () => {
|
|||
expect(chatService.sendSquadMessage).not.toHaveBeenCalled();
|
||||
expect(await service.listMappings('msteams-default')).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores only Buzz secret references and returns exact probe health', async () => {
|
||||
const buzzCompatibility = {
|
||||
probe: vi.fn().mockResolvedValue({
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'Buzz relay and read capabilities are compatible.',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [],
|
||||
evidenceKey: 'evidence-key',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
}),
|
||||
} as unknown as BuzzCompatibilityService;
|
||||
service = new CommunicationAdapterService({
|
||||
storageDir: tmpDir,
|
||||
persist: true,
|
||||
chatService: chatService as ChatService,
|
||||
outboundIntegrations: outboundIntegrations as OutboundIntegrationService,
|
||||
buzzCompatibility,
|
||||
audit,
|
||||
});
|
||||
|
||||
const adapter = await service.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
enabled: true,
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagRef: 'env:BUZZ_AUTH_TAG',
|
||||
});
|
||||
const health = await service.checkHealth('buzz-default');
|
||||
const persisted = await fs.readFile(path.join(tmpDir, 'state.json'), 'utf-8');
|
||||
|
||||
expect(adapter).toMatchObject({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagConfigured: true,
|
||||
hasCredential: true,
|
||||
});
|
||||
expect(adapter.relayWebSocketUrl).toBeUndefined();
|
||||
expect(health).toMatchObject({
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
});
|
||||
expect(persisted).toContain('env:BUZZ_PRIVATE_KEY');
|
||||
expect(persisted).not.toContain('nsec');
|
||||
expect(persisted).not.toContain('raw-access-token');
|
||||
|
||||
const cleared = await service.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
relayWebSocketUrl: null,
|
||||
expectedCommunity: null,
|
||||
authTagRef: null,
|
||||
command: null,
|
||||
});
|
||||
expect(cleared).toMatchObject({
|
||||
authTagConfigured: false,
|
||||
});
|
||||
expect(cleared.relayWebSocketUrl).toBeUndefined();
|
||||
expect(cleared.expectedCommunity).toBeUndefined();
|
||||
expect(cleared.authTagRef).toBeUndefined();
|
||||
expect(cleared.command).toBeUndefined();
|
||||
});
|
||||
|
||||
it('quarantines an invalid persisted Buzz command before it can execute', async () => {
|
||||
await service.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
enabled: true,
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
command: { executable: '/opt/buzz/bin/buzz-agent', args: ['--profile', 'safe'] },
|
||||
});
|
||||
const statePath = path.join(tmpDir, 'state.json');
|
||||
const persisted = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
||||
persisted.adapters['buzz-default'].command = {
|
||||
executable: '/bin/sh',
|
||||
args: ['-c', 'echo unsafe'],
|
||||
};
|
||||
await fs.writeFile(statePath, JSON.stringify(persisted));
|
||||
|
||||
const probe = vi.fn();
|
||||
const reloaded = new CommunicationAdapterService({
|
||||
storageDir: tmpDir,
|
||||
persist: true,
|
||||
chatService: chatService as ChatService,
|
||||
outboundIntegrations: outboundIntegrations as OutboundIntegrationService,
|
||||
buzzCompatibility: { probe } as unknown as BuzzCompatibilityService,
|
||||
audit,
|
||||
});
|
||||
|
||||
expect(await reloaded.getAdapter('buzz-default')).toMatchObject({
|
||||
kind: 'buzz',
|
||||
enabled: false,
|
||||
hasCredential: false,
|
||||
});
|
||||
expect((await reloaded.getAdapter('buzz-default'))?.command).toBeUndefined();
|
||||
expect(await reloaded.checkHealth('buzz-default')).toMatchObject({
|
||||
status: 'misconfigured',
|
||||
reasonCode: 'configuration_invalid',
|
||||
});
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(statePath, 'utf8')).not.toContain('/bin/sh');
|
||||
});
|
||||
|
||||
it('invalidates persisted Buzz compatibility from an older probe contract', async () => {
|
||||
const buzzCompatibility = {
|
||||
probe: vi.fn().mockResolvedValue({
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'compatible',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [],
|
||||
evidenceKey: 'evidence-key',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
}),
|
||||
} as unknown as BuzzCompatibilityService;
|
||||
const configured = new CommunicationAdapterService({
|
||||
storageDir: tmpDir,
|
||||
persist: true,
|
||||
chatService: chatService as ChatService,
|
||||
outboundIntegrations: outboundIntegrations as OutboundIntegrationService,
|
||||
buzzCompatibility,
|
||||
audit,
|
||||
});
|
||||
await configured.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
});
|
||||
await configured.checkHealth('buzz-default');
|
||||
|
||||
const statePath = path.join(tmpDir, 'state.json');
|
||||
const persisted = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
||||
persisted.adapters['buzz-default'].compatibility.probeRevision = 0;
|
||||
await fs.writeFile(statePath, JSON.stringify(persisted));
|
||||
|
||||
const reloaded = new CommunicationAdapterService({
|
||||
storageDir: tmpDir,
|
||||
persist: true,
|
||||
chatService: chatService as ChatService,
|
||||
outboundIntegrations: outboundIntegrations as OutboundIntegrationService,
|
||||
audit,
|
||||
});
|
||||
const adapter = await reloaded.getAdapter('buzz-default');
|
||||
expect(adapter?.compatibility).toBeUndefined();
|
||||
expect(adapter?.lastHealth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed for Buzz sends while leaving Microsoft Teams behavior intact', async () => {
|
||||
await service.configureAdapter('buzz-default', {
|
||||
kind: 'buzz',
|
||||
enabled: true,
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
});
|
||||
|
||||
const result = await service.send('buzz-default', {
|
||||
target: { kind: 'squad', squadMessageId: 'message-1' },
|
||||
message: 'must not leave Veritas',
|
||||
});
|
||||
|
||||
expect(result.delivery).toMatchObject({
|
||||
operation: 'send',
|
||||
status: 'blocked',
|
||||
error: expect.stringContaining('not implemented'),
|
||||
});
|
||||
expect(await service.listMappings('buzz-default')).toEqual([]);
|
||||
|
||||
const poll = await service.pollReplies('buzz-default');
|
||||
expect(poll.delivery).toMatchObject({
|
||||
operation: 'poll',
|
||||
status: 'skipped',
|
||||
error: expect.stringContaining('Buzz reply polling is not implemented'),
|
||||
});
|
||||
expect(outboundIntegrations.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -323,6 +323,100 @@ describe('integrations routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('configures a reference-only Buzz adapter and runs a read-only test probe', async () => {
|
||||
const buzzRecord = {
|
||||
id: 'buzz-default',
|
||||
kind: 'buzz',
|
||||
displayName: 'Buzz',
|
||||
enabled: true,
|
||||
deliveryMode: 'manual',
|
||||
replyMode: 'ingest-api',
|
||||
destinationType: 'channel',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
relayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
publicKeyFingerprint: 'public-fp',
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagConfigured: false,
|
||||
hasCredential: true,
|
||||
createdAt: '2026-07-23T18:00:00.000Z',
|
||||
updatedAt: '2026-07-23T18:00:00.000Z',
|
||||
};
|
||||
mockCommunicationAdapters.configureAdapter.mockResolvedValueOnce(buzzRecord);
|
||||
|
||||
const configured = await request(app)
|
||||
.put('/api/integrations/communication/adapters/buzz-default')
|
||||
.send({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
});
|
||||
|
||||
expect(configured.status).toBe(200);
|
||||
expect(configured.body).toMatchObject({
|
||||
kind: 'buzz',
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
hasCredential: true,
|
||||
});
|
||||
expect(JSON.stringify(configured.body)).not.toContain('nsec');
|
||||
|
||||
mockCommunicationAdapters.getAdapter
|
||||
.mockResolvedValueOnce(buzzRecord)
|
||||
.mockResolvedValueOnce(buzzRecord);
|
||||
mockCommunicationAdapters.checkHealth.mockResolvedValueOnce({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:01:00.000Z',
|
||||
detail: 'Buzz read-only compatibility probe passed.',
|
||||
reasonCode: 'ok',
|
||||
});
|
||||
|
||||
const tested = await request(app)
|
||||
.post('/api/integrations/communication/adapters/buzz-default/test')
|
||||
.send({ message: 'must not be sent' });
|
||||
|
||||
expect(tested.status).toBe(200);
|
||||
expect(tested.body).toMatchObject({
|
||||
status: 'healthy',
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
});
|
||||
expect(mockCommunicationAdapters.checkHealth).toHaveBeenCalledWith('buzz-default');
|
||||
expect(mockCommunicationAdapters.send).not.toHaveBeenCalledWith(
|
||||
'buzz-default',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects raw Buzz credentials and API-token fields', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/communication/adapters/buzz-default')
|
||||
.send({
|
||||
kind: 'buzz',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
credential: 'raw-secret',
|
||||
apiTokenRef: 'env:BUZZ_TOKEN',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
});
|
||||
expect(mockCommunicationAdapters.configureAdapter).not.toHaveBeenCalledWith(
|
||||
'buzz-default',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('ingests communication replies and broadcasts the resulting squad message', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/integrations/communication/adapters/msteams-default/replies')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
agentRoutingAccess,
|
||||
agentTaskAccess,
|
||||
diffAccess,
|
||||
integrationsAccess,
|
||||
maintenanceAccess,
|
||||
policyAccess,
|
||||
previewAccess,
|
||||
|
|
@ -109,6 +110,36 @@ describe('v1 REST permission guard presets', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('allows Buzz diagnostics to settings readers and guards Buzz mutations', () => {
|
||||
expect(
|
||||
runGuard(
|
||||
integrationsAccess,
|
||||
mockRequest('GET', '/communication/adapters/buzz-default/health', 'read-only', [
|
||||
'settings:read',
|
||||
])
|
||||
).next
|
||||
).toHaveBeenCalled();
|
||||
|
||||
for (const path of [
|
||||
'/communication/adapters/buzz-default',
|
||||
'/communication/adapters/buzz-default/test',
|
||||
'/communication/adapters/buzz-default/disconnect',
|
||||
]) {
|
||||
const method = path.endsWith('buzz-default') ? 'PUT' : 'POST';
|
||||
const { res, next } = runGuard(
|
||||
integrationsAccess,
|
||||
mockRequest(method, path, 'read-only', ['settings:read'])
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ required: ['settings:write'] }),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires admin permission for destructive telemetry maintenance routes', () => {
|
||||
const { res, next } = runGuard(activityAccess, mockRequest('DELETE', '/'));
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ vi.mock('node:dns/promises', () => ({
|
|||
import { safeFetch, validateWebhookUrl } from '../utils/url-validation.js';
|
||||
|
||||
async function listenLocalServer(
|
||||
handler: Parameters<typeof createServer>[0]
|
||||
handler: Parameters<typeof createServer>[0],
|
||||
host = '127.0.0.1'
|
||||
): Promise<{ server: Server; port: number }> {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
server.listen(0, host, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
|
|
@ -77,6 +78,28 @@ describe('url validation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('connects to an explicitly allowed bracketed IPv6 loopback address', async () => {
|
||||
const { server, port } = await listenLocalServer((req, res) => {
|
||||
expect(req.headers.host).toBe(`[::1]:${port}`);
|
||||
res.writeHead(202, { 'content-type': 'text/plain' });
|
||||
res.end('accepted-ipv6');
|
||||
}, '::1');
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`http://[::1]:${port}/hook`,
|
||||
{ method: 'POST', body: 'payload' },
|
||||
{ allowHttp: true, allowLocalhost: true }
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(202);
|
||||
await expect(response?.text()).resolves.toBe('accepted-ipv6');
|
||||
expect(mockLookup).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['10.0.0.1', '172.16.0.1', '192.168.0.1'])(
|
||||
'does not treat allowLocalhost as private-network approval for %s',
|
||||
async (address) => {
|
||||
|
|
@ -106,6 +129,58 @@ describe('url validation', () => {
|
|||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['10.0.0.1', '172.16.0.1', '192.168.0.1', '[fd00::1]'])(
|
||||
'allows only private network classes through the narrow private-network option for %s',
|
||||
(address) => {
|
||||
expect(
|
||||
validateWebhookUrl(`https://${address}/hook`, {
|
||||
allowPrivateNetwork: true,
|
||||
logFailures: false,
|
||||
}).valid
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
'169.254.170.2',
|
||||
'100.64.0.1',
|
||||
'[fe80::1]',
|
||||
'[fe90::1]',
|
||||
'[fea0::1]',
|
||||
'[febf::1]',
|
||||
'[::ffff:a9fe:a9fe]',
|
||||
])(
|
||||
'keeps link-local and CGNAT destinations blocked under private-network approval for %s',
|
||||
(address) => {
|
||||
expect(
|
||||
validateWebhookUrl(`https://${address}/hook`, {
|
||||
allowPrivateNetwork: true,
|
||||
logFailures: false,
|
||||
}).valid
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['[::ffff:7f00:1]', { allowLocalhost: true }],
|
||||
['[::ffff:a00:1]', { allowPrivateNetwork: true }],
|
||||
])('requires the matching opt-in for IPv4-mapped IPv6 destination %s', (address, allowance) => {
|
||||
const url = `http://${address}/hook`;
|
||||
expect(validateWebhookUrl(url, { allowHttp: true, logFailures: false }).valid).toBe(false);
|
||||
expect(
|
||||
validateWebhookUrl(url, { allowHttp: true, logFailures: false, ...allowance }).valid
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks deprecated IPv4-compatible IPv6 local addresses', () => {
|
||||
expect(
|
||||
validateWebhookUrl('http://[::7f00:1]/hook', {
|
||||
allowHttp: true,
|
||||
logFailures: false,
|
||||
}).valid
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('pins outbound fetches to the validated DNS answer', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { z } from 'zod';
|
|||
import { ConfigService } from '../services/config-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { ForbiddenError, NotFoundError } from '../middleware/error-handler.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { hasPermission, type AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import type { CoolifyServiceConfig, CoolifyServicesConfig } from '@veritas-kanban/shared';
|
||||
import { broadcastSquadMessage } from '../services/broadcast-service.js';
|
||||
|
|
@ -18,6 +19,10 @@ import {
|
|||
DEFAULT_ADAPTER_ID,
|
||||
getCommunicationAdapterService,
|
||||
} from '../services/communication-adapter-service.js';
|
||||
import {
|
||||
communicationAdapterConfigSchema,
|
||||
type CommunicationAdapterConfig,
|
||||
} from '../schemas/communication-adapter-schemas.js';
|
||||
import { getOutboundIntegrationService } from '../services/outbound-integration-service.js';
|
||||
import { externalTrackerRoutes } from './external-trackers.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
|
|
@ -54,20 +59,6 @@ const replyTargetSchema = z.object({
|
|||
notificationId: z.string().optional(),
|
||||
});
|
||||
|
||||
const adapterConfigSchema = z.object({
|
||||
kind: z.enum(['msteams']).optional(),
|
||||
displayName: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
deliveryMode: z.enum(['manual', 'webhook']).optional(),
|
||||
destinationType: z.enum(['channel', 'direct']).optional(),
|
||||
tenantId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
channelId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
});
|
||||
|
||||
const sendSchema = z.object({
|
||||
target: replyTargetSchema,
|
||||
message: z.string().min(1),
|
||||
|
|
@ -271,9 +262,10 @@ router.get(
|
|||
// PUT /api/integrations/communication/adapters/:adapterId
|
||||
router.put(
|
||||
'/communication/adapters/:adapterId',
|
||||
validate({ body: communicationAdapterConfigSchema }),
|
||||
asyncHandler(async (req, res) => {
|
||||
const adapterId = adapterIdParam(req.params.adapterId);
|
||||
const input = adapterConfigSchema.parse(req.body);
|
||||
const input = req.validated?.body as CommunicationAdapterConfig;
|
||||
res.json(await communicationAdapters.configureAdapter(adapterId, input));
|
||||
})
|
||||
);
|
||||
|
|
@ -294,6 +286,11 @@ router.post(
|
|||
asyncHandler(async (req, res) => {
|
||||
const adapterId = adapterIdParam(req.params.adapterId);
|
||||
await ensureAdapterExists(adapterId);
|
||||
const adapter = await communicationAdapters.getAdapter(adapterId);
|
||||
if (adapter?.kind === 'buzz') {
|
||||
res.json(await communicationAdapters.checkHealth(adapterId));
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
typeof req.body?.message === 'string' && req.body.message.trim()
|
||||
? req.body.message
|
||||
|
|
|
|||
133
server/src/schemas/communication-adapter-schemas.ts
Normal file
133
server/src/schemas/communication-adapter-schemas.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const optionalTrimmed = (max: number) => z.string().trim().min(1).max(max).optional();
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
});
|
||||
}
|
||||
|
||||
const commandTextSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.refine(
|
||||
(value) => !hasControlCharacters(value),
|
||||
'Command values cannot contain control characters'
|
||||
);
|
||||
|
||||
function isBuzzExecutable(value: string): boolean {
|
||||
const executableName = value.split(/[\\/]/).at(-1)?.toLowerCase();
|
||||
return /^(?:buzz|buzz-acp|buzz-agent)(?:\.exe)?$/.test(executableName ?? '');
|
||||
}
|
||||
|
||||
const buzzCommandSchema = z
|
||||
.object({
|
||||
executable: commandTextSchema,
|
||||
args: z.array(commandTextSchema).max(64).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((command, context) => {
|
||||
if (!isBuzzExecutable(command.executable)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['executable'],
|
||||
message: 'Configured command must be a buzz, buzz-acp, or buzz-agent executable',
|
||||
});
|
||||
}
|
||||
const credentialPattern = /(?:private[-_]?key|secret|token|authorization|bearer|nsec1)/i;
|
||||
const secretHexPattern = /\b(?:[a-f0-9]{128}|[a-f0-9]{64})\b/i;
|
||||
const executableUnsafe =
|
||||
credentialPattern.test(command.executable) || secretHexPattern.test(command.executable);
|
||||
const argumentUnsafe = command.args?.some(
|
||||
(value) => credentialPattern.test(value) || secretHexPattern.test(value)
|
||||
);
|
||||
if (executableUnsafe || argumentUnsafe) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [executableUnsafe ? 'executable' : 'args'],
|
||||
message: 'Command configuration cannot contain credential material or credential flags',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const environmentReferenceSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^env:[A-Za-z_][A-Za-z0-9_]*$/, 'Secret references must use env:VARIABLE_NAME syntax');
|
||||
|
||||
const relayUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.refine((value) => !hasControlCharacters(value), 'Relay URL cannot contain control characters')
|
||||
.superRefine((value, context) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Relay URL must use http, https, ws, or wss',
|
||||
});
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Relay URL cannot contain credentials, a query string, or a fragment',
|
||||
});
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
context.addIssue({ code: 'custom', message: 'Relay URL must include a host' });
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({ code: 'custom', message: 'Relay URL is invalid' });
|
||||
}
|
||||
});
|
||||
|
||||
const teamsAdapterConfigSchema = z
|
||||
.object({
|
||||
kind: z.literal('msteams').optional(),
|
||||
displayName: optionalTrimmed(200),
|
||||
enabled: z.boolean().optional(),
|
||||
deliveryMode: z.enum(['manual', 'webhook']).optional(),
|
||||
destinationType: z.enum(['channel', 'direct']).optional(),
|
||||
tenantId: optionalTrimmed(500),
|
||||
teamId: optionalTrimmed(500),
|
||||
channelId: optionalTrimmed(500),
|
||||
chatId: optionalTrimmed(500),
|
||||
webhookUrl: optionalTrimmed(2048),
|
||||
credential: optionalTrimmed(4096),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const buzzAdapterConfigSchema = z
|
||||
.object({
|
||||
kind: z.literal('buzz'),
|
||||
displayName: optionalTrimmed(200),
|
||||
enabled: z.boolean().optional(),
|
||||
relayHttpUrl: relayUrlSchema,
|
||||
relayWebSocketUrl: relayUrlSchema.nullable().optional(),
|
||||
expectedCommunity: optionalTrimmed(500).nullable(),
|
||||
publicKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-fA-F0-9]{64}$/, 'Buzz public key must be 64 hexadecimal characters'),
|
||||
credentialRef: environmentReferenceSchema,
|
||||
authTagRef: environmentReferenceSchema.nullable().optional(),
|
||||
allowLocalhost: z.boolean().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
command: buzzCommandSchema.nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const communicationAdapterConfigSchema = z.union([
|
||||
buzzAdapterConfigSchema,
|
||||
teamsAdapterConfigSchema,
|
||||
]);
|
||||
|
||||
export type CommunicationAdapterConfig = z.infer<typeof communicationAdapterConfigSchema>;
|
||||
export type BuzzAdapterConfig = z.infer<typeof buzzAdapterConfigSchema>;
|
||||
|
|
@ -13,6 +13,7 @@ export * from './time-schemas.js';
|
|||
export * from './archive-schemas.js';
|
||||
export * from './config-schemas.js';
|
||||
export * from './agent-schemas.js';
|
||||
export * from './communication-adapter-schemas.js';
|
||||
export * from './auth-schemas.js';
|
||||
export * from './shared-resources-schemas.js';
|
||||
export * from './doc-freshness-schemas.js';
|
||||
|
|
|
|||
906
server/src/services/buzz-compatibility-service.ts
Normal file
906
server/src/services/buzz-compatibility-service.ts
Normal file
|
|
@ -0,0 +1,906 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type {
|
||||
BuzzCommandConfig,
|
||||
BuzzCommandDiagnostic,
|
||||
BuzzCompatibilityChecks,
|
||||
BuzzCompatibilityReasonCode,
|
||||
BuzzCompatibilityResult,
|
||||
BuzzCompatibilityStatus,
|
||||
BuzzRelayContract,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
BUZZ_COMPATIBILITY_SCHEMA_VERSION,
|
||||
BUZZ_PROBE_REVISION,
|
||||
BUZZ_TESTED_COMMIT,
|
||||
BUZZ_TESTED_RELEASE,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { redactString } from '../lib/redact.js';
|
||||
import { NostrToolsBuzzNip98Signer, type BuzzNip98Signer } from './buzz-nip98-signer.js';
|
||||
import { EnvironmentCredentialSecretSource } from './credential-broker-service.js';
|
||||
import { safeFetch, type UrlValidationOptions } from '../utils/url-validation.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const BUZZ_REPOSITORY = 'https://github.com/block/buzz';
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const MAX_AUTH_TAG_BYTES = 1_024;
|
||||
const REQUIRED_NIPS = [11, 29, 42];
|
||||
const COMMAND_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'Path',
|
||||
'PATHEXT',
|
||||
'SystemRoot',
|
||||
'SYSTEMROOT',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
] as const;
|
||||
|
||||
export interface BuzzProbeConfig {
|
||||
enabled: boolean;
|
||||
relayHttpUrl: string;
|
||||
relayWebSocketUrl?: string;
|
||||
expectedCommunity?: string;
|
||||
publicKey: string;
|
||||
credentialRef: string;
|
||||
authTagRef?: string;
|
||||
allowLocalhost?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
command?: BuzzCommandConfig;
|
||||
}
|
||||
|
||||
export interface NormalizedBuzzEndpoints {
|
||||
configuredHttpUrl: string;
|
||||
configuredWebSocketUrl?: string;
|
||||
httpUrl: string;
|
||||
webSocketUrl: string;
|
||||
community: string;
|
||||
expectedCommunity?: string;
|
||||
}
|
||||
|
||||
export interface BuzzCommandRunner {
|
||||
(executable: string, args: string[]): Promise<{ stdout: string; stderr: string }>;
|
||||
}
|
||||
|
||||
export function buildBuzzCommandEnvironment(
|
||||
environment: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = { NO_COLOR: '1' };
|
||||
for (const key of COMMAND_ENV_ALLOWLIST) {
|
||||
const value = environment[key];
|
||||
if (typeof value === 'string') result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface BuzzCompatibilityServiceOptions {
|
||||
fetch?: (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
validationOptions?: UrlValidationOptions
|
||||
) => Promise<Response | null>;
|
||||
resolveSecret?: (reference: string) => Promise<string | undefined>;
|
||||
signer?: BuzzNip98Signer;
|
||||
runCommand?: BuzzCommandRunner;
|
||||
now?: () => Date;
|
||||
timeoutMs?: number;
|
||||
maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
class BuzzProbeError extends Error {
|
||||
constructor(
|
||||
readonly status: BuzzCompatibilityStatus,
|
||||
readonly reasonCode: BuzzCompatibilityReasonCode,
|
||||
message: string,
|
||||
readonly remediation?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(pathname: string): string {
|
||||
if (pathname === '/') return '';
|
||||
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
function normalizedAuthority(url: URL): string {
|
||||
let hostname = url.hostname.toLowerCase();
|
||||
if (hostname.endsWith('.')) hostname = hostname.slice(0, -1);
|
||||
return `${hostname}${url.port ? `:${url.port}` : ''}`;
|
||||
}
|
||||
|
||||
function normalizeCommunity(value: string): string {
|
||||
const candidate = value.includes('://') ? value : `https://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'endpoint_invalid',
|
||||
'Expected community is not a valid host or URL.',
|
||||
'Use the Buzz relay host, with an explicit non-default port when required.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
trimTrailingSlash(parsed.pathname)
|
||||
) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'endpoint_invalid',
|
||||
'Expected community must identify only a host and optional port.',
|
||||
'Remove credentials, path, query, and fragment components.'
|
||||
);
|
||||
}
|
||||
return normalizedAuthority(parsed);
|
||||
}
|
||||
|
||||
function parseRelayUrl(value: string, label: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'endpoint_invalid',
|
||||
`${label} is not a valid URL.`,
|
||||
'Use an http, https, ws, or wss Buzz relay URL.'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol) ||
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'endpoint_invalid',
|
||||
`${label} contains an unsupported or ambiguous component.`,
|
||||
'Use an http, https, ws, or wss URL without credentials, query, or fragment.'
|
||||
);
|
||||
}
|
||||
parsed.pathname = trimTrailingSlash(parsed.pathname);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function baseUrl(url: URL): string {
|
||||
return `${url.protocol}//${url.host}${trimTrailingSlash(url.pathname)}`;
|
||||
}
|
||||
|
||||
function withProtocol(url: URL, protocol: 'http:' | 'https:' | 'ws:' | 'wss:'): URL {
|
||||
const copy = new URL(url.toString());
|
||||
copy.protocol = protocol;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function httpProtocol(protocol: string): 'http:' | 'https:' {
|
||||
return protocol === 'https:' || protocol === 'wss:' ? 'https:' : 'http:';
|
||||
}
|
||||
|
||||
function webSocketProtocol(protocol: string): 'ws:' | 'wss:' {
|
||||
return protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:';
|
||||
}
|
||||
|
||||
export function normalizeBuzzEndpoints(input: {
|
||||
relayHttpUrl: string;
|
||||
relayWebSocketUrl?: string;
|
||||
expectedCommunity?: string;
|
||||
}): NormalizedBuzzEndpoints {
|
||||
const primary = parseRelayUrl(input.relayHttpUrl, 'Buzz relay HTTP URL');
|
||||
const http = withProtocol(primary, httpProtocol(primary.protocol));
|
||||
const derivedWebSocket = withProtocol(primary, webSocketProtocol(primary.protocol));
|
||||
|
||||
let webSocket = derivedWebSocket;
|
||||
if (input.relayWebSocketUrl) {
|
||||
const configuredWebSocket = parseRelayUrl(input.relayWebSocketUrl, 'Buzz relay WebSocket URL');
|
||||
webSocket = withProtocol(configuredWebSocket, webSocketProtocol(configuredWebSocket.protocol));
|
||||
const wsAsHttp = withProtocol(webSocket, httpProtocol(webSocket.protocol));
|
||||
if (
|
||||
normalizedAuthority(http) !== normalizedAuthority(wsAsHttp) ||
|
||||
trimTrailingSlash(http.pathname) !== trimTrailingSlash(wsAsHttp.pathname) ||
|
||||
(http.protocol === 'https:') !== (wsAsHttp.protocol === 'https:')
|
||||
) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'endpoint_mismatch',
|
||||
'Buzz HTTP and WebSocket endpoints do not identify the same relay authority and path.',
|
||||
'Configure matching http/ws or https/wss endpoints for the same host, port, and path.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const community = normalizedAuthority(http);
|
||||
const expectedCommunity = input.expectedCommunity
|
||||
? normalizeCommunity(input.expectedCommunity)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
configuredHttpUrl: input.relayHttpUrl,
|
||||
configuredWebSocketUrl: input.relayWebSocketUrl,
|
||||
httpUrl: baseUrl(http),
|
||||
webSocketUrl: baseUrl(webSocket),
|
||||
community,
|
||||
expectedCommunity,
|
||||
};
|
||||
}
|
||||
|
||||
export function fingerprintBuzzPublicKey(publicKey: string): string {
|
||||
return createHash('sha256').update(publicKey.toLowerCase()).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function endpoint(base: string, path: string): string {
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
function emptyChecks(): BuzzCompatibilityChecks {
|
||||
return {
|
||||
relayIdentity: 'unverified',
|
||||
communityBinding: 'unverified',
|
||||
configuredIdentity: 'unverified',
|
||||
authentication: 'unverified',
|
||||
membership: 'unverified',
|
||||
channelRead: 'unverified',
|
||||
messageRead: 'unverified',
|
||||
};
|
||||
}
|
||||
|
||||
function stripTerminalControlSequences(value: string): string {
|
||||
let result = '';
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x1b) {
|
||||
index += 1;
|
||||
const introducer = value[index];
|
||||
if (introducer === '[') {
|
||||
index += 1;
|
||||
while (index < value.length) {
|
||||
const sequenceCode = value.charCodeAt(index);
|
||||
index += 1;
|
||||
if (sequenceCode >= 0x40 && sequenceCode <= 0x7e) break;
|
||||
}
|
||||
} else if (introducer === ']') {
|
||||
index += 1;
|
||||
while (index < value.length) {
|
||||
if (value.charCodeAt(index) === 0x07) {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (value.charCodeAt(index) === 0x1b && value.charCodeAt(index + 1) === 0x5c) {
|
||||
index += 2;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
} else if (index < value.length) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
result += value[index];
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeDetail(value: unknown): string {
|
||||
const raw = value instanceof Error ? value.message : String(value);
|
||||
const withoutControls = stripTerminalControlSequences(raw);
|
||||
return redactString(withoutControls)
|
||||
.replace(/nsec1[a-z0-9]+/gi, '[REDACTED]')
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
function sanitizeConfiguredUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return baseUrl(parsed);
|
||||
} catch {
|
||||
return '[invalid-url]';
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeConfiguredCommunity(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return normalizeCommunity(value);
|
||||
} catch {
|
||||
return '[invalid-community]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseCanonicalDecimal(value: string, max: number): number | undefined {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed <= max ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isValidAuthTagConditions(value: string): boolean {
|
||||
if (!value) return true;
|
||||
return value.split('&').every((clause) => {
|
||||
if (clause.startsWith('kind=')) {
|
||||
return parseCanonicalDecimal(clause.slice('kind='.length), 65_535) !== undefined;
|
||||
}
|
||||
if (clause.startsWith('created_at<')) {
|
||||
return parseCanonicalDecimal(clause.slice('created_at<'.length), 4_294_967_295) !== undefined;
|
||||
}
|
||||
if (clause.startsWith('created_at>')) {
|
||||
return parseCanonicalDecimal(clause.slice('created_at>'.length), 4_294_967_295) !== undefined;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBuzzAuthTag(value: string): string {
|
||||
if (Buffer.byteLength(value, 'utf8') > MAX_AUTH_TAG_BYTES) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'auth_tag_invalid',
|
||||
'The configured Buzz NIP-OA auth tag exceeds the 1,024-byte limit.',
|
||||
'Replace the referenced value with a valid Buzz NIP-OA auth tag.'
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'auth_tag_invalid',
|
||||
'The configured Buzz NIP-OA auth tag is not valid JSON.',
|
||||
'Set the referenced value to the four-field Buzz NIP-OA auth-tag array.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length !== 4 ||
|
||||
parsed[0] !== 'auth' ||
|
||||
typeof parsed[1] !== 'string' ||
|
||||
typeof parsed[2] !== 'string' ||
|
||||
typeof parsed[3] !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(parsed[1]) ||
|
||||
!isValidAuthTagConditions(parsed[2]) ||
|
||||
!/^[a-f0-9]{128}$/.test(parsed[3])
|
||||
) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'auth_tag_invalid',
|
||||
'The configured Buzz NIP-OA auth tag does not match the supported four-field contract.',
|
||||
'Regenerate the auth tag with Buzz and update the referenced environment secret.'
|
||||
);
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
async function readBoundedBody(
|
||||
response: Response,
|
||||
limit: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let result = '';
|
||||
const read = async () => {
|
||||
if (!signal) return reader.read();
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
return new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
void reader.cancel().catch(() => {});
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
reader
|
||||
.read()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
});
|
||||
};
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > limit) {
|
||||
await reader.cancel();
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'response_too_large',
|
||||
`Buzz relay response exceeded the ${limit}-byte diagnostic limit.`,
|
||||
'Inspect the relay or reverse proxy response before retrying.'
|
||||
);
|
||||
}
|
||||
result += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
return result + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parseRelayContract(body: string): BuzzRelayContract {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'relay_info_invalid',
|
||||
'Buzz relay metadata was not valid JSON.',
|
||||
'Confirm the configured endpoint serves Buzz NIP-11 metadata.'
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'relay_info_invalid',
|
||||
'Buzz relay metadata was not an object.',
|
||||
'Confirm the configured endpoint serves Buzz NIP-11 metadata.'
|
||||
);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const supportedNips = Array.isArray(record.supported_nips)
|
||||
? record.supported_nips.filter((item): item is number => Number.isInteger(item))
|
||||
: [];
|
||||
const supportedExtensions = Array.isArray(record.supported_extensions)
|
||||
? record.supported_extensions.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
const limitation =
|
||||
record.limitation && typeof record.limitation === 'object'
|
||||
? (record.limitation as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
typeof record.software !== 'string' ||
|
||||
typeof record.version !== 'string' ||
|
||||
typeof record.self !== 'string' ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(record.self) ||
|
||||
REQUIRED_NIPS.some((nip) => !supportedNips.includes(nip))
|
||||
) {
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'relay_info_invalid',
|
||||
'Relay metadata is missing the Buzz software/version or required NIP contract.',
|
||||
`Use a supported Buzz ${BUZZ_TESTED_RELEASE} relay or update the compatibility policy.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
software: record.software,
|
||||
version: record.version.replace(/^v/, ''),
|
||||
supportedNips,
|
||||
supportedExtensions,
|
||||
relayPublicKey:
|
||||
typeof record.self === 'string' && /^[a-fA-F0-9]{64}$/.test(record.self)
|
||||
? record.self.toLowerCase()
|
||||
: undefined,
|
||||
authRequired: limitation.auth_required === true,
|
||||
};
|
||||
}
|
||||
|
||||
function evidenceKey(input: {
|
||||
config: BuzzProbeConfig;
|
||||
endpoints?: NormalizedBuzzEndpoints;
|
||||
contract?: BuzzRelayContract;
|
||||
commands: BuzzCommandDiagnostic[];
|
||||
observedSigningPublicKey?: string;
|
||||
}): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
probeRevision: BUZZ_PROBE_REVISION,
|
||||
relayHttpUrl: input.endpoints?.httpUrl ?? input.config.relayHttpUrl,
|
||||
relayWebSocketUrl: input.endpoints?.webSocketUrl ?? input.config.relayWebSocketUrl,
|
||||
expectedCommunity: input.endpoints?.expectedCommunity ?? input.config.expectedCommunity,
|
||||
publicKey: input.config.publicKey.toLowerCase(),
|
||||
observedSigningPublicKey: input.observedSigningPublicKey,
|
||||
credentialRef: input.config.credentialRef,
|
||||
authTagRef: input.config.authTagRef,
|
||||
allowLocalhost: Boolean(input.config.allowLocalhost),
|
||||
allowPrivateNetwork: Boolean(input.config.allowPrivateNetwork),
|
||||
command: input.config.command,
|
||||
contract: input.contract,
|
||||
commands: input.commands,
|
||||
})
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export class BuzzCompatibilityService {
|
||||
private readonly fetch: NonNullable<BuzzCompatibilityServiceOptions['fetch']>;
|
||||
private readonly resolveSecret: NonNullable<BuzzCompatibilityServiceOptions['resolveSecret']>;
|
||||
private readonly signer: BuzzNip98Signer;
|
||||
private readonly runCommand: BuzzCommandRunner;
|
||||
private readonly now: () => Date;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly maxResponseBytes: number;
|
||||
|
||||
constructor(options: BuzzCompatibilityServiceOptions = {}) {
|
||||
this.fetch = options.fetch ?? safeFetch;
|
||||
this.resolveSecret =
|
||||
options.resolveSecret ??
|
||||
(async (reference) => {
|
||||
if (!reference.startsWith('env:')) return undefined;
|
||||
return new EnvironmentCredentialSecretSource().resolve({
|
||||
kind: 'environment',
|
||||
reference: reference.slice(4),
|
||||
});
|
||||
});
|
||||
this.signer = options.signer ?? new NostrToolsBuzzNip98Signer();
|
||||
this.runCommand =
|
||||
options.runCommand ??
|
||||
(async (executable, args) => {
|
||||
const result = await execFileAsync(executable, args, {
|
||||
timeout: this.timeoutMs,
|
||||
maxBuffer: 64 * 1024,
|
||||
windowsHide: true,
|
||||
env: buildBuzzCommandEnvironment(),
|
||||
});
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
});
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
||||
}
|
||||
|
||||
async probe(config: BuzzProbeConfig): Promise<BuzzCompatibilityResult> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
const commands = await this.discoverCommands(config.command);
|
||||
const checks = emptyChecks();
|
||||
let endpoints: NormalizedBuzzEndpoints | undefined;
|
||||
let contract: BuzzRelayContract | undefined;
|
||||
let observedSigningPublicKey: string | undefined;
|
||||
|
||||
const result = (
|
||||
status: BuzzCompatibilityStatus,
|
||||
reasonCode: BuzzCompatibilityReasonCode,
|
||||
detail: string,
|
||||
remediation?: string
|
||||
): BuzzCompatibilityResult => ({
|
||||
schemaVersion: BUZZ_COMPATIBILITY_SCHEMA_VERSION,
|
||||
probeRevision: BUZZ_PROBE_REVISION,
|
||||
testedRelease: BUZZ_TESTED_RELEASE,
|
||||
testedCommit: BUZZ_TESTED_COMMIT,
|
||||
status,
|
||||
reasonCode,
|
||||
detail: sanitizeDetail(detail),
|
||||
remediation: remediation ? sanitizeDetail(remediation) : undefined,
|
||||
configuredRelayHttpUrl: sanitizeConfiguredUrl(config.relayHttpUrl) ?? '[not-configured]',
|
||||
resolvedRelayHttpUrl: endpoints?.httpUrl,
|
||||
configuredRelayWebSocketUrl: sanitizeConfiguredUrl(config.relayWebSocketUrl),
|
||||
resolvedRelayWebSocketUrl: endpoints?.webSocketUrl,
|
||||
expectedCommunity:
|
||||
endpoints?.expectedCommunity ?? sanitizeConfiguredCommunity(config.expectedCommunity),
|
||||
observedCommunity: endpoints?.community,
|
||||
publicKeyFingerprint: fingerprintBuzzPublicKey(config.publicKey),
|
||||
contract,
|
||||
checks: { ...checks },
|
||||
commands,
|
||||
evidenceKey: evidenceKey({
|
||||
config,
|
||||
endpoints,
|
||||
contract,
|
||||
commands,
|
||||
observedSigningPublicKey,
|
||||
}),
|
||||
checkedAt,
|
||||
});
|
||||
|
||||
try {
|
||||
endpoints = normalizeBuzzEndpoints(config);
|
||||
if (endpoints.expectedCommunity && endpoints.expectedCommunity !== endpoints.community) {
|
||||
checks.communityBinding = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'community_mismatch',
|
||||
`Configured relay resolves to community ${endpoints.community}, not the expected community.`,
|
||||
'Correct the relay host or expected community. Buzz binds community identity to the request host.'
|
||||
);
|
||||
}
|
||||
|
||||
const infoResult = await this.fetchBoundedWithTimeout(
|
||||
endpoint(endpoints.httpUrl, '/info'),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/nostr+json' },
|
||||
},
|
||||
config
|
||||
);
|
||||
if (!infoResult) {
|
||||
throw new BuzzProbeError(
|
||||
'unreachable',
|
||||
'network_policy_blocked',
|
||||
'The relay endpoint was blocked by the outbound network policy or DNS validation.',
|
||||
'Use a public HTTPS relay or explicitly allow the required local/private network class.'
|
||||
);
|
||||
}
|
||||
const { response: infoResponse, body: infoBody } = infoResult;
|
||||
if (!infoResponse.ok) {
|
||||
throw new BuzzProbeError(
|
||||
'unreachable',
|
||||
'relay_unreachable',
|
||||
`Buzz relay metadata request returned HTTP ${infoResponse.status}.`,
|
||||
'Verify the relay endpoint, reverse proxy, and host mapping.'
|
||||
);
|
||||
}
|
||||
contract = parseRelayContract(infoBody);
|
||||
if (contract.software.replace(/\/$/, '') !== BUZZ_REPOSITORY) {
|
||||
checks.relayIdentity = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'relay_software_mismatch',
|
||||
'The configured endpoint does not identify itself as the supported Buzz relay.',
|
||||
`Point Veritas at a Buzz ${BUZZ_TESTED_RELEASE} relay.`
|
||||
);
|
||||
}
|
||||
if (contract.version !== BUZZ_TESTED_RELEASE) {
|
||||
checks.relayIdentity = 'verified';
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'relay_version_unsupported',
|
||||
`Buzz relay ${contract.version} is outside the tested ${BUZZ_TESTED_RELEASE} contract.`,
|
||||
'Upgrade or pin Buzz to the tested release, or update the Veritas compatibility policy with fixtures.'
|
||||
);
|
||||
}
|
||||
checks.relayIdentity = 'verified';
|
||||
const verifiedContract = contract;
|
||||
|
||||
const privateKey = await this.resolveSecret(config.credentialRef);
|
||||
if (!privateKey) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'credential_unavailable',
|
||||
'The configured Buzz signing-key reference is unavailable.',
|
||||
`Inject the environment secret named by ${config.credentialRef}.`
|
||||
);
|
||||
}
|
||||
const authTag = config.authTagRef ? await this.resolveSecret(config.authTagRef) : undefined;
|
||||
if (config.authTagRef && !authTag) {
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'credential_unavailable',
|
||||
'The configured Buzz NIP-OA auth-tag reference is unavailable.',
|
||||
`Inject the environment secret named by ${config.authTagRef}.`
|
||||
);
|
||||
}
|
||||
const validatedAuthTag = authTag ? validateBuzzAuthTag(authTag) : undefined;
|
||||
const queryUrl = endpoint(endpoints.httpUrl, '/query');
|
||||
const verifyReadCapability = async (
|
||||
kind: 39_000 | 9,
|
||||
capability: 'channelRead' | 'messageRead',
|
||||
label: string
|
||||
): Promise<void> => {
|
||||
const body = JSON.stringify([{ kinds: [kind], limit: 1 }]);
|
||||
const signed = await this.signer.sign({
|
||||
privateKey,
|
||||
method: 'POST',
|
||||
url: queryUrl,
|
||||
body,
|
||||
});
|
||||
observedSigningPublicKey = signed.publicKey.toLowerCase();
|
||||
if (observedSigningPublicKey !== config.publicKey.toLowerCase()) {
|
||||
checks.configuredIdentity = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'public_key_mismatch',
|
||||
'The configured signing secret does not match the configured public identity.',
|
||||
'Correct the public key or signing-key secret reference.'
|
||||
);
|
||||
}
|
||||
checks.configuredIdentity = 'verified';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
Authorization: signed.authorization,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (validatedAuthTag) headers['x-auth-tag'] = validatedAuthTag;
|
||||
const queryResult = await this.fetchBoundedWithTimeout(
|
||||
queryUrl,
|
||||
{ method: 'POST', headers, body },
|
||||
config
|
||||
);
|
||||
if (!queryResult) {
|
||||
throw new BuzzProbeError(
|
||||
'unreachable',
|
||||
'network_policy_blocked',
|
||||
`The authenticated Buzz ${label} query was blocked by network policy.`,
|
||||
'Review the relay URL and explicit local/private network allowances.'
|
||||
);
|
||||
}
|
||||
const { response: queryResponse, body: queryBody } = queryResult;
|
||||
if (queryResponse.status === 401) {
|
||||
checks.authentication = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'unauthorized',
|
||||
'authentication_rejected',
|
||||
'Buzz rejected the NIP-98 identity proof.',
|
||||
'Verify the signing key, relay URL host, system clock, and Buzz NIP-98 configuration.'
|
||||
);
|
||||
}
|
||||
if (queryResponse.status === 403) {
|
||||
checks.authentication = 'verified';
|
||||
if (queryBody.includes('relay_membership_required')) {
|
||||
checks.membership = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'not_member',
|
||||
'relay_membership_required',
|
||||
'Buzz authenticated the identity but denied relay membership.',
|
||||
'Add the public identity as a relay member or provide a valid NIP-OA auth tag.'
|
||||
);
|
||||
}
|
||||
checks.membership = verifiedContract.supportedNips.includes(43)
|
||||
? 'verified'
|
||||
: 'not_enforced';
|
||||
checks[capability] = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'unauthorized',
|
||||
'read_capability_rejected',
|
||||
`Buzz authenticated the identity but rejected the ${label} read probe.`,
|
||||
'Review channel membership and relay read policy for the configured identity.'
|
||||
);
|
||||
}
|
||||
if (queryResponse.status === 404) {
|
||||
checks.communityBinding = 'failed';
|
||||
throw new BuzzProbeError(
|
||||
'misconfigured',
|
||||
'community_mismatch',
|
||||
'Buzz could not bind the authenticated query host to a community.',
|
||||
'Use the exact configured community host, including any non-default port.'
|
||||
);
|
||||
}
|
||||
if (queryResponse.status === 429) {
|
||||
throw new BuzzProbeError(
|
||||
'degraded',
|
||||
'relay_rate_limited',
|
||||
`Buzz rate-limited the ${label} read probe.`,
|
||||
'Retry after the relay rate-limit window.'
|
||||
);
|
||||
}
|
||||
if (!queryResponse.ok) {
|
||||
throw new BuzzProbeError(
|
||||
'degraded',
|
||||
'relay_error',
|
||||
`Buzz returned HTTP ${queryResponse.status} for the ${label} read probe.`,
|
||||
'Inspect relay health and retry without enabling message delivery.'
|
||||
);
|
||||
}
|
||||
checks.authentication = 'verified';
|
||||
checks.communityBinding = 'verified';
|
||||
checks.membership = verifiedContract.supportedNips.includes(43)
|
||||
? 'verified'
|
||||
: 'not_enforced';
|
||||
try {
|
||||
const parsed = JSON.parse(queryBody);
|
||||
if (!Array.isArray(parsed)) throw new Error('query response is not an array');
|
||||
} catch {
|
||||
throw new BuzzProbeError(
|
||||
'unsupported',
|
||||
'query_response_invalid',
|
||||
`Buzz returned an invalid ${label} query response.`,
|
||||
'Verify the relay build and reverse proxy response handling.'
|
||||
);
|
||||
}
|
||||
checks[capability] = 'verified';
|
||||
};
|
||||
|
||||
await verifyReadCapability(39_000, 'channelRead', 'channel metadata');
|
||||
await verifyReadCapability(9, 'messageRead', 'message');
|
||||
return result(
|
||||
'healthy',
|
||||
'ok',
|
||||
'Buzz relay identity, configured signing identity, membership posture, and read capabilities are compatible.'
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof BuzzProbeError) {
|
||||
return result(error.status, error.reasonCode, error.message, error.remediation);
|
||||
}
|
||||
return result(
|
||||
'unreachable',
|
||||
'relay_unreachable',
|
||||
`Buzz compatibility probe failed: ${sanitizeDetail(error)}`,
|
||||
'Verify relay reachability, DNS, TLS, and the configured network-policy allowances.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchBoundedWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
config: BuzzProbeConfig
|
||||
): Promise<{ response: Response; body: string } | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const response = await this.fetch(
|
||||
url,
|
||||
{ ...init, signal: controller.signal, redirect: 'manual' },
|
||||
{
|
||||
allowHttp: Boolean(config.allowLocalhost || config.allowPrivateNetwork),
|
||||
allowLocalhost: Boolean(config.allowLocalhost),
|
||||
allowPrivateNetwork: Boolean(config.allowPrivateNetwork),
|
||||
}
|
||||
);
|
||||
if (!response) return null;
|
||||
const body = await readBoundedBody(response, this.maxResponseBytes, controller.signal);
|
||||
return { response, body };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async discoverCommands(command?: BuzzCommandConfig): Promise<BuzzCommandDiagnostic[]> {
|
||||
const candidates: Array<{
|
||||
command: BuzzCommandDiagnostic['command'];
|
||||
executable: string;
|
||||
args: string[];
|
||||
}> = [
|
||||
{ command: 'buzz', executable: 'buzz', args: ['--version'] },
|
||||
{ command: 'buzz-acp', executable: 'buzz-acp', args: ['--version'] },
|
||||
{ command: 'buzz-agent', executable: 'buzz-agent', args: ['--version'] },
|
||||
];
|
||||
if (command) {
|
||||
candidates.push({
|
||||
command: 'configured',
|
||||
executable: command.executable,
|
||||
args: [...(command.args ?? []), '--version'],
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
candidates.map(async (candidate): Promise<BuzzCommandDiagnostic> => {
|
||||
try {
|
||||
const output = await this.runCommand(candidate.executable, candidate.args);
|
||||
const version = `${output.stdout}\n${output.stderr}`.trim().split(/\r?\n/, 1)[0];
|
||||
return {
|
||||
command: candidate.command,
|
||||
executable: candidate.executable,
|
||||
available: true,
|
||||
version: sanitizeDetail(version || 'version not reported'),
|
||||
};
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: undefined;
|
||||
return {
|
||||
command: candidate.command,
|
||||
executable: candidate.executable,
|
||||
available: false,
|
||||
detail:
|
||||
code === 'ENOENT'
|
||||
? 'not found'
|
||||
: sanitizeDetail(code ? `command failed (${code})` : 'command failed'),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
87
server/src/services/buzz-nip98-signer.ts
Normal file
87
server/src/services/buzz-nip98-signer.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { finalizeEvent, nip19 } from 'nostr-tools';
|
||||
|
||||
const NIP_98_KIND = 27_235;
|
||||
|
||||
export interface BuzzNip98Signer {
|
||||
sign(input: {
|
||||
privateKey: string;
|
||||
method: 'POST';
|
||||
url: string;
|
||||
body: string;
|
||||
}): Promise<{ authorization: string; publicKey: string }>;
|
||||
}
|
||||
|
||||
interface NostrToolsBuzzNip98SignerOptions {
|
||||
now?: () => number;
|
||||
createNonce?: () => string;
|
||||
}
|
||||
|
||||
function invalidPrivateKey(): Error {
|
||||
return new Error('Invalid Buzz private key format');
|
||||
}
|
||||
|
||||
function decodePrivateKey(value: string): Uint8Array {
|
||||
const candidate = value.trim();
|
||||
if (/^[a-fA-F0-9]{64}$/.test(candidate)) {
|
||||
return Uint8Array.from(Buffer.from(candidate, 'hex'));
|
||||
}
|
||||
if (!candidate.startsWith('nsec1')) throw invalidPrivateKey();
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(candidate);
|
||||
if (
|
||||
decoded.type !== 'nsec' ||
|
||||
!(decoded.data instanceof Uint8Array) ||
|
||||
decoded.data.byteLength !== 32
|
||||
) {
|
||||
throw invalidPrivateKey();
|
||||
}
|
||||
return decoded.data;
|
||||
} catch {
|
||||
throw invalidPrivateKey();
|
||||
}
|
||||
}
|
||||
|
||||
export class NostrToolsBuzzNip98Signer implements BuzzNip98Signer {
|
||||
private readonly now: () => number;
|
||||
private readonly createNonce: () => string;
|
||||
|
||||
constructor(options: NostrToolsBuzzNip98SignerOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.createNonce = options.createNonce ?? randomUUID;
|
||||
}
|
||||
|
||||
async sign(input: {
|
||||
privateKey: string;
|
||||
method: 'POST';
|
||||
url: string;
|
||||
body: string;
|
||||
}): Promise<{ authorization: string; publicKey: string }> {
|
||||
const secretKey = decodePrivateKey(input.privateKey);
|
||||
try {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: NIP_98_KIND,
|
||||
created_at: Math.floor(this.now() / 1000),
|
||||
tags: [
|
||||
['u', input.url],
|
||||
['method', input.method],
|
||||
['nonce', this.createNonce()],
|
||||
['payload', createHash('sha256').update(input.body, 'utf8').digest('hex')],
|
||||
],
|
||||
content: '',
|
||||
},
|
||||
secretKey
|
||||
);
|
||||
return {
|
||||
authorization: `Nostr ${Buffer.from(JSON.stringify(event), 'utf8').toString('base64')}`,
|
||||
publicKey: event.pubkey,
|
||||
};
|
||||
} catch {
|
||||
throw invalidPrivateKey();
|
||||
} finally {
|
||||
secretKey.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
|
|
@ -16,6 +15,12 @@ import type {
|
|||
CommunicationThreadMapping,
|
||||
SquadMessage,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
BUZZ_COMPATIBILITY_SCHEMA_VERSION,
|
||||
BUZZ_PROBE_REVISION,
|
||||
BUZZ_TESTED_COMMIT,
|
||||
BUZZ_TESTED_RELEASE,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { auditLog, type AuditEvent } from './audit-service.js';
|
||||
import { getChatService, type ChatService } from './chat-service.js';
|
||||
import {
|
||||
|
|
@ -26,6 +31,18 @@ import { withFileLock } from './file-lock.js';
|
|||
import { redactString } from '../lib/redact.js';
|
||||
import { ensureWithinBase, sanitizeCommentText, validatePathSegment } from '../utils/sanitize.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { atomicWriteFile, mkdir, readFile } from '../storage/fs-helpers.js';
|
||||
import {
|
||||
BuzzCompatibilityService,
|
||||
fingerprintBuzzPublicKey,
|
||||
normalizeBuzzEndpoints,
|
||||
type BuzzProbeConfig,
|
||||
type NormalizedBuzzEndpoints,
|
||||
} from './buzz-compatibility-service.js';
|
||||
import {
|
||||
buzzAdapterConfigSchema,
|
||||
type BuzzAdapterConfig,
|
||||
} from '../schemas/communication-adapter-schemas.js';
|
||||
|
||||
const DEFAULT_ADAPTER_ID = 'msteams-default';
|
||||
const MAX_DELIVERIES = 500;
|
||||
|
|
@ -33,6 +50,7 @@ const MAX_MESSAGE_LENGTH = 4000;
|
|||
|
||||
interface InternalCommunicationAdapterRecord extends CommunicationAdapterRecord {
|
||||
webhookUrlRaw?: string;
|
||||
buzzConfigKey?: string;
|
||||
}
|
||||
|
||||
interface CommunicationAdapterState {
|
||||
|
|
@ -49,6 +67,7 @@ export interface CommunicationAdapterServiceOptions {
|
|||
persist?: boolean;
|
||||
chatService?: ChatService;
|
||||
outboundIntegrations?: OutboundIntegrationService;
|
||||
buzzCompatibility?: BuzzCompatibilityService;
|
||||
audit?: (event: AuditEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -56,11 +75,98 @@ function nowIso(): string {
|
|||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function trimOrUndefined(value?: string): string | undefined {
|
||||
function trimOrUndefined(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
interface ValidatedBuzzAdapterConfig {
|
||||
config: BuzzAdapterConfig;
|
||||
endpoints: NormalizedBuzzEndpoints;
|
||||
probeConfig: BuzzProbeConfig;
|
||||
configKey: string;
|
||||
}
|
||||
|
||||
function buzzConfigKey(config: BuzzProbeConfig, endpoints: NormalizedBuzzEndpoints): string {
|
||||
return JSON.stringify({
|
||||
probeRevision: BUZZ_PROBE_REVISION,
|
||||
testedRelease: BUZZ_TESTED_RELEASE,
|
||||
testedCommit: BUZZ_TESTED_COMMIT,
|
||||
relayHttpUrl: endpoints.httpUrl,
|
||||
relayWebSocketUrl: endpoints.webSocketUrl,
|
||||
expectedCommunity: endpoints.expectedCommunity,
|
||||
publicKey: config.publicKey,
|
||||
credentialRef: config.credentialRef,
|
||||
authTagRef: config.authTagRef,
|
||||
allowLocalhost: Boolean(config.allowLocalhost),
|
||||
allowPrivateNetwork: Boolean(config.allowPrivateNetwork),
|
||||
command: config.command,
|
||||
});
|
||||
}
|
||||
|
||||
function validateStoredBuzzConfig(
|
||||
adapter: InternalCommunicationAdapterRecord
|
||||
): ValidatedBuzzAdapterConfig | null {
|
||||
const parsed = buzzAdapterConfigSchema.safeParse({
|
||||
kind: 'buzz',
|
||||
displayName: adapter.displayName,
|
||||
enabled: adapter.enabled,
|
||||
relayHttpUrl: adapter.relayHttpUrl,
|
||||
relayWebSocketUrl: adapter.relayWebSocketUrl,
|
||||
expectedCommunity: adapter.expectedCommunity,
|
||||
publicKey: adapter.publicKey,
|
||||
credentialRef: adapter.credentialRef,
|
||||
authTagRef: adapter.authTagRef,
|
||||
allowLocalhost: adapter.allowLocalhost,
|
||||
allowPrivateNetwork: adapter.allowPrivateNetwork,
|
||||
command: adapter.command,
|
||||
});
|
||||
if (!parsed.success) return null;
|
||||
|
||||
try {
|
||||
const endpoints = normalizeBuzzEndpoints({
|
||||
relayHttpUrl: parsed.data.relayHttpUrl,
|
||||
relayWebSocketUrl: parsed.data.relayWebSocketUrl ?? undefined,
|
||||
expectedCommunity: parsed.data.expectedCommunity ?? undefined,
|
||||
});
|
||||
const probeConfig: BuzzProbeConfig = {
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
relayHttpUrl: parsed.data.relayHttpUrl,
|
||||
relayWebSocketUrl: parsed.data.relayWebSocketUrl ?? undefined,
|
||||
expectedCommunity: parsed.data.expectedCommunity ?? undefined,
|
||||
publicKey: parsed.data.publicKey.toLowerCase(),
|
||||
credentialRef: parsed.data.credentialRef,
|
||||
authTagRef: parsed.data.authTagRef ?? undefined,
|
||||
allowLocalhost: parsed.data.allowLocalhost,
|
||||
allowPrivateNetwork: parsed.data.allowPrivateNetwork,
|
||||
command: parsed.data.command ?? undefined,
|
||||
};
|
||||
return {
|
||||
config: parsed.data,
|
||||
endpoints,
|
||||
probeConfig,
|
||||
configKey: buzzConfigKey(probeConfig, endpoints),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentBuzzCompatibility(
|
||||
adapter: InternalCommunicationAdapterRecord,
|
||||
validated: ValidatedBuzzAdapterConfig
|
||||
): boolean {
|
||||
const compatibility = adapter.compatibility;
|
||||
return Boolean(
|
||||
compatibility &&
|
||||
compatibility.schemaVersion === BUZZ_COMPATIBILITY_SCHEMA_VERSION &&
|
||||
compatibility.probeRevision === BUZZ_PROBE_REVISION &&
|
||||
compatibility.testedRelease === BUZZ_TESTED_RELEASE &&
|
||||
compatibility.testedCommit === BUZZ_TESTED_COMMIT &&
|
||||
adapter.buzzConfigKey === validated.configKey
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeUrl(url?: string): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
|
|
@ -124,6 +230,7 @@ export class CommunicationAdapterService {
|
|||
private readonly persist: boolean;
|
||||
private readonly chatService: ChatService;
|
||||
private readonly outboundIntegrations: OutboundIntegrationService;
|
||||
private readonly buzzCompatibility: BuzzCompatibilityService;
|
||||
private readonly audit: (event: AuditEvent) => Promise<void>;
|
||||
private loaded = false;
|
||||
private state: CommunicationAdapterState = this.emptyState();
|
||||
|
|
@ -133,6 +240,7 @@ export class CommunicationAdapterService {
|
|||
this.persist = options.persist ?? process.env.VITEST !== 'true';
|
||||
this.chatService = options.chatService || getChatService();
|
||||
this.outboundIntegrations = options.outboundIntegrations || getOutboundIntegrationService();
|
||||
this.buzzCompatibility = options.buzzCompatibility || new BuzzCompatibilityService();
|
||||
this.audit = options.audit || auditLog;
|
||||
}
|
||||
|
||||
|
|
@ -158,11 +266,20 @@ export class CommunicationAdapterService {
|
|||
|
||||
const timestamp = nowIso();
|
||||
const existing = this.state.adapters[adapterId];
|
||||
const kind = input.kind ?? existing?.kind ?? 'msteams';
|
||||
if (existing && input.kind && input.kind !== existing.kind) {
|
||||
throw new Error('Communication adapter kind cannot be changed after creation');
|
||||
}
|
||||
if (kind === 'buzz') {
|
||||
const validated = buzzAdapterConfigSchema.parse({ ...input, kind: 'buzz' });
|
||||
return this.configureBuzzAdapter(adapterId, validated, existing, timestamp);
|
||||
}
|
||||
|
||||
const rawWebhook =
|
||||
input.webhookUrl !== undefined ? trimOrUndefined(input.webhookUrl) : existing?.webhookUrlRaw;
|
||||
const adapter: InternalCommunicationAdapterRecord = {
|
||||
id: adapterId,
|
||||
kind: input.kind ?? existing?.kind ?? 'msteams',
|
||||
kind,
|
||||
displayName: trimOrUndefined(input.displayName) ?? existing?.displayName ?? 'Microsoft Teams',
|
||||
enabled: input.enabled ?? existing?.enabled ?? true,
|
||||
deliveryMode: input.deliveryMode ?? existing?.deliveryMode ?? 'manual',
|
||||
|
|
@ -201,12 +318,18 @@ export class CommunicationAdapterService {
|
|||
const disconnected: InternalCommunicationAdapterRecord = {
|
||||
...adapter,
|
||||
enabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookUrlRaw: undefined,
|
||||
webhookUrlConfigured: false,
|
||||
webhookUrlRedacted: false,
|
||||
hasCredential: false,
|
||||
...(adapter.kind === 'msteams'
|
||||
? {
|
||||
webhookUrl: undefined,
|
||||
webhookUrlRaw: undefined,
|
||||
webhookUrlConfigured: false,
|
||||
webhookUrlRedacted: false,
|
||||
hasCredential: false,
|
||||
}
|
||||
: {}),
|
||||
updatedAt: timestamp,
|
||||
lastHealth: undefined,
|
||||
compatibility: undefined,
|
||||
};
|
||||
this.state.adapters[adapterId] = disconnected;
|
||||
const delivery = this.recordDelivery({
|
||||
|
|
@ -223,6 +346,9 @@ export class CommunicationAdapterService {
|
|||
validatePathSegment(adapterId);
|
||||
await this.ensureLoaded();
|
||||
const adapter = this.requireAdapter(adapterId);
|
||||
if (adapter.kind === 'buzz') {
|
||||
return this.checkBuzzHealth(adapter);
|
||||
}
|
||||
const configured = this.hasDestination(adapter);
|
||||
const health: CommunicationAdapterHealth = {
|
||||
adapterId,
|
||||
|
|
@ -253,6 +379,33 @@ export class CommunicationAdapterService {
|
|||
validatePathSegment(adapterId);
|
||||
await this.ensureLoaded();
|
||||
const adapter = this.requireAdapter(adapterId);
|
||||
if (adapter.kind === 'buzz') {
|
||||
const timestamp = nowIso();
|
||||
const target = normalizeTarget(input.target);
|
||||
const mapping: CommunicationThreadMapping = {
|
||||
id: `map_${nanoid(10)}`,
|
||||
adapterId,
|
||||
externalThreadId:
|
||||
trimOrUndefined(input.externalThreadId) ?? this.buildExternalThreadId(adapterId, target),
|
||||
externalUrl: sanitizeUrl(input.externalUrl),
|
||||
target,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
createdBy: trimOrUndefined(input.actor),
|
||||
};
|
||||
const delivery = this.recordDelivery({
|
||||
adapterId,
|
||||
operation: 'send',
|
||||
status: 'blocked',
|
||||
target: mapping.target,
|
||||
externalThreadId: mapping.externalThreadId,
|
||||
actor: input.actor,
|
||||
error: 'Buzz message delivery is not implemented by the connection diagnostic adapter.',
|
||||
});
|
||||
await this.saveState();
|
||||
await this.auditDelivery(delivery);
|
||||
return { delivery, mapping };
|
||||
}
|
||||
const target = normalizeTarget(input.target);
|
||||
const externalThreadId =
|
||||
trimOrUndefined(input.externalThreadId) ?? this.buildExternalThreadId(adapterId, target);
|
||||
|
|
@ -295,6 +448,31 @@ export class CommunicationAdapterService {
|
|||
if (!externalThreadId) {
|
||||
throw new Error('externalThreadId is required');
|
||||
}
|
||||
if (adapter.kind === 'buzz') {
|
||||
const timestamp = nowIso();
|
||||
const mapping: CommunicationThreadMapping = {
|
||||
id: `map_${nanoid(10)}`,
|
||||
adapterId,
|
||||
externalThreadId,
|
||||
externalUrl: sanitizeUrl(input.externalUrl),
|
||||
target: normalizeTarget(input.target ?? { kind: 'squad' }),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
createdBy: trimOrUndefined(input.actor),
|
||||
};
|
||||
const delivery = this.recordDelivery({
|
||||
adapterId,
|
||||
operation: 'reply-ingest',
|
||||
status: 'blocked',
|
||||
target: mapping.target,
|
||||
externalThreadId,
|
||||
actor: input.actor,
|
||||
error: 'Buzz reply ingestion is not implemented by the connection diagnostic adapter.',
|
||||
});
|
||||
await this.saveState();
|
||||
await this.auditDelivery(delivery);
|
||||
return { delivery, mapping, squadMessageId: '' };
|
||||
}
|
||||
|
||||
const existing = this.findMapping(adapterId, externalThreadId);
|
||||
const target = normalizeTarget(input.target ?? existing?.target ?? { kind: 'squad' });
|
||||
|
|
@ -412,12 +590,15 @@ export class CommunicationAdapterService {
|
|||
): Promise<{ delivery: CommunicationDeliveryAudit; replies: [] }> {
|
||||
validatePathSegment(adapterId);
|
||||
await this.ensureLoaded();
|
||||
this.requireAdapter(adapterId);
|
||||
const adapter = this.requireAdapter(adapterId);
|
||||
const delivery = this.recordDelivery({
|
||||
adapterId,
|
||||
operation: 'poll',
|
||||
status: 'skipped',
|
||||
error: 'Reply polling is adapter-defined; this adapter uses the ingest API.',
|
||||
error:
|
||||
adapter.kind === 'buzz'
|
||||
? 'Buzz reply polling is not implemented by the connection diagnostic adapter.'
|
||||
: 'Reply polling is adapter-defined; this adapter uses the ingest API.',
|
||||
});
|
||||
await this.saveState();
|
||||
await this.auditDelivery(delivery);
|
||||
|
|
@ -559,6 +740,7 @@ export class CommunicationAdapterService {
|
|||
}
|
||||
|
||||
private hasDestination(adapter: InternalCommunicationAdapterRecord): boolean {
|
||||
if (adapter.kind === 'buzz') return false;
|
||||
if (adapter.deliveryMode === 'webhook') {
|
||||
return Boolean(adapter.webhookUrlRaw);
|
||||
}
|
||||
|
|
@ -577,7 +759,56 @@ export class CommunicationAdapterService {
|
|||
}
|
||||
|
||||
private publicAdapter(adapter: InternalCommunicationAdapterRecord): CommunicationAdapterRecord {
|
||||
const { webhookUrlRaw: _webhookUrlRaw, ...publicRecord } = adapter;
|
||||
if (adapter.kind === 'buzz') {
|
||||
const validated = validateStoredBuzzConfig(adapter);
|
||||
if (!validated) {
|
||||
return {
|
||||
id: adapter.id,
|
||||
kind: 'buzz',
|
||||
displayName: 'Buzz',
|
||||
enabled: false,
|
||||
deliveryMode: 'manual',
|
||||
replyMode: 'ingest-api',
|
||||
destinationType: 'channel',
|
||||
hasCredential: false,
|
||||
createdAt: adapter.createdAt,
|
||||
updatedAt: adapter.updatedAt,
|
||||
};
|
||||
}
|
||||
const config = validated.config;
|
||||
return {
|
||||
id: adapter.id,
|
||||
kind: 'buzz',
|
||||
displayName: config.displayName ?? 'Buzz',
|
||||
enabled: config.enabled ?? true,
|
||||
deliveryMode: 'manual',
|
||||
replyMode: 'ingest-api',
|
||||
destinationType: 'channel',
|
||||
hasCredential: true,
|
||||
relayHttpUrl: config.relayHttpUrl,
|
||||
relayWebSocketUrl: config.relayWebSocketUrl ?? undefined,
|
||||
expectedCommunity: config.expectedCommunity ?? undefined,
|
||||
publicKey: config.publicKey.toLowerCase(),
|
||||
publicKeyFingerprint: fingerprintBuzzPublicKey(config.publicKey),
|
||||
credentialRef: config.credentialRef,
|
||||
authTagRef: config.authTagRef ?? undefined,
|
||||
authTagConfigured: Boolean(config.authTagRef),
|
||||
allowLocalhost: config.allowLocalhost,
|
||||
allowPrivateNetwork: config.allowPrivateNetwork,
|
||||
command: config.command ?? undefined,
|
||||
compatibility: isCurrentBuzzCompatibility(adapter, validated)
|
||||
? adapter.compatibility
|
||||
: undefined,
|
||||
lastHealth: isCurrentBuzzCompatibility(adapter, validated) ? adapter.lastHealth : undefined,
|
||||
createdAt: adapter.createdAt,
|
||||
updatedAt: adapter.updatedAt,
|
||||
};
|
||||
}
|
||||
const {
|
||||
webhookUrlRaw: _webhookUrlRaw,
|
||||
buzzConfigKey: _buzzConfigKey,
|
||||
...publicRecord
|
||||
} = adapter;
|
||||
return {
|
||||
...publicRecord,
|
||||
webhookUrl: adapter.webhookUrlRaw ? sanitizeUrl(adapter.webhookUrlRaw) : undefined,
|
||||
|
|
@ -594,9 +825,9 @@ export class CommunicationAdapterService {
|
|||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(this.storageDir, { recursive: true });
|
||||
await mkdir(this.storageDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.statePath, 'utf-8');
|
||||
const raw = await readFile(this.statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<CommunicationAdapterState>;
|
||||
this.state = {
|
||||
version: 1,
|
||||
|
|
@ -627,9 +858,9 @@ export class CommunicationAdapterService {
|
|||
private async saveState(): Promise<void> {
|
||||
this.state.updatedAt = nowIso();
|
||||
if (!this.persist) return;
|
||||
await fs.mkdir(this.storageDir, { recursive: true });
|
||||
await mkdir(this.storageDir, { recursive: true });
|
||||
await withFileLock(this.statePath, async () => {
|
||||
await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
|
||||
await atomicWriteFile(this.statePath, JSON.stringify(this.state, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -685,6 +916,198 @@ export class CommunicationAdapterService {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async configureBuzzAdapter(
|
||||
adapterId: string,
|
||||
input: BuzzAdapterConfig,
|
||||
existing: InternalCommunicationAdapterRecord | undefined,
|
||||
timestamp: string
|
||||
): Promise<CommunicationAdapterRecord> {
|
||||
const relayHttpUrl = trimOrUndefined(input.relayHttpUrl) ?? existing?.relayHttpUrl;
|
||||
const publicKey = (trimOrUndefined(input.publicKey) ?? existing?.publicKey)?.toLowerCase();
|
||||
const credentialRef = trimOrUndefined(input.credentialRef) ?? existing?.credentialRef;
|
||||
if (!relayHttpUrl || !publicKey || !credentialRef) {
|
||||
throw new Error('Buzz relayHttpUrl, publicKey, and credentialRef are required');
|
||||
}
|
||||
const relayWebSocketUrl =
|
||||
input.relayWebSocketUrl !== undefined
|
||||
? trimOrUndefined(input.relayWebSocketUrl)
|
||||
: existing?.relayWebSocketUrl;
|
||||
const expectedCommunity =
|
||||
input.expectedCommunity !== undefined
|
||||
? trimOrUndefined(input.expectedCommunity)
|
||||
: existing?.expectedCommunity;
|
||||
const authTagRef =
|
||||
input.authTagRef !== undefined ? trimOrUndefined(input.authTagRef) : existing?.authTagRef;
|
||||
const endpoints = normalizeBuzzEndpoints({
|
||||
relayHttpUrl,
|
||||
relayWebSocketUrl,
|
||||
expectedCommunity,
|
||||
});
|
||||
const command = input.command !== undefined ? input.command : existing?.command;
|
||||
const allowLocalhost = input.allowLocalhost ?? existing?.allowLocalhost ?? false;
|
||||
const allowPrivateNetwork = input.allowPrivateNetwork ?? existing?.allowPrivateNetwork ?? false;
|
||||
const probeConfig: BuzzProbeConfig = {
|
||||
enabled: input.enabled ?? existing?.enabled ?? true,
|
||||
relayHttpUrl: endpoints.configuredHttpUrl.trim(),
|
||||
relayWebSocketUrl: endpoints.configuredWebSocketUrl?.trim(),
|
||||
expectedCommunity,
|
||||
publicKey,
|
||||
credentialRef,
|
||||
authTagRef,
|
||||
allowLocalhost,
|
||||
allowPrivateNetwork,
|
||||
command: command ?? undefined,
|
||||
};
|
||||
const nextBuzzConfigKey = buzzConfigKey(probeConfig, endpoints);
|
||||
const evidenceIsCurrent =
|
||||
existing?.buzzConfigKey === nextBuzzConfigKey &&
|
||||
existing.compatibility?.probeRevision === BUZZ_PROBE_REVISION &&
|
||||
existing.compatibility.testedRelease === BUZZ_TESTED_RELEASE &&
|
||||
existing.compatibility.testedCommit === BUZZ_TESTED_COMMIT;
|
||||
const adapter: InternalCommunicationAdapterRecord = {
|
||||
id: adapterId,
|
||||
kind: 'buzz',
|
||||
displayName: trimOrUndefined(input.displayName) ?? existing?.displayName ?? 'Buzz',
|
||||
enabled: input.enabled ?? existing?.enabled ?? true,
|
||||
deliveryMode: 'manual',
|
||||
replyMode: 'ingest-api',
|
||||
destinationType: 'channel',
|
||||
hasCredential: true,
|
||||
relayHttpUrl: probeConfig.relayHttpUrl,
|
||||
relayWebSocketUrl: probeConfig.relayWebSocketUrl,
|
||||
expectedCommunity,
|
||||
publicKey,
|
||||
publicKeyFingerprint: fingerprintBuzzPublicKey(publicKey),
|
||||
credentialRef,
|
||||
authTagRef,
|
||||
authTagConfigured: Boolean(authTagRef),
|
||||
allowLocalhost,
|
||||
allowPrivateNetwork,
|
||||
command: command ?? undefined,
|
||||
buzzConfigKey: nextBuzzConfigKey,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastHealth: evidenceIsCurrent ? existing?.lastHealth : undefined,
|
||||
compatibility: evidenceIsCurrent ? existing?.compatibility : undefined,
|
||||
};
|
||||
|
||||
this.state.adapters[adapterId] = adapter;
|
||||
const delivery = this.recordDelivery({
|
||||
adapterId,
|
||||
operation: 'configure',
|
||||
status: 'success',
|
||||
});
|
||||
await this.saveState();
|
||||
await this.auditAdapter('communication_adapter.configured', adapter, delivery);
|
||||
return this.publicAdapter(adapter);
|
||||
}
|
||||
|
||||
private async checkBuzzHealth(
|
||||
adapter: InternalCommunicationAdapterRecord
|
||||
): Promise<CommunicationAdapterHealth> {
|
||||
const validated = validateStoredBuzzConfig(adapter);
|
||||
if (!validated) {
|
||||
const health: CommunicationAdapterHealth = {
|
||||
adapterId: adapter.id,
|
||||
status: 'misconfigured',
|
||||
configured: false,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: nowIso(),
|
||||
detail: 'The persisted Buzz configuration is invalid and was quarantined.',
|
||||
reasonCode: 'configuration_invalid',
|
||||
remediation: 'Resave a complete reference-only Buzz connection configuration.',
|
||||
};
|
||||
adapter.enabled = false;
|
||||
adapter.command = undefined;
|
||||
adapter.lastHealth = health;
|
||||
adapter.compatibility = undefined;
|
||||
adapter.updatedAt = health.checkedAt;
|
||||
this.recordDelivery({
|
||||
adapterId: adapter.id,
|
||||
operation: 'health',
|
||||
status: 'failed',
|
||||
error: health.detail,
|
||||
});
|
||||
await this.saveState();
|
||||
return health;
|
||||
}
|
||||
|
||||
if (!validated.probeConfig.enabled) {
|
||||
const health: CommunicationAdapterHealth = {
|
||||
adapterId: adapter.id,
|
||||
status: 'disabled',
|
||||
configured: Boolean(adapter.relayHttpUrl && adapter.publicKey && adapter.credentialRef),
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: nowIso(),
|
||||
detail: 'Buzz adapter is disabled. Configuration references are retained.',
|
||||
reasonCode: 'adapter_disabled',
|
||||
remediation: 'Enable the adapter to run a read-only compatibility probe.',
|
||||
};
|
||||
adapter.lastHealth = health;
|
||||
adapter.compatibility = undefined;
|
||||
adapter.updatedAt = health.checkedAt;
|
||||
this.recordDelivery({
|
||||
adapterId: adapter.id,
|
||||
operation: 'health',
|
||||
status: 'skipped',
|
||||
});
|
||||
await this.saveState();
|
||||
return health;
|
||||
}
|
||||
|
||||
if (!adapter.relayHttpUrl || !adapter.publicKey || !adapter.credentialRef) {
|
||||
const health: CommunicationAdapterHealth = {
|
||||
adapterId: adapter.id,
|
||||
status: 'misconfigured',
|
||||
configured: false,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: nowIso(),
|
||||
detail: 'Buzz relay URL, public key, or credential reference is missing.',
|
||||
reasonCode: 'configuration_missing',
|
||||
remediation: 'Save a complete reference-only Buzz connection configuration.',
|
||||
};
|
||||
adapter.lastHealth = health;
|
||||
adapter.compatibility = undefined;
|
||||
adapter.updatedAt = health.checkedAt;
|
||||
this.recordDelivery({
|
||||
adapterId: adapter.id,
|
||||
operation: 'health',
|
||||
status: 'failed',
|
||||
error: health.detail,
|
||||
});
|
||||
await this.saveState();
|
||||
return health;
|
||||
}
|
||||
|
||||
const compatibility = await this.buzzCompatibility.probe(validated.probeConfig);
|
||||
const health: CommunicationAdapterHealth = {
|
||||
adapterId: adapter.id,
|
||||
status: compatibility.status,
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: compatibility.checkedAt,
|
||||
detail: compatibility.detail,
|
||||
reasonCode: compatibility.reasonCode,
|
||||
remediation: compatibility.remediation,
|
||||
buzz: compatibility,
|
||||
};
|
||||
adapter.compatibility = compatibility;
|
||||
adapter.lastHealth = health;
|
||||
adapter.updatedAt = health.checkedAt;
|
||||
this.recordDelivery({
|
||||
adapterId: adapter.id,
|
||||
operation: 'health',
|
||||
status: compatibility.status === 'healthy' ? 'success' : 'failed',
|
||||
error: compatibility.status === 'healthy' ? undefined : compatibility.detail,
|
||||
});
|
||||
await this.saveState();
|
||||
return health;
|
||||
}
|
||||
}
|
||||
|
||||
let communicationAdapterService: CommunicationAdapterService | null = null;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest, type IncomingHttpHeaders, type RequestOptions } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
|
||||
|
|
@ -55,6 +55,33 @@ const BLOCKED_IPV4_RANGES: Array<{
|
|||
{ start: 0x64400000, end: 0x647fffff, name: 'cgnat', addressClass: 'cgnat' },
|
||||
];
|
||||
|
||||
function ipv4BlockList(subnets: Array<[address: string, prefix: number]>): BlockList {
|
||||
const blockList = new BlockList();
|
||||
for (const [address, prefix] of subnets) {
|
||||
blockList.addSubnet(address, prefix, 'ipv4');
|
||||
}
|
||||
return blockList;
|
||||
}
|
||||
|
||||
const MAPPED_IPV4_LOCAL = ipv4BlockList([
|
||||
['0.0.0.0', 32],
|
||||
['127.0.0.0', 8],
|
||||
]);
|
||||
const MAPPED_IPV4_PRIVATE = ipv4BlockList([
|
||||
['10.0.0.0', 8],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
]);
|
||||
const MAPPED_IPV4_LINK_LOCAL = ipv4BlockList([['169.254.0.0', 16]]);
|
||||
const MAPPED_IPV4_CGNAT = ipv4BlockList([['100.64.0.0', 10]]);
|
||||
|
||||
const IPV6_LOCAL = new BlockList();
|
||||
IPV6_LOCAL.addSubnet('::', 96, 'ipv6');
|
||||
const IPV6_LINK_LOCAL = new BlockList();
|
||||
IPV6_LINK_LOCAL.addSubnet('fe80::', 10, 'ipv6');
|
||||
const IPV6_UNIQUE_LOCAL = new BlockList();
|
||||
IPV6_UNIQUE_LOCAL.addSubnet('fc00::', 7, 'ipv6');
|
||||
|
||||
/**
|
||||
* Convert IPv4 address string to 32-bit integer
|
||||
*/
|
||||
|
|
@ -97,27 +124,26 @@ function isBlockedIPv4(ip: string): BlockedAddressCheck {
|
|||
* Check if a hostname is a blocked IPv6 address
|
||||
*/
|
||||
function isBlockedIPv6(host: string): BlockedAddressCheck {
|
||||
const normalized = host.toLowerCase();
|
||||
const normalized = unbracketHostname(host).toLowerCase();
|
||||
|
||||
// Loopback
|
||||
if (normalized === '::1' || normalized === '[::1]') {
|
||||
return { blocked: true, reason: 'IPv6 loopback', addressClass: 'local' };
|
||||
if (IPV6_LOCAL.check(normalized, 'ipv6') || MAPPED_IPV4_LOCAL.check(normalized, 'ipv6')) {
|
||||
return { blocked: true, reason: 'IPv6 local address', addressClass: 'local' };
|
||||
}
|
||||
|
||||
// Link-local (fe80::/10)
|
||||
if (normalized.startsWith('fe8') || normalized.startsWith('[fe8')) {
|
||||
if (
|
||||
IPV6_LINK_LOCAL.check(normalized, 'ipv6') ||
|
||||
MAPPED_IPV4_LINK_LOCAL.check(normalized, 'ipv6')
|
||||
) {
|
||||
return { blocked: true, reason: 'IPv6 link-local', addressClass: 'link-local' };
|
||||
}
|
||||
|
||||
// Unique local (fc00::/7)
|
||||
if (
|
||||
normalized.startsWith('fc') ||
|
||||
normalized.startsWith('fd') ||
|
||||
normalized.startsWith('[fc') ||
|
||||
normalized.startsWith('[fd')
|
||||
) {
|
||||
if (IPV6_UNIQUE_LOCAL.check(normalized, 'ipv6')) {
|
||||
return { blocked: true, reason: 'IPv6 unique-local', addressClass: 'unique-local' };
|
||||
}
|
||||
if (MAPPED_IPV4_PRIVATE.check(normalized, 'ipv6')) {
|
||||
return { blocked: true, reason: 'IPv4-mapped private address', addressClass: 'private' };
|
||||
}
|
||||
if (MAPPED_IPV4_CGNAT.check(normalized, 'ipv6')) {
|
||||
return { blocked: true, reason: 'IPv4-mapped CGNAT address', addressClass: 'cgnat' };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
|
@ -129,7 +155,13 @@ function isAllowedBlockedAddress(check: BlockedAddressCheck, opts: UrlValidation
|
|||
if (opts.allowPrivateIp) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(opts.allowLocalhost && check.addressClass === 'local');
|
||||
if (opts.allowLocalhost && check.addressClass === 'local') {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
opts.allowPrivateNetwork &&
|
||||
(check.addressClass === 'private' || check.addressClass === 'unique-local')
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(address: string, opts: UrlValidationOptions): BlockedAddressCheck {
|
||||
|
|
@ -163,6 +195,10 @@ function isLocalhostHostname(hostname: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function unbracketHostname(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
||||
}
|
||||
|
||||
// ─── URL Validation ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UrlValidationResult {
|
||||
|
|
@ -187,6 +223,8 @@ export interface UrlValidationOptions {
|
|||
allowLocalhost?: boolean;
|
||||
/** Allow private IP ranges (default: false) */
|
||||
allowPrivateIp?: boolean;
|
||||
/** Allow RFC1918/ULA networks while continuing to block link-local and CGNAT ranges */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Log validation failures (default: true) */
|
||||
logFailures?: boolean;
|
||||
}
|
||||
|
|
@ -195,6 +233,7 @@ const DEFAULT_OPTIONS: UrlValidationOptions = {
|
|||
allowHttp: process.env.NODE_ENV === 'development',
|
||||
allowLocalhost: process.env.NODE_ENV === 'development',
|
||||
allowPrivateIp: false,
|
||||
allowPrivateNetwork: false,
|
||||
logFailures: true,
|
||||
};
|
||||
|
||||
|
|
@ -290,7 +329,7 @@ async function validateResolvedHostname(
|
|||
parsed: URL,
|
||||
opts: UrlValidationOptions
|
||||
): Promise<ResolvedUrlValidationResult> {
|
||||
const hostname = parsed.hostname;
|
||||
const hostname = unbracketHostname(parsed.hostname);
|
||||
const directIpFamily = isIP(hostname);
|
||||
|
||||
if (directIpFamily === 4 || directIpFamily === 6) {
|
||||
|
|
@ -407,9 +446,10 @@ async function fetchPinnedUrl(
|
|||
cb(null, resolvedAddress.address, resolvedAddress.family);
|
||||
};
|
||||
const requestBody = await bodyFromInit(init?.body ?? undefined);
|
||||
const requestHostname = unbracketHostname(parsed.hostname);
|
||||
const requestOptions: RequestOptions & { servername?: string } = {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
hostname: requestHostname,
|
||||
port: parsed.port || undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method: init?.method ?? 'GET',
|
||||
|
|
@ -419,7 +459,7 @@ async function fetchPinnedUrl(
|
|||
};
|
||||
|
||||
if (parsed.protocol === 'https:') {
|
||||
requestOptions.servername = parsed.hostname;
|
||||
requestOptions.servername = isIP(requestHostname) ? undefined : requestHostname;
|
||||
}
|
||||
|
||||
const request = parsed.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,113 @@
|
|||
import type { SquadMessage } from './chat.types.js';
|
||||
|
||||
export type CommunicationAdapterKind = 'msteams';
|
||||
export type CommunicationAdapterKind = 'msteams' | 'buzz';
|
||||
|
||||
export type CommunicationAdapterDeliveryMode = 'manual' | 'webhook';
|
||||
|
||||
export type CommunicationAdapterDestinationType = 'channel' | 'direct';
|
||||
|
||||
export type CommunicationAdapterHealthStatus = 'ok' | 'warning' | 'disabled' | 'error';
|
||||
export type CommunicationAdapterHealthStatus =
|
||||
'ok' | 'warning' | 'disabled' | 'error' | BuzzCompatibilityStatus;
|
||||
|
||||
export type CommunicationAdapterReplyMode = 'ingest-api';
|
||||
|
||||
export type CommunicationReplyTargetKind = 'squad' | 'task' | 'run' | 'approval' | 'notification';
|
||||
|
||||
export const BUZZ_COMPATIBILITY_SCHEMA_VERSION = 'buzz-compatibility/v1' as const;
|
||||
export const BUZZ_PROBE_REVISION = 1;
|
||||
export const BUZZ_TESTED_RELEASE = '0.4.24';
|
||||
export const BUZZ_TESTED_COMMIT = '710ed9fff57878a1d69f809b80a6ee0416c53fc4';
|
||||
|
||||
export type BuzzCompatibilityStatus =
|
||||
| 'healthy'
|
||||
| 'degraded'
|
||||
| 'unsupported'
|
||||
| 'unauthorized'
|
||||
| 'not_member'
|
||||
| 'misconfigured'
|
||||
| 'unreachable';
|
||||
|
||||
export type BuzzCompatibilityReasonCode =
|
||||
| 'ok'
|
||||
| 'adapter_disabled'
|
||||
| 'configuration_missing'
|
||||
| 'configuration_invalid'
|
||||
| 'endpoint_invalid'
|
||||
| 'endpoint_mismatch'
|
||||
| 'network_policy_blocked'
|
||||
| 'relay_unreachable'
|
||||
| 'response_too_large'
|
||||
| 'relay_info_invalid'
|
||||
| 'query_response_invalid'
|
||||
| 'relay_software_mismatch'
|
||||
| 'relay_version_unsupported'
|
||||
| 'community_mismatch'
|
||||
| 'credential_unavailable'
|
||||
| 'auth_tag_invalid'
|
||||
| 'public_key_mismatch'
|
||||
| 'authentication_rejected'
|
||||
| 'relay_membership_required'
|
||||
| 'read_capability_rejected'
|
||||
| 'relay_rate_limited'
|
||||
| 'relay_error';
|
||||
|
||||
export type BuzzVerificationState = 'verified' | 'not_enforced' | 'unverified' | 'failed';
|
||||
|
||||
export interface BuzzCommandConfig {
|
||||
executable: string;
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
export interface BuzzCommandDiagnostic {
|
||||
command: 'buzz' | 'buzz-acp' | 'buzz-agent' | 'configured';
|
||||
executable: string;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface BuzzCompatibilityChecks {
|
||||
relayIdentity: BuzzVerificationState;
|
||||
communityBinding: BuzzVerificationState;
|
||||
configuredIdentity: BuzzVerificationState;
|
||||
authentication: BuzzVerificationState;
|
||||
membership: BuzzVerificationState;
|
||||
channelRead: BuzzVerificationState;
|
||||
messageRead: BuzzVerificationState;
|
||||
}
|
||||
|
||||
export interface BuzzRelayContract {
|
||||
software: string;
|
||||
version: string;
|
||||
supportedNips: number[];
|
||||
supportedExtensions: string[];
|
||||
relayPublicKey?: string;
|
||||
authRequired: boolean;
|
||||
}
|
||||
|
||||
export interface BuzzCompatibilityResult {
|
||||
schemaVersion: typeof BUZZ_COMPATIBILITY_SCHEMA_VERSION;
|
||||
probeRevision: typeof BUZZ_PROBE_REVISION;
|
||||
testedRelease: typeof BUZZ_TESTED_RELEASE;
|
||||
testedCommit: typeof BUZZ_TESTED_COMMIT;
|
||||
status: BuzzCompatibilityStatus;
|
||||
reasonCode: BuzzCompatibilityReasonCode;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
configuredRelayHttpUrl: string;
|
||||
resolvedRelayHttpUrl?: string;
|
||||
configuredRelayWebSocketUrl?: string;
|
||||
resolvedRelayWebSocketUrl?: string;
|
||||
expectedCommunity?: string;
|
||||
observedCommunity?: string;
|
||||
publicKeyFingerprint: string;
|
||||
contract?: BuzzRelayContract;
|
||||
checks: BuzzCompatibilityChecks;
|
||||
commands: BuzzCommandDiagnostic[];
|
||||
evidenceKey: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface CommunicationReplyTarget {
|
||||
kind: CommunicationReplyTargetKind;
|
||||
squadMessageId?: string;
|
||||
|
|
@ -37,6 +133,18 @@ export interface CommunicationAdapterRecord {
|
|||
webhookUrlConfigured?: boolean;
|
||||
webhookUrlRedacted?: boolean;
|
||||
hasCredential: boolean;
|
||||
relayHttpUrl?: string;
|
||||
relayWebSocketUrl?: string;
|
||||
expectedCommunity?: string;
|
||||
publicKey?: string;
|
||||
publicKeyFingerprint?: string;
|
||||
credentialRef?: string;
|
||||
authTagRef?: string;
|
||||
authTagConfigured?: boolean;
|
||||
allowLocalhost?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
command?: BuzzCommandConfig;
|
||||
compatibility?: BuzzCompatibilityResult;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastHealth?: CommunicationAdapterHealth;
|
||||
|
|
@ -54,6 +162,15 @@ export interface CommunicationAdapterInput {
|
|||
chatId?: string;
|
||||
webhookUrl?: string;
|
||||
credential?: string;
|
||||
relayHttpUrl?: string;
|
||||
relayWebSocketUrl?: string | null;
|
||||
expectedCommunity?: string | null;
|
||||
publicKey?: string;
|
||||
credentialRef?: string;
|
||||
authTagRef?: string | null;
|
||||
allowLocalhost?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
command?: BuzzCommandConfig | null;
|
||||
}
|
||||
|
||||
export interface CommunicationAdapterHealth {
|
||||
|
|
@ -64,6 +181,9 @@ export interface CommunicationAdapterHealth {
|
|||
canReceiveReplies: boolean;
|
||||
checkedAt: string;
|
||||
detail: string;
|
||||
reasonCode?: BuzzCompatibilityReasonCode;
|
||||
remediation?: string;
|
||||
buzz?: BuzzCompatibilityResult;
|
||||
}
|
||||
|
||||
export interface CommunicationThreadMapping {
|
||||
|
|
@ -78,12 +198,7 @@ export interface CommunicationThreadMapping {
|
|||
}
|
||||
|
||||
export type CommunicationDeliveryOperation =
|
||||
| 'configure'
|
||||
| 'health'
|
||||
| 'send'
|
||||
| 'reply-ingest'
|
||||
| 'poll'
|
||||
| 'disconnect';
|
||||
'configure' | 'health' | 'send' | 'reply-ingest' | 'poll' | 'disconnect';
|
||||
|
||||
export type CommunicationDeliveryStatus = 'success' | 'queued' | 'failed' | 'blocked' | 'skipped';
|
||||
|
||||
|
|
@ -113,6 +228,8 @@ export interface CommunicationSendResult {
|
|||
mapping: CommunicationThreadMapping;
|
||||
}
|
||||
|
||||
export type CommunicationAdapterTestResult = CommunicationSendResult | CommunicationAdapterHealth;
|
||||
|
||||
export interface CommunicationReplyIngestInput {
|
||||
externalThreadId: string;
|
||||
externalReplyId?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BoardTab } from '@/components/settings/tabs/BoardTab';
|
||||
import { EnforcementTab } from '@/components/settings/tabs/EnforcementTab';
|
||||
import { NotificationsTab } from '@/components/settings/tabs/NotificationsTab';
|
||||
|
|
@ -92,17 +93,93 @@ const mocks = vi.hoisted(() => ({
|
|||
'Adapter can send through the configured delivery path and receive replies through the ingest API.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'buzz-default',
|
||||
kind: 'buzz',
|
||||
displayName: 'Buzz',
|
||||
enabled: true,
|
||||
deliveryMode: 'manual',
|
||||
replyMode: 'ingest-api',
|
||||
destinationType: 'channel',
|
||||
relayHttpUrl: 'https://relay.example.test',
|
||||
relayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
publicKey: 'ab'.repeat(32),
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
credentialRef: 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagConfigured: false,
|
||||
hasCredential: true,
|
||||
createdAt: '2026-07-23T18:00:00.000Z',
|
||||
updatedAt: '2026-07-23T18:00:00.000Z',
|
||||
lastHealth: {
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz relay and read capabilities are compatible.',
|
||||
reasonCode: 'ok',
|
||||
},
|
||||
},
|
||||
]),
|
||||
communicationHealth: vi.fn(async () => ({
|
||||
adapterId: 'msteams-default',
|
||||
status: 'ok',
|
||||
configured: true,
|
||||
canSend: true,
|
||||
canReceiveReplies: true,
|
||||
checkedAt: '2026-06-04T08:00:00.000Z',
|
||||
detail:
|
||||
'Adapter can send through the configured delivery path and receive replies through the ingest API.',
|
||||
})),
|
||||
communicationHealth: vi.fn(async (adapterId: string) =>
|
||||
adapterId === 'buzz-default'
|
||||
? {
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz relay and read capabilities are compatible.',
|
||||
reasonCode: 'ok',
|
||||
buzz: {
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'compatible',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
command: 'buzz',
|
||||
executable: 'buzz',
|
||||
available: false,
|
||||
detail: 'not found',
|
||||
},
|
||||
],
|
||||
evidenceKey: 'safe-evidence',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
},
|
||||
}
|
||||
: {
|
||||
adapterId: 'msteams-default',
|
||||
status: 'ok',
|
||||
configured: true,
|
||||
canSend: true,
|
||||
canReceiveReplies: true,
|
||||
checkedAt: '2026-06-04T08:00:00.000Z',
|
||||
detail:
|
||||
'Adapter can send through the configured delivery path and receive replies through the ingest API.',
|
||||
}
|
||||
),
|
||||
communicationDeliveries: vi.fn(async () => [
|
||||
{
|
||||
id: 'comm_1',
|
||||
|
|
@ -297,6 +374,19 @@ describe('Settings tab Mantine controls', () => {
|
|||
expect(await screen.findByText('Communication Health')).toBeDefined();
|
||||
expect(screen.getByText('Local Squad Chat')).toBeDefined();
|
||||
expect(screen.getByText('Human Reply Adapter')).toBeDefined();
|
||||
expect(screen.getByText('Buzz Connection')).toBeDefined();
|
||||
expect(screen.getByLabelText(/Relay HTTP URL/)).toBeDefined();
|
||||
expect(screen.getByLabelText(/Relay WebSocket URL/)).toBeDefined();
|
||||
expect(screen.getByLabelText(/Expected community/)).toBeDefined();
|
||||
expect(screen.getByLabelText(/Public key/)).toBeDefined();
|
||||
expect(screen.getByLabelText(/Signing key reference/)).toBeDefined();
|
||||
expect(screen.getByText('Signing reference:').parentElement?.textContent).toContain(
|
||||
'configured'
|
||||
);
|
||||
expect(await screen.findByText('Buzz 0.4.24, probe 1')).toBeDefined();
|
||||
expect(screen.getByText('Compatibility chain')).toBeDefined();
|
||||
expect(screen.getByText('Community binding')).toBeDefined();
|
||||
expect(screen.queryByText(/nsec1/)).toBeNull();
|
||||
expect(screen.getAllByText('Squad Chat Webhook').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/Visually verified: not recorded in VK/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('X-VK-Signature')).toBeDefined();
|
||||
|
|
@ -318,6 +408,47 @@ describe('Settings tab Mantine controls', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows unconfigured Buzz placeholders and preserves keyboard field order', async () => {
|
||||
mocks.communicationAdapters.mockResolvedValueOnce([]);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NotificationsTab />);
|
||||
|
||||
expect((await screen.findAllByText('not configured')).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('Signing reference:').parentElement?.textContent).toContain(
|
||||
'not configured'
|
||||
);
|
||||
const relayHttp = screen.getByRole('textbox', { name: /Relay HTTP URL/ });
|
||||
relayHttp.focus();
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('textbox', { name: /Relay WebSocket URL/ })
|
||||
);
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('textbox', { name: /Expected community/ })
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Buzz failure status and visible remediation', async () => {
|
||||
mocks.communicationHealth.mockResolvedValueOnce({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'not_member',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz authenticated the identity but denied relay membership.',
|
||||
reasonCode: 'relay_membership_required',
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
} as unknown as Awaited<ReturnType<typeof mocks.communicationHealth>>);
|
||||
renderWithProviders(<NotificationsTab />);
|
||||
|
||||
expect(await screen.findByText('not_member')).toBeDefined();
|
||||
expect(
|
||||
screen.getByText('relay_membership_required: Add the public identity as a relay member.')
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders Enforcement ceremony and agent selection through direct Mantine Select', async () => {
|
||||
const { container } = renderWithProviders(<EnforcementTab />);
|
||||
|
||||
|
|
|
|||
380
web/src/components/settings/tabs/BuzzConnectionPanel.tsx
Normal file
380
web/src/components/settings/tabs/BuzzConnectionPanel.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type CommunicationAdapterInput, type CommunicationAdapterRecord } from '@/lib/api';
|
||||
import { SectionHeader, ToggleRow } from '../shared';
|
||||
|
||||
const BUZZ_ADAPTER_ID = 'buzz-default';
|
||||
|
||||
interface BuzzFormState {
|
||||
enabled: boolean;
|
||||
relayHttpUrl: string;
|
||||
relayWebSocketUrl: string;
|
||||
expectedCommunity: string;
|
||||
publicKey: string;
|
||||
credentialRef: string;
|
||||
authTagRef: string;
|
||||
commandExecutable: string;
|
||||
commandArgs: string[];
|
||||
allowLocalhost: boolean;
|
||||
allowPrivateNetwork: boolean;
|
||||
}
|
||||
|
||||
function adapterToForm(adapter?: CommunicationAdapterRecord): BuzzFormState {
|
||||
return {
|
||||
enabled: adapter?.enabled ?? false,
|
||||
relayHttpUrl: adapter?.relayHttpUrl ?? '',
|
||||
relayWebSocketUrl: adapter?.relayWebSocketUrl ?? '',
|
||||
expectedCommunity: adapter?.expectedCommunity ?? '',
|
||||
publicKey: adapter?.publicKey ?? '',
|
||||
credentialRef: adapter?.credentialRef ?? 'env:BUZZ_PRIVATE_KEY',
|
||||
authTagRef: adapter?.authTagRef ?? '',
|
||||
commandExecutable: adapter?.command?.executable ?? '',
|
||||
commandArgs: adapter?.command?.args ?? [],
|
||||
allowLocalhost: adapter?.allowLocalhost ?? false,
|
||||
allowPrivateNetwork: adapter?.allowPrivateNetwork ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function formToInput(form: BuzzFormState): CommunicationAdapterInput {
|
||||
return {
|
||||
kind: 'buzz',
|
||||
enabled: form.enabled,
|
||||
displayName: 'Buzz',
|
||||
relayHttpUrl: form.relayHttpUrl,
|
||||
relayWebSocketUrl: form.relayWebSocketUrl || null,
|
||||
expectedCommunity: form.expectedCommunity || null,
|
||||
publicKey: form.publicKey,
|
||||
credentialRef: form.credentialRef,
|
||||
authTagRef: form.authTagRef || null,
|
||||
command: form.commandExecutable
|
||||
? {
|
||||
executable: form.commandExecutable,
|
||||
args: form.commandArgs.length ? form.commandArgs : undefined,
|
||||
}
|
||||
: null,
|
||||
allowLocalhost: form.allowLocalhost,
|
||||
allowPrivateNetwork: form.allowPrivateNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
function healthColor(status?: string): string {
|
||||
if (status === 'healthy') return 'green';
|
||||
if (status === 'degraded' || status === 'warning') return 'yellow';
|
||||
if (!status || status === 'disabled') return 'gray';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function verificationColor(state?: string): string {
|
||||
if (state === 'verified') return 'green';
|
||||
if (state === 'not_enforced') return 'blue';
|
||||
if (state === 'failed') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
export function BuzzConnectionPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: adapters = [] } = useQuery({
|
||||
queryKey: ['integrations', 'communication', 'adapters'],
|
||||
queryFn: api.integrations.communicationAdapters,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
const adapter = adapters.find((candidate) => candidate.id === BUZZ_ADAPTER_ID);
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['integrations', 'communication', 'health', BUZZ_ADAPTER_ID],
|
||||
queryFn: () => api.integrations.communicationHealth(BUZZ_ADAPTER_ID),
|
||||
enabled: Boolean(adapter),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
const effectiveHealth = health ?? adapter?.lastHealth;
|
||||
const compatibility = effectiveHealth?.buzz ?? adapter?.compatibility;
|
||||
const [form, setForm] = useState<BuzzFormState>(() => adapterToForm(adapter));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) setForm(adapterToForm(adapter));
|
||||
}, [adapter, dirty]);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['integrations', 'communication'] });
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.integrations.configureCommunicationAdapter(BUZZ_ADAPTER_ID, formToInput(form)),
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
const test = useMutation({
|
||||
mutationFn: () => api.integrations.testCommunicationAdapter(BUZZ_ADAPTER_ID),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => api.integrations.disconnectCommunicationAdapter(BUZZ_ADAPTER_ID),
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const update = <K extends keyof BuzzFormState>(key: K, value: BuzzFormState[K]) => {
|
||||
setDirty(true);
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const error = save.error || test.error || disconnect.error;
|
||||
const verificationSteps = [
|
||||
['Relay', compatibility?.checks.relayIdentity],
|
||||
['Community binding', compatibility?.checks.communityBinding],
|
||||
['Configured identity', compatibility?.checks.configuredIdentity],
|
||||
['Authentication', compatibility?.checks.authentication],
|
||||
['Membership', compatibility?.checks.membership],
|
||||
[
|
||||
'Read paths',
|
||||
compatibility?.checks.channelRead === 'verified' &&
|
||||
compatibility?.checks.messageRead === 'verified'
|
||||
? 'verified'
|
||||
: compatibility?.checks.channelRead === 'failed' ||
|
||||
compatibility?.checks.messageRead === 'failed'
|
||||
? 'failed'
|
||||
: 'unverified',
|
||||
],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title="Buzz Connection" />
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Verify Buzz relay identity, NIP-98 authentication, membership, and read capability without
|
||||
sending a message
|
||||
</p>
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="sm" align="flex-start">
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
Read-only compatibility
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{effectiveHealth?.detail ??
|
||||
'Save a reference-only connection to enable compatibility diagnostics.'}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color={healthColor(effectiveHealth?.status)} variant="light" tt="none">
|
||||
{effectiveHealth?.status ?? (adapter ? 'not checked' : 'not configured')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<TextInput
|
||||
label="Relay HTTP URL"
|
||||
value={form.relayHttpUrl}
|
||||
onChange={(event) => update('relayHttpUrl', event.target.value)}
|
||||
placeholder="https://community.example.com"
|
||||
type="url"
|
||||
required
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="Relay WebSocket URL"
|
||||
value={form.relayWebSocketUrl}
|
||||
onChange={(event) => update('relayWebSocketUrl', event.target.value)}
|
||||
placeholder="Derived from the HTTP URL when omitted"
|
||||
type="url"
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="Expected community"
|
||||
value={form.expectedCommunity}
|
||||
onChange={(event) => update('expectedCommunity', event.target.value)}
|
||||
placeholder="community.example.com"
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="Public key"
|
||||
value={form.publicKey}
|
||||
onChange={(event) => update('publicKey', event.target.value)}
|
||||
placeholder="64-character Nostr public key hex"
|
||||
required
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="Signing key reference"
|
||||
value={form.credentialRef}
|
||||
onChange={(event) => update('credentialRef', event.target.value)}
|
||||
placeholder="env:BUZZ_PRIVATE_KEY"
|
||||
description="Environment reference only. The private key is never stored here."
|
||||
required
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="NIP-OA auth tag reference"
|
||||
value={form.authTagRef}
|
||||
onChange={(event) => update('authTagRef', event.target.value)}
|
||||
placeholder="env:BUZZ_AUTH_TAG"
|
||||
description="Optional environment reference for delegated agent membership."
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label="Buzz command"
|
||||
value={form.commandExecutable}
|
||||
onChange={(event) => update('commandExecutable', event.target.value)}
|
||||
placeholder="Optional executable path"
|
||||
description="Optional version diagnostic. Veritas never invokes a shell."
|
||||
size="xs"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<ToggleRow
|
||||
label="Enable Buzz diagnostics"
|
||||
description="Allow read-only relay and identity probes. Message delivery remains disabled."
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(value) => update('enabled', value)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Allow localhost relay"
|
||||
description="Explicitly permit loopback endpoints for local Buzz development."
|
||||
checked={form.allowLocalhost}
|
||||
onCheckedChange={(value) => update('allowLocalhost', value)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Allow private-network relay"
|
||||
description="Explicitly permit RFC1918 or IPv6 ULA destinations. Link-local and metadata ranges remain blocked."
|
||||
checked={form.allowPrivateNetwork}
|
||||
onCheckedChange={(value) => update('allowPrivateNetwork', value)}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="sm" p="xs">
|
||||
<Text size="xs" fw={600} mb={6}>
|
||||
Compatibility chain
|
||||
</Text>
|
||||
<SimpleGrid
|
||||
cols={{ base: 2, sm: 6 }}
|
||||
spacing={4}
|
||||
aria-label="Buzz compatibility verification chain"
|
||||
>
|
||||
{verificationSteps.map(([label, state]) => (
|
||||
<Paper key={label} withBorder radius="xs" p={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
color={verificationColor(state)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
tt="none"
|
||||
mt={3}
|
||||
>
|
||||
{state ?? 'unverified'}
|
||||
</Badge>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="sm" p="xs">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={4}>
|
||||
<Text size="xs">
|
||||
Signing reference:{' '}
|
||||
<Code>{adapter?.credentialRef ? 'configured' : 'not configured'}</Code>
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Auth-tag reference:{' '}
|
||||
<Code>{adapter?.authTagConfigured ? 'configured' : 'not configured'}</Code>
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Community:{' '}
|
||||
<Code>
|
||||
{compatibility?.expectedCommunity ?? adapter?.expectedCommunity ?? 'not set'} →{' '}
|
||||
{compatibility?.observedCommunity ?? 'not observed'}
|
||||
</Code>
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Public-key fingerprint:{' '}
|
||||
<Code>
|
||||
{compatibility?.publicKeyFingerprint ?? adapter?.publicKeyFingerprint ?? 'n/a'}
|
||||
</Code>
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Tested contract:{' '}
|
||||
<Code>
|
||||
{compatibility
|
||||
? `Buzz ${compatibility.testedRelease}, probe ${compatibility.probeRevision}`
|
||||
: 'not checked'}
|
||||
</Code>
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Last check:{' '}
|
||||
<Code>
|
||||
{effectiveHealth?.checkedAt
|
||||
? new Date(effectiveHealth.checkedAt).toLocaleString()
|
||||
: 'not checked'}
|
||||
</Code>
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
{effectiveHealth?.reasonCode && effectiveHealth.reasonCode !== 'ok' && (
|
||||
<Text size="xs" c="red" mt="xs">
|
||||
{effectiveHealth.reasonCode}:{' '}
|
||||
{effectiveHealth.remediation ?? effectiveHealth.detail}
|
||||
</Text>
|
||||
)}
|
||||
{compatibility?.commands.length ? (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Commands:{' '}
|
||||
{compatibility.commands
|
||||
.map(
|
||||
(command) =>
|
||||
`${command.command}=${command.available ? command.version : 'not found'}`
|
||||
)
|
||||
.join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => test.mutate()}
|
||||
disabled={!adapter || test.isPending || save.isPending}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => disconnect.mutate()}
|
||||
disabled={!adapter || disconnect.isPending}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
<Button type="button" size="xs" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Save Buzz
|
||||
</Button>
|
||||
</Group>
|
||||
{error && (
|
||||
<Text size="xs" c="red">
|
||||
{error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type OutboundDeliveryAttempt,
|
||||
type OutboundEndpointRecord,
|
||||
} from '@/lib/api';
|
||||
import { BuzzConnectionPanel } from './BuzzConnectionPanel';
|
||||
|
||||
type CommunicationState = 'ok' | 'warn' | 'off' | 'unknown';
|
||||
|
||||
|
|
@ -389,6 +390,10 @@ export function NotificationsTab() {
|
|||
|
||||
<div className="border-t my-6" />
|
||||
|
||||
<BuzzConnectionPanel />
|
||||
|
||||
<div className="border-t my-6" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title="Human Reply Adapter" />
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export type {
|
|||
CommunicationAdapterHealth,
|
||||
CommunicationAdapterInput,
|
||||
CommunicationAdapterRecord,
|
||||
CommunicationAdapterTestResult,
|
||||
CommunicationDeliveryAudit,
|
||||
CommunicationReplyIngestInput,
|
||||
CommunicationReplyIngestResult,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
CommunicationAdapterHealth,
|
||||
CommunicationAdapterInput,
|
||||
CommunicationAdapterRecord,
|
||||
CommunicationAdapterTestResult,
|
||||
CommunicationDeliveryAudit,
|
||||
CommunicationReplyIngestInput,
|
||||
CommunicationReplyIngestResult,
|
||||
|
|
@ -111,8 +112,8 @@ export const integrationsApi = {
|
|||
testCommunicationAdapter: async (
|
||||
adapterId: string,
|
||||
message?: string
|
||||
): Promise<CommunicationSendResult> => {
|
||||
return apiFetch<CommunicationSendResult>(
|
||||
): Promise<CommunicationAdapterTestResult> => {
|
||||
return apiFetch<CommunicationAdapterTestResult>(
|
||||
`${API_BASE}/integrations/communication/adapters/${encodeURIComponent(adapterId)}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue