diff --git a/apps/api/package.json b/apps/api/package.json
index 89248481..9285844e 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -17,6 +17,7 @@
"test:m5-02": "node ../../tools/m5-02/run-focused-api-test.mjs",
"test:m5-03": "node ../../tools/m5-03/run-focused-api-test.mjs",
"test:m5-04": "node ../../tools/m5-04/run-focused-api-test.mjs",
+ "test:m5-05": "node ../../tools/m5-05/run-focused-api-test.mjs",
"test:m4-01": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/m4-01-meta-app-config.spec.js",
"test:m3-09": "node ../../tools/m3-09/run-focused-test.mjs",
"test:m3-08": "node ../../tools/m3-08/run-focused-test.mjs",
diff --git a/apps/api/src/automation-config/automation-config.controller.ts b/apps/api/src/automation-config/automation-config.controller.ts
new file mode 100644
index 00000000..2620eda9
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.controller.ts
@@ -0,0 +1,118 @@
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Get,
+ Headers,
+ HttpException,
+ HttpStatus,
+ Param,
+ Patch,
+ Req,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
+import type { Response } from 'express';
+import type { components } from '@ca-meta/contracts';
+import { PublicRoute } from '../common/security/public-route.decorator.js';
+import { PortalSessionGuard, type PortalRequest } from '../identity/portal-session.guard.js';
+import { PORTAL_CONTEXT_COOKIE, readPortalCookie } from '../identity/portal-session.service.js';
+import { AutomationConfigService, automationConfigEtag } from './automation-config.service.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ UnsupportedAutomationConfigFieldError,
+} from './automation-config.types.js';
+
+const CONTROL_SAFE_TEXT = /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$/;
+
+class AutomationConfigPatchDto {
+ @IsInt()
+ @Min(0)
+ @Max(Number.MAX_SAFE_INTEGER)
+ version!: number;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ privateWelcomeMessage?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ commentPublicReply?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ commentPrivateReply?: string;
+}
+
+function contextRef(request: PortalRequest, headerContext?: string): string {
+ const ref = headerContext ?? readPortalCookie(request.headers.cookie, PORTAL_CONTEXT_COOKIE);
+ if (!ref || !/^[A-Za-z0-9._~-]{16,512}$/.test(ref)) throw new BadRequestException();
+ return ref;
+}
+
+function requestId(request: PortalRequest): string { return request.requestId ?? 'unknown'; }
+
+function parseIfMatch(value: string | undefined): number {
+ const match = value ? /^"automation-v(0|[1-9]\d*)"$/.exec(value) : null;
+ const version = match ? Number(match[1]) : NaN;
+ if (!Number.isSafeInteger(version) || version < 0) throw new BadRequestException();
+ return version;
+}
+
+function mapAutomationError(error: unknown): never {
+ if (error instanceof AutomationConfigNotFoundError) throw new HttpException('Resource not available.', HttpStatus.NOT_FOUND);
+ if (error instanceof AutomationConfigVersionConflictError) throw new HttpException('Configuration changed. Reload before saving.', HttpStatus.PRECONDITION_FAILED);
+ if (error instanceof AutomationConfigValidationError || error instanceof UnsupportedAutomationConfigFieldError) {
+ throw new HttpException('Check the supported fields and message text.', HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+ throw error;
+}
+
+@Controller()
+export class AutomationConfigController {
+ constructor(private readonly config: AutomationConfigService) {}
+
+ @Get('assets/:assetId/automation-config/channels')
+ @PublicRoute()
+ @UseGuards(PortalSessionGuard)
+ async read(
+ @Param('assetId') assetId: string,
+ @Req() request: PortalRequest,
+ @Headers('x-tenant-context') headerContext?: string,
+ @Res({ passthrough: true }) response?: Response,
+ ): Promise {
+ try {
+ const result = await this.config.read(request.portalSession, contextRef(request, headerContext), assetId, requestId(request));
+ response?.setHeader('ETag', automationConfigEtag(result.version));
+ return result;
+ } catch (error) { return mapAutomationError(error); }
+ }
+
+ @Patch('assets/:assetId/automation-config/channels')
+ @PublicRoute()
+ @UseGuards(PortalSessionGuard)
+ async update(
+ @Param('assetId') assetId: string,
+ @Body() body: AutomationConfigPatchDto,
+ @Req() request: PortalRequest,
+ @Headers('x-tenant-context') headerContext?: string,
+ @Headers('if-match') ifMatch?: string,
+ @Res({ passthrough: true }) response?: Response,
+ ): Promise {
+ try {
+ const result = await this.config.update(request.portalSession, contextRef(request, headerContext), assetId,
+ requestId(request), parseIfMatch(ifMatch), body);
+ response?.setHeader('ETag', automationConfigEtag(result.version));
+ return result;
+ } catch (error) { return mapAutomationError(error); }
+ }
+}
diff --git a/apps/api/src/automation-config/automation-config.service.ts b/apps/api/src/automation-config/automation-config.service.ts
new file mode 100644
index 00000000..5f161c03
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.service.ts
@@ -0,0 +1,111 @@
+import type { components } from '@ca-meta/contracts';
+import { IdentityDeniedError } from '../identity/identity.errors.js';
+import { isVerifiedPortalSession, type VerifiedPortalSession } from '../identity/identity.trust.js';
+import { TenantAuthorizationService, TenantContextService } from '../identity/identity.service.js';
+import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js';
+import type { MetaAssetsRepository } from '../meta-assets/mysql-meta-assets.repository.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ UnsupportedAutomationConfigFieldError,
+ automationConfigAssetKind,
+ automationConfigFieldsForAssetKind,
+ type AutomationConfigPatch,
+ type AutomationConfigRepository,
+} from './automation-config.types.js';
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const MAX_MESSAGE_LENGTH = 4096;
+const DISALLOWED_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
+
+function isEditor(roles: readonly string[]): boolean {
+ return roles.some((role) => role === 'owner' || role === 'admin' || role === 'operator');
+}
+
+function validatePatch(patch: AutomationConfigPatch, supportedFields: readonly string[]): void {
+ if (!Number.isSafeInteger(patch.version) || patch.version < 0) throw new AutomationConfigVersionConflictError();
+ const fields = ['privateWelcomeMessage', 'commentPublicReply', 'commentPrivateReply'] as const;
+ const changed = fields.filter((field) => Object.prototype.hasOwnProperty.call(patch, field));
+ if (changed.length === 0) throw new AutomationConfigValidationError();
+ for (const field of changed) {
+ if (!supportedFields.includes(field)) throw new AutomationConfigValidationError();
+ const value = patch[field];
+ if (typeof value !== 'string' || value.length > MAX_MESSAGE_LENGTH || DISALLOWED_CONTROLS.test(value)) {
+ throw new AutomationConfigValidationError();
+ }
+ }
+}
+
+export function automationConfigEtag(version: number): string { return `"automation-v${version}"`; }
+
+export class AutomationConfigService {
+ constructor(
+ private readonly repository: AutomationConfigRepository,
+ private readonly assets: MetaAssetsRepository,
+ private readonly contexts: TenantContextService,
+ private readonly authorization: TenantAuthorizationService,
+ private readonly environment: MetaEnvironment,
+ ) {}
+
+ async read(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string): Promise {
+ const scope = await this.scope(session, contextRef);
+ await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId);
+ const asset = await this.findAsset(scope.tenantId, assetId);
+ const assetKind = automationConfigAssetKind(asset.kind);
+ if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
+ const state = await this.repository.read({ tenantId: scope.tenantId, assetId: asset.assetRef });
+ return this.serialize(asset.assetRef, assetKind, state, isEditor(scope.roles), requestId);
+ }
+
+ async update(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string,
+ ifMatchVersion: number, patch: AutomationConfigPatch): Promise {
+ const scope = await this.scope(session, contextRef);
+ await this.authorization.authorize(scope, 'MANAGE_AUTOMATION_CONFIG', scope.tenantId);
+ const asset = await this.findAsset(scope.tenantId, assetId);
+ const assetKind = automationConfigAssetKind(asset.kind);
+ if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
+ const supportedFields = automationConfigFieldsForAssetKind(assetKind);
+ ifMatchVersion = Number.isSafeInteger(ifMatchVersion) && ifMatchVersion >= 0 ? ifMatchVersion : -1;
+ if (ifMatchVersion !== patch.version) throw new AutomationConfigVersionConflictError();
+ validatePatch(patch, supportedFields);
+ const state = await this.repository.update({
+ tenantId: scope.tenantId,
+ assetId: asset.assetRef,
+ actorId: scope.principalId,
+ requestId,
+ patch,
+ });
+ return this.serialize(asset.assetRef, assetKind, state, true, requestId);
+ }
+
+ private async scope(session: VerifiedPortalSession, contextRef: string) {
+ if (!isVerifiedPortalSession(session)) throw new IdentityDeniedError();
+ return this.contexts.resolveCurrent(session, contextRef);
+ }
+
+ private async findAsset(tenantId: string, assetId: string) {
+ if (!UUID.test(assetId)) throw new AutomationConfigNotFoundError();
+ const asset = await this.assets.findAssetCatalog({ tenantId, environment: this.environment, assetRef: assetId });
+ if (!asset) throw new AutomationConfigNotFoundError();
+ return asset;
+ }
+
+ private serialize(assetId: string, assetKind: components['schemas']['AutomationConfigAssetKind'],
+ state: Awaited>, canEdit: boolean, requestId: string): components['schemas']['AutomationChannelConfig'] {
+ return Object.freeze({
+ assetId,
+ assetKind,
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: automationConfigFieldsForAssetKind(assetKind),
+ privateWelcomeMessage: state.privateWelcomeMessage,
+ commentPublicReply: state.commentPublicReply,
+ commentPrivateReply: state.commentPrivateReply,
+ capabilities: Object.freeze({ canEdit, executable: false }),
+ version: state.version,
+ updatedAt: state.updatedAt,
+ projectionState: state.projectionState,
+ requestId,
+ });
+ }
+}
diff --git a/apps/api/src/automation-config/automation-config.tokens.ts b/apps/api/src/automation-config/automation-config.tokens.ts
new file mode 100644
index 00000000..cd2dd7cd
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.tokens.ts
@@ -0,0 +1 @@
+export const AUTOMATION_CONFIG_REPOSITORY = Symbol('AUTOMATION_CONFIG_REPOSITORY');
diff --git a/apps/api/src/automation-config/automation-config.types.ts b/apps/api/src/automation-config/automation-config.types.ts
new file mode 100644
index 00000000..1325acc2
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.types.ts
@@ -0,0 +1,64 @@
+import type { components } from '@ca-meta/contracts';
+
+export type AutomationConfigField = components['schemas']['AutomationConfigField'];
+export type AutomationConfigAssetKind = components['schemas']['AutomationConfigAssetKind'];
+export type AutomationConfigProjectionState = components['schemas']['AutomationConfigProjectionState'];
+
+export interface AutomationConfigState {
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly version: number;
+ readonly updatedAt: string | null;
+ readonly projectionState: AutomationConfigProjectionState;
+}
+
+export interface AutomationConfigPatch {
+ readonly version: number;
+ readonly privateWelcomeMessage?: string;
+ readonly commentPublicReply?: string;
+ readonly commentPrivateReply?: string;
+}
+
+export interface AutomationConfigRepository {
+ read(input: { readonly tenantId: string; readonly assetId: string }): Promise;
+ update(input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise;
+}
+
+export const AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND: Readonly> = Object.freeze({
+ WHATSAPP_PHONE_NUMBER: Object.freeze(['privateWelcomeMessage'] as const),
+ FACEBOOK_PAGE: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+ INSTAGRAM_BUSINESS_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+ INSTAGRAM_CREATOR_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+});
+
+export function automationConfigAssetKind(kind: string): AutomationConfigAssetKind | undefined {
+ return Object.prototype.hasOwnProperty.call(AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND, kind)
+ ? kind as AutomationConfigAssetKind : undefined;
+}
+
+export function automationConfigFieldsForAssetKind(kind: AutomationConfigAssetKind): readonly AutomationConfigField[] {
+ return AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND[kind];
+}
+
+export class AutomationConfigNotFoundError extends Error {
+ constructor() { super('Automation configuration asset is unavailable.'); this.name = 'AutomationConfigNotFoundError'; }
+}
+
+export class UnsupportedAutomationConfigFieldError extends Error {
+ constructor() { super('The requested field is not available for this asset.'); this.name = 'UnsupportedAutomationConfigFieldError'; }
+}
+
+export class AutomationConfigValidationError extends Error {
+ constructor() { super('Automation configuration update is invalid.'); this.name = 'AutomationConfigValidationError'; }
+}
+
+export class AutomationConfigVersionConflictError extends Error {
+ constructor() { super('Automation configuration version changed.'); this.name = 'AutomationConfigVersionConflictError'; }
+}
diff --git a/apps/api/src/automation-config/mysql-automation-config.repository.ts b/apps/api/src/automation-config/mysql-automation-config.repository.ts
new file mode 100644
index 00000000..3bf732e8
--- /dev/null
+++ b/apps/api/src/automation-config/mysql-automation-config.repository.ts
@@ -0,0 +1,153 @@
+import { createHash, randomUUID } from 'node:crypto';
+import type { EntityManager } from 'typeorm';
+import type { DurableWorkDatabase } from '../jobs/durable-work-database.js';
+import type { OutboxService } from '../outbox/outbox-service.js';
+import { AutomationConfigVersionConflictError, type AutomationConfigPatch, type AutomationConfigRepository, type AutomationConfigState } from './automation-config.types.js';
+
+interface AutomationConfigRow {
+ private_welcome_message: string | null;
+ comment_public_reply: string | null;
+ comment_private_reply: string | null;
+ version: number | string;
+ updated_at: string | Date | null;
+ projection_state: AutomationConfigState['projectionState'];
+}
+
+const EMPTY_CONFIG: AutomationConfigState = Object.freeze({
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+});
+
+function isRecordAffected(result: unknown, expected: number): boolean {
+ const header = Array.isArray(result) ? result[0] : result;
+ return typeof header === 'object' && header !== null && 'affectedRows' in header &&
+ Number((header as { affectedRows?: number }).affectedRows ?? 0) === expected;
+}
+
+function sqlTimestamp(value: string): string {
+ return `${value.slice(0, 19).replace('T', ' ')}.${value.slice(20, 23)}000`;
+}
+
+function formatTimestamp(value: string | Date | null): string | null {
+ if (value === null) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
+}
+
+function fromRow(row: AutomationConfigRow | undefined): AutomationConfigState {
+ if (!row) return EMPTY_CONFIG;
+ const version = Number(row.version);
+ if (!Number.isSafeInteger(version) || version < 0) throw new Error('Automation configuration version is outside the supported range.');
+ return Object.freeze({
+ privateWelcomeMessage: row.private_welcome_message,
+ commentPublicReply: row.comment_public_reply,
+ commentPrivateReply: row.comment_private_reply,
+ version,
+ updatedAt: formatTimestamp(row.updated_at),
+ projectionState: row.projection_state,
+ });
+}
+
+function actorHash(actorId: string): string {
+ return createHash('sha256').update(`automation-config-actor\0${actorId}`, 'utf8').digest('hex');
+}
+
+export class MysqlAutomationConfigRepository implements AutomationConfigRepository {
+ constructor(private readonly database: DurableWorkDatabase, private readonly outbox: OutboxService) {}
+
+ async read(input: { readonly tenantId: string; readonly assetId: string }): Promise {
+ const dataSource = await this.database.getDataSource();
+ const rows = await dataSource.query(
+ `SELECT private_welcome_message, comment_public_reply, comment_private_reply, version, updated_at, projection_state
+ FROM ca_meta_asset_automation_configs WHERE tenant_id = ? AND asset_id = ? LIMIT 1`,
+ [input.tenantId, input.assetId],
+ ) as AutomationConfigRow[];
+ return fromRow(rows[0]);
+ }
+
+ async update(input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise {
+ const dataSource = await this.database.getDataSource();
+ return dataSource.transaction(async (manager) => this.updateWithin(manager, input));
+ }
+
+ private async updateWithin(manager: EntityManager, input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise {
+ const now = new Date().toISOString();
+ const timestamp = sqlTimestamp(now);
+ await manager.query(
+ `INSERT IGNORE INTO ca_meta_asset_automation_configs
+ (tenant_id, asset_id, private_welcome_message, comment_public_reply, comment_private_reply,
+ version, projection_state, created_at, updated_at)
+ VALUES (?, ?, NULL, NULL, NULL, 0, 'UNKNOWN', ?, NULL)`,
+ [input.tenantId, input.assetId, timestamp],
+ );
+ const rows = await manager.query(
+ `SELECT private_welcome_message, comment_public_reply, comment_private_reply, version, updated_at, projection_state
+ FROM ca_meta_asset_automation_configs WHERE tenant_id = ? AND asset_id = ? FOR UPDATE`,
+ [input.tenantId, input.assetId],
+ ) as AutomationConfigRow[];
+ const current = fromRow(rows[0]);
+ if (!rows[0] || current.version !== input.patch.version || current.version >= Number.MAX_SAFE_INTEGER) {
+ throw new AutomationConfigVersionConflictError();
+ }
+ const nextVersion = current.version + 1;
+ const next = Object.freeze({
+ privateWelcomeMessage: input.patch.privateWelcomeMessage === undefined ? current.privateWelcomeMessage : input.patch.privateWelcomeMessage || null,
+ commentPublicReply: input.patch.commentPublicReply === undefined ? current.commentPublicReply : input.patch.commentPublicReply || null,
+ commentPrivateReply: input.patch.commentPrivateReply === undefined ? current.commentPrivateReply : input.patch.commentPrivateReply || null,
+ version: nextVersion,
+ updatedAt: now,
+ projectionState: 'PENDING' as const,
+ });
+ const updateResult = await manager.query(
+ `UPDATE ca_meta_asset_automation_configs
+ SET private_welcome_message = ?, comment_public_reply = ?, comment_private_reply = ?,
+ version = ?, projection_state = 'PENDING', updated_at = ?
+ WHERE tenant_id = ? AND asset_id = ? AND version = ?`,
+ [next.privateWelcomeMessage, next.commentPublicReply, next.commentPrivateReply, nextVersion, timestamp,
+ input.tenantId, input.assetId, current.version],
+ );
+ if (!isRecordAffected(updateResult, 1)) {
+ throw new AutomationConfigVersionConflictError();
+ }
+ const changedByHash = actorHash(input.actorId);
+ const changedFields = ['privateWelcomeMessage', 'commentPublicReply', 'commentPrivateReply']
+ .filter((field) => Object.prototype.hasOwnProperty.call(input.patch, field));
+ await manager.query(
+ `INSERT INTO ca_meta_asset_automation_config_versions
+ (tenant_id, asset_id, version, changed_fields_json, actor_ref_hash, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [input.tenantId, input.assetId, nextVersion, JSON.stringify(changedFields), changedByHash, timestamp],
+ );
+ await this.outbox.appendWithin(manager, {
+ eventId: `automation-config:${input.assetId}:${nextVersion}`,
+ tenantRef: input.tenantId,
+ schemaVersion: 1,
+ eventType: 'automation.config.changed',
+ aggregateRef: input.assetId,
+ aggregateVersion: nextVersion,
+ actor: { kind: 'USER', ref: input.actorId },
+ correlationId: input.requestId,
+ causationId: input.requestId,
+ createdAt: now,
+ enqueuedAt: now,
+ data: { kind: 'AUTOMATION_CONFIG_CHANGED', assetRef: input.assetId, configurationVersion: nextVersion },
+ });
+ return next;
+ }
+}
diff --git a/apps/api/src/common/security/portal-request-security.ts b/apps/api/src/common/security/portal-request-security.ts
index 62966c78..1b66ce43 100644
--- a/apps/api/src/common/security/portal-request-security.ts
+++ b/apps/api/src/common/security/portal-request-security.ts
@@ -69,6 +69,7 @@ export function portalRequestSecurityMiddleware(
}
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
+ response.setHeader('Access-Control-Expose-Headers', 'ETag');
response.vary('Origin');
}
@@ -84,7 +85,7 @@ export function portalRequestSecurityMiddleware(
const requestedMethod = oneHeader(request.headers['access-control-request-method']);
const requestedHeaders = oneHeader(request.headers['access-control-request-headers']);
const allowedMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
- const allowedHeaders = new Set(['content-type', 'x-csrf-token', 'x-tenant-context', 'idempotency-key']);
+ const allowedHeaders = new Set(['content-type', 'x-csrf-token', 'x-tenant-context', 'idempotency-key', 'if-match']);
const normalizedHeaders = requestedHeaders?.split(',').map((header) => header.trim().toLowerCase()).filter(Boolean) ?? [];
if (origin === undefined || !requestedMethod || !allowedMethods.has(requestedMethod.toUpperCase()) ||
normalizedHeaders.some((header) => !allowedHeaders.has(header))) {
@@ -92,7 +93,7 @@ export function portalRequestSecurityMiddleware(
return;
}
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
- response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token, X-Tenant-Context, Idempotency-Key');
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token, X-Tenant-Context, Idempotency-Key, If-Match');
response.setHeader('Access-Control-Max-Age', '600');
response.status(HttpStatus.NO_CONTENT).end();
return;
diff --git a/apps/api/src/database/migrations/automation-config.ts b/apps/api/src/database/migrations/automation-config.ts
new file mode 100644
index 00000000..d1ee8f79
--- /dev/null
+++ b/apps/api/src/database/migrations/automation-config.ts
@@ -0,0 +1,144 @@
+import type { QueryRunner } from 'typeorm';
+import { migrationChecksum } from './migration-fingerprint.js';
+import { assertExpectedColumns, tableExists } from './schema-inspection.js';
+import type { DdlStep, VersionedMigration } from './migration.types.js';
+
+interface IndexShape {
+ readonly name: string;
+ readonly columns: readonly string[];
+ readonly unique: boolean;
+}
+
+async function verifyIndexes(queryRunner: QueryRunner, table: string, expected: readonly IndexShape[]): Promise {
+ const rows = await queryRunner.query(
+ 'SELECT index_name AS name, column_name, non_unique FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? ORDER BY index_name, seq_in_index',
+ [table],
+ ) as Array<{ name: string; column_name: string; non_unique: number | string }>;
+ const actual = new Map();
+ for (const row of rows) {
+ const index = actual.get(row.name) ?? { columns: [], unique: Number(row.non_unique) === 0 };
+ index.columns.push(row.column_name);
+ actual.set(row.name, index);
+ }
+ if (actual.size !== expected.length || expected.some((index) => {
+ const found = actual.get(index.name);
+ return !found || found.unique !== index.unique || found.columns.join('\0') !== index.columns.join('\0');
+ })) throw new Error(`Migration found an incompatible index on ${table}.`);
+}
+
+function tableStep(id: string, table: string, columns: readonly string[], indexes: readonly IndexShape[], sql: string): DdlStep {
+ return {
+ id,
+ phase: 'expand',
+ sql,
+ definitionVersion: 'v1',
+ definition: JSON.stringify({ table, columns, indexes, helpers: [assertExpectedColumns.toString(), verifyIndexes.toString()] }),
+ retryWhenAbsent: true,
+ verify: async ({ queryRunner }) => {
+ if (!(await tableExists(queryRunner, table))) return false;
+ await assertExpectedColumns(queryRunner, table, columns);
+ await verifyIndexes(queryRunner, table, indexes);
+ return true;
+ },
+ };
+}
+
+async function triggerExists(queryRunner: QueryRunner, name: string, event: 'UPDATE' | 'DELETE'): Promise {
+ const rows = await queryRunner.query(
+ 'SELECT action_timing, event_manipulation, event_object_table, action_statement FROM information_schema.triggers WHERE trigger_schema = DATABASE() AND trigger_name = ?',
+ [name],
+ ) as Array<{ action_timing: string; event_manipulation: string; event_object_table: string; action_statement: string }>;
+ if (rows.length === 0) return false;
+ const row = rows[0];
+ const statement = row?.action_statement.toUpperCase().replace(/[\s`]+/g, '');
+ if (row?.action_timing !== 'BEFORE' || row.event_manipulation !== event ||
+ row.event_object_table !== 'ca_meta_asset_automation_config_versions' ||
+ statement !== "SIGNALSQLSTATE'45000'SETMESSAGE_TEXT='APPEND-ONLYAUTOMATIONHISTORY'") {
+ throw new Error(`Migration found an incompatible trigger ${name}.`);
+ }
+ return true;
+}
+
+function immutableHistoryTrigger(id: string, event: 'UPDATE' | 'DELETE'): DdlStep {
+ const triggerName = `tr_ca_meta_automation_config_history_no_${event.toLowerCase()}`;
+ const statement = "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'append-only automation history'";
+ return {
+ id,
+ phase: 'expand',
+ sql: `CREATE TRIGGER ${triggerName} BEFORE ${event} ON ca_meta_asset_automation_config_versions FOR EACH ROW ${statement}`,
+ definitionVersion: 'v1',
+ definition: JSON.stringify({ triggerName, table: 'ca_meta_asset_automation_config_versions', event, statement }),
+ retryWhenAbsent: true,
+ verify: ({ queryRunner }) => triggerExists(queryRunner, triggerName, event),
+ };
+}
+
+const config = tableStep(
+ 'create-asset-automation-configs',
+ 'ca_meta_asset_automation_configs',
+ ['tenant_id', 'asset_id', 'private_welcome_message', 'comment_public_reply', 'comment_private_reply', 'version', 'projection_state', 'created_at', 'updated_at'],
+ [
+ { name: 'PRIMARY', columns: ['tenant_id', 'asset_id'], unique: true },
+ { name: 'ix_ca_meta_automation_config_projection', columns: ['projection_state', 'updated_at'], unique: false },
+ ],
+ `CREATE TABLE IF NOT EXISTS ca_meta_asset_automation_configs (
+ tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ private_welcome_message TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ comment_public_reply TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ comment_private_reply TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ version BIGINT UNSIGNED NOT NULL DEFAULT 0,
+ projection_state VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'UNKNOWN',
+ created_at DATETIME(6) NOT NULL,
+ updated_at DATETIME(6) NULL,
+ PRIMARY KEY (tenant_id, asset_id),
+ KEY ix_ca_meta_automation_config_projection (projection_state, updated_at),
+ CONSTRAINT ck_ca_meta_automation_config_projection CHECK (projection_state IN ('UNKNOWN', 'PENDING', 'SYNCED', 'STALE', 'FAILED')),
+ CONSTRAINT fk_ca_meta_automation_config_asset FOREIGN KEY (tenant_id, asset_id)
+ REFERENCES ca_meta_assets (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`,
+);
+
+const history = tableStep(
+ 'create-asset-automation-config-versions',
+ 'ca_meta_asset_automation_config_versions',
+ ['tenant_id', 'asset_id', 'version', 'changed_fields_json', 'actor_ref_hash', 'created_at'],
+ [
+ { name: 'PRIMARY', columns: ['tenant_id', 'asset_id', 'version'], unique: true },
+ { name: 'ix_ca_meta_automation_config_history_actor', columns: ['tenant_id', 'actor_ref_hash', 'created_at'], unique: false },
+ ],
+ `CREATE TABLE IF NOT EXISTS ca_meta_asset_automation_config_versions (
+ tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ version BIGINT UNSIGNED NOT NULL,
+ changed_fields_json JSON NOT NULL,
+ actor_ref_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ created_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (tenant_id, asset_id, version),
+ KEY ix_ca_meta_automation_config_history_actor (tenant_id, actor_ref_hash, created_at),
+ CONSTRAINT fk_ca_meta_automation_config_history_asset FOREIGN KEY (tenant_id, asset_id)
+ REFERENCES ca_meta_assets (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`,
+);
+
+const definition = {
+ id: '202611120023-asset-automation-config',
+ schemaVersion: 23,
+ minimumSupportedApiVersion: 1,
+ steps: [
+ config,
+ history,
+ immutableHistoryTrigger('automation-config-history-no-update', 'UPDATE'),
+ immutableHistoryTrigger('automation-config-history-no-delete', 'DELETE'),
+ ],
+} as const;
+
+export const AUTOMATION_CONFIG_MIGRATION: VersionedMigration = {
+ ...definition,
+ checksum: migrationChecksum(definition),
+};
+
+export const AUTOMATION_CONFIG_TABLES = [
+ 'ca_meta_asset_automation_configs',
+ 'ca_meta_asset_automation_config_versions',
+] as const;
diff --git a/apps/api/src/database/migrations/migrations.ts b/apps/api/src/database/migrations/migrations.ts
index fcd04dd3..fc7d6c18 100644
--- a/apps/api/src/database/migrations/migrations.ts
+++ b/apps/api/src/database/migrations/migrations.ts
@@ -21,6 +21,7 @@ import { ASSET_OBSERVATIONS_MIGRATION, ASSET_OBSERVATION_TABLES } from './asset-
import { CONNECTION_LIFECYCLE_MIGRATION, CONNECTION_LIFECYCLE_TABLES } from './connection-lifecycle.js';
import { INTERNAL_ASSET_OPERATIONS_MIGRATION, INTERNAL_ASSET_OPERATION_TABLES } from './internal-asset-operations.js';
import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from './onboarding-coexistence-lock.js';
+import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from './automation-config.js';
function ddl(
id: string,
@@ -293,6 +294,7 @@ const migrationsWithoutChecksums = [
CONNECTION_LIFECYCLE_MIGRATION,
INTERNAL_ASSET_OPERATIONS_MIGRATION,
ONBOARDING_COEXISTENCE_LOCK_MIGRATION,
+ AUTOMATION_CONFIG_MIGRATION,
] as const;
export const MIGRATIONS: readonly VersionedMigration[] = migrationsWithoutChecksums.map((migration) => ({
@@ -326,5 +328,6 @@ export const DOMAIN_TABLES = [
...CONNECTION_LIFECYCLE_TABLES,
...INTERNAL_ASSET_OPERATION_TABLES,
...ONBOARDING_COEXISTENCE_LOCK_TABLES,
+ ...AUTOMATION_CONFIG_TABLES,
] as const;
diff --git a/apps/api/src/identity/identity.service.ts b/apps/api/src/identity/identity.service.ts
index 1a852abf..ef794b5f 100644
--- a/apps/api/src/identity/identity.service.ts
+++ b/apps/api/src/identity/identity.service.ts
@@ -217,6 +217,7 @@ const ROLE_PERMISSIONS: Readonly new MysqlAutomationConfigRepository(database, outbox),
+ inject: [DURABLE_WORK_DATABASE, OutboxService],
+ },
+ {
+ provide: AutomationConfigService,
+ useFactory: (repository: AutomationConfigRepository, assets: MetaAssetsRepository,
+ contexts: TenantContextService, authorization: TenantAuthorizationService) =>
+ new AutomationConfigService(repository, assets, contexts, authorization, environment.nodeEnv),
+ inject: [AUTOMATION_CONFIG_REPOSITORY, META_ASSETS_REPOSITORY, TenantContextService, TenantAuthorizationService],
+ },
{ provide: CREDENTIAL_KEY_PROVIDER, useFactory: () => new ConfiguredCredentialKeyProvider(environment.credentialKeyRing) },
{ provide: CREDENTIAL_VAULT_AUTHORIZATION, useClass: FailClosedCredentialVaultAuthorization },
{
diff --git a/apps/api/test/m3-04-session.spec.ts b/apps/api/test/m3-04-session.spec.ts
index 852b61ce..cc941430 100644
--- a/apps/api/test/m3-04-session.spec.ts
+++ b/apps/api/test/m3-04-session.spec.ts
@@ -398,6 +398,9 @@ test('mutations reject untrusted origins, Fetch Metadata and session CSRF mismat
const good = await invokeMiddleware(middleware, request({ ...baseHeaders, 'x-csrf-token': csrf }));
assert.equal(good.continued, true);
assert.equal(good.response.headerValue('access-control-allow-origin'), 'https://portal.example');
+ const readableResponse = await invokeMiddleware(middleware, request({ origin: 'https://portal.example' }, 'GET'));
+ assert.equal(readableResponse.continued, true);
+ assert.match(String(readableResponse.response.headerValue('access-control-expose-headers')), /(?:^|,\s*)ETag(?:,|$)/i);
const missingCsrf = await invokeMiddleware(middleware, request(baseHeaders, 'POST', '/api/v1/session/logout'));
assert.equal((missingCsrf.response.bodyValue() as { code: string }).code, 'CSRF_INVALID');
@@ -407,11 +410,12 @@ test('mutations reject untrusted origins, Fetch Metadata and session CSRF mismat
const preflight = await invokeMiddleware(middleware, request({
origin: 'https://portal.example',
- 'access-control-request-method': 'DELETE',
- 'access-control-request-headers': 'content-type,x-csrf-token',
+ 'access-control-request-method': 'PATCH',
+ 'access-control-request-headers': 'content-type,x-csrf-token,if-match',
}, 'OPTIONS'));
assert.equal(preflight.response.statusCodeValue(), 204);
assert.match(String(preflight.response.headerValue('access-control-allow-headers')), /X-CSRF-Token/);
+ assert.match(String(preflight.response.headerValue('access-control-allow-headers')), /If-Match/);
const legacyPreflight = await invokeMiddleware(middleware, request({
origin: 'https://portal.example',
'access-control-request-method': 'DELETE',
diff --git a/apps/api/test/m5-05-automation-config.integration.spec.ts b/apps/api/test/m5-05-automation-config.integration.spec.ts
new file mode 100644
index 00000000..d701e091
--- /dev/null
+++ b/apps/api/test/m5-05-automation-config.integration.spec.ts
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { DataSource, EntityManager } from 'typeorm';
+import type { ApiEnvironment } from '../src/config/environment.js';
+import { DurableWorkDatabase } from '../src/jobs/durable-work-database.js';
+import { DurableJobStore } from '../src/jobs/job-store.js';
+import { OutboxService } from '../src/outbox/outbox-service.js';
+import { AutomationConfigVersionConflictError } from '../src/automation-config/automation-config.types.js';
+import { MysqlAutomationConfigRepository } from '../src/automation-config/mysql-automation-config.repository.js';
+
+const enabled = process.env.M5_05_AUTOMATION_TEST === '1';
+const tenantId = '90000000-0000-4000-8000-000000000059';
+const appConfigurationId = '90000000-0000-4000-8000-000000000051';
+const firstAssetId = '90000000-0000-4000-8000-000000000052';
+const secondAssetId = '90000000-0000-4000-8000-000000000053';
+const actorId = '90000000-0000-4000-8000-000000000054';
+const requestId = 'req-m5-05-synthetic-0001';
+const message = 'M5.05 synthetic text never copied to the outbox';
+
+test('MariaDB transaction keeps asset settings isolated, rejects stale versions and stores only intent metadata in the outbox', { skip: !enabled }, async () => {
+ const required = [
+ 'API_DATABASE_HOST', 'API_DATABASE_PORT', 'API_DATABASE_NAME', 'API_DATABASE_USER', 'API_DATABASE_PASSWORD',
+ ] as const;
+ const missing = required.filter((name) => !process.env[name]);
+ assert.deepEqual(missing, [], 'scoped API database environment must be provided for the isolated integration test');
+
+ const environment = {
+ database: {
+ host: process.env.API_DATABASE_HOST!,
+ port: Number(process.env.API_DATABASE_PORT),
+ name: process.env.API_DATABASE_NAME!,
+ user: process.env.API_DATABASE_USER!,
+ password: process.env.API_DATABASE_PASSWORD!,
+ connectTimeoutMs: 5000,
+ },
+ } as ApiEnvironment;
+ const database = new DurableWorkDatabase(environment);
+ const dataSource = await database.getDataSource();
+ const runner = dataSource.createQueryRunner();
+ await runner.connect();
+ await runner.startTransaction();
+
+ try {
+ await runner.query(
+ `INSERT INTO ca_meta_tenants (id, status, version, created_at, updated_at)
+ VALUES (?, 'ACTIVE', 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, [tenantId],
+ );
+ await runner.query(
+ `INSERT INTO ca_meta_app_configurations
+ (id, tenant_id, provider, app_external_id, environment, credential_ref, version, created_at, updated_at)
+ VALUES (?, ?, 'META', 'm5-05-synthetic-app', 'test', NULL, 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`,
+ [appConfigurationId, tenantId],
+ );
+ for (const [assetId, externalId] of [[firstAssetId, 'm5-05-number-1'], [secondAssetId, 'm5-05-number-2']] as const) {
+ await runner.query(
+ `INSERT INTO ca_meta_assets
+ (id, tenant_id, app_configuration_id, provider, environment, asset_kind, external_id,
+ display_name, external_name, version, created_at, updated_at)
+ VALUES (?, ?, ?, 'META', 'test', 'WHATSAPP_PHONE_NUMBER', ?, 'Synthetic M5.05 asset',
+ 'Synthetic M5.05 asset', 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`,
+ [assetId, tenantId, appConfigurationId, externalId],
+ );
+ }
+
+ // Repository and outbox transactions share the test-owned transaction. Rolling it back
+ // removes every synthetic fixture and append-only audit row at the end of this test.
+ const scopedDataSource = {
+ query: (sql: string, parameters?: readonly unknown[]) => runner.manager.query(sql, parameters),
+ transaction: async (operation: (manager: EntityManager) => Promise): Promise => operation(runner.manager),
+ } as unknown as DataSource;
+ const scopedDatabase = {
+ getDataSource: async () => scopedDataSource,
+ } as unknown as DurableWorkDatabase;
+ const jobs = new DurableJobStore(scopedDatabase);
+ const outbox = new OutboxService(scopedDatabase, jobs);
+ const repository = new MysqlAutomationConfigRepository(scopedDatabase, outbox);
+
+ const saved = await repository.update({
+ tenantId, assetId: firstAssetId, actorId, requestId,
+ patch: { version: 0, privateWelcomeMessage: message },
+ });
+ const separateAsset = await repository.read({ tenantId, assetId: secondAssetId });
+ assert.equal(saved.version, 1);
+ assert.equal(saved.privateWelcomeMessage, message);
+ assert.equal(saved.projectionState, 'PENDING');
+ assert.equal(separateAsset.version, 0);
+ assert.equal(separateAsset.privateWelcomeMessage, null);
+
+ await assert.rejects(repository.update({
+ tenantId, assetId: firstAssetId, actorId, requestId: `${requestId}-stale`,
+ patch: { version: 0, privateWelcomeMessage: 'stale synthetic value' },
+ }), AutomationConfigVersionConflictError);
+
+ const historyRows = await runner.manager.query(
+ `SELECT version, changed_fields_json FROM ca_meta_asset_automation_config_versions
+ WHERE tenant_id = ? AND asset_id = ? ORDER BY version`, [tenantId, firstAssetId],
+ ) as Array<{ version: number | string; changed_fields_json: string | readonly string[] }>;
+ assert.equal(historyRows.length, 1);
+ assert.equal(Number(historyRows[0]?.version), 1);
+ const changedFields = typeof historyRows[0]?.changed_fields_json === 'string'
+ ? JSON.parse(historyRows[0].changed_fields_json) as readonly string[]
+ : historyRows[0]?.changed_fields_json;
+ assert.deepEqual(changedFields, ['privateWelcomeMessage']);
+
+ const outboxRows = await runner.manager.query(
+ `SELECT event_type, payload_json FROM ca_meta_outbox_events WHERE tenant_id = ? ORDER BY event_id`, [tenantId],
+ ) as Array<{ event_type: string; payload_json: string | object }>;
+ assert.equal(outboxRows.length, 1);
+ assert.equal(outboxRows[0]?.event_type, 'automation.config.changed');
+ const payload = typeof outboxRows[0]?.payload_json === 'string'
+ ? outboxRows[0].payload_json : JSON.stringify(outboxRows[0]?.payload_json);
+ assert.ok(payload);
+ assert.equal(payload.includes(message), false);
+ assert.match(payload, /AUTOMATION_CONFIG_CHANGED/);
+
+ const jobsRows = await runner.manager.query(
+ 'SELECT COUNT(*) AS count FROM ca_meta_jobs WHERE tenant_id = ? AND job_type = \'OUTBOX_DELIVER\'', [tenantId],
+ ) as Array<{ count: number | string }>;
+ assert.equal(Number(jobsRows[0]?.count), 1);
+ } finally {
+ if (runner.isTransactionActive) await runner.rollbackTransaction();
+ await runner.release();
+ await database.close();
+ }
+});
diff --git a/apps/api/test/m5-05-automation-config.spec.ts b/apps/api/test/m5-05-automation-config.spec.ts
new file mode 100644
index 00000000..f8181de6
--- /dev/null
+++ b/apps/api/test/m5-05-automation-config.spec.ts
@@ -0,0 +1,296 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { components } from '@ca-meta/contracts';
+import { BadRequestException } from '@nestjs/common';
+import { AutomationConfigController } from '../src/automation-config/automation-config.controller.js';
+import { AutomationConfigService } from '../src/automation-config/automation-config.service.js';
+import { IdentityDeniedError } from '../src/identity/identity.errors.js';
+import type { IdentityRepository } from '../src/identity/identity.repository.js';
+import { TenantAuthorizationService, TenantContextService, hashTenantContextReference } from '../src/identity/identity.service.js';
+import { PortalSessionVerifier, type VerifiedPortalSession } from '../src/identity/identity.trust.js';
+import type { PortalRequest } from '../src/identity/portal-session.guard.js';
+import type { AuthorizedTenantScope, MembershipRole } from '../src/identity/identity.types.js';
+import type { AssetCatalogReadModel, MetaEnvironment } from '../src/meta-assets/meta-assets.types.js';
+import type { MetaAssetsRepository } from '../src/meta-assets/mysql-meta-assets.repository.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ type AutomationConfigPatch,
+ type AutomationConfigRepository,
+ type AutomationConfigState,
+} from '../src/automation-config/automation-config.types.js';
+
+const tenantA = '00000000-0000-4000-8000-000000000001';
+const tenantB = '00000000-0000-4000-8000-000000000002';
+const principalId = '00000000-0000-4000-8000-000000000003';
+const appConfigurationId = '00000000-0000-4000-8000-000000000004';
+const numberOne = '00000000-0000-4000-8000-000000000011';
+const numberTwo = '00000000-0000-4000-8000-000000000012';
+const facebookPage = '00000000-0000-4000-8000-000000000013';
+const instagramAccount = '00000000-0000-4000-8000-000000000014';
+const contextRef = 'synthetic-current-tenant-context-01';
+const sessionToken = 'synthetic-portal-session-token-value-00000001';
+
+function asset(assetRef: string, kind: AssetCatalogReadModel['kind']): AssetCatalogReadModel {
+ return {
+ assetRef,
+ appConfigurationId,
+ kind,
+ displayLabel: `Synthetic ${kind}`,
+ status: 'ACTIVE',
+ authorizationStatus: 'VERIFIED',
+ observedAt: '2026-11-11T10:00:00.000Z',
+ lastVerifiedAt: '2026-11-11T10:00:00.000Z',
+ verificationSource: 'META_API',
+ stale: false,
+ capabilityEvidence: [],
+ diagnostics: {
+ onboardingStatus: 'UNKNOWN',
+ onboardingReadiness: 'UNKNOWN',
+ onboardingNextAction: 'UNKNOWN',
+ tokenHealth: 'UNKNOWN',
+ tokenHealthObservedAt: null,
+ metaState: 'UNKNOWN',
+ metaObservedAt: null,
+ subscriptionState: 'UNKNOWN',
+ projectionState: 'UNKNOWN',
+ },
+ reauthorizationRequired: false,
+ };
+}
+
+function setup(input: { readonly roles?: readonly MembershipRole[] } = {}) {
+ const roles = input.roles ?? ['owner'];
+ const identityRepository = {
+ async findMembership(requestPrincipalId: string, tenantId: string) {
+ if (requestPrincipalId !== principalId || ![tenantA, tenantB].includes(tenantId)) return undefined;
+ return {
+ principalId,
+ tenantId,
+ principalKind: 'USER' as const,
+ principalStatus: 'ACTIVE' as const,
+ tenantStatus: 'ACTIVE' as const,
+ roles,
+ status: 'ACTIVE' as const,
+ version: 1,
+ };
+ },
+ } as unknown as IdentityRepository;
+ const contexts = new TenantContextService(identityRepository);
+ const authorization = new TenantAuthorizationService(identityRepository);
+ const contextHash = hashTenantContextReference(contextRef);
+ const assets = new Map([
+ [`${tenantA}:${numberOne}`, asset(numberOne, 'WHATSAPP_PHONE_NUMBER')],
+ [`${tenantA}:${numberTwo}`, asset(numberTwo, 'WHATSAPP_PHONE_NUMBER')],
+ [`${tenantA}:${facebookPage}`, asset(facebookPage, 'FACEBOOK_PAGE')],
+ [`${tenantA}:${instagramAccount}`, asset(instagramAccount, 'INSTAGRAM_BUSINESS_ACCOUNT')],
+ ]);
+ const assetLookups: Array<{ tenantId: string; assetRef: string; environment: MetaEnvironment }> = [];
+ const assetRepository = {
+ async findAssetCatalog(query: { tenantId: string; assetRef: string; environment: MetaEnvironment }) {
+ assetLookups.push(query);
+ return assets.get(`${query.tenantId}:${query.assetRef}`);
+ },
+ } as unknown as MetaAssetsRepository;
+ const rows = new Map();
+ const history: Array<{ tenantId: string; assetId: string; version: number; fields: readonly string[] }> = [];
+ const configRepository: AutomationConfigRepository = {
+ async read(query) {
+ return rows.get(`${query.tenantId}:${query.assetId}`) ?? Object.freeze({
+ privateWelcomeMessage: null, commentPublicReply: null, commentPrivateReply: null,
+ version: 0, updatedAt: null, projectionState: 'UNKNOWN',
+ });
+ },
+ async update(query) {
+ const key = `${query.tenantId}:${query.assetId}`;
+ const current = rows.get(key) ?? Object.freeze({
+ privateWelcomeMessage: null, commentPublicReply: null, commentPrivateReply: null,
+ version: 0, updatedAt: null, projectionState: 'UNKNOWN',
+ });
+ if (query.patch.version !== current.version) throw new AutomationConfigVersionConflictError();
+ const next = Object.freeze({
+ privateWelcomeMessage: query.patch.privateWelcomeMessage === undefined ? current.privateWelcomeMessage : query.patch.privateWelcomeMessage || null,
+ commentPublicReply: query.patch.commentPublicReply === undefined ? current.commentPublicReply : query.patch.commentPublicReply || null,
+ commentPrivateReply: query.patch.commentPrivateReply === undefined ? current.commentPrivateReply : query.patch.commentPrivateReply || null,
+ version: current.version + 1,
+ updatedAt: '2026-11-11T10:01:00.000Z',
+ projectionState: 'PENDING' as const,
+ });
+ rows.set(key, next);
+ history.push({ tenantId: query.tenantId, assetId: query.assetId, version: next.version,
+ fields: Object.keys(query.patch).filter((field) => field !== 'version') });
+ return next;
+ },
+ };
+ const service = new AutomationConfigService(configRepository, assetRepository, contexts, authorization, 'test');
+ const sessions = new PortalSessionVerifier({
+ async findActiveBySecretHash() {
+ return {
+ sessionId: '00000000-0000-4000-8000-000000000005',
+ principalId,
+ tenantId: tenantA,
+ contextVersion: 1,
+ contextRefHash: contextHash,
+ csrfTokenHash: 'a'.repeat(64),
+ entryMode: 'PORTAL_TOP_LEVEL' as const,
+ };
+ },
+ });
+ return {
+ service,
+ sessions,
+ rows,
+ history,
+ assetLookups,
+ async session(tenantId: string = tenantA): Promise {
+ const tenantSessionVerifier = tenantId === tenantA ? sessions : new PortalSessionVerifier({
+ async findActiveBySecretHash() {
+ return {
+ sessionId: '00000000-0000-4000-8000-000000000006', principalId, tenantId,
+ contextVersion: 1, contextRefHash: contextHash, csrfTokenHash: 'b'.repeat(64), entryMode: 'PORTAL_TOP_LEVEL' as const,
+ };
+ },
+ });
+ const result = await tenantSessionVerifier.verify(sessionToken);
+ assert.ok(result);
+ return result;
+ },
+ };
+}
+
+test('channel capabilities keep WhatsApp number and Facebook/Instagram reply fields distinct and non-executable', async () => {
+ const state = setup();
+ const session = await state.session();
+ const whatsapp = await state.service.read(session, contextRef, numberOne, 'req-synthetic-automation-read');
+ const facebook = await state.service.read(session, contextRef, facebookPage, 'req-synthetic-automation-read');
+ const instagram = await state.service.read(session, contextRef, instagramAccount, 'req-synthetic-automation-read');
+
+ assert.equal(whatsapp.assetKind, 'WHATSAPP_PHONE_NUMBER');
+ assert.deepEqual(whatsapp.supportedFields, ['privateWelcomeMessage']);
+ assert.deepEqual(facebook.supportedFields, ['commentPublicReply', 'commentPrivateReply']);
+ assert.deepEqual(instagram.supportedFields, ['commentPublicReply', 'commentPrivateReply']);
+ assert.equal(whatsapp.inheritanceMode, 'ASSET_ONLY');
+ assert.equal(whatsapp.capabilities.executable, false);
+ assert.equal(whatsapp.capabilities.canEdit, true);
+});
+
+test('controller returns strong version ETags and rejects malformed preconditions', async () => {
+ const responseBody: components['schemas']['AutomationChannelConfig'] = {
+ assetId: numberOne,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-controller-01',
+ };
+ const controller = new AutomationConfigController({
+ async read() { return responseBody; },
+ async update() { return { ...responseBody, version: 1, projectionState: 'PENDING' }; },
+ } as unknown as AutomationConfigService);
+ const request = { requestId: 'req-synthetic-controller-01', portalSession: {} as VerifiedPortalSession } as PortalRequest;
+ const headers = new Map();
+ const response = { setHeader(name: string, value: string) { headers.set(name, value); } } as unknown as import('express').Response;
+ const currentContext = 'synthetic-current-tenant-context-01';
+
+ await controller.read(numberOne, request, currentContext, response);
+ assert.equal(headers.get('ETag'), '"automation-v0"');
+ const updated = await controller.update(numberOne, { version: 0, privateWelcomeMessage: 'Synthetic' },
+ request, currentContext, '"automation-v0"', response);
+ assert.equal(updated.version, 1);
+ assert.equal(headers.get('ETag'), '"automation-v1"');
+ await assert.rejects(controller.update(numberOne, { version: 1, privateWelcomeMessage: 'stale' },
+ request, currentContext, 'W/"automation-v1"', response), BadRequestException);
+});
+
+test('updates stay isolated per number and only supported fields create history', async () => {
+ const state = setup();
+ const session = await state.session();
+ const saved = await state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ privateWelcomeMessage: 'Synthetic welcome',
+ });
+ const untouchedNumber = await state.service.read(session, contextRef, numberTwo, 'req-synthetic-automation-read');
+ assert.equal(saved.privateWelcomeMessage, 'Synthetic welcome');
+ assert.equal(saved.version, 1);
+ assert.equal(saved.projectionState, 'PENDING');
+ assert.equal(untouchedNumber.privateWelcomeMessage, null);
+ assert.equal(untouchedNumber.version, 0);
+ assert.deepEqual(state.history.map((entry) => [entry.assetId, entry.version, entry.fields]), [
+ [numberOne, 1, ['privateWelcomeMessage']],
+ ]);
+
+ const cleared = await state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-clear', 1, {
+ version: 1,
+ privateWelcomeMessage: '',
+ });
+ assert.equal(cleared.privateWelcomeMessage, null);
+ assert.equal(cleared.version, 2);
+
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-save', 1, {
+ version: 1,
+ privateWelcomeMessage: 'not supported here',
+ commentPublicReply: 'unsupported on a WhatsApp number',
+ }), AutomationConfigValidationError);
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ privateWelcomeMessage: 'unsupported on a Facebook page',
+ }), AutomationConfigValidationError);
+});
+
+test('stale ETag/body version cannot overwrite a newer configuration', async () => {
+ const state = setup();
+ const session = await state.session();
+ await state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ commentPublicReply: 'Synthetic public reply',
+ });
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-stale', 0, {
+ version: 0,
+ commentPrivateReply: 'stale update',
+ }), AutomationConfigVersionConflictError);
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-stale', 1, {
+ version: 0,
+ commentPrivateReply: 'mismatched ETag and body',
+ }), AutomationConfigVersionConflictError);
+ const current = await state.service.read(session, contextRef, facebookPage, 'req-synthetic-automation-read');
+ assert.equal(current.version, 1);
+ assert.equal(current.commentPublicReply, 'Synthetic public reply');
+ assert.equal(current.commentPrivateReply, null);
+});
+
+test('viewer reads are safe but cannot update; foreign-tenant asset IDs are not found', async () => {
+ const viewerState = setup({ roles: ['viewer'] });
+ const viewer = await viewerState.session();
+ const readable = await viewerState.service.read(viewer, contextRef, numberOne, 'req-synthetic-automation-read');
+ assert.equal(readable.capabilities.canEdit, false);
+ await assert.rejects(viewerState.service.update(viewer, contextRef, numberOne, 'req-synthetic-automation-save', 0, {
+ version: 0, privateWelcomeMessage: 'blocked viewer write',
+ }), IdentityDeniedError);
+
+ const ownerState = setup();
+ const foreignTenantSession = await ownerState.session(tenantB);
+ await assert.rejects(ownerState.service.read(foreignTenantSession, contextRef, numberOne, 'req-synthetic-automation-read'), AutomationConfigNotFoundError);
+ assert.ok(ownerState.assetLookups.every((lookup) => lookup.tenantId === tenantB));
+ assert.equal(ownerState.rows.size, 0);
+});
+
+test('oversized and control-character message bodies fail validation before persistence', async () => {
+ const state = setup();
+ const session = await state.session();
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-invalid', 0, {
+ version: 0,
+ privateWelcomeMessage: 'x'.repeat(4097),
+ }), AutomationConfigValidationError);
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-invalid', 0, {
+ version: 0,
+ privateWelcomeMessage: 'bad\u0000text',
+ }), AutomationConfigValidationError);
+ assert.equal(state.rows.size, 0);
+});
diff --git a/apps/api/test/migration-unit.spec.ts b/apps/api/test/migration-unit.spec.ts
index 83494606..fe2461ce 100644
--- a/apps/api/test/migration-unit.spec.ts
+++ b/apps/api/test/migration-unit.spec.ts
@@ -21,6 +21,7 @@ import { ASSET_OBSERVATIONS_MIGRATION, ASSET_OBSERVATION_TABLES } from '../src/d
import { CONNECTION_LIFECYCLE_MIGRATION } from '../src/database/migrations/connection-lifecycle.js';
import { INTERNAL_ASSET_OPERATIONS_MIGRATION } from '../src/database/migrations/internal-asset-operations.js';
import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from '../src/database/migrations/onboarding-coexistence-lock.js';
+import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from '../src/database/migrations/automation-config.js';
import { assertMigrationGrantRecords, CoexistenceLockDeleteGrantRequiredError, safeMigrationError } from '../src/database/migrations/migration-runner.js';
const validEnvironment: NodeJS.ProcessEnv = {
@@ -104,9 +105,9 @@ test('migration grants require the lock-trigger definer privilege only on its ex
test('versioned migrations carry deterministic checksums and explicit lifecycle phases', () => {
const schemaVersions = MIGRATIONS.map((migration) => migration.schemaVersion);
- assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]);
- assert.equal(schemaVersions.at(-1), 22);
- assert.equal(MIGRATIONS.length, 22);
+ assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]);
+ assert.equal(schemaVersions.at(-1), 23);
+ assert.equal(MIGRATIONS.length, 23);
for (const migration of MIGRATIONS) assert.match(migration.checksum, /^[a-f0-9]{64}$/);
assert.deepEqual(MIGRATIONS[0]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'expand']);
assert.deepEqual(MIGRATIONS[1]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'backfill', 'validate']);
@@ -152,6 +153,8 @@ test('versioned migrations carry deterministic checksums and explicit lifecycle
assert.equal(INTERNAL_ASSET_OPERATIONS_MIGRATION.schemaVersion, 21);
assert.equal(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id, '202611120022-onboarding-coexistence-serialization');
assert.equal(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.schemaVersion, 22);
+ assert.equal(AUTOMATION_CONFIG_MIGRATION.id, '202611120023-asset-automation-config');
+ assert.equal(AUTOMATION_CONFIG_MIGRATION.schemaVersion, 23);
const coexistenceLockTable = ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.find((step) => step.id === 'create-onboarding-coexistence-locks');
assert.ok(coexistenceLockTable && 'sql' in coexistenceLockTable);
assert.match(coexistenceLockTable.sql, /PRIMARY KEY \(tenant_id\)/);
@@ -161,6 +164,7 @@ test('versioned migrations carry deterministic checksums and explicit lifecycle
assert.ok(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.some((step) => 'runBatch' in step && step.id === 'backfill-active-onboarding-coexistence-locks'));
assert.ok(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.some((step) => step.id === 'release-coexistence-lock-on-terminal-onboarding-status'));
assert.ok(ONBOARDING_COEXISTENCE_LOCK_TABLES.every((table) => DOMAIN_TABLES.includes(table as typeof DOMAIN_TABLES[number])));
+ assert.ok(AUTOMATION_CONFIG_TABLES.every((table) => DOMAIN_TABLES.includes(table as typeof DOMAIN_TABLES[number])));
assert.deepEqual(ASSET_OBSERVATIONS_MIGRATION.steps.map((step) => step.id), [
'create-meta-asset-observations', 'asset-observation-no-update', 'asset-observation-no-delete',
]);
diff --git a/apps/api/test/migrations.integration.spec.ts b/apps/api/test/migrations.integration.spec.ts
index eeeb7bd3..504b5240 100644
--- a/apps/api/test/migrations.integration.spec.ts
+++ b/apps/api/test/migrations.integration.spec.ts
@@ -207,10 +207,14 @@ test('MariaDB migration ledger, implicit-commit recovery, backfill, role separat
'the fresh schema pauses safely at v22 when the table-only trigger-definer grant is absent',
);
const stagedStatus = await readMigrationStatus(environment);
- assert.equal(stagedStatus.migrations.length, MIGRATIONS.length);
- assert.ok(stagedStatus.migrations.slice(0, -1).every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
- assert.equal(stagedStatus.migrations.at(-1)?.id, ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id);
- assert.equal(stagedStatus.migrations.at(-1)?.state, 'RUNNING');
+ const coexistenceLockIndex = MIGRATIONS.findIndex(
+ (migration) => migration.id === ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id,
+ );
+ assert.ok(coexistenceLockIndex >= 0, 'the Coexistence lock migration is registered');
+ assert.equal(stagedStatus.migrations.length, coexistenceLockIndex + 1, 'the failed deployment stops at the Coexistence lock migration');
+ assert.ok(stagedStatus.migrations.slice(0, coexistenceLockIndex).every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
+ assert.equal(stagedStatus.migrations[coexistenceLockIndex]?.id, ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id);
+ assert.equal(stagedStatus.migrations[coexistenceLockIndex]?.state, 'RUNNING');
const stagedSteps = await queryRows<{ step_id: string; state: string }>(grantAdmin,
`SELECT step_id, state FROM ${MIGRATION_STEP_LEDGER} WHERE migration_id = ?`,
[ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id],
@@ -235,7 +239,7 @@ test('MariaDB migration ledger, implicit-commit recovery, backfill, role separat
);
await deployMigrations(environment);
const recoveredStatus = await readMigrationStatus(environment);
- assert.equal(recoveredStatus.migrations.length, 22);
+ assert.equal(recoveredStatus.migrations.length, MIGRATIONS.length);
assert.ok(recoveredStatus.migrations.every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
const recoveredTrigger = await queryRows<{ trigger_name: string }>(grantAdmin,
'SELECT trigger_name FROM information_schema.triggers WHERE trigger_schema = ? AND trigger_name = ?',
diff --git a/apps/web/package.json b/apps/web/package.json
index fffd38a4..bc6fcce6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,6 +11,7 @@
"test:m5-02": "node ../../tools/m5-02/run-focused-web-tests.mjs",
"test:m5-03": "node ../../tools/m5-03/run-focused-web-tests.mjs",
"test:m5-04": "node ../../tools/m5-04/run-focused-web-tests.mjs",
+ "test:m5-05": "node ../../tools/m5-05/run-focused-web-tests.mjs",
"typecheck": "ngc -p tsconfig.app.json --noEmit",
"lint": "node ../../tools/m2/check-web-boundary.mjs"
},
diff --git a/apps/web/src/app/core/portal/automation-config.service.spec.ts b/apps/web/src/app/core/portal/automation-config.service.spec.ts
new file mode 100644
index 00000000..8c1038fb
--- /dev/null
+++ b/apps/web/src/app/core/portal/automation-config.service.spec.ts
@@ -0,0 +1,69 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import type { components } from '@ca-meta/contracts';
+import { META_API_BASE_URL } from '../config/public-runtime-config';
+import { AutomationConfigService } from './automation-config.service';
+
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
+
+describe('AutomationConfigService', () => {
+ let service: AutomationConfigService;
+ let http: HttpTestingController;
+
+ const config: AutomationConfig = {
+ assetId: '00000000-0000-4000-8000-000000000051',
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-automation-ui-01',
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: META_API_BASE_URL, useValue: '/api' },
+ ],
+ });
+ service = TestBed.inject(AutomationConfigService);
+ http = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => http.verify());
+
+ it('uses the internal asset endpoint and enforces response ETag versioning for reads and updates', async () => {
+ const loaded = service.read(config.assetId);
+ const readRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
+ expect(readRequest.request.method).toBe('GET');
+ readRequest.flush(config, { headers: { ETag: '"automation-v0"' } });
+ const current = await loaded;
+ expect(current.etag).toBe('"automation-v0"');
+
+ const update = { version: 0, privateWelcomeMessage: 'Synthetic portal value' };
+ const saving = service.update(config.assetId, update, current.etag);
+ const updateRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
+ expect(updateRequest.request.method).toBe('PATCH');
+ expect(updateRequest.request.headers.get('If-Match')).toBe('"automation-v0"');
+ expect(updateRequest.request.body).toEqual(update);
+ updateRequest.flush({ ...config, privateWelcomeMessage: update.privateWelcomeMessage, version: 1 }, {
+ headers: { ETag: '"automation-v1"' },
+ });
+ await expect(saving).resolves.toMatchObject({ etag: '"automation-v1"', config: { version: 1 } });
+ });
+
+ it('rejects a response whose ETag does not match the body version', async () => {
+ const loaded = service.read(config.assetId);
+ const request = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
+ request.flush(config, { headers: { ETag: '"automation-v1"' } });
+ await expect(loaded).rejects.toThrow('The automation config response is incomplete.');
+ });
+});
diff --git a/apps/web/src/app/core/portal/automation-config.service.ts b/apps/web/src/app/core/portal/automation-config.service.ts
new file mode 100644
index 00000000..6885f90f
--- /dev/null
+++ b/apps/web/src/app/core/portal/automation-config.service.ts
@@ -0,0 +1,49 @@
+import { HttpClient, HttpResponse } from '@angular/common/http';
+import { Inject, Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { META_API_BASE_URL } from '../config/public-runtime-config';
+import { normalizeApiV1BaseUrl } from './portal-session.service';
+
+export type AutomationConfig = components['schemas']['AutomationChannelConfig'];
+export type AutomationConfigUpdate = components['schemas']['AutomationChannelConfigUpdate'];
+
+export interface VersionedAutomationConfig {
+ readonly config: AutomationConfig;
+ readonly etag: string;
+}
+
+function validEtag(value: string | null, version: number): value is string {
+ return value === `"automation-v${version}"`;
+}
+
+@Injectable({ providedIn: 'root' })
+export class AutomationConfigService {
+ private readonly baseUrl: string;
+
+ constructor(private readonly http: HttpClient, @Inject(META_API_BASE_URL) baseUrl: string) {
+ this.baseUrl = normalizeApiV1BaseUrl(baseUrl);
+ }
+
+ async read(assetId: string): Promise {
+ const response = await firstValueFrom(this.http.get(
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config/channels`, { observe: 'response' },
+ ));
+ return this.versioned(response);
+ }
+
+ async update(assetId: string, update: AutomationConfigUpdate, etag: string): Promise {
+ const response = await firstValueFrom(this.http.patch(
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config/channels`, update,
+ { observe: 'response', headers: { 'If-Match': etag } },
+ ));
+ return this.versioned(response);
+ }
+
+ private versioned(response: HttpResponse): VersionedAutomationConfig {
+ const config = response.body;
+ const etag = response.headers.get('ETag');
+ if (!config || !validEtag(etag, config.version)) throw new Error('The automation config response is incomplete.');
+ return Object.freeze({ config, etag });
+ }
+}
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.html b/apps/web/src/app/features/portal/asset-automation-config.component.html
new file mode 100644
index 00000000..024325d5
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.html
@@ -0,0 +1,41 @@
+
+ Mensagens e respostas
+ Cada número ou página mantém sua própria configuração. Valores de conta não são copiados automaticamente para outros recursos.
+
+ Carregando configuração…
+
+ {{ errorMessage }} Referência: {{ errorRequestId }}
+
+
+
+ Não há campos de mensagem disponíveis para este tipo de recurso.
+
+
+ {{ savedMessage }}
+
+ Sincronização empresarial:
+
+ pendente; a configuração central já foi salva.
+ confirmada.
+ desatualizada.
+ falhou e pode precisar de reconciliação.
+ não confirmada.
+
+
+ Salvar esta configuração não envia mensagens. A execução continua indisponível até que as regras e permissões de cada canal sejam confirmadas.
+
+ Detalhes técnicos para suporte
+ Versão {{ current.version }} · atualização: {{ current.updatedAt || 'ainda não salva' }} · referência: {{ current.requestId }}
+
+
+
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.scss b/apps/web/src/app/features/portal/asset-automation-config.component.scss
new file mode 100644
index 00000000..ab52fb11
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.scss
@@ -0,0 +1,19 @@
+:host { display: block; }
+.automation-config { margin-top: 1.25rem; padding: 1.1rem; border: 1px solid #e1e6ea; border-radius: 9px; background: #fff; }
+h2 { margin: 0; font-size: 1.2rem; }
+.intro, .note, .projection, .state, .field small, details { color: #53616c; line-height: 1.5; }
+.field { display: grid; gap: .4rem; margin: 1rem 0; }
+label { color: #263640; font-weight: 650; }
+textarea { width: 100%; box-sizing: border-box; resize: vertical; border: 1px solid #aebbc5; border-radius: 6px; padding: .65rem; color: #253640; background: #fff; font: inherit; line-height: 1.45; }
+textarea:focus-visible, button:focus-visible, summary:focus-visible { outline: 3px solid #145da0; outline-offset: 2px; }
+textarea:disabled { color: #59666f; background: #f3f5f6; }
+.field small { font-size: .82rem; }
+button { min-height: 42px; border: 1px solid #0a5eb7; border-radius: 6px; padding: .55rem .9rem; color: #fff; background: #0866c6; font: inherit; font-weight: 650; cursor: pointer; }
+button:disabled { border-color: #c5cdd3; color: #697681; background: #edf0f2; cursor: not-allowed; }
+.warning { padding: .7rem .8rem; border-left: 3px solid #a56b00; color: #624110; background: #fff9e8; line-height: 1.45; }
+.error { padding: .7rem .8rem; border: 1px solid #f0c7c7; border-radius: 6px; color: #8f2525; background: #fff3f3; }
+.saved { padding: .65rem .75rem; border-left: 3px solid #237443; color: #175931; background: #edf8f0; }
+.projection { margin-top: 1rem; }
+.note { font-size: .87rem; }
+summary { width: fit-content; cursor: pointer; }
+details p { font-size: .85rem; overflow-wrap: anywhere; }
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
new file mode 100644
index 00000000..90563a87
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
@@ -0,0 +1,207 @@
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { TestBed } from '@angular/core/testing';
+import { NEVER, Subject, of } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { AutomationConfigService, type VersionedAutomationConfig } from '../../core/portal/automation-config.service';
+import { PortalContextStore } from '../../core/portal/portal-context.store';
+import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
+import { AssetAutomationConfigComponent } from './asset-automation-config.component';
+
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
+
+describe('AssetAutomationConfigComponent', () => {
+ it('shows channel-supported fields and keeps edits disabled until the tenant identity gate is confirmed', async () => {
+ const config: AutomationConfig = {
+ assetId: '00000000-0000-4000-8000-000000000051',
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-automation-ui-02',
+ };
+ const settings = { read: vi.fn(), update: vi.fn() };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: NEVER } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: { contextChanged$: NEVER } },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: false,
+ reason: 'TENANT_IDENTITY_UNCONFIRMED',
+ message: 'Identidade da empresa não confirmada',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.componentInstance.config = config;
+ fixture.componentInstance.isLoading = false;
+ fixture.detectChanges();
+ const view = fixture.nativeElement as HTMLElement;
+ const fields = Array.from(view.querySelectorAll('textarea')).map((field) => field.id);
+ expect(fields).toEqual(['privateWelcomeMessage']);
+ expect(view.textContent).toContain('Identidade da empresa não confirmada');
+ expect((view.querySelector('button[type="submit"]') as HTMLButtonElement).disabled).toBe(true);
+ expect(settings.update).not.toHaveBeenCalled();
+ fixture.destroy();
+ });
+
+ it('does not apply a delayed save response after the active tenant context changes', async () => {
+ const assetId = '00000000-0000-4000-8000-000000000051';
+ const config = (privateWelcomeMessage: string, version: number): VersionedAutomationConfig => ({
+ etag: `"automation-v${version}"`,
+ config: {
+ assetId,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: `req-synthetic-automation-ui-${version}`,
+ },
+ });
+ const oldContextConfig = config('Mensagem do tenant anterior', 2);
+ const newContextConfig = config('Mensagem do tenant atual', 3);
+ const contextChanged$ = new Subject();
+ let contextRef = 'synthetic-tenant-context-reference-111';
+ const context = {
+ contextChanged$: contextChanged$.asObservable(),
+ get contextRef() { return contextRef; },
+ };
+ let resolveUpdate!: (value: VersionedAutomationConfig) => void;
+ const settings = {
+ read: vi.fn().mockResolvedValueOnce(config('Mensagem original', 1)).mockResolvedValueOnce(newContextConfig),
+ update: vi.fn(() => new Promise((resolve) => { resolveUpdate = resolve; })),
+ };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: assetId })) } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: context },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: true,
+ tenantDisplayName: 'Empresa sintética',
+ source: 'server-session',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const component = fixture.componentInstance;
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem original');
+ component.form.privateWelcomeMessage = 'Mensagem enviada pelo tenant anterior';
+ const save = component.save();
+ expect(component.isSaving).toBe(true);
+
+ contextRef = 'synthetic-tenant-context-reference-222';
+ contextChanged$.next();
+ await fixture.whenStable();
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.isSaving).toBe(false);
+
+ resolveUpdate(oldContextConfig);
+ await save;
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.savedMessage).toBe('');
+ expect(component.errorMessage).toBe('');
+ expect(component.isSaving).toBe(false);
+ fixture.destroy();
+ });
+
+ it('ignores a delayed save rejection while a save for the new tenant is in progress', async () => {
+ const assetId = '00000000-0000-4000-8000-000000000052';
+ const config = (privateWelcomeMessage: string, version: number): VersionedAutomationConfig => ({
+ etag: `"automation-v${version}"`,
+ config: {
+ assetId,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: `req-synthetic-automation-reject-${version}`,
+ },
+ });
+ const contextChanged$ = new Subject();
+ let contextRef = 'synthetic-tenant-context-reference-333';
+ const context = {
+ contextChanged$: contextChanged$.asObservable(),
+ get contextRef() { return contextRef; },
+ };
+ const pendingUpdates: Array<{
+ readonly resolve: (value: VersionedAutomationConfig) => void;
+ readonly reject: (reason: unknown) => void;
+ }> = [];
+ const settings = {
+ read: vi.fn().mockResolvedValueOnce(config('Tenant inicial', 1)).mockResolvedValueOnce(config('Tenant atual', 2)),
+ update: vi.fn(() => new Promise((resolve, reject) => pendingUpdates.push({ resolve, reject }))),
+ };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: assetId })) } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: context },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: true,
+ tenantDisplayName: 'Empresa sintética',
+ source: 'server-session',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const component = fixture.componentInstance;
+ component.form.privateWelcomeMessage = 'Alteração do tenant anterior';
+ const oldSave = component.save();
+ expect(component.isSaving).toBe(true);
+
+ contextRef = 'synthetic-tenant-context-reference-444';
+ contextChanged$.next();
+ await fixture.whenStable();
+ expect(component.config?.privateWelcomeMessage).toBe('Tenant atual');
+ expect(component.isSaving).toBe(false);
+
+ component.form.privateWelcomeMessage = 'Alteração do tenant atual';
+ const currentSave = component.save();
+ expect(component.isSaving).toBe(true);
+ pendingUpdates[0]!.reject(new Error('synthetic late old-tenant failure'));
+ await oldSave;
+ expect(component.errorMessage).toBe('');
+ expect(component.errorRequestId).toBeNull();
+ expect(component.savedMessage).toBe('');
+ expect(component.isSaving).toBe(true);
+
+ pendingUpdates[1]!.resolve(config('Salvo no tenant atual', 3));
+ await currentSave;
+ expect(component.config?.privateWelcomeMessage).toBe('Salvo no tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Salvo no tenant atual');
+ expect(component.savedMessage).toContain('Configuração salva');
+ expect(component.isSaving).toBe(false);
+ fixture.destroy();
+ });
+});
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.ts b/apps/web/src/app/features/portal/asset-automation-config.component.ts
new file mode 100644
index 00000000..ba763424
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.ts
@@ -0,0 +1,160 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Subject, takeUntil } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { AutomationConfigService, type VersionedAutomationConfig } from '../../core/portal/automation-config.service';
+import { PortalContextStore } from '../../core/portal/portal-context.store';
+import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
+import { safeRequestId } from '../../core/portal/safe-api-error';
+
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
+type AutomationConfigField = components['schemas']['AutomationConfigField'];
+type AutomationConfigUpdate = components['schemas']['AutomationChannelConfigUpdate'];
+type EditableValues = {
+ privateWelcomeMessage: string;
+ commentPublicReply: string;
+ commentPrivateReply: string;
+};
+
+const FIELD_LABELS: Readonly> = Object.freeze({
+ privateWelcomeMessage: 'Mensagem privada de boas-vindas',
+ commentPublicReply: 'Resposta pública a comentários',
+ commentPrivateReply: 'Resposta privada a comentários',
+});
+
+@Component({
+ selector: 'app-asset-automation-config',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ templateUrl: './asset-automation-config.component.html',
+ styleUrl: './asset-automation-config.component.scss',
+})
+export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
+ config: AutomationConfig | null = null;
+ form: EditableValues = { privateWelcomeMessage: '', commentPublicReply: '', commentPrivateReply: '' };
+ isLoading = true;
+ isSaving = false;
+ errorMessage = '';
+ errorRequestId: string | null = null;
+ savedMessage = '';
+ private etag = '';
+ private assetId = '';
+ private generation = 0;
+ private readonly destroy$ = new Subject();
+
+ constructor(
+ private readonly route: ActivatedRoute,
+ private readonly settings: AutomationConfigService,
+ private readonly context: PortalContextStore,
+ readonly tenantIdentity: PortalTenantIdentityService,
+ ) {}
+
+ get supportedFields(): readonly AutomationConfigField[] { return this.config?.supportedFields ?? []; }
+ get canSave(): boolean {
+ return !!this.config?.capabilities.canEdit && this.tenantIdentity.current.confirmed && this.isDirty && !this.isSaving;
+ }
+ get isDirty(): boolean {
+ if (!this.config) return false;
+ return this.supportedFields.some((field) => this.form[field] !== (this.config?.[field] ?? ''));
+ }
+ get writesUnavailableCopy(): string {
+ return this.tenantIdentity.current.confirmed
+ ? ''
+ : 'Identidade da empresa não confirmada. A edição de mensagens está indisponível neste ambiente.';
+ }
+
+ ngOnInit(): void {
+ this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ this.assetId = params.get('id') ?? '';
+ void this.load();
+ });
+ this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => { void this.load(); });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ fieldLabel(field: AutomationConfigField): string { return FIELD_LABELS[field]; }
+ supports(field: AutomationConfigField): boolean { return this.supportedFields.includes(field); }
+
+ async save(): Promise {
+ if (!this.config || !this.canSave || !this.etag) return;
+ const generation = this.generation;
+ const assetId = this.assetId;
+ const contextRef = this.context.contextRef;
+ if (!contextRef) return;
+ const update = {
+ version: this.config.version,
+ ...(this.supports('privateWelcomeMessage') && this.form.privateWelcomeMessage !== (this.config.privateWelcomeMessage ?? '')
+ ? { privateWelcomeMessage: this.form.privateWelcomeMessage } : {}),
+ ...(this.supports('commentPublicReply') && this.form.commentPublicReply !== (this.config.commentPublicReply ?? '')
+ ? { commentPublicReply: this.form.commentPublicReply } : {}),
+ ...(this.supports('commentPrivateReply') && this.form.commentPrivateReply !== (this.config.commentPrivateReply ?? '')
+ ? { commentPrivateReply: this.form.commentPrivateReply } : {}),
+ } satisfies AutomationConfigUpdate;
+ if (Object.keys(update).length < 2) return;
+ this.isSaving = true;
+ this.errorMessage = '';
+ this.savedMessage = '';
+ this.errorRequestId = null;
+ try {
+ const next = await this.settings.update(assetId, update, this.etag);
+ if (!this.isCurrentSave(generation, assetId, contextRef)) return;
+ this.setConfig(next);
+ this.savedMessage = 'Configuração salva no catálogo central. A sincronização empresarial está pendente quando indicada abaixo.';
+ } catch (error: unknown) {
+ if (!this.isCurrentSave(generation, assetId, contextRef)) return;
+ this.errorMessage = error instanceof HttpErrorResponse && error.status === 412
+ ? 'A configuração mudou em outra sessão. Atualize e revise os valores antes de salvar.'
+ : 'Não foi possível salvar a configuração. Nenhuma mensagem foi enviada.';
+ this.errorRequestId = safeRequestId(error);
+ } finally {
+ if (this.isCurrentSave(generation, assetId, contextRef)) this.isSaving = false;
+ }
+ }
+
+ private async load(): Promise {
+ const generation = ++this.generation;
+ this.config = null;
+ this.isLoading = true;
+ this.isSaving = false;
+ this.errorMessage = '';
+ this.errorRequestId = null;
+ this.savedMessage = '';
+ if (!this.assetId) {
+ this.isLoading = false;
+ this.errorMessage = 'Este recurso não foi encontrado.';
+ return;
+ }
+ try {
+ const current = await this.settings.read(this.assetId);
+ if (generation !== this.generation) return;
+ this.setConfig(current);
+ } catch (error: unknown) {
+ if (generation !== this.generation) return;
+ this.errorMessage = 'Não foi possível carregar a configuração deste recurso.';
+ this.errorRequestId = safeRequestId(error);
+ } finally {
+ if (generation === this.generation) this.isLoading = false;
+ }
+ }
+
+ private setConfig(value: VersionedAutomationConfig): void {
+ this.config = value.config;
+ this.etag = value.etag;
+ this.form = {
+ privateWelcomeMessage: value.config.privateWelcomeMessage ?? '',
+ commentPublicReply: value.config.commentPublicReply ?? '',
+ commentPrivateReply: value.config.commentPrivateReply ?? '',
+ };
+ }
+
+ private isCurrentSave(generation: number, assetId: string, contextRef: string): boolean {
+ return generation === this.generation && assetId === this.assetId && contextRef === this.context.contextRef;
+ }
+}
diff --git a/apps/web/src/app/features/portal/asset-detail.component.ts b/apps/web/src/app/features/portal/asset-detail.component.ts
index 5002abee..92ec23de 100644
--- a/apps/web/src/app/features/portal/asset-detail.component.ts
+++ b/apps/web/src/app/features/portal/asset-detail.component.ts
@@ -1,16 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { AssetAutomationConfigComponent } from './asset-automation-config.component';
@Component({
selector: 'app-asset-detail',
standalone: true,
- imports: [CommonModule, RouterLink],
+ imports: [CommonModule, RouterLink, AssetAutomationConfigComponent],
template: `
← Voltar às conexões
Detalhes do recurso
- Os detalhes serão exibidos após a verificação do ativo e da autorização pelo servidor.
+
`,
styles: [`
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.html b/apps/web/src/app/features/portal/connection-record-detail.component.html
index cb9679d7..c87041e5 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.html
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.html
@@ -80,6 +80,8 @@ {{ asset.displayLabel || 'Identificação não confirmada' }}
Última confirmação {{ asset.lastVerifiedAt || asset.observedAt }}. O status pode ter mudado; isso não confirma perda do recurso.
+ Configurar mensagens e respostas deste recurso
+
Solicitar reautorização
Desconectar este recurso
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.scss b/apps/web/src/app/features/portal/connection-record-detail.component.scss
index 53a1a21a..bd04303b 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.scss
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.scss
@@ -30,6 +30,9 @@ h3 { margin: .2rem 0 0; overflow-wrap: anywhere; font-size: 1.05rem; }
.dimension-state { color: #66737e; font-size: .82rem; }
.dimension-list small { display: block; color: #63717c; line-height: 1.4; overflow-wrap: anywhere; }
.stale-note { margin: .9rem 0 0; padding: .65rem .75rem; border-left: 3px solid #a56b00; color: #60420d; background: #fff9e8; line-height: 1.45; }
+.config-link { display: inline-block; margin-top: .85rem; color: #0067d8; font-weight: 650; text-decoration: none; }
+.config-link:hover { text-decoration: underline; }
+.config-link:focus-visible { outline: 3px solid #145da0; outline-offset: 2px; }
.asset-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; margin-top: 1rem; padding-top: .8rem; border-top: 1px solid #e6ebee; }
.asset-actions button { min-height: 40px; border: 1px solid #c5cdd3; border-radius: 6px; padding: .45rem .7rem; color: #59666f; background: #edf0f2; font: inherit; font-weight: 600; cursor: not-allowed; }
.asset-actions p { flex-basis: 100%; margin: 0; color: #624110; font-size: .85rem; line-height: 1.45; }
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts b/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
index a2d6ae2f..7f6fdea6 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
@@ -109,6 +109,8 @@ describe('ConnectionRecordDetailComponent', () => {
expect(Array.from(view.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
expect(view.querySelector('details summary')?.textContent).toContain('Detalhes técnicos para suporte');
expect(view.textContent).toContain('req-synthetic-connection-02');
+ expect(view.querySelector('.config-link')?.textContent).toContain('Configurar mensagens e respostas deste recurso');
+ expect(view.querySelector('.config-link')?.getAttribute('href')).toBe('/assets/00000000-0000-4000-8000-000000000012');
fixture.destroy();
});
});
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.ts b/apps/web/src/app/features/portal/connection-record-detail.component.ts
index 885697d7..b4af2380 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.ts
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.ts
@@ -82,6 +82,9 @@ export class ConnectionRecordDetailComponent implements OnInit, OnDestroy {
canShowReauthorization(asset: ConnectionAsset): boolean { return asset.capabilities?.canReauthorize === true; }
canShowDisconnect(asset: ConnectionAsset): boolean { return asset.capabilities?.canDisconnect === true; }
+ canShowAutomationConfig(asset: ConnectionAsset): boolean {
+ return asset.kind === 'PHONE_NUMBER' || asset.kind === 'PAGE' || asset.kind === 'INSTAGRAM_ACCOUNT';
+ }
/** SessionContext has no safe company label in the current contract, so tenant writes stay disabled. */
get writesAllowed(): boolean { return false; }
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
new file mode 100644
index 00000000..08743f04
--- /dev/null
+++ b/docs/delivery/M5/M5.05.md
@@ -0,0 +1,83 @@
+# M5.05 — Configuração local de mensagens e respostas
+
+## Escopo e decisões
+
+Issue [#59](https://github.com/costaapps/ca_meta_app/issues/59), implementada no branch `desktop/m5-05` sobre a base M5.02 `ae867c6`.
+
+O catálogo central mantém `PrivateWelcomeMessage` por número do WhatsApp e `CommentPublicReply`/`CommentPrivateReply` por Página do Facebook ou conta profissional do Instagram. O contrato retorna os campos autorizados por tipo de ativo, modo `ASSET_ONLY`, versão, capabilities e estado da projeção. Uma configuração ausente começa com `null`; não há valor padrão global nem herança. Texto vazio limpa apenas o campo e o ativo enviados. Portanto, salvar para um número não altera os outros números da WABA.
+
+Leituras exigem sessão verificada, contexto tenant atual, membership ativa e `RESOURCE_READ`. Gravações exigem `MANAGE_AUTOMATION_CONFIG` e papel `owner`, `admin` ou `operator`; assets de outro tenant não são encontrados. O detalhe da conexão liga cada ativo suportado ao editor do próprio asset. A UI só oferece campos recebidos em `supportedFields` e mantém Salvar indisponível enquanto o gate de identidade M1.08 continuar sem empresa confirmada.
+
+`GET /api/v1/assets/{assetId}/automation-config/channels` retorna ETag forte `"automation-vN"`. `PATCH` exige `If-Match` e versão do corpo correspondentes. O repositório bloqueia a linha do ativo e compara a versão antes de gravar; conflitos retornam `412` sem substituir o estado atual. Campos têm limite de 4096 caracteres e rejeitam controles inválidos. Erros de asset indisponível, precondição e conteúdo inválido retornam `404`, `412` e `422` com mensagens seguras. A rota `/channels` mantém a representação M5.05 aditiva e preserva o contrato v1 anterior em `/automation-config`.
+
+Para a configuração exata de Origin já permitida pelo servidor, CORS agora aceita `If-Match` no preflight e expõe `ETag` ao navegador. O allowlist de Origins, a validação same-origin/Fetch Metadata e o CSRF das mutações permanecem iguais; nenhuma origem foi acrescentada.
+
+O valor atual, o histórico append-only de metadados, o evento `automation.config.changed` e o job de entrega local são gravados na mesma transação MySQL. O histórico registra campos alterados, hash do ator, versão e horário; não replica os textos. O evento contém somente referência interna do asset e versão. A configuração fica vinculada à chave interna `(tenant_id, asset_id)`, fora dos registros de credencial/autorização; o fluxo de reautorização não a limpa. O estado inicial é `UNKNOWN`, passa a `PENDING` ao salvar e não passa a `SYNCED` sem projeção confirmada.
+
+## Critérios e evidência
+
+| Critério | Estado | Evidência local |
+|---|---|---|
+| Campos por canal e por ativo; leitura e gravação tenant-scoped | Implementado | `automation-config.service.ts`, `mysql-automation-config.repository.ts`; testes cobrem WABA-number isolado, Página/Instagram, viewer e asset cross-tenant. |
+| Defaults/herança explícitos sem fanout na WABA | Implementado | Contrato `ASSET_ONLY`; defaults `null`; chave `(tenant_id, asset_id)`; teste mantém o segundo número sem alteração. Campo vazio limpa somente o ativo atual. |
+| Version/ETag e conflito sem sobrescrita | Implementado | Controller envia e valida `"automation-vN"`; serviço confere ETag/corpo; repositório usa transação, `FOR UPDATE` e CAS. Teste de controller e teste MariaDB cobrem versões obsoletas. |
+| Preservar estado durante reautorização | Implementado no catálogo local | Configuração separada de credenciais e autorizações, referenciada pelo UUID interno do asset; reautorização atualiza vínculos do catálogo e não tem rotina de exclusão desta configuração. Continuidade depende de a resolução do ativo reutilizar o mesmo UUID interno. |
+| Estado de projeção/outbox sem alegar transação distribuída | Implementado localmente | Estado `PENDING`; outbox/job transacional somente no MariaDB. Nenhuma confirmação externa é emitida. |
+| Conteúdo e tamanho validados; erros seguros | Implementado | Limite 4096, controles proibidos, DTO estrito, respostas `400`/`404`/`412`/`422`; testes de texto oversized, controle, ETag e conflito. |
+| UI de edição/consulta preserva o gate existente | Implementado com gravação fechada no estado atual | Link por asset a partir do detalhe; campos suportados, status de projeção, request ID, save bloqueado até identidade confirmada. Testes Angular validam endpoint/ETag, campos e bloqueio. |
+| Anti-loop/echo e chamada de execução Meta | Fail-closed | `capabilities.executable=false`; nenhum sender/adaptador Meta foi adicionado. Não há prova local ou externa de deduplicação de echo, webhook ou efeitos. |
+
+## Not confirmed — regras externas
+
+- Os identificadores `PrivateWelcomeMessage`, `CommentPublicReply` e `CommentPrivateReply` vêm do escopo da issue. Não foi confirmado que sejam nomes de configuração suportados pela API da Meta, nem que um valor salvo localmente possa ser projetado para um ativo Meta.
+- A API/conta/permissões exatas e limites de cada tipo de resposta dependem do produto e do fluxo de autorização Meta. A documentação oficial de Private Replies não pôde ser aberta durante esta execução (HTTP 429). Nenhuma janela de envio, quantidade por comentário ou permissão é assumida pelo código.
+- Não foi confirmado se a saudação privada do WhatsApp possui uma configuração pública correspondente na API Cloud ou se é apenas uma opção gerida no produto. A funcionalidade permanece armazenamento local.
+- O handler de `OUTBOX_DELIVER` não configura mensagens na Meta. Não há executor de automação, prova de echo-loop, sincronização empresarial ou confirmação de efeito; esses critérios permanecem bloqueados por integração externa futura. Não houve chamadas reais, envio de mensagens, uso de token Meta ou acesso a dados de cliente.
+- A identidade empresarial do portal permanece não confirmada pela sessão atual. O editor pode consultar a configuração, mas não habilita a gravação na UI até que a evidência M1.08 seja fornecida.
+
+## Banco e harness
+
+A migração candidata reindexada usa schema version `23`, ID `202611120023-asset-automation-config`. Na base M5.04 integrada, M5.07 ocupa v21 e M5.03 ocupa v22; a migração M5.05 vem depois delas e seu checksum é recalculado a partir da definição. A evidência histórica v21 descrita abaixo pertence à base candidata anterior, antes desta recomposição. O teste MariaDB executa dentro de uma transação externa e faz rollback, incluindo as linhas de auditoria append-only e o job/outbox sintéticos. O schema dedicado `ca_meta_m5_05` permanece isolado e não foi apagado.
+
+O harness `tools/m5-05/run-focused-api-test.mjs` compila o `tsconfig.build.json` com TypeScript, aplica migrations apenas quando `M5_05_AUTOMATION_TEST=1`, compila os testes para diretório temporário exclusivo, executa os dois arquivos M5.05 e a regressão do middleware M3.04 e remove somente esse diretório. A integração lê `API_DATABASE_*` e a CLI usa `MIGRATION_DATABASE_*`; os valores de segredo não são impressos. Dependências seguem `package-lock.json` existente, sem dependência nova. O harness Angular escreve/parseia JUnit em pasta temporária e a remove.
+
+## Evidência do candidato
+
+Worktree: `C:\Users\user\.codex\worktrees\m5-05-messages\ca_meta_app` · branch `desktop/m5-05` · base `ae867c6` · Node `v24.22.2` · npm `11.19.1` · Windows PowerShell. O junction compartilhado de dependências aponta ao checkout raiz; aliases privados de `@ca-meta/contracts` apontam ao pacote `packages/contracts` deste worktree.
+
+| Comando | Resultado |
+|---|---|
+| `npm run generate --workspace=@ca-meta/contracts` | Passou; 12 artefatos de contrato gerados. |
+| `npm run check:generated --workspace=@ca-meta/contracts` | Passou; 12 artefatos verificados. |
+| `npm run lint --workspace=@ca-meta/contracts` | Passou; quatro avisos preexistentes sobre respostas `4XX` nos handlers GET/HEAD de magic-link, sem relação com M5.05. |
+| `npm run typecheck --workspace=@ca-meta/api` | Passou para código e testes. |
+| `npm run test:m5-05 --workspace=@ca-meta/api` com `M5_05_AUTOMATION_TEST=1` | Passou; migrations aplicadas ao schema M5.05 e 7 testes focados, incluindo MariaDB real local (nenhuma fixture persistida). |
+| `npm run typecheck --workspace=@ca-meta/web` | Passou. |
+| `npm run test:m5-05 --workspace=@ca-meta/web` | Passou; 7 specs Angular focadas. |
+| `git diff-tree --check HEAD^ HEAD` | Passou no commit candidato. |
+
+O `nest` CLI não existe no `node_modules` compartilhado deste worktree, então o comando padrão `npm run build --workspace=@ca-meta/api` não pôde iniciar. Para o harness focado, TypeScript compilou o mesmo `tsconfig.build.json` diretamente; o CLI de migration foi executado no build compilado. A suíte workspace completa e qualquer validação com Meta/Suite4/PHP implantado não foram executadas.
+
+SHA candidato funcional original testado: `a9871712525f6d0363616f3a0c0a3c121901e99b`.
+
+## Correção focada de troca de contexto
+
+Uma revisão independente apontou que uma resposta PATCH atrasada poderia substituir o estado exibido após a troca de tenant. `save()` agora captura a geração, o UUID interno do ativo e o contexto opaco do servidor; sucesso, erro e `finally` só alteram a tela se os três ainda forem atuais. O carregamento do novo contexto também libera `isSaving`. As regressões usam PATCH diferido, mudam o contexto e carregam os valores do novo tenant; tanto a resposta antiga resolvida como a rejeitada são ignoradas, inclusive enquanto um novo save está ativo.
+
+No SHA `7369c9a1174b829df58a538cd237c3168dffeaf2`, `tools/m5-05/run-focused-web-tests.mjs` via Node 24.22.2 passou 9/9 specs, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. A revisão final também passou; o commit acrescenta somente a regressão da rejeição atrasada ao código de produção validado em `32f31f5cca0a3e6c7a9b3a3d498439d4082f7832`. A correção não altera API, contrato ou migração; gates não relacionados não foram repetidos.
+
+## Recomposição sobre M5.04
+
+A branch `desktop/m5-05` foi rebaseada sobre o squash M5.04 `e73456d037d3e0d05be2ab684820bb4be660390e`, reaplicando somente os seis commits M5.05 após a base antiga `ae867c6`. Os scripts focados M5.03/M5.04 foram preservados junto dos scripts M5.05. O módulo MetaAssets registra os controllers de Templates WhatsApp e Automation Config; o registry mantém migrations v21/v22 da base e registra M5.05 como v23. O cliente gerado M1.07 foi regenerado a partir do contrato espelhado combinado.
+
+O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado para substituir seu registro v21. A migration v23 foi validada em outro schema MariaDB fresco e descartável, `ca_meta_m5_05_v23_36b9f62f`, no serviço Docker local `ca-meta-m5-db-20261111` (MariaDB `11.8.8-MariaDB-ubu2404`, `127.0.0.1:13307`), em worktree detached no SHA `c4773ba1016382071a5319cae957ead99f083cb0`. Com Node `v24.22.2`, npm `11.19.1` e `M5_05_AUTOMATION_TEST=1`, `npm.cmd run test:m5-05 --workspace=@ca-meta/api` aplicou migrations até v23 e passou 7/7, 0 falhas/0 skips, incluindo a transação MariaDB. A primeira execução parou no gate de privilégio de `DELETE` da v22; concedi somente `DELETE` table-scoped ao migrator de teste no schema descartável e repeti. Schema e usuários temporários foram removidos; verificação confirmou zero recursos temporários restantes. O banco v21 foi preservado.
+
+No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida.
+
+O HEAD final do pacote é `3abc5d1800075f8acd0432bea9f43bf31ff1a1e0`. Com Node 24.22.2/npm 11.19.1, os checks exatos deste SHA passaram: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente gerado e 53 linhas de compatibilidade); `npm run check:breaking --prefix tools/m1-07` (nenhuma quebra no contrato v1); `npm run test:m5-05 --workspace=@ca-meta/api` (16 passaram, 0 falhas, 1 skip intencional da integração MariaDB); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). O harness API cobre também preflight PATCH com `If-Match` e exposição de `ETag` após Origin exata. A integração MariaDB v23 passou separadamente 7/7 em schema fresco no SHA `c4773ba`; o código da migration/registry é idêntico neste SHA. Revisão funcional e security review passaram em `3abc5d1`; `git diff-tree --check HEAD^ HEAD` passou no commit do candidato. Advisory: migration não verifica FKs/CHECKs em schema preexistente; não foi confirmado bypass de tenant. A UI mantém o gate de identidade M1.08 e o executor de mensagens permanece desabilitado/fail-closed.
+
+A limpeza do worktree detached usado pela validação foi bloqueada pela política automática ao tentar remover recursivamente arquivos ignorados; o registro Git do worktree foi removido e os resíduos foram preservados em `C:\Users\user\.codex\worktrees\m5-05-validation-c4773\ca_meta_app`. O schema e usuários temporários já foram removidos.
+
+## Harness M2.03 com a migration v23
+
+O primeiro gate `m2-08-mariadb` no head `a2ff484` falhou porque o fixture M2.03 tratava a migration v22 como última da registry e esperava 22 entradas depois da recuperação. A fixture agora localiza a migration Coexistence v22 pelo ID, verifica que o ledger termina nela durante a pausa e compara a recuperação com `MIGRATIONS.length`; `tools/m2-03/README.md` lista também a M5.05 v23. No código `b6554452485b12aa434607f75f190d1441272633`, o comando `M2_NODE_RUNTIME_DIR=C:\Users\user\AppData\Local\Temp\ca-meta-m5-node-runtime-20261111 pwsh -NoLogo -NoProfile -File tools/m2-03/run-mariadb-tests.ps1` passou com Node 24.22.2/npm 11.19.1 e MariaDB 11.8.8: 392 testes, 0 falhas, 20 skips; migrations 1–23 aplicadas; backup/restore sintético verificou 10.005 ativos e preservação do ledger. A fixture descartável foi removida pelo próprio harness.
diff --git a/docs/delivery/M5/decisions.md b/docs/delivery/M5/decisions.md
index 37e4614e..a1aca389 100644
--- a/docs/delivery/M5/decisions.md
+++ b/docs/delivery/M5/decisions.md
@@ -30,3 +30,11 @@
- **Decisão:** enquanto essa lacuna existir, o portal mostra “Identidade da empresa não confirmada”, oculta o seletor empresarial e falha fechado para toda mutação tenant-scoped. Leituras server-scoped e emissão pública de magic-link continuam disponíveis. Não derivar nome de `tenantRef`/`cUUID`, não inventar endpoint de rótulo e não liberar chamadas legadas externas quando um rótulo seguro for adicionado depois.
- **Consequência:** M5.01–M5.03 exercitam o fluxo de gravação apenas com capability confirmada sintética em testes. Em produção, ações permanecem indisponíveis até uma alteração revisada do contrato que forneça contexto de empresa confiável; identidade visível por si só nunca autoriza consumidores Msg4/PHP.
+## D-M5-06 — Configuração por canal em rota aditiva
+
+- **Critério da issue #59:** oferecer leitura e atualização PATCH da representação de mensagens/respostas por canal em `/api/v1/assets/{assetId}/automation-config`.
+- **Motivo:** essa rota e os schemas `AutomationConfig`/`AutomationConfigUpdate` já pertencem ao contrato v1 congelado e são usados por consumidores existentes. Substituí-los pela nova representação de M5.05 faz `check:breaking` falhar e remove os campos `enabled`/`replyText` legados.
+- **Decisão:** manter inalterados o GET/PUT e os schemas v1 na rota base. Publicar a representação M5.05 com `GET/PATCH /api/v1/assets/{assetId}/automation-config/channels` e schemas `AutomationChannelConfig`/`AutomationChannelConfigUpdate`.
+- **Consequência:** os consumidores M5.05 usam explicitamente a rota `/channels`; consumidores v1 continuam podendo usar seu contrato original. A issue diverge do caminho base pedido para manter compatibilidade binária de contrato.
+- **Gate:** aceitar a rota após revisão do diff de contrato e `check:breaking`; qualquer consolidação futura na rota base requer uma versão compatível, migração dos consumidores identificados e gate próprio de breaking-change.
+
diff --git a/docs/meta-v2/04-identity-api-and-security.md b/docs/meta-v2/04-identity-api-and-security.md
index ca8a16d9..3d7cd409 100644
--- a/docs/meta-v2/04-identity-api-and-security.md
+++ b/docs/meta-v2/04-identity-api-and-security.md
@@ -66,7 +66,7 @@ Os paths são contrato inicial para ratificação em OpenAPI. Segurança de toda
| Reautorizar | POST `/connections/:id/reauthorizations` | Cria onboarding novo, preserva grant anterior quando válido |
| Desconectar | DELETE `/connections/:id` ou comando tipado | Escopo e impacto descritos; cleanup externo separado |
| Templates | GET/POST `/whatsapp/accounts/:id/templates` | Conta/grant MANAGE_TEMPLATES, payload permitido |
-| Automação | PATCH `/assets/:id/automation-config` | Campos por canal, version/ETag e outbox |
+| Automação | GET/PATCH `/assets/:id/automation-config/channels` | Campos por canal, version/ETag e outbox; preserva GET/PUT e schemas v1 existentes na rota base |
| Notificações | GET `/notifications`, POST `/notifications/:id/actions` | Tenant completo e operationId; leitura separada de efeito externo |
| Operações | GET `/operations/:id` | Outcome incluindo UNKNOWN, sem token/resposta bruta |
| Webhook | GET/POST `/webhooks/meta/:appConfigRef` | Challenge/assinatura/raw body/limites; referência pública não é segredo |
diff --git a/docs/meta-v2/contracts/events/outbox-event.schema.json b/docs/meta-v2/contracts/events/outbox-event.schema.json
index 8090c7fd..ca151459 100644
--- a/docs/meta-v2/contracts/events/outbox-event.schema.json
+++ b/docs/meta-v2/contracts/events/outbox-event.schema.json
@@ -25,7 +25,7 @@
"schemaVersion": { "const": 1 },
"eventType": {
"type": "string",
- "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested"]
+ "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested", "automation.config.changed"]
},
"aggregateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"aggregateVersion": { "type": "integer", "minimum": 1 },
@@ -125,6 +125,16 @@
"templateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"operationRef": { "type": "string", "minLength": 1, "maxLength": 128 }
}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "assetRef", "configurationVersion"],
+ "properties": {
+ "kind": { "const": "AUTOMATION_CONFIG_CHANGED" },
+ "assetRef": { "type": "string", "format": "uuid" },
+ "configurationVersion": { "type": "integer", "minimum": 1 }
+ }
}
]
}
@@ -157,6 +167,10 @@
{
"if": { "properties": { "eventType": { "const": "template.creation.requested" } }, "required": ["eventType"] },
"then": { "properties": { "data": { "properties": { "kind": { "const": "TEMPLATE_CREATION_REQUESTED" } } } } }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "automation.config.changed" } }, "required": ["eventType"] },
+ "then": { "properties": { "data": { "properties": { "kind": { "const": "AUTOMATION_CONFIG_CHANGED" } } } } }
}
],
"examples": [
diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml
index 18359e37..f9c45cb5 100644
--- a/docs/meta-v2/contracts/openapi.yaml
+++ b/docs/meta-v2/contracts/openapi.yaml
@@ -1781,7 +1781,7 @@ paths:
sanitized:
summary: Sanitized success example
value:
- assetId: 10000000-0000-4000-8000-000000000004
+ assetId: asset_example_01
enabled: true
replyText: Thanks for contacting our team.
version: 7
@@ -1842,7 +1842,7 @@ paths:
sanitized:
summary: Sanitized success example
value:
- assetId: 10000000-0000-4000-8000-000000000004
+ assetId: asset_example_01
enabled: false
replyText: ""
version: 8
@@ -1860,6 +1860,138 @@ paths:
"503": *a20
default: *a21
x-retry-policy: On 412, read the current representation and make an explicit new update.
+ /api/v1/assets/{assetId}/automation-config/channels:
+ parameters:
+ - name: assetId
+ in: path
+ required: true
+ description: Opaque resource identifier; authorization is always checked against the server-derived
+ tenant.
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
+ pattern: ^[A-Za-z0-9_-]+$
+ get:
+ operationId: getAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Read automation/reply configuration
+ description: Tenant and asset authorization are checked for the requested capability. ETag is
+ returned for conditional updates.
+ security: *a10
+ parameters: *a22
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v7"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: 10000000-0000-4000-8000-000000000004
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields: [privateWelcomeMessage]
+ privateWelcomeMessage: Olá! Como podemos ajudar?
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
+ version: 7
+ updatedAt: 2026-10-29T11:00:00Z
+ projectionState: PENDING
+ requestId: req_example_30
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ patch:
+ operationId: patchAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Update channel-supported automation/reply fields conditionally
+ description: Requires exact Origin, CSRF, current tenant context and If-Match from the latest
+ representation. Unknown fields are rejected. This changes local configuration; delivery of a
+ reply is a separate external effect.
+ security: *a10
+ parameters:
+ - *a26
+ - *a27
+ - *a28
+ - *a29
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ description: Request uses a closed, size-bounded schema.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfigUpdate"
+ examples:
+ whatsapp:
+ summary: Sanitized WhatsApp phone update
+ value:
+ version: 7
+ privateWelcomeMessage: ""
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v8"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: 10000000-0000-4000-8000-000000000004
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields: [privateWelcomeMessage]
+ privateWelcomeMessage: ""
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
+ version: 8
+ updatedAt: 2026-10-29T12:02:00Z
+ projectionState: PENDING
+ requestId: req_example_31
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ x-retry-policy: On 412, read the current representation and make an explicit new update.
/api/v1/assets/{assetId}/replies:
parameters:
- name: assetId
@@ -4134,6 +4266,78 @@ components:
type: string
minLength: 1
maxLength: 128
+ AutomationChannelConfig:
+ type: object
+ additionalProperties: false
+ required:
+ - assetId
+ - assetKind
+ - inheritanceMode
+ - supportedFields
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ - capabilities
+ - version
+ - updatedAt
+ - projectionState
+ - requestId
+ properties:
+ assetId:
+ type: string
+ format: uuid
+ assetKind:
+ $ref: "#/components/schemas/AutomationConfigAssetKind"
+ inheritanceMode:
+ type: string
+ const: ASSET_ONLY
+ description: Each resource owns independent values. No account-level default is applied.
+ supportedFields:
+ type: array
+ uniqueItems: true
+ items:
+ $ref: "#/components/schemas/AutomationConfigField"
+ privateWelcomeMessage:
+ type: [string, "null"]
+ maxLength: 4096
+ commentPublicReply:
+ type: [string, "null"]
+ maxLength: 4096
+ commentPrivateReply:
+ type: [string, "null"]
+ maxLength: 4096
+ capabilities:
+ type: object
+ additionalProperties: false
+ required: [canEdit, executable]
+ properties:
+ canEdit:
+ type: boolean
+ executable:
+ type: boolean
+ const: false
+ description: The API stores settings only; message execution is a separate fail-closed capability.
+ version:
+ type: integer
+ minimum: 0
+ updatedAt:
+ type: [string, "null"]
+ format: date-time
+ projectionState:
+ $ref: "#/components/schemas/AutomationConfigProjectionState"
+ requestId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ AutomationConfigAssetKind:
+ type: string
+ enum: [WHATSAPP_PHONE_NUMBER, FACEBOOK_PAGE, INSTAGRAM_BUSINESS_ACCOUNT, INSTAGRAM_CREATOR_ACCOUNT]
+ AutomationConfigField:
+ type: string
+ enum: [privateWelcomeMessage, commentPublicReply, commentPrivateReply]
+ AutomationConfigProjectionState:
+ type: string
+ enum: [UNKNOWN, PENDING, SYNCED, STALE, FAILED]
AutomationConfigUpdate:
type: object
additionalProperties: false
@@ -4146,6 +4350,30 @@ components:
replyText:
type: string
maxLength: 4096
+ AutomationChannelConfigUpdate:
+ type: object
+ additionalProperties: false
+ minProperties: 2
+ required: [version]
+ properties:
+ version:
+ type: integer
+ minimum: 0
+ privateWelcomeMessage:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
+ commentPublicReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
+ commentPrivateReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
ReplyRequest:
type: object
additionalProperties: false
diff --git a/docs/meta-v2/experience/journey-spec.md b/docs/meta-v2/experience/journey-spec.md
index 4345d6ce..cf580faf 100644
--- a/docs/meta-v2/experience/journey-spec.md
+++ b/docs/meta-v2/experience/journey-spec.md
@@ -112,7 +112,7 @@ Actions are rendered from server state, user role, and capability:
## Existing features to preserve
- **Templates:** keep `/templates`, WhatsApp-number selection, list, status and creation. New contract path is `GET/POST /api/v1/whatsapp/accounts/{assetId}/templates`; it is WhatsApp-account scoped and requires `MANAGE_TEMPLATES`. Show `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`, `UNKNOWN` as distinct plain-language states. The current Angular form includes language/examples/body; the M1.07 `TemplateCreateRequest` only specifies name/language/category/bodyText. Until the contract preserves the existing language/example inputs, disable template creation with the explanation “Não é possível enviar este formulário sem preservar o idioma e os exemplos.” Never submit a reduced payload or imply that a disabled button is an error in the user's account.
-- **Automation/messages:** keep the private welcome and, where supported by current product behavior, public/private comment reply settings. New contract endpoint is `GET/PATCH /api/v1/assets/{assetId}/automation-config`, ETag-protected. The M1.07 `AutomationConfig` currently has only `enabled` and one `replyText`, not the baseline's private welcome/public comment/private comment fields. Keep the current editor read-only or save disabled with a clear compatibility explanation until a typed contract maps every existing field by channel. On a stale `412`, reload and let the user review before saving again. A saved local configuration is not proof the provider delivered a message.
+- **Automation/messages:** keep the private welcome and, where supported by current product behavior, public/private comment reply settings. The additive M5.05 endpoint is `GET/PATCH /api/v1/assets/{assetId}/automation-config/channels`, ETag-protected. The frozen v1 `GET/PUT /api/v1/assets/{assetId}/automation-config` route and its schemas remain unchanged. The frozen M1.07 `AutomationConfig` still has only `enabled` and `replyText`; the new typed `AutomationChannelConfig` maps the channel-specific fields for the M5.05 editor without replacing that v1 shape. Keep saves disabled while the M1.08 tenant-identity gate is unconfirmed. On a stale `412`, reload and let the user review before saving again. A saved local configuration is not proof the provider delivered a message.
- **Notifications:** retain bell, loading/empty/error/refresh, and supported actions. New `GET /api/v1/notifications` only returns `safeSummary` and references; the baseline notification card uses `MessageText`. Keep a concise safe summary and status, but do not silently substitute it for a message transcript. The full message/reply-context contract is a gate before migration of message display. Render `actions[]` only through approved labels; `MARK_READ`/`DISMISS` are local. `REPLY` is an external effect and returns a pending job; keep it disabled with an explanation until authorized message context and the reply path are available. When an action outcome is unknown, reconcile before offering another send.
- **No feature removal:** differences between the legacy UI and the proposed API are contract migration work. Do not remove template fields, notification actions, language, message behavior, or status detail without an explicit product decision and the affected tests.
diff --git a/docs/meta-v2/experience/screen-api-permission-test.csv b/docs/meta-v2/experience/screen-api-permission-test.csv
index 7271f31c..5bedda86 100644
--- a/docs/meta-v2/experience/screen-api-permission-test.csv
+++ b/docs/meta-v2/experience/screen-api-permission-test.csv
@@ -13,7 +13,7 @@ Refresh asset,STATE-09,/channels selected asset,refreshAsset POST /api/v1/assets
Reauthorize connection,STATE-06,/channels selected connection,createReauthorization POST /api/v1/connections/{connectionId}/reauthorizations,session current tenant + resource authorization + CSRF; exact role code Not confirmed,202 JobAccepted lacks onboarding ID/launch URL so keep action disabled with explanation until the contract exposes a safe reference,read returned job but do not offer provider launch without a session/launch ref,walkthrough REC-03 STATE-06,Prior verified grant remains while valid; API contract gap blocks full reauthorization UX
Disconnect connection,STATE-08,/channels detail confirmation,disconnectConnection DELETE /api/v1/connections/{connectionId},operator/owner scope + current tenant + CSRF,connection-specific scope and async job,202 means request started; status/job reconcile; UNKNOWN cannot be resubmitted automatically,walkthrough REC-03 STATE-08,Affected provider revocation/cleanup is separate and may be UNKNOWN
Templates list/create,FEATURE-01,/templates,listTemplates GET and createTemplate POST /api/v1/whatsapp/accounts/{assetId}/templates,session current tenant + asset grant with MANAGE_TEMPLATES for create,per-number list with status; existing name/category/language/body/example UI,creation disabled with explanation until contract preserves language/examples; never send reduced payload silently,walkthrough FEATURE-01,Current UI includes example values but M1.07 request does not
-Automation config,FEATURE-02,/channels automation editor,getAutomationConfig GET and replaceAutomationConfig PATCH /api/v1/assets/{assetId}/automation-config,session current tenant + requested asset capability; PATCH uses ETag/If-Match,baseline has three channel-specific fields while M1.07 only has enabled/replyText; editor is read-only and save is disabled until fully mapped,412 reload then explicit review; no reduced payload,walkthrough FEATURE-02,Exact capability names by channel Not confirmed; preserve all existing messages before enabling writes
+Automation config,FEATURE-02,/channels automation editor,getAutomationChannelConfig GET and patchAutomationChannelConfig PATCH /api/v1/assets/{assetId}/automation-config/channels; frozen-v1 getAutomationConfig GET and replaceAutomationConfig PUT remain at /api/v1/assets/{assetId}/automation-config,session current tenant + requested asset capability; M5.05 PATCH uses ETag/If-Match,the frozen M1.07 schema has enabled/replyText; M5.05 AutomationChannelConfig maps channel fields and preserves them in its additive route; editor save disabled until the M1.08 identity gate is confirmed,412 reload then explicit review; no reduced payload,walkthrough FEATURE-02,Exact capability names by channel Not confirmed; preserve all existing messages before enabling writes
Notifications read,FEATURE-03,/channels notification bell,listNotifications GET /api/v1/notifications,authenticated session + current tenant; response returns safe summaries/references,loading/empty/error and action list from server,keep error distinct from empty; retry read only,walkthrough FEATURE-03,Do not render message bodies/raw provider payload from synthetic events
Notification action,FEATURE-03,/channels notification bell,actOnNotification POST /api/v1/notifications/{notificationId}/actions,current membership + asset grant + role + CSRF,render MARK_READ/DISMISS when returned; REPLY remains disabled until authorized message context is available,202 does not prove send; getJob/getOperation and no blind repeat,walkthrough FEATURE-03 REC-03,"Current notification UI uses MessageText; proposed model only has safeSummary/reference, so don't silently remove message context"
Template creation progress,FEATURE-01,/templates selected number,getJob GET /api/v1/jobs/{jobId} after createTemplate 202,tenant/resource access to returned job,status pending/approved/rejected/paused/unknown from template projection,don't resubmit on unknown; refresh status only,walkthrough FEATURE-01 REC-03,Template status lacks reason text in M1.07 schema
diff --git a/packages/contracts/events/outbox-event.schema.json b/packages/contracts/events/outbox-event.schema.json
index 8090c7fd..ca151459 100644
--- a/packages/contracts/events/outbox-event.schema.json
+++ b/packages/contracts/events/outbox-event.schema.json
@@ -25,7 +25,7 @@
"schemaVersion": { "const": 1 },
"eventType": {
"type": "string",
- "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested"]
+ "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested", "automation.config.changed"]
},
"aggregateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"aggregateVersion": { "type": "integer", "minimum": 1 },
@@ -125,6 +125,16 @@
"templateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"operationRef": { "type": "string", "minLength": 1, "maxLength": 128 }
}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "assetRef", "configurationVersion"],
+ "properties": {
+ "kind": { "const": "AUTOMATION_CONFIG_CHANGED" },
+ "assetRef": { "type": "string", "format": "uuid" },
+ "configurationVersion": { "type": "integer", "minimum": 1 }
+ }
}
]
}
@@ -157,6 +167,10 @@
{
"if": { "properties": { "eventType": { "const": "template.creation.requested" } }, "required": ["eventType"] },
"then": { "properties": { "data": { "properties": { "kind": { "const": "TEMPLATE_CREATION_REQUESTED" } } } } }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "automation.config.changed" } }, "required": ["eventType"] },
+ "then": { "properties": { "data": { "properties": { "kind": { "const": "AUTOMATION_CONFIG_CHANGED" } } } } }
}
],
"examples": [
diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml
index 189bf6bc..27e8fb5e 100644
--- a/packages/contracts/openapi.yaml
+++ b/packages/contracts/openapi.yaml
@@ -1920,7 +1920,7 @@ paths:
sanitized:
summary: Sanitized success example
value:
- assetId: 10000000-0000-4000-8000-000000000004
+ assetId: asset_example_01
enabled: true
replyText: Thanks for contacting our team.
version: 7
@@ -1982,7 +1982,7 @@ paths:
sanitized:
summary: Sanitized success example
value:
- assetId: 10000000-0000-4000-8000-000000000004
+ assetId: asset_example_01
enabled: false
replyText: ""
version: 8
@@ -2000,6 +2000,141 @@ paths:
"503": *a20
default: *a21
x-retry-policy: On 412, read the current representation and make an explicit new update.
+ /api/v1/assets/{assetId}/automation-config/channels:
+ parameters:
+ - name: assetId
+ in: path
+ required: true
+ description: Opaque resource identifier; authorization is always checked against
+ the server-derived tenant.
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
+ pattern: ^[A-Za-z0-9_-]+$
+ get:
+ operationId: getAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Read automation/reply configuration
+ description: Tenant and asset authorization are checked for the requested
+ capability. ETag is returned for conditional updates.
+ security: *a10
+ parameters: *a22
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v7"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: 10000000-0000-4000-8000-000000000004
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields:
+ - privateWelcomeMessage
+ privateWelcomeMessage: Olá! Como podemos ajudar?
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
+ version: 7
+ updatedAt: 2026-10-29T11:00:00Z
+ projectionState: PENDING
+ requestId: req_example_30
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ patch:
+ operationId: patchAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Update channel-supported automation/reply fields conditionally
+ description: Requires exact Origin, CSRF, current tenant context and If-Match
+ from the latest representation. Unknown fields are rejected. This
+ changes local configuration; delivery of a reply is a separate external
+ effect.
+ security: *a10
+ parameters:
+ - *a25
+ - *a26
+ - *a27
+ - *a28
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ description: Request uses a closed, size-bounded schema.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfigUpdate"
+ examples:
+ whatsapp:
+ summary: Sanitized WhatsApp phone update
+ value:
+ version: 7
+ privateWelcomeMessage: ""
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v8"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: 10000000-0000-4000-8000-000000000004
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields:
+ - privateWelcomeMessage
+ privateWelcomeMessage: ""
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
+ version: 8
+ updatedAt: 2026-10-29T12:02:00Z
+ projectionState: PENDING
+ requestId: req_example_31
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ x-retry-policy: On 412, read the current representation and make an explicit new update.
/api/v1/assets/{assetId}/replies:
parameters:
- name: assetId
@@ -4205,6 +4340,102 @@ components:
type: string
minLength: 1
maxLength: 128
+ AutomationChannelConfig:
+ type: object
+ additionalProperties: false
+ required:
+ - assetId
+ - assetKind
+ - inheritanceMode
+ - supportedFields
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ - capabilities
+ - version
+ - updatedAt
+ - projectionState
+ - requestId
+ properties:
+ assetId:
+ type: string
+ format: uuid
+ assetKind:
+ $ref: "#/components/schemas/AutomationConfigAssetKind"
+ inheritanceMode:
+ type: string
+ const: ASSET_ONLY
+ description: Each resource owns independent values. No account-level default is
+ applied.
+ supportedFields:
+ type: array
+ uniqueItems: true
+ items:
+ $ref: "#/components/schemas/AutomationConfigField"
+ privateWelcomeMessage:
+ type:
+ - string
+ - "null"
+ maxLength: 4096
+ commentPublicReply:
+ type:
+ - string
+ - "null"
+ maxLength: 4096
+ commentPrivateReply:
+ type:
+ - string
+ - "null"
+ maxLength: 4096
+ capabilities:
+ type: object
+ additionalProperties: false
+ required:
+ - canEdit
+ - executable
+ properties:
+ canEdit:
+ type: boolean
+ executable:
+ type: boolean
+ const: false
+ description: The API stores settings only; message execution is a separate
+ fail-closed capability.
+ version:
+ type: integer
+ minimum: 0
+ updatedAt:
+ type:
+ - string
+ - "null"
+ format: date-time
+ projectionState:
+ $ref: "#/components/schemas/AutomationConfigProjectionState"
+ requestId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ AutomationConfigAssetKind:
+ type: string
+ enum:
+ - WHATSAPP_PHONE_NUMBER
+ - FACEBOOK_PAGE
+ - INSTAGRAM_BUSINESS_ACCOUNT
+ - INSTAGRAM_CREATOR_ACCOUNT
+ AutomationConfigField:
+ type: string
+ enum:
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ AutomationConfigProjectionState:
+ type: string
+ enum:
+ - UNKNOWN
+ - PENDING
+ - SYNCED
+ - STALE
+ - FAILED
AutomationConfigUpdate:
type: object
additionalProperties: false
@@ -4217,6 +4448,31 @@ components:
replyText:
type: string
maxLength: 4096
+ AutomationChannelConfigUpdate:
+ type: object
+ additionalProperties: false
+ minProperties: 2
+ required:
+ - version
+ properties:
+ version:
+ type: integer
+ minimum: 0
+ privateWelcomeMessage:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
+ commentPublicReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
+ commentPrivateReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
ReplyRequest:
type: object
additionalProperties: false
diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts
index 53390b08..e17c872d 100644
--- a/packages/contracts/src/generated/openapi.d.ts
+++ b/packages/contracts/src/generated/openapi.d.ts
@@ -581,6 +581,33 @@ export interface paths {
readonly patch?: never;
readonly trace?: never;
};
+ readonly "/api/v1/assets/{assetId}/automation-config/channels": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /**
+ * Read automation/reply configuration
+ * @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
+ */
+ readonly get: operations["getAutomationChannelConfig"];
+ readonly put?: never;
+ readonly post?: never;
+ readonly delete?: never;
+ readonly options?: never;
+ readonly head?: never;
+ /**
+ * Update channel-supported automation/reply fields conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly patch: operations["patchAutomationChannelConfig"];
+ readonly trace?: never;
+ };
readonly "/api/v1/assets/{assetId}/replies": {
readonly parameters: {
readonly query?: never;
@@ -1195,10 +1222,52 @@ export interface components {
readonly updatedAt: string;
readonly requestId: string;
};
+ readonly AutomationChannelConfig: {
+ /** Format: uuid */
+ readonly assetId: string;
+ readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
+ /**
+ * @description Each resource owns independent values. No account-level default is applied.
+ * @constant
+ */
+ readonly inheritanceMode: "ASSET_ONLY";
+ readonly supportedFields: readonly components["schemas"]["AutomationConfigField"][];
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly capabilities: {
+ readonly canEdit: boolean;
+ /**
+ * @description The API stores settings only; message execution is a separate fail-closed capability.
+ * @constant
+ */
+ readonly executable: false;
+ };
+ readonly version: number;
+ /** Format: date-time */
+ readonly updatedAt: string | null;
+ readonly projectionState: components["schemas"]["AutomationConfigProjectionState"];
+ readonly requestId: string;
+ };
+ /** @enum {string} */
+ readonly AutomationConfigAssetKind: "WHATSAPP_PHONE_NUMBER" | "FACEBOOK_PAGE" | "INSTAGRAM_BUSINESS_ACCOUNT" | "INSTAGRAM_CREATOR_ACCOUNT";
+ /** @enum {string} */
+ readonly AutomationConfigField: "privateWelcomeMessage" | "commentPublicReply" | "commentPrivateReply";
+ /** @enum {string} */
+ readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
readonly enabled: boolean;
readonly replyText: string;
};
+ readonly AutomationChannelConfigUpdate: {
+ readonly version: number;
+ /** @description Empty text clears the local value for this asset. */
+ readonly privateWelcomeMessage?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPublicReply?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPrivateReply?: string;
+ };
readonly ReplyRequest: {
readonly inReplyToRef: string;
readonly text: string;
@@ -2742,6 +2811,101 @@ export interface operations {
readonly default: components["responses"]["UnexpectedError"];
};
};
+ readonly getAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
+ readonly patchAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /**
+ * @description Exact configured browser Origin; wildcard matching is forbidden.
+ * @example https://portal.example.invalid
+ */
+ readonly Origin: components["parameters"]["Origin"];
+ /** @description Must match the configured exact-origin topology; cross-site, missing or invalid values fail. */
+ readonly "Sec-Fetch-Site": components["parameters"]["FetchSite"];
+ /** @description Session-bound anti-CSRF value; not a DTO property and not sufficient without the session cookie. */
+ readonly "X-CSRF-Token": components["parameters"]["CsrfToken"];
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ /**
+ * @description ETag from the latest representation. Missing/stale precondition is rejected; no blind overwrite.
+ * @example "automation-v7"
+ */
+ readonly "If-Match": components["parameters"]["IfMatch"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /** @description Request uses a closed, size-bounded schema. */
+ readonly requestBody: {
+ readonly content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfigUpdate"];
+ };
+ };
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
readonly sendReply: {
readonly parameters: {
readonly query?: never;
diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts
index 53390b08..e17c872d 100644
--- a/tools/m1-07/src/generated/openapi.d.ts
+++ b/tools/m1-07/src/generated/openapi.d.ts
@@ -581,6 +581,33 @@ export interface paths {
readonly patch?: never;
readonly trace?: never;
};
+ readonly "/api/v1/assets/{assetId}/automation-config/channels": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /**
+ * Read automation/reply configuration
+ * @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
+ */
+ readonly get: operations["getAutomationChannelConfig"];
+ readonly put?: never;
+ readonly post?: never;
+ readonly delete?: never;
+ readonly options?: never;
+ readonly head?: never;
+ /**
+ * Update channel-supported automation/reply fields conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly patch: operations["patchAutomationChannelConfig"];
+ readonly trace?: never;
+ };
readonly "/api/v1/assets/{assetId}/replies": {
readonly parameters: {
readonly query?: never;
@@ -1195,10 +1222,52 @@ export interface components {
readonly updatedAt: string;
readonly requestId: string;
};
+ readonly AutomationChannelConfig: {
+ /** Format: uuid */
+ readonly assetId: string;
+ readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
+ /**
+ * @description Each resource owns independent values. No account-level default is applied.
+ * @constant
+ */
+ readonly inheritanceMode: "ASSET_ONLY";
+ readonly supportedFields: readonly components["schemas"]["AutomationConfigField"][];
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly capabilities: {
+ readonly canEdit: boolean;
+ /**
+ * @description The API stores settings only; message execution is a separate fail-closed capability.
+ * @constant
+ */
+ readonly executable: false;
+ };
+ readonly version: number;
+ /** Format: date-time */
+ readonly updatedAt: string | null;
+ readonly projectionState: components["schemas"]["AutomationConfigProjectionState"];
+ readonly requestId: string;
+ };
+ /** @enum {string} */
+ readonly AutomationConfigAssetKind: "WHATSAPP_PHONE_NUMBER" | "FACEBOOK_PAGE" | "INSTAGRAM_BUSINESS_ACCOUNT" | "INSTAGRAM_CREATOR_ACCOUNT";
+ /** @enum {string} */
+ readonly AutomationConfigField: "privateWelcomeMessage" | "commentPublicReply" | "commentPrivateReply";
+ /** @enum {string} */
+ readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
readonly enabled: boolean;
readonly replyText: string;
};
+ readonly AutomationChannelConfigUpdate: {
+ readonly version: number;
+ /** @description Empty text clears the local value for this asset. */
+ readonly privateWelcomeMessage?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPublicReply?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPrivateReply?: string;
+ };
readonly ReplyRequest: {
readonly inReplyToRef: string;
readonly text: string;
@@ -2742,6 +2811,101 @@ export interface operations {
readonly default: components["responses"]["UnexpectedError"];
};
};
+ readonly getAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
+ readonly patchAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /**
+ * @description Exact configured browser Origin; wildcard matching is forbidden.
+ * @example https://portal.example.invalid
+ */
+ readonly Origin: components["parameters"]["Origin"];
+ /** @description Must match the configured exact-origin topology; cross-site, missing or invalid values fail. */
+ readonly "Sec-Fetch-Site": components["parameters"]["FetchSite"];
+ /** @description Session-bound anti-CSRF value; not a DTO property and not sufficient without the session cookie. */
+ readonly "X-CSRF-Token": components["parameters"]["CsrfToken"];
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ /**
+ * @description ETag from the latest representation. Missing/stale precondition is rejected; no blind overwrite.
+ * @example "automation-v7"
+ */
+ readonly "If-Match": components["parameters"]["IfMatch"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /** @description Request uses a closed, size-bounded schema. */
+ readonly requestBody: {
+ readonly content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfigUpdate"];
+ };
+ };
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
readonly sendReply: {
readonly parameters: {
readonly query?: never;
diff --git a/tools/m2-03/README.md b/tools/m2-03/README.md
index dff5bd42..4a413f76 100644
--- a/tools/m2-03/README.md
+++ b/tools/m2-03/README.md
@@ -1,6 +1,6 @@
# M2.03 synthetic migration harness
-The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–22: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, and the M5.03 Coexistence onboarding lock v22. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions.
+The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–23: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, the M5.03 Coexistence onboarding lock v22, and the M5.05 per-channel asset automation configuration v23. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions.
Prerequisites: Node 24.22.2, npm 11.19.1, Docker Engine, Docker Compose v2, and network access to the pinned MariaDB image on its first run. The PowerShell harness verifies those exact versions. It uses `%LOCALAPPDATA%\Codex\runtimes\node-v24.22.2` by default; set `M2_NODE_RUNTIME_DIR` to that runtime directory when it is elsewhere.
diff --git a/tools/m5-05/run-focused-api-test.mjs b/tools/m5-05/run-focused-api-test.mjs
new file mode 100644
index 00000000..870d7472
--- /dev/null
+++ b/tools/m5-05/run-focused-api-test.mjs
@@ -0,0 +1,67 @@
+import { mkdir, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+if (Number(process.versions.node.split('.')[0]) !== 24) {
+ throw new Error(`M5.05 validation requires the repository's Node 24 runtime; received ${process.version}.`);
+}
+
+const npmCli = process.env.npm_execpath;
+if (!npmCli) throw new Error('Run this focused harness through the npm workspace script.');
+const apiRoot = join(repositoryRoot, 'apps', 'api');
+const compiler = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc');
+const outputRoot = join(apiRoot, '.test-runs');
+const temporaryOutput = join(outputRoot, `m5-05-${process.pid}-${Date.now()}`);
+const safePrefix = `${resolve(outputRoot)}${sep}`;
+
+function run(args) {
+ const result = spawnSync(process.execPath, args, {
+ cwd: repositoryRoot,
+ stdio: 'inherit',
+ windowsHide: true,
+ env: process.env,
+ });
+ if (result.error) throw result.error;
+ return result.status === 0;
+}
+
+try {
+ await mkdir(outputRoot, { recursive: true });
+ await mkdir(temporaryOutput, { recursive: false });
+ process.stdout.write(`M5.05 focused API + CORS regression tests — Node ${process.version}\n`);
+
+ if (!run([compiler, '--project', join(apiRoot, 'tsconfig.build.json'), '--pretty', 'false'])) {
+ process.exitCode = 1;
+ } else {
+ if (process.env.M5_05_AUTOMATION_TEST === '1' &&
+ !run([join(apiRoot, 'dist', 'database', 'migrations', 'cli.js'), 'deploy'])) {
+ process.exitCode = 1;
+ }
+
+ if (process.exitCode !== 1 && !run([
+ join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'),
+ '--project', join(apiRoot, 'tsconfig.test.json'),
+ '--outDir', temporaryOutput,
+ '--pretty', 'false',
+ ])) {
+ process.exitCode = 1;
+ }
+
+ if (process.exitCode !== 1 && !run([
+ '--test', '--test-concurrency=1',
+ join(temporaryOutput, 'test', 'm5-05-automation-config.spec.js'),
+ join(temporaryOutput, 'test', 'm5-05-automation-config.integration.spec.js'),
+ join(temporaryOutput, 'test', 'm3-04-session.spec.js'),
+ ])) {
+ process.exitCode = 1;
+ }
+ }
+} finally {
+ const resolvedOutput = resolve(temporaryOutput);
+ if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-05-`)) {
+ throw new Error('M5.05 harness refused to remove an output directory outside its private workspace.');
+ }
+ await rm(resolvedOutput, { recursive: true, force: true });
+}
diff --git a/tools/m5-05/run-focused-web-tests.mjs b/tools/m5-05/run-focused-web-tests.mjs
new file mode 100644
index 00000000..ac12ba51
--- /dev/null
+++ b/tools/m5-05/run-focused-web-tests.mjs
@@ -0,0 +1,59 @@
+import { mkdir, readFile, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+if (Number(process.versions.node.split('.')[0]) !== 24) {
+ throw new Error(`M5.05 validation requires the repository's Node 24 runtime; received ${process.version}.`);
+}
+const cli = join(repositoryRoot, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
+const outputRoot = join(repositoryRoot, 'apps', 'web', '.test-runs');
+await mkdir(outputRoot, { recursive: true });
+const temporaryOutput = join(outputRoot, `m5-05-${process.pid}-${Date.now()}`);
+await mkdir(temporaryOutput, { recursive: false });
+const safePrefix = `${resolve(outputRoot)}${sep}`;
+
+function run(args) {
+ const result = spawnSync(process.execPath, [cli, ...args], {
+ cwd: join(repositoryRoot, 'apps', 'web'),
+ stdio: 'inherit',
+ windowsHide: true,
+ });
+ if (result.error) throw result.error;
+ return result.status === 0;
+}
+
+try {
+ process.stdout.write(`M5.05 focused Angular specs — Node ${process.version}\n`);
+ const outputFile = join(temporaryOutput, 'web.junit.xml');
+ const args = [
+ 'test', 'meta-integration', '--watch=false', `--output-file=${outputFile}`,
+ '--include=src/app/core/portal/automation-config.service.spec.ts',
+ '--include=src/app/features/portal/asset-automation-config.component.spec.ts',
+ '--include=src/app/features/portal/connection-record-detail.component.spec.ts',
+ ];
+ const commandSucceeded = run(args);
+ try {
+ const report = await readFile(outputFile, 'utf8');
+ const suites = [...report.matchAll(/]*)>/g)];
+ const total = (attribute) => suites.reduce((sum, suite) => sum + Number(suite[1].match(new RegExp(`\\b${attribute}="(\\d+)"`))?.[1] ?? 0), 0);
+ const tests = total('tests');
+ const failures = total('failures') + total('errors');
+ if (!commandSucceeded || tests === 0 || failures !== 0) {
+ process.stderr.write(`M5.05 Angular JUnit result: ${tests} test(s), ${failures} failure(s).\n`);
+ process.exitCode = 1;
+ } else {
+ process.stdout.write(`M5.05 Angular specs: ${tests} test(s), ${failures} failure(s).\n`);
+ }
+ } catch (error) {
+ if (commandSucceeded) throw error;
+ process.exitCode = 1;
+ }
+} finally {
+ const resolvedOutput = resolve(temporaryOutput);
+ if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-05-`)) {
+ throw new Error('M5.05 harness refused to remove an output directory outside its private workspace.');
+ }
+ await rm(resolvedOutput, { recursive: true, force: true });
+}