diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6f3cf..6b791d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.2] - 2026-02-06 (@git-stunts/alfred) + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.1] - 2026-02-06 (@git-stunts/alfred) ### Changed @@ -174,6 +180,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.2] - 2026-02-06 (@git-stunts/alfred-live) + +### Added + +- Audit-first command pipeline with attempt/result hooks. +- Auth provider hooks with allow-all and opaque-token helpers. +- Audit sinks for console and in-memory usage. +- Optional `includeRaw` flag to attach raw payloads to audit events. + ## [0.10.1] - 2026-02-06 (@git-stunts/alfred-live) ### Added diff --git a/README.md b/README.md index 280bdc7..7d2d855 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Alfred is a **policy engine** for async resilience: composable, observable, test ## Latest Release -`v0.10.1` (2026-02-06) — JSONL command channel test coverage improvements. +`v0.10.2` (2026-02-06) — Audit-first command pipeline + auth hooks for the control plane. ## Package Badges diff --git a/alfred-live/CHANGELOG.md b/alfred-live/CHANGELOG.md index 9724bf3..d41716e 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.2] - 2026-02-06 + +### Added + +- Audit-first command pipeline with attempt/result hooks. +- Auth provider hooks with allow-all and opaque-token helpers. +- Audit sinks for console and in-memory usage. +- Optional `includeRaw` flag to attach raw payloads to audit events. + ## [0.10.1] - 2026-02-06 ### Added diff --git a/alfred-live/README.md b/alfred-live/README.md index ceee531..a03b213 100644 --- a/alfred-live/README.md +++ b/alfred-live/README.md @@ -103,6 +103,40 @@ const outgoing = encodeCommandEnvelope({ if (outgoing.ok) console.log(outgoing.data); ``` +## Audit + Auth Hooks + +Attach audit and auth hooks when executing JSONL command lines. Audits record +attempt + result events, and auth runs before validation/execution. + +```javascript +import { + CommandRouter, + ConfigRegistry, + InMemoryAuditSink, + opaqueTokenAuth, + executeCommandLine, +} from '@git-stunts/alfred-live'; + +const registry = new ConfigRegistry(); +const router = new CommandRouter(registry); + +const audit = new InMemoryAuditSink(); +const auth = opaqueTokenAuth(['secret-token']); + +const line = JSON.stringify({ + id: 'cmd-1', + cmd: 'list_config', + args: { prefix: 'retry' }, + auth: 'secret-token', +}); + +const resultLine = executeCommandLine(router, line, { audit, auth }); +console.log(resultLine.data); +console.log(audit.entries()); +``` + +Pass `{ includeRaw: true }` to `executeCommandLine()` if you want audit events to include raw payloads. + ## CLI (`alfredctl`) `alfredctl` emits JSONL commands to stdout. Pipe its output into your control @@ -175,7 +209,7 @@ registry.write('gateway/api/retry/retries', '5'); ## Status -v0.10.0 control plane primitives implemented: +v0.10.2 control plane primitives implemented: - `Adaptive` live values with version + updatedAt. - `ConfigRegistry` for typed config and validation. @@ -183,3 +217,4 @@ v0.10.0 control plane primitives implemented: - `LivePolicyPlan` + `ControlPlane.registerLivePolicy` for live policy stacks. - Canonical JSONL command envelope + helpers. - `alfredctl` CLI for emitting JSONL commands. +- Audit-first command pipeline + auth hooks. diff --git a/alfred-live/jsr.json b/alfred-live/jsr.json index f2a7056..0bfb9d6 100644 --- a/alfred-live/jsr.json +++ b/alfred-live/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.1", + "version": "0.10.2", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "license": "Apache-2.0", "exports": { diff --git a/alfred-live/package.json b/alfred-live/package.json index 6854949..fd4e94c 100644 --- a/alfred-live/package.json +++ b/alfred-live/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.1", + "version": "0.10.2", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "type": "module", "sideEffects": false, @@ -16,7 +16,7 @@ "alfredctl": "./bin/alfredctl.js" }, "dependencies": { - "@git-stunts/alfred": "0.10.1" + "@git-stunts/alfred": "0.10.2" }, "engines": { "node": ">=20.0.0" diff --git a/alfred-live/src/audit.js b/alfred-live/src/audit.js new file mode 100644 index 0000000..8395794 --- /dev/null +++ b/alfred-live/src/audit.js @@ -0,0 +1,100 @@ +/** + * Redact sensitive fields recursively. + * @param {unknown} value + * @returns {unknown} + */ +function redactSensitive(value) { + if (Array.isArray(value)) { + return value.map((entry) => redactSensitive(entry)); + } + if (!value || typeof value !== 'object') { + return value; + } + + const redacted = {}; + for (const [key, entry] of Object.entries(value)) { + if (/auth|token|password/i.test(key)) { + redacted[key] = '[REDACTED]'; + } else { + redacted[key] = redactSensitive(entry); + } + } + return redacted; +} + +/** + * In-memory audit sink for command events. + */ +export class InMemoryAuditSink { + #events = []; + + /** + * @param {import('./index.d.ts').CommandAuditEvent} event + */ + record(event) { + this.#events.push(event); + } + + /** + * @returns {import('./index.d.ts').CommandAuditEvent[]} + */ + entries() { + return [...this.#events]; + } + + /** + * Clear all captured audit events. + */ + clear() { + this.#events.length = 0; + } +} + +/** + * Console audit sink for command events. + */ +export class ConsoleAuditSink { + #logger; + + /** + * @param {{ log: (...args: unknown[]) => void }} [logger] + */ + constructor(logger = console) { + this.#logger = logger; + } + + /** + * @param {import('./index.d.ts').CommandAuditEvent} event + */ + record(event) { + const payload = redactSensitive(event); + this.#logger.log('[alfred-live.audit]', payload); + } +} + +/** + * Fan-out audit sink for multiple destinations. + */ +export class MultiAuditSink { + #sinks; + + /** + * @param {Array<{ record(event: import('./index.d.ts').CommandAuditEvent): void }>} sinks + */ + constructor(sinks) { + this.#sinks = Array.isArray(sinks) ? sinks : []; + } + + /** + * @param {import('./index.d.ts').CommandAuditEvent} event + */ + record(event) { + for (const sink of this.#sinks) { + try { + sink?.record?.(event); + } catch { + // Swallow to avoid one sink breaking fan-out. + } + } + } +} diff --git a/alfred-live/src/auth.js b/alfred-live/src/auth.js new file mode 100644 index 0000000..a826634 --- /dev/null +++ b/alfred-live/src/auth.js @@ -0,0 +1,79 @@ +import { AuthorizationError, ValidationError, errorResult, okResult } from './errors.js'; + +/** + * @param {unknown} value + * @returns {value is Iterable} + */ +function isIterable(value) { + if (!value) { + return false; + } + return typeof value[Symbol.iterator] === 'function'; +} + +/** + * @param {unknown} result + * @returns {import('./index.d.ts').Result} + */ +function ensureAuthResult(result) { + if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') { + return errorResult(new ValidationError('Auth provider returned an invalid result.')); + } + return result; +} + +/** + * Auth provider that always allows commands. + * @returns {{ authorize(context: unknown): { ok: true; data: { allowed: true } } }} + */ +export function allowAllAuth() { + return { + authorize() { + return okResult({ allowed: true }); + }, + }; +} + +/** + * Auth provider that checks for a matching opaque token string. + * @param {Iterable | string} tokens + * @returns {{ authorize(context: { auth?: string }): { ok: true; data: { allowed: true } } | { ok: false; error: { code: string, message: string, details?: unknown } } }} + */ +export function opaqueTokenAuth(tokens) { + if ( + tokens !== undefined && + tokens !== null && + typeof tokens !== 'string' && + !isIterable(tokens) + ) { + return { + authorize() { + return errorResult(new ValidationError('Auth tokens must be iterable or a string.')); + }, + }; + } + + const tokenSet = typeof tokens === 'string' ? new Set([tokens]) : new Set(tokens ?? []); + + const provider = { + authorize(context) { + if (!context || typeof context !== 'object') { + return errorResult(new AuthorizationError('Missing auth context.')); + } + const auth = context.auth; + if (typeof auth !== 'string' || auth.trim().length === 0) { + return errorResult(new AuthorizationError('Missing auth token.')); + } + if (!tokenSet.has(auth)) { + return errorResult(new AuthorizationError('Invalid auth token.')); + } + return okResult({ allowed: true }); + }, + }; + + return { + authorize(context) { + return ensureAuthResult(provider.authorize(context)); + }, + }; +} diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index 096e814..2e2f8d2 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -1,4 +1,5 @@ import { + AlfredLiveError, ErrorCode, InvalidCommandError, ValidationError, @@ -25,6 +26,38 @@ function isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Normalize an envelope id. + * @param {unknown} value + * @param {string} fallbackId + * @returns {string} + */ +function normalizeId(value, fallbackId) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + return fallbackId; +} + +/** + * Parse a JSONL command line. + * @param {string} line + * @returns {{ ok: true, data: unknown } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +function parseCommandLine(line) { + if (typeof line !== 'string' || line.trim().length === 0) { + return errorResult(new InvalidCommandError('Command line must be a JSON object.')); + } + + try { + return okResult(JSON.parse(line)); + } catch (error) { + return errorResult( + new InvalidCommandError('Command line is not valid JSON.', { error: String(error) }) + ); + } +} + function hasOnlyKeys(value, allowed) { return Object.keys(value).every((key) => allowed.includes(key)); } @@ -79,6 +112,154 @@ function normalizeEnvelope(envelope) { }; } +/** + * Build an audit preview for a raw payload. + * @param {unknown} payload + * @param {string} fallbackId + * @param {boolean} includeRaw - When true, audit events include the raw payload. + * @returns {{ id: string, cmd?: string, args?: unknown, auth?: string, raw?: unknown, includeRaw: boolean }} + */ +function buildAuditPreview(payload, fallbackId, includeRaw = false) { + if (!isPlainObject(payload)) { + return { id: fallbackId, raw: payload, includeRaw: Boolean(includeRaw) }; + } + + return { + id: normalizeId(payload.id, fallbackId), + cmd: typeof payload.cmd === 'string' ? payload.cmd : undefined, + args: payload.args, + auth: typeof payload.auth === 'string' ? payload.auth : undefined, + raw: payload, + includeRaw: Boolean(includeRaw), + }; +} + +/** + * Build an audit event. + * @param {'attempt' | 'result'} phase + * @param {{ id: string, cmd?: string, args?: unknown, auth?: string, raw?: unknown, includeRaw?: boolean }} preview + * @param {{ ok: boolean, error?: { code: string, message: string, details?: unknown } }} [result] + * @returns {import('./index.d.ts').CommandAuditEvent} + */ +function buildAuditEvent(phase, preview, result) { + const event = { + phase, + timestamp: Date.now(), + id: preview.id, + cmd: preview.cmd, + args: preview.args, + auth: preview.auth, + }; + + if (preview.includeRaw) { + event.raw = preview.raw; + } + + if (result) { + event.ok = result.ok; + if (!result.ok) { + event.error = result.error; + } + } + + return event; +} + +/** + * Record an audit event. + * @param {{ record(event: import('./index.d.ts').CommandAuditEvent): void } | undefined} audit + * @param {import('./index.d.ts').CommandAuditEvent} event + * @returns {{ ok: true, data: null } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +function recordAuditEvent(audit, event) { + if (!audit) { + return okResult(null); + } + if (typeof audit.record !== 'function') { + return errorResult(new ValidationError('Audit sink must implement record().')); + } + try { + audit.record(event); + } catch (error) { + return errorResult( + new AlfredLiveError(ErrorCode.INTERNAL_ERROR, 'Audit sink failed.', { + error: String(error), + }) + ); + } + return okResult(null); +} + +/** + * Authorize a command execution. + * @param {{ authorize(context: import('./index.d.ts').CommandAuthContext): { ok: true, data: unknown } | { ok: false, error: { code: string, message: string, details?: unknown } } } | undefined} auth + * @param {import('./index.d.ts').CommandAuthContext} context + * @returns {{ ok: true, data: unknown } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +function authorizeCommand(auth, context) { + if (!auth) { + return okResult(null); + } + if (typeof auth.authorize !== 'function') { + return errorResult(new ValidationError('Auth provider must implement authorize().')); + } + + let result; + try { + result = auth.authorize(context); + } catch (error) { + return errorResult( + new AlfredLiveError(ErrorCode.INTERNAL_ERROR, 'Auth provider threw.', { + error: String(error), + }) + ); + } + + if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') { + return errorResult(new ValidationError('Auth provider returned an invalid result.')); + } + + return result; +} + +/** + * Build an auth context from a preview. + * @param {{ id: string, cmd?: string, args?: unknown, auth?: string, raw?: unknown }} preview + * @returns {import('./index.d.ts').CommandAuthContext} + */ +function buildAuthContext(preview) { + const { id, cmd, args, auth, raw } = preview; + return { id, cmd, args, auth, raw }; +} + +/** + * Encode and audit a result envelope. + * @param {{ id: string, cmd?: string, args?: unknown, auth?: string, raw?: unknown, includeRaw?: boolean }} preview + * @param {{ record(event: import('./index.d.ts').CommandAuditEvent): void } | undefined} audit + * @param {import('./index.d.ts').ResultEnvelope} resultEnvelope + * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +function encodeAuditedResult(preview, audit, resultEnvelope) { + // Audit failures override the command result to enforce audit-first semantics. + const resultAudit = recordAuditEvent(audit, buildAuditEvent('result', preview, resultEnvelope)); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } + return encodeResultEnvelope(resultEnvelope); +} + +/** + * Encode an error result and audit it. + * @param {{ id: string, cmd?: string, args?: unknown, auth?: string, raw?: unknown, includeRaw?: boolean }} preview + * @param {{ record(event: import('./index.d.ts').CommandAuditEvent): void } | undefined} audit + * @param {{ ok: false, error: { code: string, message: string, details?: unknown } }} result + * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +function encodeFailure(preview, audit, result) { + const resultEnvelope = buildResultEnvelope(preview.id, result); + return encodeAuditedResult(preview, audit, resultEnvelope); +} + /** * Validate a command envelope. * @param {unknown} envelope @@ -137,20 +318,11 @@ export function validateCommandEnvelope(envelope) { * @returns {{ ok: true, data: import('./index.d.ts').CommandEnvelope } | { ok: false, error: { code: string, message: string, details?: unknown } }} */ export function decodeCommandEnvelope(line) { - if (typeof line !== 'string' || line.trim().length === 0) { - return errorResult(new InvalidCommandError('Command line must be a JSON object.')); + const parsed = parseCommandLine(line); + if (!parsed.ok) { + return parsed; } - - let payload; - try { - payload = JSON.parse(line); - } catch (error) { - return errorResult( - new InvalidCommandError('Command line is not valid JSON.', { error: String(error) }) - ); - } - - return validateCommandEnvelope(payload); + return validateCommandEnvelope(parsed.data); } /** @@ -312,16 +484,34 @@ export function executeCommandEnvelope(router, envelope) { * Decode, validate, and execute a JSONL command line. * @param {import('./router.js').CommandRouter} router * @param {string} line - * @param {{ fallbackId?: string }} [options] + * @param {{ fallbackId?: string, includeRaw?: boolean, audit?: { record(event: import('./index.d.ts').CommandAuditEvent): void }, auth?: { authorize(context: import('./index.d.ts').CommandAuthContext): { ok: true, data: unknown } | { ok: false, error: { code: string, message: string, details?: unknown } } } }} [options] + * @param {boolean} [options.includeRaw] - Include raw payloads in audit events (disabled by default). * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} */ export function executeCommandLine(router, line, options = {}) { const fallbackId = options.fallbackId ?? 'unknown'; - const decoded = decodeCommandEnvelope(line); + const parsed = parseCommandLine(line); + const preview = buildAuditPreview(parsed.ok ? parsed.data : line, fallbackId, options.includeRaw); + + const attemptAudit = recordAuditEvent(options.audit, buildAuditEvent('attempt', preview)); + if (!attemptAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, attemptAudit)); + } + + if (!parsed.ok) { + return encodeFailure(preview, options.audit, parsed); + } + + const authResult = authorizeCommand(options.auth, buildAuthContext(preview)); + if (!authResult.ok) { + return encodeFailure(preview, options.audit, authResult); + } + + const decoded = validateCommandEnvelope(parsed.data); if (!decoded.ok) { - return encodeResultEnvelope(buildResultEnvelope(fallbackId, decoded)); + return encodeFailure(preview, options.audit, decoded); } const resultEnvelope = executeCommandEnvelope(router, decoded.data); - return encodeResultEnvelope(resultEnvelope); + return encodeAuditedResult(preview, options.audit, resultEnvelope); } diff --git a/alfred-live/src/errors.js b/alfred-live/src/errors.js index 6f28297..ca9a5da 100644 --- a/alfred-live/src/errors.js +++ b/alfred-live/src/errors.js @@ -7,6 +7,7 @@ export const ErrorCode = Object.freeze({ VALIDATION_FAILED: 'VALIDATION_FAILED', ALREADY_REGISTERED: 'ALREADY_REGISTERED', INVALID_COMMAND: 'INVALID_COMMAND', + AUTH_DENIED: 'AUTH_DENIED', INVALID_CODEC: 'INVALID_CODEC', INVALID_ADAPTIVE: 'INVALID_ADAPTIVE', INTERNAL_ERROR: 'INTERNAL_ERROR', @@ -74,6 +75,15 @@ export class InvalidCommandError extends AlfredLiveError { } } +/** + * Error for authorization failures. + */ +export class AuthorizationError extends AlfredLiveError { + constructor(message = 'Authorization denied.', details) { + super(ErrorCode.AUTH_DENIED, message, details); + } +} + /** * Error for invalid codec implementations. */ diff --git a/alfred-live/src/index.d.ts b/alfred-live/src/index.d.ts index d0f837c..14cf9e8 100644 --- a/alfred-live/src/index.d.ts +++ b/alfred-live/src/index.d.ts @@ -18,6 +18,7 @@ export const ErrorCode: { readonly VALIDATION_FAILED: 'VALIDATION_FAILED'; readonly ALREADY_REGISTERED: 'ALREADY_REGISTERED'; readonly INVALID_COMMAND: 'INVALID_COMMAND'; + readonly AUTH_DENIED: 'AUTH_DENIED'; readonly INVALID_CODEC: 'INVALID_CODEC'; readonly INVALID_ADAPTIVE: 'INVALID_ADAPTIVE'; readonly INTERNAL_ERROR: 'INTERNAL_ERROR'; @@ -42,6 +43,89 @@ export interface ErrorShape { */ export type Result = { ok: true; data: T } | { ok: false; error: ErrorShape }; +/** + * Audit phase for command events. + */ +export type AuditPhase = 'attempt' | 'result'; + +/** + * Audit event emitted for command attempts and results. + */ +export interface CommandAuditEvent { + phase: AuditPhase; + timestamp: number; + id: string; + cmd?: string; + args?: unknown; + auth?: string; + /** + * Raw payload (only included when `includeRaw` is enabled). + */ + raw?: unknown; + ok?: boolean; + error?: ErrorShape; +} + +/** + * Audit sink interface. + */ +export interface CommandAuditSink { + record(event: CommandAuditEvent): void; +} + +/** + * In-memory audit sink for commands. + */ +export class InMemoryAuditSink implements CommandAuditSink { + record(event: CommandAuditEvent): void; + entries(): CommandAuditEvent[]; + clear(): void; +} + +/** + * Console audit sink for commands. + */ +export class ConsoleAuditSink implements CommandAuditSink { + constructor(logger?: { log: (...args: unknown[]) => void }); + record(event: CommandAuditEvent): void; +} + +/** + * Fan-out audit sink for multiple destinations. + */ +export class MultiAuditSink implements CommandAuditSink { + constructor(sinks: CommandAuditSink[]); + record(event: CommandAuditEvent): void; +} + +/** + * Context passed to auth providers. + */ +export interface CommandAuthContext { + id: string; + cmd?: string; + args?: unknown; + auth?: string; + raw?: unknown; +} + +/** + * Auth provider interface. + */ +export interface CommandAuthorizer { + authorize(context: CommandAuthContext): Result; +} + +/** + * Auth provider that always allows commands. + */ +export function allowAllAuth(): CommandAuthorizer; + +/** + * Auth provider that checks for a matching opaque token string. + */ +export function opaqueTokenAuth(tokens: Iterable | string): CommandAuthorizer; + /** * Base error type for Alfred Live failures. */ @@ -86,6 +170,13 @@ export class InvalidCommandError extends AlfredLiveError { constructor(message?: string, details?: unknown); } +/** + * Error for authorization failures. + */ +export class AuthorizationError extends AlfredLiveError { + constructor(message?: string, details?: unknown); +} + /** * Error for invalid codec implementations. */ @@ -316,7 +407,15 @@ export function executeCommandEnvelope( export function executeCommandLine( router: CommandRouter, line: string, - options?: { fallbackId?: string } + options?: { + fallbackId?: string; + /** + * Include raw payloads in audit events (disabled by default). + */ + includeRaw?: boolean; + audit?: CommandAuditSink; + auth?: CommandAuthorizer; + } ): Result; /** diff --git a/alfred-live/src/index.js b/alfred-live/src/index.js index c29b10d..122734a 100644 --- a/alfred-live/src/index.js +++ b/alfred-live/src/index.js @@ -12,6 +12,8 @@ export { Adaptive } from './adaptive.js'; export { ConfigRegistry } from './registry.js'; export { CommandRouter } from './router.js'; +export { InMemoryAuditSink, ConsoleAuditSink, MultiAuditSink } from './audit.js'; +export { allowAllAuth, opaqueTokenAuth } from './auth.js'; export { buildResultEnvelope, decodeCommandEnvelope, @@ -30,6 +32,7 @@ export { ValidationError, AlreadyRegisteredError, InvalidCommandError, + AuthorizationError, InvalidCodecError, InvalidAdaptiveError, } from './errors.js'; diff --git a/alfred-live/test/unit/command-pipeline.test.js b/alfred-live/test/unit/command-pipeline.test.js new file mode 100644 index 0000000..6ce6414 --- /dev/null +++ b/alfred-live/test/unit/command-pipeline.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { CommandRouter } from '../../src/router.js'; +import { ConfigRegistry } from '../../src/registry.js'; +import { InMemoryAuditSink } from '../../src/audit.js'; +import { opaqueTokenAuth } from '../../src/auth.js'; +import { executeCommandLine } from '../../src/command-envelope.js'; + +describe('command pipeline', () => { + it('audits invalid JSON lines', () => { + const registry = new ConfigRegistry(); + const router = new CommandRouter(registry); + const audit = new InMemoryAuditSink(); + + const resultLine = executeCommandLine(router, '{"bad":', { audit }); + expect(resultLine.ok).toBe(true); + + const events = audit.entries(); + expect(events).toHaveLength(2); + expect(events[0].phase).toBe('attempt'); + expect(events[1].phase).toBe('result'); + expect(events[1].ok).toBe(false); + }); + + it('auth denies before validation and is audited', () => { + const registry = new ConfigRegistry(); + const router = new CommandRouter(registry); + const audit = new InMemoryAuditSink(); + const auth = opaqueTokenAuth(['good-token']); + + const line = JSON.stringify({ + id: 'cmd-1', + cmd: 'read_config', + args: {}, + auth: 'bad-token', + }); + + const resultLine = executeCommandLine(router, line, { audit, auth }); + expect(resultLine.ok).toBe(true); + if (!resultLine.ok) return; + + const result = JSON.parse(resultLine.data); + expect(result.ok).toBe(false); + expect(result.error.code).toBe('AUTH_DENIED'); + + const events = audit.entries(); + expect(events).toHaveLength(2); + expect(events[0].phase).toBe('attempt'); + expect(events[1].phase).toBe('result'); + expect(events[1].ok).toBe(false); + }); +}); diff --git a/alfred/CHANGELOG.md b/alfred/CHANGELOG.md index 5380046..8c37094 100644 --- a/alfred/CHANGELOG.md +++ b/alfred/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.2] - 2026-02-06 + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.1] - 2026-02-06 ### Changed diff --git a/alfred/jsr.json b/alfred/jsr.json index f944604..9371b4f 100644 --- a/alfred/jsr.json +++ b/alfred/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.1", + "version": "0.10.2", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "license": "Apache-2.0", "exports": { diff --git a/alfred/package.json b/alfred/package.json index 8bb2b4d..6f655a3 100644 --- a/alfred/package.json +++ b/alfred/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.1", + "version": "0.10.2", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "type": "module", "sideEffects": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 548098c..f49031b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: alfred-live: dependencies: '@git-stunts/alfred': - specifier: 0.10.1 + specifier: 0.10.2 version: link:../alfred devDependencies: '@eslint/js':