From 3c6af0254ffc16c0784eb8a58b842de159605aa0 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Thu, 5 Feb 2026 21:43:25 -0800 Subject: [PATCH 1/5] feat(alfred-live): add audit/auth command pipeline --- CHANGELOG.md | 14 ++ README.md | 2 +- alfred-live/CHANGELOG.md | 8 + alfred-live/README.md | 35 +++- alfred-live/jsr.json | 2 +- alfred-live/package.json | 4 +- alfred-live/src/audit.js | 70 +++++++ alfred-live/src/auth.js | 70 +++++++ alfred-live/src/command-envelope.js | 180 ++++++++++++++++-- alfred-live/src/errors.js | 10 + alfred-live/src/index.d.ts | 90 ++++++++- alfred-live/src/index.js | 3 + .../test/unit/command-pipeline.test.js | 52 +++++ alfred/CHANGELOG.md | 6 + alfred/jsr.json | 2 +- alfred/package.json | 2 +- pnpm-lock.yaml | 2 +- 17 files changed, 527 insertions(+), 25 deletions(-) create mode 100644 alfred-live/src/audit.js create mode 100644 alfred-live/src/auth.js create mode 100644 alfred-live/test/unit/command-pipeline.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6f3cf..48e64f3 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,14 @@ 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. + ## [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..2b30471 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -5,6 +5,14 @@ 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. + ## [0.10.1] - 2026-02-06 ### Added diff --git a/alfred-live/README.md b/alfred-live/README.md index ceee531..9e8ee33 100644 --- a/alfred-live/README.md +++ b/alfred-live/README.md @@ -103,6 +103,38 @@ 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()); +``` + ## CLI (`alfredctl`) `alfredctl` emits JSONL commands to stdout. Pipe its output into your control @@ -175,7 +207,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 +215,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..615c976 --- /dev/null +++ b/alfred-live/src/audit.js @@ -0,0 +1,70 @@ +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 = []; + + record(event) { + this.#events.push(event); + } + + entries() { + return [...this.#events]; + } + + clear() { + this.#events.length = 0; + } +} + +/** + * Console audit sink for command events. + */ +export class ConsoleAuditSink { + #logger; + + constructor(logger = console) { + this.#logger = logger; + } + + record(event) { + const payload = redactSensitive(event); + this.#logger.log('[alfred-live.audit]', payload); + } +} + +/** + * Fan-out audit sink for multiple destinations. + */ +export class MultiAuditSink { + #sinks; + + constructor(sinks) { + this.#sinks = Array.isArray(sinks) ? sinks : []; + } + + record(event) { + for (const sink of this.#sinks) { + sink?.record?.(event); + } + } +} diff --git a/alfred-live/src/auth.js b/alfred-live/src/auth.js new file mode 100644 index 0000000..50ec43c --- /dev/null +++ b/alfred-live/src/auth.js @@ -0,0 +1,70 @@ +import { AuthorizationError, ValidationError, errorResult, okResult } from './errors.js'; + +function validateAuthProvider(provider) { + if (!provider || typeof provider !== 'object') { + return new ValidationError('Auth provider must be an object.'); + } + if (typeof provider.authorize !== 'function') { + return new ValidationError('Auth provider must implement authorize().'); + } + return null; +} + +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} tokens + * @returns {{ authorize(context: { auth?: string }): { ok: true; data: { allowed: true } } | { ok: false; error: { code: string, message: string, details?: unknown } } }} + */ +export function opaqueTokenAuth(tokens) { + 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 }); + }, + }; + + const error = validateAuthProvider(provider); + if (error) { + return { + authorize() { + return errorResult(error); + }, + }; + } + + 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..8a22d8a 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,27 @@ function isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function normalizeId(value, fallbackId) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + return fallbackId; +} + +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 +101,86 @@ function normalizeEnvelope(envelope) { }; } +function buildAuditPreview(payload, fallbackId) { + if (!isPlainObject(payload)) { + return { id: fallbackId, raw: payload }; + } + + 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, + }; +} + +function buildAuditEvent(phase, preview, result) { + const event = { + phase, + timestamp: Date.now(), + id: preview.id, + cmd: preview.cmd, + args: preview.args, + auth: preview.auth, + raw: preview.raw, + }; + + if (result) { + event.ok = result.ok; + if (!result.ok) { + event.error = result.error; + } + } + + return event; +} + +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); +} + +function authorizeCommand(auth, context) { + if (!auth) { + return okResult({ allowed: true }); + } + 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; +} + /** * Validate a command envelope. * @param {unknown} envelope @@ -137,20 +239,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.')); - } - - let payload; - try { - payload = JSON.parse(line); - } catch (error) { - return errorResult( - new InvalidCommandError('Command line is not valid JSON.', { error: String(error) }) - ); + const parsed = parseCommandLine(line); + if (!parsed.ok) { + return parsed; } - - return validateCommandEnvelope(payload); + return validateCommandEnvelope(parsed.data); } /** @@ -312,16 +405,71 @@ 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, 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] * @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); + + const attemptAudit = recordAuditEvent(options.audit, buildAuditEvent('attempt', preview)); + if (!attemptAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, attemptAudit)); + } + + if (!parsed.ok) { + const resultEnvelope = buildResultEnvelope(preview.id, parsed); + const resultAudit = recordAuditEvent( + options.audit, + buildAuditEvent('result', preview, resultEnvelope) + ); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } + return encodeResultEnvelope(resultEnvelope); + } + + const authContext = { + id: preview.id, + cmd: preview.cmd, + args: preview.args, + auth: preview.auth, + raw: preview.raw, + }; + const authResult = authorizeCommand(options.auth, authContext); + if (!authResult.ok) { + const resultEnvelope = buildResultEnvelope(preview.id, authResult); + const resultAudit = recordAuditEvent( + options.audit, + buildAuditEvent('result', preview, resultEnvelope) + ); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } + return encodeResultEnvelope(resultEnvelope); + } + + const decoded = validateCommandEnvelope(parsed.data); if (!decoded.ok) { - return encodeResultEnvelope(buildResultEnvelope(fallbackId, decoded)); + const resultEnvelope = buildResultEnvelope(preview.id, decoded); + const resultAudit = recordAuditEvent( + options.audit, + buildAuditEvent('result', preview, resultEnvelope) + ); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } + return encodeResultEnvelope(resultEnvelope); } const resultEnvelope = executeCommandEnvelope(router, decoded.data); + const resultAudit = recordAuditEvent( + options.audit, + buildAuditEvent('result', preview, resultEnvelope) + ); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } return encodeResultEnvelope(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..ec7c821 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,86 @@ 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?: 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 +167,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 +404,7 @@ export function executeCommandEnvelope( export function executeCommandLine( router: CommandRouter, line: string, - options?: { fallbackId?: string } + options?: { fallbackId?: string; 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': From a07248d7ec1b9d92b3d103b38bd83ea16a15b323 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Thu, 5 Feb 2026 21:44:23 -0800 Subject: [PATCH 2/5] refactor(alfred-live): simplify command pipeline --- alfred-live/src/command-envelope.js | 71 ++++++++++++----------------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index 8a22d8a..a628b45 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -181,6 +181,29 @@ function authorizeCommand(auth, context) { return result; } +function buildAuthContext(preview) { + return { + id: preview.id, + cmd: preview.cmd, + args: preview.args, + auth: preview.auth, + raw: preview.raw, + }; +} + +function encodeAuditedResult(preview, audit, resultEnvelope) { + const resultAudit = recordAuditEvent(audit, buildAuditEvent('result', preview, resultEnvelope)); + if (!resultAudit.ok) { + return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); + } + return encodeResultEnvelope(resultEnvelope); +} + +function encodeFailure(preview, audit, result) { + const resultEnvelope = buildResultEnvelope(preview.id, result); + return encodeAuditedResult(preview, audit, resultEnvelope); +} + /** * Validate a command envelope. * @param {unknown} envelope @@ -419,57 +442,19 @@ export function executeCommandLine(router, line, options = {}) { } if (!parsed.ok) { - const resultEnvelope = buildResultEnvelope(preview.id, parsed); - const resultAudit = recordAuditEvent( - options.audit, - buildAuditEvent('result', preview, resultEnvelope) - ); - if (!resultAudit.ok) { - return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); - } - return encodeResultEnvelope(resultEnvelope); + return encodeFailure(preview, options.audit, parsed); } - const authContext = { - id: preview.id, - cmd: preview.cmd, - args: preview.args, - auth: preview.auth, - raw: preview.raw, - }; - const authResult = authorizeCommand(options.auth, authContext); + const authResult = authorizeCommand(options.auth, buildAuthContext(preview)); if (!authResult.ok) { - const resultEnvelope = buildResultEnvelope(preview.id, authResult); - const resultAudit = recordAuditEvent( - options.audit, - buildAuditEvent('result', preview, resultEnvelope) - ); - if (!resultAudit.ok) { - return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); - } - return encodeResultEnvelope(resultEnvelope); + return encodeFailure(preview, options.audit, authResult); } const decoded = validateCommandEnvelope(parsed.data); if (!decoded.ok) { - const resultEnvelope = buildResultEnvelope(preview.id, decoded); - const resultAudit = recordAuditEvent( - options.audit, - buildAuditEvent('result', preview, resultEnvelope) - ); - if (!resultAudit.ok) { - return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); - } - return encodeResultEnvelope(resultEnvelope); + return encodeFailure(preview, options.audit, decoded); } const resultEnvelope = executeCommandEnvelope(router, decoded.data); - const resultAudit = recordAuditEvent( - options.audit, - buildAuditEvent('result', preview, resultEnvelope) - ); - if (!resultAudit.ok) { - return encodeResultEnvelope(buildResultEnvelope(preview.id, resultAudit)); - } - return encodeResultEnvelope(resultEnvelope); + return encodeAuditedResult(preview, options.audit, resultEnvelope); } From 54d998b977f8f0ecd8cdef60a3e5275d81192b84 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 6 Feb 2026 04:21:59 -0800 Subject: [PATCH 3/5] fix(alfred-live): harden audit/auth hooks --- alfred-live/README.md | 2 + alfred-live/src/audit.js | 29 +++++++++- alfred-live/src/auth.js | 43 ++++++++------- alfred-live/src/command-envelope.js | 82 ++++++++++++++++++++++++----- alfred-live/src/index.d.ts | 7 ++- 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/alfred-live/README.md b/alfred-live/README.md index 9e8ee33..a03b213 100644 --- a/alfred-live/README.md +++ b/alfred-live/README.md @@ -135,6 +135,8 @@ 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 diff --git a/alfred-live/src/audit.js b/alfred-live/src/audit.js index 615c976..219141f 100644 --- a/alfred-live/src/audit.js +++ b/alfred-live/src/audit.js @@ -1,3 +1,8 @@ +/** + * Redact sensitive fields recursively. + * @param {unknown} value + * @returns {unknown} + */ function redactSensitive(value) { if (Array.isArray(value)) { return value.map((entry) => redactSensitive(entry)); @@ -23,10 +28,16 @@ function redactSensitive(value) { 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]; } @@ -42,10 +53,16 @@ export class InMemoryAuditSink { 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); @@ -58,13 +75,23 @@ export class ConsoleAuditSink { 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) { - sink?.record?.(event); + 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 index 50ec43c..f4c7c75 100644 --- a/alfred-live/src/auth.js +++ b/alfred-live/src/auth.js @@ -1,15 +1,18 @@ import { AuthorizationError, ValidationError, errorResult, okResult } from './errors.js'; -function validateAuthProvider(provider) { - if (!provider || typeof provider !== 'object') { - return new ValidationError('Auth provider must be an object.'); - } - if (typeof provider.authorize !== 'function') { - return new ValidationError('Auth provider must implement authorize().'); - } - return null; +/** + * @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.')); @@ -31,10 +34,23 @@ export function allowAllAuth() { /** * Auth provider that checks for a matching opaque token string. - * @param {Iterable} tokens + * @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 = { @@ -53,15 +69,6 @@ export function opaqueTokenAuth(tokens) { }, }; - const error = validateAuthProvider(provider); - if (error) { - return { - authorize() { - return errorResult(error); - }, - }; - } - 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 a628b45..cccae06 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -26,6 +26,12 @@ 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; @@ -33,6 +39,11 @@ function normalizeId(value, fallbackId) { 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.')); @@ -101,9 +112,16 @@ function normalizeEnvelope(envelope) { }; } -function buildAuditPreview(payload, fallbackId) { +/** + * Build an audit preview for a raw payload. + * @param {unknown} payload + * @param {string} fallbackId + * @param {boolean} includeRaw + * @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 }; + return { id: fallbackId, raw: payload, includeRaw: Boolean(includeRaw) }; } return { @@ -112,9 +130,17 @@ function buildAuditPreview(payload, fallbackId) { 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, @@ -123,9 +149,12 @@ function buildAuditEvent(phase, preview, result) { cmd: preview.cmd, args: preview.args, auth: preview.auth, - raw: preview.raw, }; + if (preview.includeRaw) { + event.raw = preview.raw; + } + if (result) { event.ok = result.ok; if (!result.ok) { @@ -136,6 +165,12 @@ function buildAuditEvent(phase, preview, result) { 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); @@ -155,9 +190,15 @@ function recordAuditEvent(audit, event) { 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({ allowed: true }); + return okResult(null); } if (typeof auth.authorize !== 'function') { return errorResult(new ValidationError('Auth provider must implement authorize().')); @@ -181,17 +222,25 @@ function authorizeCommand(auth, context) { 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) { - return { - id: preview.id, - cmd: preview.cmd, - args: preview.args, - auth: preview.auth, - raw: preview.raw, - }; + 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)); @@ -199,6 +248,13 @@ function encodeAuditedResult(preview, audit, resultEnvelope) { 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); @@ -428,13 +484,13 @@ 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, 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 {{ 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] * @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 parsed = parseCommandLine(line); - const preview = buildAuditPreview(parsed.ok ? parsed.data : line, fallbackId); + const preview = buildAuditPreview(parsed.ok ? parsed.data : line, fallbackId, options.includeRaw); const attemptAudit = recordAuditEvent(options.audit, buildAuditEvent('attempt', preview)); if (!attemptAudit.ok) { diff --git a/alfred-live/src/index.d.ts b/alfred-live/src/index.d.ts index ec7c821..4d6c2d9 100644 --- a/alfred-live/src/index.d.ts +++ b/alfred-live/src/index.d.ts @@ -404,7 +404,12 @@ export function executeCommandEnvelope( export function executeCommandLine( router: CommandRouter, line: string, - options?: { fallbackId?: string; audit?: CommandAuditSink; auth?: CommandAuthorizer } + options?: { + fallbackId?: string; + includeRaw?: boolean; + audit?: CommandAuditSink; + auth?: CommandAuthorizer; + } ): Result; /** From 3f42742f53159f092f140ec649023b185e095fec Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 6 Feb 2026 04:22:30 -0800 Subject: [PATCH 4/5] fix(alfred-live): satisfy lint for auth helpers --- alfred-live/src/auth.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/alfred-live/src/auth.js b/alfred-live/src/auth.js index f4c7c75..a826634 100644 --- a/alfred-live/src/auth.js +++ b/alfred-live/src/auth.js @@ -5,7 +5,9 @@ import { AuthorizationError, ValidationError, errorResult, okResult } from './er * @returns {value is Iterable} */ function isIterable(value) { - if (!value) return false; + if (!value) { + return false; + } return typeof value[Symbol.iterator] === 'function'; } From 455f0536fc866043227e0a1f37a0a4f703167c85 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 6 Feb 2026 04:26:43 -0800 Subject: [PATCH 5/5] docs(alfred-live): tighten audit docs and changelog --- CHANGELOG.md | 1 + alfred-live/CHANGELOG.md | 1 + alfred-live/src/audit.js | 3 +++ alfred-live/src/command-envelope.js | 3 ++- alfred-live/src/index.d.ts | 6 ++++++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e64f3..6b791d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) diff --git a/alfred-live/CHANGELOG.md b/alfred-live/CHANGELOG.md index 2b30471..d41716e 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/alfred-live/src/audit.js b/alfred-live/src/audit.js index 219141f..8395794 100644 --- a/alfred-live/src/audit.js +++ b/alfred-live/src/audit.js @@ -42,6 +42,9 @@ export class InMemoryAuditSink { return [...this.#events]; } + /** + * Clear all captured audit events. + */ clear() { this.#events.length = 0; } diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index cccae06..2e2f8d2 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -116,7 +116,7 @@ function normalizeEnvelope(envelope) { * Build an audit preview for a raw payload. * @param {unknown} payload * @param {string} fallbackId - * @param {boolean} includeRaw + * @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) { @@ -485,6 +485,7 @@ export function executeCommandEnvelope(router, envelope) { * @param {import('./router.js').CommandRouter} router * @param {string} line * @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 = {}) { diff --git a/alfred-live/src/index.d.ts b/alfred-live/src/index.d.ts index 4d6c2d9..14cf9e8 100644 --- a/alfred-live/src/index.d.ts +++ b/alfred-live/src/index.d.ts @@ -58,6 +58,9 @@ export interface CommandAuditEvent { cmd?: string; args?: unknown; auth?: string; + /** + * Raw payload (only included when `includeRaw` is enabled). + */ raw?: unknown; ok?: boolean; error?: ErrorShape; @@ -406,6 +409,9 @@ export function executeCommandLine( line: string, options?: { fallbackId?: string; + /** + * Include raw payloads in audit events (disabled by default). + */ includeRaw?: boolean; audit?: CommandAuditSink; auth?: CommandAuthorizer;