Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions alfred-live/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion alfred-live/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -175,11 +209,12 @@ registry.write('gateway/api/retry/retries', '5');

## Status

v0.10.0 control plane primitives implemented:
v0.10.2 control plane primitives implemented:

- `Adaptive<T>` live values with version + updatedAt.
- `ConfigRegistry` for typed config and validation.
- Command router for `read_config`, `write_config`, `list_config`.
- `LivePolicyPlan` + `ControlPlane.registerLivePolicy` for live policy stacks.
- Canonical JSONL command envelope + helpers.
- `alfredctl` CLI for emitting JSONL commands.
- Audit-first command pipeline + auth hooks.
2 changes: 1 addition & 1 deletion alfred-live/jsr.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions alfred-live/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down
100 changes: 100 additions & 0 deletions alfred-live/src/audit.js
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
}
79 changes: 79 additions & 0 deletions alfred-live/src/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { AuthorizationError, ValidationError, errorResult, okResult } from './errors.js';

/**
* @param {unknown} value
* @returns {value is Iterable<unknown>}
*/
function isIterable(value) {
if (!value) {
return false;
}
return typeof value[Symbol.iterator] === 'function';
}

/**
* @param {unknown} result
* @returns {import('./index.d.ts').Result<unknown>}
*/
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> | 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));
},
};
}
Loading