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
27 changes: 12 additions & 15 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
# Compatibility Contract

## Web Console 1.6.200 / bridge 0.2.2

The [published components](docs/releases/web-console-1.6.200.md) retain normal
UI/session/Full-access behavior and require a matching Engine with private
Catalog ingress protection, live actor/Key scope, CAS and per-child graph
validation. Catalog launch cannot safely target Engine 0.183.358. Archive/restore
reuses the native stopped-only owner and never starts restored workloads. Fixed
background authorization-denied results remain failed, warn about possible
partial changes and cannot be blindly replayed.

The original producer passed required full CI and CodeQL; the published assets
passed anonymous byte verification. The published tag and producer share the
same source tree. Combined Server, Catalog/Archive browser, role and weak-model
runtime acceptance remain **PENDING**; partial source coverage is not a whole
business-group PASS.
## Web Console 1.6.201 / bridge 0.2.3 candidate

External Service and DNS Alias workflows need Engine 0.183.360. Alias creation
is empty/stopped; binding selected local service references needs a separate
human-approved plan. Unsupported link types and cross-source cleanup remain
explicitly unavailable. Existing Catalog/Archive/session behavior is preserved.
Typed audit queries need both the matching Engine and installed broker owner.
An older Cookie-only adapter returns an actionable denial; the bridge must not
forge a Cookie or switch credentials. Raw audit payloads stay human-only.
Normal Full/legacy permissions remain unchanged. Required CI, clean-install and
combined runtime acceptance are pending. See the
[candidate release note](docs/releases/web-console-1.6.201.md).

## Web Console 1.6.195

Expand Down
45 changes: 20 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,26 @@ history, authorship, licenses, and dependency notices.

## Current component

Web Console **1.6.200** and stdio bridge **0.2.2** are [published](https://github.com/PastureStack/web-console/releases/tag/1.6.200).
They retain the twelve typed tools and add bounded Catalog Compose launch and
stopped-resource Archive/restore workflows. Private Catalog answers require
normal human input/review; restoration does not start workloads. Other Catalog
administration, advanced forms and remaining frontend workflows are pending.

The [release note](docs/releases/web-console-1.6.200.md) records the original
qualified producer, immutable archive/SBOM hashes and published tag. Full CI
passed 1,121 Ember tests with zero failures; required CodeQL and anonymous hash
verification of all 13 release assets passed. The standalone ZIP passed a clean
install and 48 focused adapter/SDK cases.

Combined Server acceptance and the designated Catalog/Archive browser, role and
weak-model workflows remain **PENDING**. The inventory records partial typed
source coverage for 54 of 243 business groups; this is not whole-group acceptance
or complete frontend coverage. Earlier released artifacts remain unchanged.

The release producer uses Node.js `24.22.1` / npm `12.0.2` in a digest-pinned
container and produces an immutable static archive with separate runtime and
build CycloneDX SBOMs. [SBOM coverage](docs/security/sbom.md) remains explicitly
incomplete for inherited vendor metadata; hosted-runner Ruby is tracked
separately. CI, Server assembly and deployment require their own evidence.

Use the [Server installation guide](https://github.com/PastureStack/server#quick-start)
for deployment. Historical changes remain in [release notes](docs/releases).
Current source is Web Console **1.6.201** / bridge **0.2.3**, not yet published.
The latest published client remains
[1.6.200 / 0.2.2](https://github.com/PastureStack/web-console/releases/tag/1.6.200).

The candidate retains twelve tools and extends typed workflows for External
Services, DNS Aliases and audit metadata. Alias creation and selecting targets
are separate human-reviewed changes. Queries use opaque references; no raw
resource IDs, private audit payloads or credentials are exposed to tools.
Matching Engine 0.183.360 and broker audit-query components are required.

Normal UI, session, Full-access, existing Catalog/Archive and human-confirmation
protections remain. Required CI/CodeQL, immutable artifact/clean-install proof,
combined Server and designated browser/role/weak-model acceptance are pending.
The [candidate release note](docs/releases/web-console-1.6.201.md) records the
remaining boundaries. This candidate does not complete all 243 business groups.

The release producer uses Node.js `24.22.1` / npm `12.0.2` and separate runtime /
build CycloneDX SBOMs. Dependencies remain unchanged. Use the
[Server installation guide](https://github.com/PastureStack/server#quick-start)
for deployment. Historical published evidence remains in [release notes](docs/releases).

## Features and operator guides

Expand Down
27 changes: 27 additions & 0 deletions app/utils/ai-alias-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {CommandError} from './ai-command-contract';

export const ALIAS_WORKFLOW = Object.freeze(['create', 'update_targets', 'optional_start']);
export const ALIAS_CREATE_NEXT_STEP = 'Only an empty stopped Alias is being created; no targets are bound yet. After this create operation is verified, find the new dnsService by its exact name and Stack, then preview update_targets with the selected same-session serviceRefs for a separate human approval. Start is another optional approved plan. Do not report the complete Alias workflow as finished after create.';

export function aliasTargetIds(references, resolve) {
if ( !Array.isArray(references) || references.length > 16 ) { throw new CommandError('INVALID_INPUT', 'parameters.serviceRefs'); }
const ids = [];
for ( const reference of references ) {
const target = resolve(reference);
if ( target.scope !== 'project' || !['service', 'dnsService', 'externalService', 'loadBalancerService'].includes(target.resourceType) || ids.includes(target.id) ) {
throw new CommandError('INVALID_INPUT', 'parameters.serviceRefs');
}
ids.push(target.id);
}
return ids;
}

export function safeAliasTargetSummary(value) {
if ( value === null ) { return; }
let names;
try { names = JSON.parse(value); } catch (error) { throw new CommandError('INVALID_RESPONSE'); }
if ( !Array.isArray(names) || names.length > 16 || names.some((name) => typeof name !== 'string' || name.length > 768) ) {
throw new CommandError('INVALID_RESPONSE');
}
return names;
}
130 changes: 130 additions & 0 deletions app/utils/ai-audit-reader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { CommandError } from './ai-command-contract';

const PATH = 'pasturestack/ai-audit-queries';
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
const PRESENCE = ['present', 'not_projected'];
const ID_FIELDS = ['id', 'accountId', 'authenticatedAsAccountId', 'keyId', 'resourceId', 'targetId', 'processId'];
export const AUDIT_FIELDS = Object.freeze(['created', 'eventType', 'resourceType', 'authType', 'decision', 'outcome',
'responseCode', 'httpStatus', 'requestId', 'targetType', 'operation', 'phase', 'policyRevision', 'preview',
'processName', 'failureCode', 'interactionChannel', 'payloadsIncluded', 'requestPayloadPresence', 'responsePayloadPresence']);
const invalid = (field = 'auditFilters') => { throw new CommandError('INVALID_INPUT', field); };
const badResponse = () => { throw new CommandError('INVALID_RESPONSE'); };
const object = value => value && typeof value === 'object' && !Array.isArray(value);
const token = value => typeof value === 'string' && TOKEN.test(value);
const instant = value => typeof value === 'string' && value.length <= 64 &&
/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));

export function auditFilterSignature(filters) {
return JSON.stringify(Object.keys(filters).sort().map(key => [key, filters[key]]));
}

export function auditSafeFields(row) {
const result = {};
for (const field of AUDIT_FIELDS) if (Object.hasOwn(row, field)) result[field] = row[field];
return result;
}

export function createAuditReader({request, context, resolveResource, transportKind}) {
async function read(data, captured, signal) {
context.assertCurrent(captured, signal);
if (!['browser', 'stdio'].includes(transportKind)) throw new CommandError('UNAVAILABLE', 'auditFilters.transport');
let response;
try { response = await request({url: PATH, method: 'POST', data, signal}); }
catch (error) {
context.assertCurrent(captured, signal);
const status = Number(error && error.status), code = error && error.body && error.body.code;
if (status === 503 && ['AuditQueryUnavailable', 'AuditQueryAuditUnavailable'].includes(code)) {
throw new CommandError('UNAVAILABLE', code === 'AuditQueryAuditUnavailable' ? 'auditFilters.audit' : 'auditFilters.service');
}
if (status === 422 && ['AuditQueryInputInvalid', 'AuditQueryNarrowQuery'].includes(code)) {
invalid(code === 'AuditQueryNarrowQuery' ? 'auditFilters.timeRange' : 'auditFilters');
}
if (status === 502 && ['invalid_audit_response', 'invalid_key_authority', 'invalid_key_audit_scope'].includes(code)) badResponse();
if ([500, 502, 503].includes(status) && ['audit_upstream_unavailable', 'key_audit_unavailable', 'audit_query_failed'].includes(code)) {
throw new CommandError('UNAVAILABLE', 'auditFilters.service');
}
if ([400, 422].includes(Number(error && error.status)) &&
['result_set_too_large', 'time_range_too_large', 'incomplete_time_range', 'invalid_time_range'].includes(error.body && error.body.code)) {
invalid('auditFilters.timeRange');
}
throw error;
}
context.assertCurrent(captured, signal);
const body = response && response.body;
if (!object(body) || body.resourceType !== 'auditLog' || body.payloadPolicy !== 'HUMAN_ONLY_NATIVE_AUDIT_UI' ||
!Array.isArray(body.data) || body.data.length > 50 || !Number.isInteger(body.total) || body.total < 0 || body.total > 20000 ||
!object(body.range) || Object.keys(body.range).some(key => !['from', 'to'].includes(key)) ||
!instant(body.range.from) || !instant(body.range.to) ||
Date.parse(body.range.from) >= Date.parse(body.range.to) ||
Date.parse(body.range.to) - Date.parse(body.range.from) > 366 * 86400000 ||
data.lastMinutes && Date.parse(body.range.to) - Date.parse(body.range.from) !== data.lastMinutes * 60000 ||
data.from && Date.parse(data.from) !== Date.parse(body.range.from) ||
data.to && Date.parse(data.to) !== Date.parse(body.range.to)) badResponse();
const allowed = [...ID_FIELDS, ...AUDIT_FIELDS, 'type'];
const rows = body.data.map(row => {
if (!object(row) || row.type !== 'auditLog' || !token(row.id) || row.payloadsIncluded !== false ||
!PRESENCE.includes(row.requestPayloadPresence) || !PRESENCE.includes(row.responsePayloadPresence) ||
Object.keys(row).some(key => !allowed.includes(key)) ||
data.environmentId && row.accountId !== data.environmentId || data.keyId && row.keyId !== data.keyId) badResponse();
for (const field of [...ID_FIELDS, ...AUDIT_FIELDS]) {
const value = row[field];
if (value === undefined) continue;
if (['preview', 'payloadsIncluded'].includes(field)) {
if (typeof value !== 'boolean') badResponse();
} else if (['httpStatus', 'policyRevision'].includes(field)) {
if (!Number.isSafeInteger(value) || value < 0) badResponse();
} else if (field === 'created') {
if (!instant(value)) badResponse();
} else if (!token(value)) badResponse();
}
return {...row};
});
if (new Set(rows.map(row => row.id)).size !== rows.length) badResponse();
const offset = data.offset || 0;
const end = Math.min(offset, body.total) + rows.length;
if (end > body.total || (end < body.total ? body.nextOffset !== end || end <= offset : body.nextOffset !== null)) badResponse();
return {rows, total: body.total, nextOffset: body.nextOffset, range: {...body.range}};
}

function query(filters, captured) {
const data = {environmentId: captured.projectId};
if (filters.environmentScope === 'all-visible') delete data.environmentId;
if (filters.environmentRef) {
if (filters.environmentScope === 'all-visible') invalid('auditFilters.environmentRef');
const target = resolveResource(filters.environmentRef, captured, 'auditFilters.environmentRef');
if (target.resourceType !== 'project' || target.scope !== 'global') invalid('auditFilters.environmentRef');
data.environmentId = target.id;
}
if (filters.keyRef) {
const target = resolveResource(filters.keyRef, captured, 'auditFilters.keyRef');
if (!['apiKey', 'apiKeyRestricted'].includes(target.resourceType) || target.scope !== 'global') invalid('auditFilters.keyRef');
data.keyId = target.id;
}
if (filters.lastMinutes !== undefined && (filters.from !== undefined || filters.to !== undefined)) invalid('auditFilters.timeRange');
if (filters.from !== undefined || filters.to !== undefined) {
if (!instant(filters.from) || !instant(filters.to) || Date.parse(filters.from) >= Date.parse(filters.to) ||
Date.parse(filters.to) - Date.parse(filters.from) > 366 * 86400000) invalid('auditFilters.timeRange');
}
for (const field of ['from', 'to', 'lastMinutes', 'decision', 'outcome', 'operation', 'targetType', 'requestId', 'httpStatus', 'limit']) {
if (filters[field] !== undefined) data[field] = filters[field];
}
return data;
}
return Object.freeze({
async find(filters, cursor, captured, signal) {
const selected = query(filters, captured);
const signature = auditFilterSignature(filters);
if (cursor && (cursor.auditFilterSignature !== signature || !object(cursor.auditQuery))) invalid('cursor');
const data = cursor ? {...cursor.auditQuery, offset: cursor.offset} : selected;
const result = await read(data, captured, signal);
const frozen = {...selected, from: result.range.from, to: result.range.to};
delete frozen.lastMinutes;
return {...result, query: frozen, signature};
},
async get(target, captured, signal) {
const result = await read({...target.auditQuery, eventId: target.id}, captured, signal);
if (result.rows.length !== 1 || result.rows[0].id !== target.id || result.nextOffset !== null) badResponse();
return result.rows[0];
},
});
}
Loading
Loading