From f6b86051f5b5a6f3211da92e68e54be8319d20a5 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 16:33:51 -0300 Subject: [PATCH 01/17] feat(m5-04): add scoped WhatsApp template surface --- apps/api/package.json | 1 + apps/api/src/jobs/event-validation.ts | 30 +- .../src/jobs/job-execution-authorization.ts | 25 ++ apps/api/src/jobs/job-store.ts | 1 + .../api/src/meta-assets/meta-assets.module.ts | 25 +- .../src/whatsapp-templates/template-body.ts | 68 ++++ .../template-create.worker.ts | 164 +++++++++ .../whatsapp-templates.controller.ts | 87 +++++ .../whatsapp-templates.service.ts | 218 +++++++++++ apps/api/src/worker/main.ts | 8 + .../api/test/m5-04-whatsapp-templates.spec.ts | 206 +++++++++++ apps/api/tsconfig.m5-04-test.json | 11 + apps/web/package.json | 1 + apps/web/src/app/app.routes.ts | 3 +- .../core/portal/whatsapp-templates.service.ts | 47 +++ .../portal/whatsapp-templates.component.html | 113 ++++++ .../portal/whatsapp-templates.component.scss | 77 ++++ .../whatsapp-templates.component.spec.ts | 122 +++++++ .../portal/whatsapp-templates.component.ts | 337 ++++++++++++++++++ docs/delivery/M5/M5.04.md | 43 +++ docs/meta-v2/contracts/openapi.yaml | 68 +++- packages/contracts/openapi.yaml | 140 +++++--- packages/contracts/src/generated/openapi.d.ts | 27 +- tools/m1-07/src/generated/openapi.d.ts | 24 +- tools/m5-04/README.md | 12 + tools/m5-04/run-focused-api-test.mjs | 38 ++ tools/m5-04/run-focused-web-tests.mjs | 66 ++++ 27 files changed, 1874 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/whatsapp-templates/template-body.ts create mode 100644 apps/api/src/whatsapp-templates/template-create.worker.ts create mode 100644 apps/api/src/whatsapp-templates/whatsapp-templates.controller.ts create mode 100644 apps/api/src/whatsapp-templates/whatsapp-templates.service.ts create mode 100644 apps/api/test/m5-04-whatsapp-templates.spec.ts create mode 100644 apps/api/tsconfig.m5-04-test.json create mode 100644 apps/web/src/app/core/portal/whatsapp-templates.service.ts create mode 100644 apps/web/src/app/features/portal/whatsapp-templates.component.html create mode 100644 apps/web/src/app/features/portal/whatsapp-templates.component.scss create mode 100644 apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts create mode 100644 apps/web/src/app/features/portal/whatsapp-templates.component.ts create mode 100644 docs/delivery/M5/M5.04.md create mode 100644 tools/m5-04/README.md create mode 100644 tools/m5-04/run-focused-api-test.mjs create mode 100644 tools/m5-04/run-focused-web-tests.mjs diff --git a/apps/api/package.json b/apps/api/package.json index 53b42755..89248481 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "test:m3-12": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/m3-12-asset-catalog.spec.js", "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: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/jobs/event-validation.ts b/apps/api/src/jobs/event-validation.ts index 9bceecf1..f50f2599 100644 --- a/apps/api/src/jobs/event-validation.ts +++ b/apps/api/src/jobs/event-validation.ts @@ -2,6 +2,7 @@ import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { createRequire } from 'node:module'; import type { MetaAuthVariant, MetaFlowVariant } from '../credentials/credential-resolver.types.js'; +import type { TemplateCreateRequest } from '../whatsapp-templates/template-body.js'; export interface InboxEventEnvelope { readonly eventId: string; @@ -36,7 +37,7 @@ export interface OutboxEventEnvelope { readonly data: Readonly>; } -export type JobType = 'INBOX_PROCESS' | 'OUTBOX_DELIVER' | 'ONBOARDING_STEP' | 'PROJECTION_RECONCILE' | 'HISTORY_SYNC' | 'CREDENTIAL_INSPECTION' | 'ASSET_REFRESH'; +export type JobType = 'INBOX_PROCESS' | 'OUTBOX_DELIVER' | 'ONBOARDING_STEP' | 'PROJECTION_RECONCILE' | 'HISTORY_SYNC' | 'CREDENTIAL_INSPECTION' | 'ASSET_REFRESH' | 'TEMPLATE_CREATE'; export type JobPayload = | { readonly kind: 'INBOX_PROCESS'; readonly inboxEventRef: string } @@ -55,6 +56,10 @@ export type JobPayload = | { readonly kind: 'ASSET_REFRESH'; readonly assetId: string; readonly appConfigurationId: string; readonly environment: 'development' | 'test' | 'staging' | 'production'; + } + | { + readonly kind: 'TEMPLATE_CREATE'; readonly assetId: string; readonly appConfigurationId: string; + readonly environment: 'development' | 'test' | 'staging' | 'production'; readonly request: TemplateCreateRequest; }; export type JobLane = 'WEBHOOK_URGENT' | 'ONBOARDING' | 'HISTORICAL_PROJECTION'; @@ -102,6 +107,11 @@ export const JOB_POLICIES: Readonly> = Object.freeze( lane: 'HISTORICAL_PROJECTION', effectMode: 'SAFE_RETRY', maxAttempts: 3, maxElapsedMs: 15 * 60_000, maxHandlerMs: 60_000, retryBaseMs: 2_000, retryMaxMs: 60_000, }, + TEMPLATE_CREATE: { + // A provider template submission is ambiguous after a timeout; the worker never repeats it. + lane: 'ONBOARDING', effectMode: 'AMBIGUOUS', maxAttempts: 1, + maxElapsedMs: 15 * 60_000, maxHandlerMs: 60_000, retryBaseMs: 1_000, retryMaxMs: 1_000, + }, }); export const MAX_WAITING_JOBS_PER_TENANT = 1_000; @@ -172,6 +182,24 @@ const jobPayloadSchemas: Readonly> = { appConfigurationId: { type: 'string', format: 'uuid' }, environment: { enum: ['development', 'test', 'staging', 'production'] }, }, }, + TEMPLATE_CREATE: { + type: 'object', additionalProperties: false, + required: ['kind', 'assetId', 'appConfigurationId', 'environment', 'request'], + properties: { + kind: { const: 'TEMPLATE_CREATE' }, assetId: { type: 'string', format: 'uuid' }, + appConfigurationId: { type: 'string', format: 'uuid' }, environment: { enum: ['development', 'test', 'staging', 'production'] }, + request: { + type: 'object', additionalProperties: false, required: ['name', 'language', 'category', 'bodyText'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[a-z0-9_]+$' }, + language: { type: 'string', minLength: 2, maxLength: 16 }, + category: { enum: ['AUTHENTICATION', 'MARKETING', 'UTILITY'] }, + bodyText: { type: 'string', minLength: 1, maxLength: 1024 }, + exampleValues: { type: 'array', maxItems: 100, items: { type: 'string', minLength: 1, maxLength: 1024 } }, + }, + }, + }, + }, }; const jobValidators = new Map>( diff --git a/apps/api/src/jobs/job-execution-authorization.ts b/apps/api/src/jobs/job-execution-authorization.ts index a437552f..243e9b68 100644 --- a/apps/api/src/jobs/job-execution-authorization.ts +++ b/apps/api/src/jobs/job-execution-authorization.ts @@ -59,6 +59,7 @@ const REQUIRED_JOB_SCOPES: Readonly>> = Ob HISTORY_SYNC: 'TENANT_RESOURCE_READ', CREDENTIAL_INSPECTION: 'CREDENTIAL_USE', ASSET_REFRESH: 'META_CONNECTION_EXECUTE', + TEMPLATE_CREATE: 'CREDENTIAL_USE', } as const satisfies Readonly>>); function resourceReference(type: JobType, payload: JobPayload): string | undefined { @@ -71,6 +72,7 @@ function resourceReference(type: JobType, payload: JobPayload): string | undefin case 'HISTORY_SYNC': return undefined; case 'CREDENTIAL_INSPECTION': return payload.credentialId; case 'ASSET_REFRESH': return payload.assetId; + case 'TEMPLATE_CREATE': return payload.assetId; } } @@ -266,6 +268,29 @@ export class MysqlJobExecutionAuthorizationPort implements JobExecutionAuthoriza // Match the current usable-authorization states required by the M3.08 credential resolver. return rows.length === 1; } + case 'TEMPLATE_CREATE': { + const rows = await database.query( + `SELECT a.id + FROM ca_meta_assets AS a + INNER JOIN ca_meta_asset_authorizations AS aa ON aa.tenant_id = a.tenant_id AND aa.asset_id = a.id + AND aa.app_configuration_id = a.app_configuration_id AND aa.provider = a.provider AND aa.environment = a.environment + INNER JOIN ca_meta_authorizations AS auth ON auth.tenant_id = aa.tenant_id AND auth.id = aa.authorization_id + AND auth.app_configuration_id = aa.app_configuration_id AND auth.provider = aa.provider AND auth.environment = aa.environment + INNER JOIN ca_meta_asset_grants AS g ON g.tenant_id = aa.tenant_id AND g.authorization_id = aa.authorization_id + AND g.asset_id = aa.asset_id AND g.app_configuration_id = aa.app_configuration_id + AND g.provider = aa.provider AND g.environment = aa.environment + WHERE a.tenant_id = ? AND a.id = ? AND a.app_configuration_id = ? AND a.environment = ? + AND a.provider = 'META' AND a.asset_kind = 'WHATSAPP_BUSINESS_ACCOUNT' + AND aa.state = 'ACTIVE' AND aa.verification_state = 'VERIFIED' + AND auth.authorization_state IN ('ACTIVE', 'PARTIAL') + AND auth.flow_variant = 'WHATSAPP_EXCLUSIVE' AND auth.auth_variant = 'WHATSAPP_EMBEDDED_SIGNUP' + AND g.grant_name = 'whatsapp_business_management' AND g.status = 'GRANTED' + LIMIT 1`, + [tenantId, payload.assetId, payload.appConfigurationId, payload.environment], + ) as Array<{ id: string }>; + // The operation grant name is a candidate only; real Meta eligibility remains a release gate. + return rows.length === 1; + } } } } diff --git a/apps/api/src/jobs/job-store.ts b/apps/api/src/jobs/job-store.ts index ef1f9122..8bbcbe9a 100644 --- a/apps/api/src/jobs/job-store.ts +++ b/apps/api/src/jobs/job-store.ts @@ -178,6 +178,7 @@ function resourceScope(input: EnqueueJobInput, payload: JobPayload): string { case 'HISTORY_SYNC': return payload.checkpointRef; case 'CREDENTIAL_INSPECTION': return payload.credentialId; case 'ASSET_REFRESH': return payload.assetId; + case 'TEMPLATE_CREATE': return payload.assetId; } } diff --git a/apps/api/src/meta-assets/meta-assets.module.ts b/apps/api/src/meta-assets/meta-assets.module.ts index a319f90c..0a8c9d29 100644 --- a/apps/api/src/meta-assets/meta-assets.module.ts +++ b/apps/api/src/meta-assets/meta-assets.module.ts @@ -33,6 +33,12 @@ import { AssetCatalogController } from './asset-catalog.controller.js'; import { AssetCatalogService, META_FLOW_ADAPTER_VARIANTS } from './asset-catalog.service.js'; import { AssetRefreshCoordinator, META_ASSET_REFRESH_ADAPTER, type MetaAssetRefreshAdapter } from './asset-refresh.service.js'; import type { FlowCatalogVariant } from './meta-assets.types.js'; +import { WhatsAppTemplatesController } from '../whatsapp-templates/whatsapp-templates.controller.js'; +import { + META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY, META_WHATSAPP_TEMPLATES_CREATE_ENABLED, META_WHATSAPP_TEMPLATES_PROVIDER, + MysqlWhatsAppTemplatesAccountDirectory, WhatsAppTemplatesService, +} from '../whatsapp-templates/whatsapp-templates.service.js'; +import type { WhatsAppTemplatesProvider } from '../whatsapp-templates/whatsapp-templates.service.js'; @Module({}) export class MetaAssetsModule { @@ -40,7 +46,7 @@ export class MetaAssetsModule { return { module: MetaAssetsModule, imports: [identityModule, JobsModule.register(environment), PrivacyModule.register(environment)], - controllers: [AssetCatalogController], + controllers: [AssetCatalogController, WhatsAppTemplatesController], providers: [ { provide: PortalSessionGuard, @@ -56,6 +62,23 @@ export class MetaAssetsModule { inject: [META_ASSETS_REPOSITORY, TenantContextService, TenantAuthorizationService, AssetRefreshCoordinator, META_FLOW_ADAPTER_VARIANTS], }, + { + provide: META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY, + useFactory: (database: DurableWorkDatabase) => new MysqlWhatsAppTemplatesAccountDirectory(database, environment.nodeEnv), + inject: [DURABLE_WORK_DATABASE], + }, + // The provider list adapter and create worker are absent until current grant/capability eligibility is confirmed. + { provide: META_WHATSAPP_TEMPLATES_PROVIDER, useValue: null }, + { provide: META_WHATSAPP_TEMPLATES_CREATE_ENABLED, useValue: false }, + { + provide: WhatsAppTemplatesService, + useFactory: (contexts: TenantContextService, authorization: TenantAuthorizationService, + accounts: MysqlWhatsAppTemplatesAccountDirectory, provider: WhatsAppTemplatesProvider | null, + createEnabled: boolean, database: DurableWorkDatabase, jobs: DurableJobStore) => + new WhatsAppTemplatesService(contexts, authorization, accounts, provider, createEnabled, database, jobs), + inject: [TenantContextService, TenantAuthorizationService, META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY, + META_WHATSAPP_TEMPLATES_PROVIDER, META_WHATSAPP_TEMPLATES_CREATE_ENABLED, DURABLE_WORK_DATABASE, DURABLE_JOB_STORE], + }, // No real flow adapter is installed in this runtime. { provide: META_FLOW_ADAPTER_VARIANTS, useFactory: (): ReadonlySet => new Set() }, // No real Meta adapter ships with this runtime; tests may inject an isolated fake. diff --git a/apps/api/src/whatsapp-templates/template-body.ts b/apps/api/src/whatsapp-templates/template-body.ts new file mode 100644 index 00000000..27833191 --- /dev/null +++ b/apps/api/src/whatsapp-templates/template-body.ts @@ -0,0 +1,68 @@ +import type { components } from '@ca-meta/contracts'; + +export type TemplateCreateRequest = components['schemas']['TemplateCreateRequest']; + +const NAME = /^[a-z0-9_]{1,128}$/; +const LANGUAGE = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8}){0,2}$/; +const PLACEHOLDER = /\{\{([1-9][0-9]{0,2})\}\}/g; +const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; +const CATEGORIES = new Set(['AUTHENTICATION', 'MARKETING', 'UTILITY']); + +export class TemplateInputError extends Error { + constructor() { super('The WhatsApp template request is invalid.'); this.name = 'TemplateInputError'; } +} + +/** + * Validates the local BODY request shape. Placeholder/example pairing and order are + * this API's compatibility rule; they are not represented as a current Meta rule. + */ +export function parseTemplateCreateRequest(value: unknown): TemplateCreateRequest { + if (!record(value) || Object.keys(value).some((key) => + !['name', 'language', 'category', 'bodyText', 'exampleValues'].includes(key))) throw new TemplateInputError(); + const { name, language, category, bodyText, exampleValues } = value; + if (typeof name !== 'string' || !NAME.test(name) || + typeof language !== 'string' || !LANGUAGE.test(language) || language.length > 16 || + typeof category !== 'string' || !CATEGORIES.has(category) || + typeof bodyText !== 'string' || bodyText.trim().length === 0 || bodyText.length > 1024 || CONTROL.test(bodyText)) { + throw new TemplateInputError(); + } + + const normalizedExamples = exampleValues === undefined ? [] : exampleValues; + if (!Array.isArray(normalizedExamples) || normalizedExamples.length > 100 || + normalizedExamples.some((item) => typeof item !== 'string' || item.trim().length === 0 || item.length > 1024 || CONTROL.test(item))) { + throw new TemplateInputError(); + } + + const indices = new Set(); + for (const match of bodyText.matchAll(PLACEHOLDER)) { + const index = Number(match[1]); + if (!Number.isSafeInteger(index) || index > 100 || (!indices.has(index) && index !== indices.size + 1)) { + throw new TemplateInputError(); + } + indices.add(index); + } + const withoutPlaceholders = bodyText.replace(new RegExp(PLACEHOLDER.source, 'g'), ''); + if (withoutPlaceholders.includes('{{') || withoutPlaceholders.includes('}}') || indices.size !== normalizedExamples.length) { + throw new TemplateInputError(); + } + + return Object.freeze({ name, language, category: category as TemplateCreateRequest['category'], bodyText, + exampleValues: Object.freeze([...normalizedExamples]) }); +} + +export function templateBodyProviderComponents(request: TemplateCreateRequest): Readonly> { + return Object.freeze({ + name: request.name, + language: request.language, + category: request.category, + components: Object.freeze([Object.freeze({ + type: 'BODY', + text: request.bodyText, + ...(request.exampleValues?.length ? { example: Object.freeze({ body_text: Object.freeze([[...request.exampleValues]]) }) } : {}), + })]), + }); +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/api/src/whatsapp-templates/template-create.worker.ts b/apps/api/src/whatsapp-templates/template-create.worker.ts new file mode 100644 index 00000000..cef4f591 --- /dev/null +++ b/apps/api/src/whatsapp-templates/template-create.worker.ts @@ -0,0 +1,164 @@ +import type { ClaimedJob, JobOutcome } from '../jobs/job-store.js'; +import type { VerifiedJobExecutionContext } from '../jobs/job-execution-authorization.js'; +import { isVerifiedJobExecutionContext } from '../jobs/job-execution-authorization.js'; +import type { TemplateCreateRequest } from './template-body.js'; +import { parseTemplateCreateRequest } from './template-body.js'; +import type { JobHandler } from '../worker/durable-worker.js'; +import type { CredentialResolverService } from '../credentials/credential-resolver.service.js'; +import type { MetaAppConfigRegistry } from '../meta-config/meta-app-config.registry.js'; +import type { MetaGraphClient } from '../meta-graph/meta-graph.client.js'; +import type { MetaGraphExecutionContext, MetaGraphInput, MetaGraphJson } from '../meta-graph/meta-graph.types.js'; +import type { WhatsAppTemplatesAccountDirectory } from './whatsapp-templates.service.js'; +import { templateBodyProviderComponents } from './template-body.js'; +import { isVerifiedServiceTenantScope } from '../identity/identity.service.js'; + +export interface WhatsAppTemplateDispatchInput { + readonly tenantId: string; + readonly assetId: string; + readonly appConfigurationId: string; + readonly environment: 'development' | 'test' | 'staging' | 'production'; + readonly request: TemplateCreateRequest; + readonly serviceScope: VerifiedJobExecutionContext['serviceScope']; + readonly correlationId: string; +} + +export type TemplateDispatchResult = { readonly kind: 'ACCEPTED' } | { readonly kind: 'REJECTED' } | { readonly kind: 'NO_EFFECT' } | { readonly kind: 'UNKNOWN' }; +export type TemplateReconciliationResult = { readonly kind: 'FOUND' } | { readonly kind: 'NOT_FOUND' | 'AMBIGUOUS' }; + +/** A future adapter must resolve MANAGE_TEMPLATES through M3.08 and use the M4.01 Graph client. */ +export interface WhatsAppTemplateDispatchAdapter { + submit(input: WhatsAppTemplateDispatchInput, signal: AbortSignal): Promise; + /** Read-only and exact-account scoped: compare name, language and BODY content; never submit here. */ + reconcile(input: WhatsAppTemplateDispatchInput, signal: AbortSignal): Promise; +} + +/** + * Reuses the M3.08 operation resolver and M4.01 Graph client. The class exists as an + * injection point, while API/worker registration stays disabled until capability evidence is reviewed. + */ +export class MetaGraphWhatsAppTemplateDispatchAdapter implements WhatsAppTemplateDispatchAdapter { + constructor( + private readonly configurations: MetaAppConfigRegistry, + private readonly credentials: Pick, + private readonly graph: Pick, + private readonly accounts: WhatsAppTemplatesAccountDirectory, + ) {} + + async submit(input: WhatsAppTemplateDispatchInput, signal: AbortSignal): Promise { + const resolved = await this.resolve(input); + if (!resolved) return { kind: 'NO_EFFECT' }; + if (signal.aborted) return { kind: 'NO_EFFECT' }; + const createInput: Extract = Object.freeze({ + endpoint: 'TEMPLATE_CREATE', context: resolved.context, credentialHandle: resolved.handle, + credentialCapability: 'MANAGE_TEMPLATES', operation: 'MANAGE_TEMPLATES', + correlationId: input.correlationId, resourceId: resolved.context.providerObjectId, + body: templateBodyProviderComponents(input.request) as Readonly>, + }); + const result = await this.graph.execute(createInput); + if (result.outcome === 'UNKNOWN' || result.ambiguous) return { kind: 'UNKNOWN' }; + if (result.outcome === 'FAILED') return { kind: 'REJECTED' }; + // A 2xx with a provider error or a body without a verifiable template reference is not success evidence. + if (!record(result.data) || typeof result.data['id'] !== 'string' || !/^\d{1,32}$/.test(result.data['id'])) { + return { kind: 'UNKNOWN' }; + } + return { kind: 'ACCEPTED' }; + } + + async reconcile(input: WhatsAppTemplateDispatchInput, signal: AbortSignal): Promise { + const resolved = await this.resolve(input); + if (!resolved || signal.aborted) return { kind: 'AMBIGUOUS' }; + const listInput: Extract = Object.freeze({ + endpoint: 'TEMPLATES_LIST', context: resolved.context, credentialHandle: resolved.handle, + credentialCapability: 'MANAGE_TEMPLATES', operation: 'MANAGE_TEMPLATES', + correlationId: input.correlationId, resourceId: resolved.context.providerObjectId, + query: Object.freeze({ fields: 'id,name,language,category,components', limit: 100 }), + }); + const result = await this.graph.collect(listInput, { maxPages: 100, maxItems: 10_000 }); + if (result.outcome !== 'SUCCEEDED') return { kind: 'AMBIGUOUS' }; + return classifyTemplateReconciliationCandidates(result.data, input.request); + } + + private async resolve(input: WhatsAppTemplateDispatchInput): Promise<{ + readonly context: MetaGraphExecutionContext; + readonly handle: Awaited>['handle']; + } | undefined> { + if (!isVerifiedServiceTenantScope(input.serviceScope) || input.serviceScope.tenantId !== input.tenantId || + input.environment !== 'development' && input.environment !== 'test' && input.environment !== 'staging' && input.environment !== 'production') { + return undefined; + } + const account = await this.accounts.resolveForWorker(input.serviceScope, input.assetId, input.appConfigurationId, input.environment); + if (!account) return undefined; + const profile = this.configurations.resolve('WHATSAPP_EXCLUSIVE', 'WHATSAPP_EMBEDDED_SIGNUP', input.environment); + if (!profile || profile.versions.graphApi.status !== 'VERIFIED') return undefined; + const actor = Object.freeze({ serviceScope: input.serviceScope, correlationId: input.correlationId }); + let credential: Awaited>; + try { + credential = await this.credentials.resolve({ tenantId: input.tenantId, provider: 'META', + appConfigurationId: input.appConfigurationId, environment: input.environment, + assetId: input.assetId, operation: 'MANAGE_TEMPLATES' }, actor); + } catch { return undefined; } + if (credential.status !== 'READY' || credential.capability !== 'MANAGE_TEMPLATES') return undefined; + const context: MetaGraphExecutionContext = Object.freeze({ tenantId: input.tenantId, provider: 'META', + appConfigurationId: input.appConfigurationId, environment: input.environment, assetId: input.assetId, + operation: 'MANAGE_TEMPLATES', flowVariant: 'WHATSAPP_EXCLUSIVE', authVariant: 'WHATSAPP_EMBEDDED_SIGNUP', + configurationRef: profile.configurationRef, providerObjectId: account.providerWabaId }); + return Object.freeze({ context, handle: credential.handle }); + } +} + +export function createTemplateCreateJobHandler(adapter: WhatsAppTemplateDispatchAdapter): JobHandler { + return async (job: ClaimedJob, signal: AbortSignal, authorization: VerifiedJobExecutionContext): Promise => { + if (!isVerifiedJobExecutionContext(authorization) || !job || job.type !== 'TEMPLATE_CREATE' || + job.payload.kind !== 'TEMPLATE_CREATE' || authorization.tenantId !== job.tenantId || + authorization.resourceRef !== job.payload.assetId || authorization.serviceScope.tenantId !== job.tenantId || + !/^[0-9a-f-]{36}$/i.test(job.payload.assetId) || !/^[0-9a-f-]{36}$/i.test(job.payload.appConfigurationId)) { + return { kind: 'PERMANENT_FAILURE', safeErrorCode: 'INVALID_REFERENCE' }; + } + let request: TemplateCreateRequest; + try { request = parseTemplateCreateRequest(job.payload.request); } catch { + return { kind: 'PERMANENT_FAILURE', safeErrorCode: 'INVALID_REFERENCE' }; + } + const input: WhatsAppTemplateDispatchInput = Object.freeze({ + tenantId: job.tenantId, + assetId: job.payload.assetId, + appConfigurationId: job.payload.appConfigurationId, + environment: job.payload.environment, + request, + serviceScope: authorization.serviceScope, + correlationId: job.correlationId, + }); + + return await submitAndReconcileTemplateCreate(adapter, input, signal); + }; +} + +/** One submission at most; ambiguous replies get one read-only observation and never a resubmit. */ +export async function submitAndReconcileTemplateCreate(adapter: WhatsAppTemplateDispatchAdapter, + input: WhatsAppTemplateDispatchInput, signal: AbortSignal): Promise { + let submitted: TemplateDispatchResult | null | undefined; + try { submitted = await adapter.submit(input, signal); } catch { submitted = undefined; } + if (submitted?.kind === 'ACCEPTED') return { kind: 'SUCCEEDED' }; + if (submitted?.kind === 'REJECTED' || submitted?.kind === 'NO_EFFECT') { + return { kind: 'PERMANENT_FAILURE', safeErrorCode: 'HANDLER_FAILED' }; + } + + // A timed-out/empty/explicitly ambiguous reply is observed read-only. A miss does not authorize retry. + let observed: TemplateReconciliationResult | null | undefined; + try { observed = await adapter.reconcile(input, signal); } catch { observed = undefined; } + if (observed?.kind === 'FOUND') return { kind: 'SUCCEEDED' }; + return { kind: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN' }; +} + +/** + * Lists are scoped to the internal WABA and can detect a possible name/language collision, but Meta's + * current API exposes no confirmed request correlation here. Even one exact match may predate this job, + * so it must remain ambiguous rather than being reported as successful submission. + */ +export function classifyTemplateReconciliationCandidates(rows: readonly unknown[], request: TemplateCreateRequest): TemplateReconciliationResult { + const matchingNames = rows.filter((row) => record(row) && row['name'] === request.name && row['language'] === request.language); + return matchingNames.length === 0 ? { kind: 'NOT_FOUND' } : { kind: 'AMBIGUOUS' }; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/api/src/whatsapp-templates/whatsapp-templates.controller.ts b/apps/api/src/whatsapp-templates/whatsapp-templates.controller.ts new file mode 100644 index 00000000..41e8718c --- /dev/null +++ b/apps/api/src/whatsapp-templates/whatsapp-templates.controller.ts @@ -0,0 +1,87 @@ +import { + BadRequestException, ConflictException, Controller, Get, Headers, HttpCode, HttpException, HttpStatus, + NotFoundException, Param, Post, Query, Req, Res, ServiceUnavailableException, UseGuards, +} from '@nestjs/common'; +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 { + WhatsAppTemplateIdempotencyConflictError, WhatsAppTemplateNotFoundError, + WhatsAppTemplatesService, WhatsAppTemplatesUnavailableError, +} from './whatsapp-templates.service.js'; +import { TemplateInputError } from './template-body.js'; + +function tenantContextRef(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 query(query: Record): { readonly cursor?: string; readonly limit: number } { + const rawLimit = query['limit']; + const limit = rawLimit === undefined ? 50 : typeof rawLimit === 'string' && /^\d{1,3}$/.test(rawLimit) ? Number(rawLimit) : NaN; + const cursor = query['cursor']; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || + (cursor !== undefined && (typeof cursor !== 'string' || !/^[A-Za-z0-9._~+/%=-]{1,512}$/.test(cursor))) || + Object.keys(query).some((key) => !['cursor', 'limit'].includes(key))) throw new BadRequestException(); + return { limit, ...(typeof cursor === 'string' ? { cursor } : {}) }; +} + +@Controller() +export class WhatsAppTemplatesController { + constructor(private readonly templates: WhatsAppTemplatesService) {} + + @Get('whatsapp/accounts/:assetId/templates') + @PublicRoute() + @UseGuards(PortalSessionGuard) + async list( + @Param('assetId') assetId: string, + @Query() rawQuery: Record, + @Req() request: PortalRequest, + @Headers('x-tenant-context') headerContext?: string, + @Res({ passthrough: true }) response?: Response, + ): Promise { + try { + const result = await this.templates.list(request.portalSession, tenantContextRef(request, headerContext), + assetId, query(rawQuery), requestId(request)); + response?.setHeader('Cache-Control', 'no-store'); + return result; + } catch (error) { + this.mapError(error); + } + } + + @Post('whatsapp/accounts/:assetId/templates') + @PublicRoute() + @UseGuards(PortalSessionGuard) + @HttpCode(HttpStatus.ACCEPTED) + async create( + @Param('assetId') assetId: string, + @Req() request: PortalRequest, + @Headers('x-tenant-context') headerContext?: string, + @Headers('idempotency-key') idempotencyKey?: string, + @Res({ passthrough: true }) response?: Response, + ): Promise { + try { + const result = await this.templates.create(request.portalSession, tenantContextRef(request, headerContext), + assetId, idempotencyKey ?? '', request.body as unknown, requestId(request)); + response?.setHeader('Location', result.location); + response?.setHeader('Cache-Control', 'no-store'); + return result; + } catch (error) { + this.mapError(error); + } + } + + private mapError(error: unknown): never { + if (error instanceof TemplateInputError) throw new BadRequestException(); + if (error instanceof WhatsAppTemplateNotFoundError) throw new NotFoundException(); + if (error instanceof WhatsAppTemplateIdempotencyConflictError) throw new ConflictException(); + if (error instanceof WhatsAppTemplatesUnavailableError) throw new ServiceUnavailableException(); + throw error; + } +} diff --git a/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts new file mode 100644 index 00000000..e406bf84 --- /dev/null +++ b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto'; +import type { components } from '@ca-meta/contracts'; +import { IdentityDeniedError } from '../identity/identity.errors.js'; +import { isVerifiedPortalSession, type VerifiedPortalSession } from '../identity/identity.trust.js'; +import type { AuthorizedTenantScope, TenantOperation } from '../identity/identity.types.js'; +import type { VerifiedServiceTenantScope } from '../identity/identity.types.js'; +import { isVerifiedServiceTenantScope, TenantAuthorizationService, TenantContextService } from '../identity/identity.service.js'; +import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js'; +import type { DurableWorkDatabase } from '../jobs/durable-work-database.js'; +import type { DurableJobStore } from '../jobs/job-store.js'; +import { TemplateInputError, parseTemplateCreateRequest, type TemplateCreateRequest } from './template-body.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 CURSOR = /^[A-Za-z0-9._~+/%=-]{1,512}$/; +const PROVIDER_ID = /^\d{1,32}$/; + +export interface WhatsAppTemplatesAccount { + readonly tenantId: string; + readonly assetId: string; + readonly appConfigurationId: string; + readonly environment: MetaEnvironment; + /** Internal server-only mapping; never read from a browser request or returned by this service. */ + readonly providerWabaId: string; +} + +export interface WhatsAppTemplatesAccountDirectory { + resolve(scope: AuthorizedTenantScope, assetId: string): Promise; + resolveForWorker(scope: VerifiedServiceTenantScope, assetId: string, appConfigurationId: string, + environment: MetaEnvironment): Promise; +} + +export interface WhatsAppTemplatesProvider { + list(account: WhatsAppTemplatesAccount, input: { readonly cursor?: string; readonly limit: number; readonly requestId: string }): + Promise; +} + +export const META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY = Symbol('META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY'); +export const META_WHATSAPP_TEMPLATES_PROVIDER = Symbol('META_WHATSAPP_TEMPLATES_PROVIDER'); +/** Set true only when the reviewed API admission and matching worker adapter are both installed. */ +export const META_WHATSAPP_TEMPLATES_CREATE_ENABLED = Symbol('META_WHATSAPP_TEMPLATES_CREATE_ENABLED'); + +export class WhatsAppTemplateNotFoundError extends Error { + constructor() { super('WhatsApp template account was not found.'); this.name = 'WhatsAppTemplateNotFoundError'; } +} +export class WhatsAppTemplatesUnavailableError extends Error { + constructor() { super('WhatsApp template operations are unavailable.'); this.name = 'WhatsAppTemplatesUnavailableError'; } +} +export class WhatsAppTemplateIdempotencyConflictError extends Error { + constructor() { super('The template idempotency key was already used for a different request.'); this.name = 'WhatsAppTemplateIdempotencyConflictError'; } +} + +/** Only a server-resolved, verified WABA asset with a candidate operation grant is an eligible target. */ +export class MysqlWhatsAppTemplatesAccountDirectory implements WhatsAppTemplatesAccountDirectory { + constructor(private readonly database: DurableWorkDatabase, private readonly environment: MetaEnvironment) {} + + async resolve(scope: AuthorizedTenantScope, assetId: string): Promise { + if (!UUID.test(scope.tenantId) || !UUID.test(assetId)) return undefined; + return await this.resolveForTenant(scope.tenantId, assetId, this.environment); + } + + async resolveForWorker(scope: VerifiedServiceTenantScope, assetId: string, appConfigurationId: string, + environment: MetaEnvironment): Promise { + if (!isVerifiedServiceTenantScope(scope) || !UUID.test(scope.tenantId) || !UUID.test(assetId) || + !UUID.test(appConfigurationId) || environment !== this.environment) return undefined; + return await this.resolveForTenant(scope.tenantId, assetId, environment, appConfigurationId); + } + + private async resolveForTenant(tenantId: string, assetId: string, environment: MetaEnvironment, + appConfigurationId?: string): Promise { + const database = await this.database.getDataSource(); + const rows = await database.query( + `SELECT a.id AS asset_id, a.tenant_id, a.app_configuration_id, a.environment, a.external_id AS provider_waba_id + FROM ca_meta_assets a + WHERE a.tenant_id = ? AND a.id = ? AND a.provider = 'META' AND a.environment = ? + AND (? IS NULL OR a.app_configuration_id = ?) + AND a.asset_kind = 'WHATSAPP_BUSINESS_ACCOUNT' + AND EXISTS ( + SELECT 1 + FROM ca_meta_asset_authorizations aa + INNER JOIN ca_meta_authorizations auth ON auth.tenant_id = aa.tenant_id + AND auth.id = aa.authorization_id AND auth.app_configuration_id = aa.app_configuration_id + AND auth.provider = aa.provider AND auth.environment = aa.environment + INNER JOIN ca_meta_asset_grants g ON g.tenant_id = aa.tenant_id + AND g.authorization_id = aa.authorization_id AND g.asset_id = aa.asset_id + AND g.app_configuration_id = aa.app_configuration_id AND g.provider = aa.provider + AND g.environment = aa.environment + WHERE aa.tenant_id = a.tenant_id AND aa.asset_id = a.id + AND aa.app_configuration_id = a.app_configuration_id AND aa.provider = a.provider + AND aa.environment = a.environment AND aa.state = 'ACTIVE' AND aa.verification_state = 'VERIFIED' + AND auth.authorization_state IN ('ACTIVE', 'PARTIAL') + AND auth.flow_variant = 'WHATSAPP_EXCLUSIVE' AND auth.auth_variant = 'WHATSAPP_EMBEDDED_SIGNUP' + AND g.grant_name = 'whatsapp_business_management' AND g.status = 'GRANTED' + ) + LIMIT 1`, + [tenantId, assetId, environment, appConfigurationId ?? null, appConfigurationId ?? null], + ) as Array<{ asset_id: string; tenant_id: string; app_configuration_id: string; environment: string; provider_waba_id: string }>; + const row = rows[0]; + if (!row || row.asset_id !== assetId || row.tenant_id !== tenantId || + !UUID.test(row.app_configuration_id) || row.environment !== environment || !PROVIDER_ID.test(row.provider_waba_id)) return undefined; + return Object.freeze({ tenantId: row.tenant_id, assetId: row.asset_id, appConfigurationId: row.app_configuration_id, + environment: this.environment, providerWabaId: row.provider_waba_id }); + } +} + +export class WhatsAppTemplatesService { + constructor( + private readonly contexts: TenantContextService, + private readonly authorization: TenantAuthorizationService, + private readonly accounts: WhatsAppTemplatesAccountDirectory, + private readonly provider: WhatsAppTemplatesProvider | null, + private readonly createEnabled: boolean, + private readonly database: DurableWorkDatabase, + private readonly jobs: DurableJobStore, + ) {} + + async list(session: VerifiedPortalSession, tenantContextRef: string, assetId: string, + input: { readonly cursor?: string; readonly limit: number }, requestId: string): + Promise { + if (!validPagination(input)) throw new TemplateInputError(); + const { account } = await this.authorizedAccount(session, tenantContextRef, assetId, 'CONNECT'); + if (!this.provider) throw new WhatsAppTemplatesUnavailableError(); + try { + const page = await this.provider.list(account, { ...input, requestId }); + return sanitizeTemplatePage(page, requestId); + } catch (error) { + if (error instanceof TemplateInputError || error instanceof WhatsAppTemplatesUnavailableError) throw error; + throw new WhatsAppTemplatesUnavailableError(); + } + } + + async create(session: VerifiedPortalSession, tenantContextRef: string, assetId: string, idempotencyKey: string, + body: unknown, requestId: string): Promise { + if (!/^[A-Za-z0-9._~-]{16,128}$/.test(idempotencyKey)) throw new TemplateInputError(); + const request = parseTemplateCreateRequest(body); + const { scope, account } = await this.authorizedAccount(session, tenantContextRef, assetId, 'CONNECT'); + if (!this.createEnabled) throw new WhatsAppTemplatesUnavailableError(); + + const digest = createHash('sha256').update(idempotencyKey, 'utf8').digest('hex'); + const correlationId = stableCorrelationId(scope.tenantId, digest); + const dataSource = await this.database.getDataSource(); + let jobId: string; + try { + const queued = await dataSource.transaction((manager) => this.jobs.enqueueWithin(manager, { + tenantId: scope.tenantId, + type: 'TEMPLATE_CREATE', + idempotencyKey: `templates:${digest}`, + correlationId, + resourceScopeRef: account.assetId, + payload: { kind: 'TEMPLATE_CREATE', assetId: account.assetId, + appConfigurationId: account.appConfigurationId, environment: account.environment, request }, + })); + jobId = queued.id; + } catch (error) { + if (error instanceof Error && error.message === 'Idempotency key conflicts with an existing job.') { + throw new WhatsAppTemplateIdempotencyConflictError(); + } + throw new WhatsAppTemplatesUnavailableError(); + } + return Object.freeze({ jobId, status: 'PENDING', location: `/api/v1/jobs/${jobId}`, requestId }); + } + + private async authorizedAccount(session: VerifiedPortalSession, tenantContextRef: string, assetId: string, + operation: TenantOperation): Promise<{ readonly scope: AuthorizedTenantScope; readonly account: WhatsAppTemplatesAccount }> { + if (!isVerifiedPortalSession(session) || !UUID.test(assetId)) throw new WhatsAppTemplateNotFoundError(); + let scope: AuthorizedTenantScope; + try { + scope = await this.contexts.resolveCurrent(session, tenantContextRef); + await this.authorization.authorize(scope, operation, scope.tenantId); + } catch (error) { + if (error instanceof IdentityDeniedError) throw error; + throw new WhatsAppTemplateNotFoundError(); + } + const account = await this.accounts.resolve(scope, assetId); + if (!account || account.tenantId !== scope.tenantId || account.assetId !== assetId || !UUID.test(account.appConfigurationId) || + !['development', 'test', 'staging', 'production'].includes(account.environment) || !PROVIDER_ID.test(account.providerWabaId)) { + throw new WhatsAppTemplateNotFoundError(); + } + return Object.freeze({ scope, account }); + } +} + +function validPagination(input: { readonly cursor?: string; readonly limit: number }): boolean { + return Number.isSafeInteger(input.limit) && input.limit >= 1 && input.limit <= 100 && + (input.cursor === undefined || CURSOR.test(input.cursor)); +} + +function sanitizeTemplatePage(value: unknown, requestId: string): components['schemas']['TemplateListPage'] { + if (!record(value) || !Array.isArray(value.items) || value.items.length > 100 || + !(value.nextCursor === null || (typeof value.nextCursor === 'string' && CURSOR.test(value.nextCursor)))) { + throw new WhatsAppTemplatesUnavailableError(); + } + const items: components['schemas']['Template'][] = []; + for (const item of value.items) { + if (!record(item) || typeof item.id !== 'string' || item.id.length < 1 || item.id.length > 128 || + typeof item.name !== 'string' || item.name.length < 1 || item.name.length > 128 || + typeof item.language !== 'string' || item.language.length < 2 || item.language.length > 16 || + typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt))) { + throw new WhatsAppTemplatesUnavailableError(); + } + // Exact current provider status/category meanings are Not confirmed; do not infer approval. + items.push(Object.freeze({ id: item.id, name: item.name, language: item.language, status: 'UNKNOWN', category: 'UNKNOWN', + updatedAt: new Date(item.updatedAt).toISOString() })); + } + return Object.freeze({ items: Object.freeze(items), nextCursor: value.nextCursor as string | null, requestId }); +} + +function stableCorrelationId(tenantId: string, digest: string): string { + const bytes = createHash('sha256').update(`TEMPLATE_CREATE\0${tenantId}\0${digest}`).digest(); + const hex = bytes.toString('hex').slice(0, 32).split(''); + hex[12] = '4'; + hex[16] = ((Number.parseInt(hex[16] ?? '0', 16) & 0x3) | 0x8).toString(16); + const value = hex.join(''); + return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/api/src/worker/main.ts b/apps/api/src/worker/main.ts index 81be99aa..b94a737f 100644 --- a/apps/api/src/worker/main.ts +++ b/apps/api/src/worker/main.ts @@ -23,6 +23,7 @@ import { MysqlOnboardingCodeConsumer } from '../onboarding/onboarding-code-submi import { createOnboardingSagaHandler } from '../onboarding/onboarding-saga.worker.js'; import { MetaOnboardingConfigurationResolver } from '../onboarding/meta-onboarding-configuration.resolver.js'; import { MetaAppConfigRegistry } from '../meta-config/meta-app-config.registry.js'; +import { createTemplateCreateJobHandler, type WhatsAppTemplateDispatchAdapter } from '../whatsapp-templates/template-create.worker.js'; interface CredentialLifecycleAdapters { readonly policies: CredentialLifecyclePolicyPort; @@ -45,6 +46,11 @@ function configuredMetaOnboardingSagaAdapter(): OnboardingSagaAdapter | undefine return undefined; } +/** No reviewed template adapter is installed; create jobs are not claimed by the default worker. */ +function configuredWhatsAppTemplateDispatchAdapter(): WhatsAppTemplateDispatchAdapter | undefined { + return undefined; +} + interface MetaWebhookWorkerAdapters { readonly payloadProtector: MetaWebhookPayloadProtector; readonly processor: MetaWebhookItemProcessor; @@ -107,6 +113,8 @@ export async function bootstrapWorker(): Promise { new MetaAppConfigRegistry(environment.metaAppConfigurations ?? []), environment.nodeEnv); handlers.set('ONBOARDING_STEP', createOnboardingSagaHandler(database, codeConsumer, onboardingSagaAdapter, undefined, configurations)); } + const templateAdapter = configuredWhatsAppTemplateDispatchAdapter(); + if (templateAdapter) handlers.set('TEMPLATE_CREATE', createTemplateCreateJobHandler(templateAdapter)); let authorization: JobExecutionAuthorizationPort = new FailClosedJobExecutionAuthorizationPort(); if (handlers.size > 0) { const configuredIdentity = new WorkerServiceIdentityVerifier(environment.identity?.workerServiceCredentials ?? []) diff --git a/apps/api/test/m5-04-whatsapp-templates.spec.ts b/apps/api/test/m5-04-whatsapp-templates.spec.ts new file mode 100644 index 00000000..2beda09f --- /dev/null +++ b/apps/api/test/m5-04-whatsapp-templates.spec.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { IdentityDeniedError } from '../src/identity/identity.errors.js'; +import { PortalSessionVerifier, type VerifiedPortalSession } from '../src/identity/identity.trust.js'; +import { hashTenantContextReference } from '../src/identity/identity.service.js'; +import { MysqlWhatsAppTemplatesAccountDirectory, WhatsAppTemplateIdempotencyConflictError, + WhatsAppTemplateNotFoundError, WhatsAppTemplatesService, WhatsAppTemplatesUnavailableError } from '../src/whatsapp-templates/whatsapp-templates.service.js'; +import { TemplateInputError, parseTemplateCreateRequest } from '../src/whatsapp-templates/template-body.js'; +import { submitAndReconcileTemplateCreate, type WhatsAppTemplateDispatchAdapter, + classifyTemplateReconciliationCandidates, type WhatsAppTemplateDispatchInput } from '../src/whatsapp-templates/template-create.worker.js'; +import type { AuthorizedTenantScope } from '../src/identity/identity.types.js'; +import type { DurableWorkDatabase } from '../src/jobs/durable-work-database.js'; +import type { DurableJobStore, EnqueueJobInput } from '../src/jobs/job-store.js'; +import type { WhatsAppTemplatesAccount } from '../src/whatsapp-templates/whatsapp-templates.service.js'; + +const tenantId = '00000000-0000-4000-8000-000000000001'; +const otherTenantId = '00000000-0000-4000-8000-000000000007'; +const assetId = '00000000-0000-4000-8000-000000000002'; +const appConfigurationId = '00000000-0000-4000-8000-000000000003'; +const contextRef = 'synthetic-template-context-reference-0001'; +const account: WhatsAppTemplatesAccount = Object.freeze({ tenantId, assetId, appConfigurationId, + environment: 'test', providerWabaId: '100000000000001' }); + +async function sessionFor(tenant: string = tenantId): Promise { + const verifier = new PortalSessionVerifier({ + async findActiveBySecretHash() { + return { sessionId: '00000000-0000-4000-8000-000000000004', principalId: '00000000-0000-4000-8000-000000000005', + tenantId: tenant, contextVersion: 1, contextRefHash: hashTenantContextReference(contextRef), csrfTokenHash: 'a'.repeat(64), + entryMode: 'PORTAL_TOP_LEVEL' as const }; + }, + }); + const session = await verifier.verify('synthetic-session-secret-value-000000000001'); + assert.ok(session); + return session; +} + +function scope(tenant: string = tenantId): AuthorizedTenantScope { + return { tenantId: tenant, principalId: '00000000-0000-4000-8000-000000000005', + sessionId: '00000000-0000-4000-8000-000000000004', roles: ['operator'], membershipVersion: 1, + contextVersion: 1 } as unknown as AuthorizedTenantScope; +} + +function setup(options: { readonly currentTenant?: string; readonly allow?: boolean; readonly provider?: object | null; + readonly createEnabled?: boolean; + readonly accountFor?: (tenant: string, assetRef: string) => WhatsAppTemplatesAccount | undefined } = {}) { + const enqueued = new Map(); + let insertionCount = 0; + let transactionTail = Promise.resolve(); + const manager = {}; + const databaseSource = { + async transaction(work: (transactionManager: object) => Promise): Promise { + const result = transactionTail.then(() => work(manager)); + transactionTail = result.then(() => undefined, () => undefined); + return await result; + }, + }; + const database = { async getDataSource() { return databaseSource; } } as unknown as DurableWorkDatabase; + const jobs = { + async enqueueWithin(_manager: object, input: EnqueueJobInput) { + const key = `${input.tenantId}:${input.type}:${input.idempotencyKey}`; + const existing = enqueued.get(key); + if (existing) { + if (JSON.stringify(existing.input.payload) !== JSON.stringify(input.payload)) { + throw new Error('Idempotency key conflicts with an existing job.'); + } + return { id: existing.id, duplicate: true }; + } + const id = `job_template_${++insertionCount}`; + enqueued.set(key, { id, input }); + return { id, duplicate: false }; + }, + } as unknown as DurableJobStore; + const calls: Array<{ readonly account: WhatsAppTemplatesAccount; readonly cursor?: string; readonly limit: number }> = []; + const provider = options.provider === null ? null : options.provider ?? { + async list(target: WhatsAppTemplatesAccount, input: { readonly cursor?: string; readonly limit: number }) { + calls.push({ account: target, ...input }); + return { items: [{ id: 'provider-template-1', name: 'order_update', language: 'en', status: 'APPROVED', + category: 'UTILITY', updatedAt: '2026-11-10T12:00:00Z' }], nextCursor: null, requestId: 'discarded' }; + }, + }; + const tenant = options.currentTenant ?? tenantId; + const contexts = { async resolveCurrent(_session: VerifiedPortalSession, ref: string) { + if (ref !== contextRef) throw new IdentityDeniedError(); + return scope(tenant); + } } as never; + const authorization = { async authorize(_scope: AuthorizedTenantScope, operation: string, resourceTenantId: string) { + assert.equal(operation, 'CONNECT'); + assert.equal(resourceTenantId, tenant); + if (options.allow === false) throw new IdentityDeniedError(); + } } as never; + const accounts = { async resolve(authorizedScope: AuthorizedTenantScope, assetRef: string) { + return options.accountFor ? options.accountFor(authorizedScope.tenantId, assetRef) : + authorizedScope.tenantId === tenantId && assetRef === assetId ? account : undefined; + }, async resolveForWorker() { return undefined; } }; + const service = new WhatsAppTemplatesService(contexts, authorization, accounts, provider as never, options.createEnabled ?? true, database, jobs); + return { service, calls, enqueued, get insertions() { return insertionCount; } }; +} + +const baseBody = Object.freeze({ name: 'order_update', language: 'en', category: 'UTILITY', + bodyText: 'Hello {{1}}, code {{2}} and again {{1}}', exampleValues: ['Ana', '1234'] }); + +test('M5.04 BODY validation accepts empty/reused examples and rejects example count, gap, order and malformed braces', () => { + assert.deepEqual(parseTemplateCreateRequest({ name: 'receipt', language: 'pt_BR', category: 'UTILITY', bodyText: 'Ready.' }).exampleValues, []); + assert.deepEqual(parseTemplateCreateRequest(baseBody).exampleValues, ['Ana', '1234']); + for (const invalid of [ + { ...baseBody, exampleValues: ['Ana'] }, + { ...baseBody, bodyText: 'Hi {{1}} {{3}}', exampleValues: ['a', 'b'] }, + { ...baseBody, bodyText: 'Hi {{2}} {{1}}', exampleValues: ['a', 'b'] }, + { ...baseBody, bodyText: 'Hi {{1', exampleValues: ['a'] }, + { ...baseBody, bodyText: 'Hi {{1}}', exampleValues: [] }, + ]) assert.throws(() => parseTemplateCreateRequest(invalid), TemplateInputError); +}); + +test('M5.04 account directory constrains lookup to the tenant WABA and active verified EXCLUSIVE grant', async () => { + let sql = ''; + let values: readonly unknown[] = []; + const database = { async getDataSource() { return { async query(statement: string, parameters: readonly unknown[]) { + sql = statement; values = parameters; + return [{ asset_id: assetId, tenant_id: tenantId, app_configuration_id: appConfigurationId, + environment: 'test', provider_waba_id: '100000000000001' }]; + } }; } } as unknown as DurableWorkDatabase; + const directory = new MysqlWhatsAppTemplatesAccountDirectory(database, 'test'); + const found = await directory.resolve(scope(), assetId); + assert.equal(found?.providerWabaId, '100000000000001'); + assert.deepEqual(values.slice(0, 3), [tenantId, assetId, 'test']); + assert.match(sql, /a\.tenant_id = \? AND a\.id = \?/); + assert.match(sql, /a\.asset_kind = 'WHATSAPP_BUSINESS_ACCOUNT'/); + assert.match(sql, /auth\.flow_variant = 'WHATSAPP_EXCLUSIVE'/); + assert.match(sql, /auth\.authorization_state IN \('ACTIVE', 'PARTIAL'\)/); + assert.match(sql, /aa\.verification_state = 'VERIFIED'/); + assert.match(sql, /g\.grant_name = 'whatsapp_business_management' AND g\.status = 'GRANTED'/); +}); + +test('M5.04 listing scopes the internal WABA, forwards pagination and maps unknown provider fields safely', async () => { + const fixture = setup(); + const page = await fixture.service.list(await sessionFor(), contextRef, assetId, { cursor: 'opaque/+cursor=', limit: 23 }, 'req-list-01'); + assert.deepEqual(fixture.calls[0], { account, cursor: 'opaque/+cursor=', limit: 23, requestId: 'req-list-01' }); + assert.equal(page.requestId, 'req-list-01'); + assert.equal(page.items[0]?.status, 'UNKNOWN'); + assert.equal(page.items[0]?.category, 'UNKNOWN'); + const session = await sessionFor(); + await assert.rejects(() => fixture.service.list(session, contextRef, assetId, { cursor: 'bad cursor', limit: 23 }, 'req-list-02'), TemplateInputError); + assert.equal(fixture.calls.length, 1); +}); + +test('M5.04 prevents cross-tenant and non-granted asset list access', async () => { + const fixture = setup({ accountFor: () => undefined }); + const otherTenantSession = await sessionFor(otherTenantId); + await assert.rejects(() => fixture.service.list(otherTenantSession, contextRef, assetId, + { limit: 50 }, 'req-tenant-01'), WhatsAppTemplateNotFoundError); + assert.equal(fixture.calls.length, 0); + const denied = setup({ allow: false }); + const session = await sessionFor(); + await assert.rejects(() => denied.service.list(session, contextRef, assetId, { limit: 50 }, 'req-grant-01'), IdentityDeniedError); + assert.equal(denied.calls.length, 0); +}); + +test('M5.04 fails closed when the real list and create adapters are disabled', async () => { + const session = await sessionFor(); + const list = setup({ provider: null }); + await assert.rejects(() => list.service.list(session, contextRef, assetId, { limit: 50 }, 'req-list-closed-01'), WhatsAppTemplatesUnavailableError); + const create = setup({ createEnabled: false }); + await assert.rejects(() => create.service.create(session, contextRef, assetId, 'disabled-template-create-key-01', baseBody, + 'req-create-closed-01'), WhatsAppTemplatesUnavailableError); + assert.equal(create.enqueued.size, 0); +}); + +test('M5.04 create uses tenant-scoped durable idempotency sequentially and concurrently', async () => { + const fixture = setup(); + const session = await sessionFor(); + const submit = () => fixture.service.create(session, contextRef, assetId, 'stable-template-create-key-0001', baseBody, 'req-create-01'); + const first = await submit(); + const repeated = await submit(); + const [concurrentA, concurrentB] = await Promise.all([submit(), submit()]); + assert.deepEqual([first.jobId, repeated.jobId, concurrentA.jobId, concurrentB.jobId], Array(4).fill(first.jobId)); + assert.equal(fixture.insertions, 1); + assert.equal(fixture.enqueued.values().next().value?.input.type, 'TEMPLATE_CREATE'); + await assert.rejects(() => fixture.service.create(session, contextRef, assetId, 'stable-template-create-key-0001', + { ...baseBody, bodyText: 'Different {{1}} body {{2}}' }, 'req-create-02'), WhatsAppTemplateIdempotencyConflictError); + await assert.rejects(() => fixture.service.create(session, contextRef, assetId, 'stable-template-create-key-0001', + { ...baseBody, cUUID: 'forbidden' }, 'req-create-03'), TemplateInputError); +}); + +test('M5.04 read-only reconciliation handles accepted, rejected, null and ambiguous outcomes without retry', async () => { + const input: WhatsAppTemplateDispatchInput = { tenantId, assetId, appConfigurationId, environment: 'test', request: parseTemplateCreateRequest(baseBody), + serviceScope: {} as WhatsAppTemplateDispatchInput['serviceScope'], correlationId: '00000000-0000-4000-8000-000000000006' }; + const run = async (submit: WhatsAppTemplateDispatchAdapter['submit'], reconcile: WhatsAppTemplateDispatchAdapter['reconcile']) => { + let submissions = 0; + let observations = 0; + const adapter = { async submit(...args: Parameters) { submissions += 1; return submit(...args); }, + async reconcile(...args: Parameters) { observations += 1; return reconcile(...args); } }; + const outcome = await submitAndReconcileTemplateCreate(adapter, input, new AbortController().signal); + return { outcome, submissions, observations }; + }; + assert.deepEqual(await run(async () => ({ kind: 'ACCEPTED' }), async () => ({ kind: 'FOUND' })), + { outcome: { kind: 'SUCCEEDED' }, submissions: 1, observations: 0 }); + assert.equal((await run(async () => ({ kind: 'REJECTED' }), async () => ({ kind: 'FOUND' }))).outcome.kind, 'PERMANENT_FAILURE'); + assert.deepEqual(await run(async () => null, async () => ({ kind: 'AMBIGUOUS' })), + { outcome: { kind: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN' }, submissions: 1, observations: 1 }); + assert.deepEqual(await run(async () => ({ kind: 'UNKNOWN' }), async () => null), + { outcome: { kind: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN' }, submissions: 1, observations: 1 }); + assert.deepEqual(classifyTemplateReconciliationCandidates([], input.request), { kind: 'NOT_FOUND' }); + assert.deepEqual(classifyTemplateReconciliationCandidates([{ name: input.request.name, language: input.request.language, + category: input.request.category, components: [{ type: 'BODY', text: input.request.bodyText }] }], input.request), + { kind: 'AMBIGUOUS' }, 'an identical pre-existing template cannot prove that this job caused the effect'); +}); diff --git a/apps/api/tsconfig.m5-04-test.json b/apps/api/tsconfig.m5-04-test.json new file mode 100644 index 00000000..b3747e86 --- /dev/null +++ b/apps/api/tsconfig.m5-04-test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts", "test/m5-04-whatsapp-templates.spec.ts"], + "exclude": ["node_modules", "dist", "dist-test"] +} diff --git a/apps/web/package.json b/apps/web/package.json index e851cf7c..fffd38a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "test": "ng test --watch=false", "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", "typecheck": "ngc -p tsconfig.app.json --noEmit", "lint": "node ../../tools/m2/check-web-boundary.mjs" }, diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 9c1ab051..ace028ad 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -18,8 +18,7 @@ export const routes: Routes = [ { path: 'assets/:id', loadComponent: () => import('./features/portal/asset-detail.component').then((m) => m.AssetDetailComponent) }, { path: 'templates', - loadComponent: () => import('./features/portal/portal-section.component').then((m) => m.PortalSectionComponent), - data: { title: 'Modelos', description: 'Os modelos disponíveis serão exibidos após a confirmação dos recursos e permissões desta empresa.' }, + loadComponent: () => import('./features/portal/whatsapp-templates.component').then((m) => m.WhatsAppTemplatesComponent), }, { path: 'notifications', diff --git a/apps/web/src/app/core/portal/whatsapp-templates.service.ts b/apps/web/src/app/core/portal/whatsapp-templates.service.ts new file mode 100644 index 00000000..07370403 --- /dev/null +++ b/apps/web/src/app/core/portal/whatsapp-templates.service.ts @@ -0,0 +1,47 @@ +import { HttpClient, HttpHeaders, HttpParams } 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 TemplateRecord = components['schemas']['Template']; +export type TemplateListPage = components['schemas']['TemplateListPage']; +export type TemplateCreateRequest = components['schemas']['TemplateCreateRequest']; +export type TemplateAsset = components['schemas']['AssetListItem']; +export type TemplateJobStatus = components['schemas']['JobStatus']; + +@Injectable({ providedIn: 'root' }) +export class WhatsAppTemplatesService { + private readonly baseUrl: string; + + constructor(private readonly http: HttpClient, @Inject(META_API_BASE_URL) baseUrl: string) { + this.baseUrl = normalizeApiV1BaseUrl(baseUrl); + } + + async accounts(): Promise { + const params = new HttpParams().set('channel', 'WHATSAPP').set('limit', '100'); + const page = await firstValueFrom(this.http.get(`${this.baseUrl}/assets`, { params })); + return Object.freeze(page.items.filter((item) => item.kind === 'WHATSAPP_BUSINESS_ACCOUNT')); + } + + list(assetId: string, cursor?: string): Promise { + let params = new HttpParams().set('limit', '50'); + if (cursor) params = params.set('cursor', cursor); + return firstValueFrom(this.http.get( + `${this.baseUrl}/whatsapp/accounts/${encodeURIComponent(assetId)}/templates`, { params }, + )); + } + + create(assetId: string, request: TemplateCreateRequest, idempotencyKey: string): Promise { + if (!/^[A-Za-z0-9._~-]{16,128}$/.test(idempotencyKey)) throw new Error('A valid idempotency key is required.'); + return firstValueFrom(this.http.post( + `${this.baseUrl}/whatsapp/accounts/${encodeURIComponent(assetId)}/templates`, request, + { headers: new HttpHeaders({ 'Idempotency-Key': idempotencyKey }) }, + )); + } + + job(jobId: string): Promise { + return firstValueFrom(this.http.get(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`)); + } +} diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.html b/apps/web/src/app/features/portal/whatsapp-templates.component.html new file mode 100644 index 00000000..15292299 --- /dev/null +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.html @@ -0,0 +1,113 @@ +
+

Portal do WhatsApp

+

Modelos de mensagem

+

Consulte e solicite modelos BODY de uma conta WhatsApp Business autorizada.

+ +

Carregando contas WhatsApp…

+ + + +

Nenhuma conta WhatsApp Business está disponível nesta empresa.

+ + + + + +

+ A listagem e a criação ficam indisponíveis enquanto a conta e a permissão de administrar modelos não forem confirmadas. +

+ +
+
+
+

Modelos existentes

+

Os dados e estados abaixo dependem da última resposta confirmada pelo servidor.

+
+ +
+ +

Consultando modelos…

+ +

Esta conta não retornou modelos nesta página.

+ +
+
+
+

{{ item.name }}

+ {{ item.status }} +
+
+
Idioma
{{ item.language }}
+
Categoria informada
{{ item.category }}
+
Última atualização observada
{{ item.updatedAt }}
+
+
+
+ + + +
+ Referência para suporte +

Identificador da solicitação: {{ requestId }}

+
+ +
+

Solicitar modelo BODY

+

Os exemplos seguem a convenção local deste formulário: um exemplo para cada marcador distinto, na ordem em que aparece.

+
+
+ + + + + +
+ +

{{ statusMessage }}

+
+

Pedido de acompanhamento: {{ jobId }}

+ +

Status local do trabalho: {{ jobStatus.status }}

+ +
+ +
+
+
+
+
+
diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.scss b/apps/web/src/app/features/portal/whatsapp-templates.component.scss new file mode 100644 index 00000000..1602c0e7 --- /dev/null +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.scss @@ -0,0 +1,77 @@ +:host { display: block; } + +.templates-page { + max-width: 980px; + margin: 0 auto; + padding: 1.5rem; + border: 1px solid #e1e6ea; + border-radius: 8px; + background: #fff; +} + +.eyebrow { color: #0070f2; font-size: .76rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +h1 { margin: .35rem 0; font-size: 1.8rem; } +h2 { margin: 0; font-size: 1.25rem; } +h3 { margin: 0; font-size: 1rem; } +.intro, .section-heading p, .create-panel > p { color: #63717c; line-height: 1.55; } +.intro { margin: .35rem 0 1.25rem; } + +label { display: grid; gap: .4rem; color: #27333c; font-weight: 600; } +input, select, textarea { + width: 100%; + min-height: 2.65rem; + padding: .55rem .65rem; + border: 1px solid #b7c2cb; + border-radius: 5px; + background: #fff; + color: #17212a; + font: inherit; +} +textarea { min-height: auto; resize: vertical; } +input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { + outline: 3px solid #82b8ff; + outline-offset: 2px; +} + +.account-field { max-width: 440px; margin-bottom: .4rem; } +.notice, .empty-state, .state, .error, .success { padding: .8rem 1rem; border-radius: 5px; line-height: 1.5; } +.notice, .empty-state, .state { background: #f3f6f8; color: #394750; } +.error { background: #fff1f0; color: #87211b; } +.success { background: #eef9f0; color: #1b5e2b; } + +.template-content { margin-top: 1.5rem; } +.section-heading, .template-title-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.section-heading { margin-bottom: .8rem; } +.section-heading p { margin: .35rem 0 0; } +.template-list { display: grid; gap: .75rem; } +.template-card { padding: 1rem; border: 1px solid #e1e6ea; border-radius: 6px; } +.status { padding: .2rem .5rem; border-radius: 20px; background: #f1f4f6; color: #3a4650; font-size: .8rem; } +dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; margin: .8rem 0 0; } +dl div { min-width: 0; } +dt { color: #63717c; font-size: .78rem; } +dd { overflow-wrap: anywhere; margin: .15rem 0 0; } + +.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin: 1rem 0; } +.support-details { margin: .8rem 0; color: #63717c; } +.support-details code { color: #26343e; } +.job-status { margin: .8rem 0; color: #394750; } +.job-status code { overflow-wrap: anywhere; } +.status-detail { font-size: .9rem; } + +.create-panel { margin-top: 1.5rem; padding: 1.25rem; border: 1px solid #dce4e9; border-radius: 6px; background: #fafcfd; } +.create-panel > p { margin: .45rem 0 1rem; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.wide-field { grid-column: 1 / -1; } +button { min-height: 2.6rem; padding: .55rem 1rem; border: 1px solid #0067d8; border-radius: 5px; background: #0067d8; color: white; font: inherit; font-weight: 650; cursor: pointer; } +button:hover:not(:disabled) { background: #0056b8; } +button:disabled { opacity: .55; cursor: not-allowed; } +button.secondary-button { background: white; color: #0067d8; } +button.secondary-button:hover:not(:disabled) { background: #f0f6ff; } + +@media (max-width: 680px) { + .templates-page { padding: 1rem; } + .section-heading { align-items: flex-start; flex-direction: column; } + .form-grid { grid-template-columns: minmax(0, 1fr); } + .wide-field { grid-column: auto; } + dl { grid-template-columns: minmax(0, 1fr); } +} diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts new file mode 100644 index 00000000..45617f93 --- /dev/null +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -0,0 +1,122 @@ +import { Subject } from 'rxjs'; +import type { components } from '@ca-meta/contracts'; +import { PortalContextStore } from '../../core/portal/portal-context.store'; +import { PortalSessionService } from '../../core/portal/portal-session.service'; +import { WhatsAppTemplatesService, type TemplateAsset } from '../../core/portal/whatsapp-templates.service'; +import { WhatsAppTemplatesComponent } from './whatsapp-templates.component'; + +const asset = { + id: '00000000-0000-4000-8000-000000000002', + kind: 'WHATSAPP_BUSINESS_ACCOUNT', + displayLabel: 'Conta de teste', + status: 'ACTIVE', + authorizationStatus: 'VERIFIED', + observedAt: '2026-11-10T12:00:00.000Z', + lastVerifiedAt: '2026-11-10T12:00:00.000Z', + verificationSource: 'META_API', + stale: false, + capabilities: { canSend: false, canManageTemplates: true, canSubscribe: false, canReauthorize: false, canDisconnect: false }, +} as TemplateAsset; + +function acceptedJob(jobId = 'job_template_01'): components['schemas']['JobAccepted'] { + return { jobId, status: 'PENDING', location: `/api/v1/jobs/${jobId}`, requestId: 'req-template-create-01' }; +} + +async function settle(): Promise { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); +} + +async function createFixture(options: { + readonly capabilities?: boolean; + readonly create?: (assetId: string, request: components['schemas']['TemplateCreateRequest'], key: string) => Promise; + readonly job?: (jobId: string) => Promise; +} = {}) { + const list = vi.fn().mockResolvedValue({ items: [], nextCursor: null, requestId: 'req-template-list-01' }); + const create = vi.fn(options.create ?? (async () => acceptedJob())); + const job = vi.fn(options.job ?? (async () => ({ jobId: 'job_template_01', status: 'UNKNOWN', requestId: 'req-template-job-01' }))); + const service = { + accounts: vi.fn().mockResolvedValue([options.capabilities === false ? { + ...asset, capabilities: { canSend: false, canManageTemplates: false, canSubscribe: false, canReauthorize: false, canDisconnect: false }, + } : asset]), + list, + create, + job, + } as unknown as WhatsAppTemplatesService; + const contextChanged$ = new Subject(); + const sessionExpired$ = new Subject(); + const component = new WhatsAppTemplatesComponent(service, + { ensureCurrentSession: vi.fn().mockResolvedValue({}) } as unknown as PortalSessionService, + { contextChanged$: contextChanged$.asObservable(), sessionExpired$: sessionExpired$.asObservable() } as unknown as PortalContextStore); + component.ngOnInit(); + await settle(); + return { component, service, list, create, job, contextChanged$, sessionExpired$ }; +} + +describe('WhatsAppTemplatesComponent', () => { + it('keeps list and create unavailable when local capability evidence is absent', async () => { + const state = await createFixture({ capabilities: false }); + expect(state.list).not.toHaveBeenCalled(); + state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Ready.' }; + await state.component.createTemplate(); + expect(state.create).not.toHaveBeenCalled(); + expect(state.component.accountCanManageTemplates).toBe(false); + state.component.ngOnDestroy(); + }); + + it('sends local ordered examples and never describes HTTP acceptance as Meta approval', async () => { + const state = await createFixture(); + state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Hello {{1}}, code {{2}} and again {{1}}' }; + state.component.examplesText = 'Ana\n1234'; + await state.component.createTemplate(); + expect(state.create).toHaveBeenCalledWith(asset.id, { + name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Hello {{1}}, code {{2}} and again {{1}}', exampleValues: ['Ana', '1234'], + }, expect.stringMatching(/^[A-Za-z0-9._~-]{16,128}$/)); + expect(state.component.statusMessage).toContain('não confirma aprovação do modelo pela Meta'); + state.component.ngOnDestroy(); + }); + + it('reuses the idempotency key after an ambiguous request and treats UNKNOWN as unreconciled', async () => { + const firstFailure = new Error('synthetic network timeout'); + const create = vi.fn() + .mockRejectedValueOnce(firstFailure) + .mockResolvedValueOnce(acceptedJob()); + const state = await createFixture({ create: create as never }); + state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Ready.' }; + await state.component.createTemplate(); + expect(state.component.formError).toContain('Consulte o status'); + await state.component.createTemplate(); + expect(create.mock.calls[0]?.[2]).toBe(create.mock.calls[1]?.[2]); + await state.component.checkJobStatus(); + expect(state.component.jobStatus?.status).toBe('UNKNOWN'); + expect(state.component.statusMessage).toContain('resultado ainda não está confirmado'); + state.component.ngOnDestroy(); + }); + + it('rejects gaps in BODY examples before calling the API', async () => { + const state = await createFixture(); + state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Hi {{1}}, then {{3}}' }; + state.component.examplesText = 'Ana\n1234'; + await state.component.createTemplate(); + expect(state.component.formError).toContain('marcadores'); + expect(state.create).not.toHaveBeenCalled(); + state.component.ngOnDestroy(); + }); + + it('clears the old tenant draft and ignores an in-flight create after context changes', async () => { + let resolveCreate!: (value: components['schemas']['JobAccepted']) => void; + const create = vi.fn(() => new Promise((resolve) => { resolveCreate = resolve; })); + const state = await createFixture({ create: create as never }); + state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Private draft.' }; + const pendingCreate = state.component.createTemplate(); + expect(create).toHaveBeenCalledOnce(); + state.contextChanged$.next(); + await settle(); + expect(state.component.form.name).toBe(''); + expect(state.component.jobId).toBeNull(); + resolveCreate(acceptedJob()); + await pendingCreate; + expect(state.component.jobId).toBeNull(); + expect(state.component.statusMessage).not.toContain('Pedido enviado'); + state.component.ngOnDestroy(); + }); +}); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.ts new file mode 100644 index 00000000..805e3ccf --- /dev/null +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.ts @@ -0,0 +1,337 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; +import type { components } from '@ca-meta/contracts'; +import { PortalContextStore } from '../../core/portal/portal-context.store'; +import { PortalSessionService } from '../../core/portal/portal-session.service'; +import { safeRequestId } from '../../core/portal/safe-api-error'; +import { TemplateAsset, TemplateCreateRequest, TemplateJobStatus, TemplateListPage, TemplateRecord, WhatsAppTemplatesService } from '../../core/portal/whatsapp-templates.service'; + +type TemplateCategory = TemplateCreateRequest['category']; +type TemplateForm = { name: string; language: string; category: TemplateCategory; bodyText: string }; + +@Component({ + selector: 'app-whatsapp-templates', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './whatsapp-templates.component.html', + styleUrl: './whatsapp-templates.component.scss', +}) +export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { + accounts: readonly TemplateAsset[] = []; + selectedAssetId = ''; + templates: readonly TemplateRecord[] = []; + nextCursor: string | null = null; + pageIndex = 0; + isLoadingAccounts = true; + isLoadingTemplates = false; + isCreating = false; + accountError = ''; + listError = ''; + formError = ''; + statusMessage = ''; + requestId: string | null = null; + jobId: string | null = null; + jobStatus: TemplateJobStatus | null = null; + jobStatusError = ''; + isCheckingJob = false; + examplesText = ''; + form: TemplateForm = { name: '', language: '', category: 'UTILITY', bodyText: '' }; + private readonly destroy$ = new Subject(); + private accountGeneration = 0; + private templateGeneration = 0; + private lastCreateFingerprint = ''; + private createIdempotencyKey = ''; + private readonly cursorHistory: Array = [undefined]; + + constructor( + private readonly service: WhatsAppTemplatesService, + private readonly sessions: PortalSessionService, + private readonly context: PortalContextStore, + ) {} + + ngOnInit(): void { + void this.loadAccounts(); + this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => { void this.reloadForCurrentContext(); }); + this.context.sessionExpired$.pipe(takeUntil(this.destroy$)).subscribe(() => this.clearForExpiredSession()); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get selectedAccount(): TemplateAsset | undefined { return this.accounts.find((asset) => asset.id === this.selectedAssetId); } + get accountCanManageTemplates(): boolean { + const selected = this.selectedAccount; + return selected?.kind === 'WHATSAPP_BUSINESS_ACCOUNT' && selected.authorizationStatus === 'VERIFIED' && + selected.status === 'ACTIVE' && !selected.stale && selected.capabilities?.canManageTemplates === true; + } + + selectAccount(assetId: string): void { + if (!this.accounts.some((asset) => asset.id === assetId)) return; + const changed = assetId !== this.selectedAssetId; + if (changed) { + this.accountGeneration += 1; + this.templateGeneration += 1; + this.isCreating = false; + this.isCheckingJob = false; + this.jobId = null; + this.jobStatus = null; + this.jobStatusError = ''; + this.lastCreateFingerprint = ''; + this.createIdempotencyKey = ''; + } + this.selectedAssetId = assetId; + this.templates = []; + this.cursorHistory.splice(0, this.cursorHistory.length, undefined); + this.pageIndex = 0; + this.nextCursor = null; + this.requestId = null; + this.listError = ''; + this.statusMessage = ''; + if (this.accountCanManageTemplates) void this.loadTemplates(); + } + + previousPage(): Promise { + if (this.pageIndex === 0 || !this.accountCanManageTemplates) return Promise.resolve(); + this.pageIndex -= 1; + return this.loadTemplates(); + } + + nextPage(): Promise { + if (!this.nextCursor || !this.accountCanManageTemplates) return Promise.resolve(); + this.cursorHistory[this.pageIndex + 1] = this.nextCursor; + this.pageIndex += 1; + return this.loadTemplates(); + } + + async createTemplate(): Promise { + this.formError = ''; + this.statusMessage = ''; + if (!this.accountCanManageTemplates || this.isCreating) return; + const generation = this.accountGeneration; + const assetId = this.selectedAssetId; + const examples = this.parseExamples(this.form.bodyText, this.examplesText); + if (!examples) { + this.formError = 'Confira os marcadores do texto e informe um exemplo para cada valor distinto, na ordem.'; + return; + } + const request: TemplateCreateRequest = { + name: this.form.name.trim(), + language: this.form.language.trim(), + category: this.form.category, + bodyText: this.form.bodyText, + exampleValues: examples, + }; + if (!this.validRequest(request)) { + this.formError = 'Revise nome, idioma, categoria e texto do modelo.'; + return; + } + + const fingerprint = JSON.stringify(request); + if (fingerprint !== this.lastCreateFingerprint) { + const key = globalThis.crypto?.randomUUID?.(); + if (!key) { + this.formError = 'Este navegador não conseguiu criar uma chave segura para o pedido.'; + return; + } + this.lastCreateFingerprint = fingerprint; + this.createIdempotencyKey = key; + } + + this.isCreating = true; + this.requestId = null; + try { + const accepted = await this.service.create(assetId, request, this.createIdempotencyKey); + if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; + if (!this.isAcceptedJob(accepted)) throw new Error('The template request was not accepted.'); + this.requestId = accepted.requestId; + this.jobId = accepted.jobId; + this.jobStatus = null; + this.jobStatusError = ''; + this.statusMessage = 'Pedido enviado para processamento. A aceitação da solicitação não confirma aprovação do modelo pela Meta.'; + } catch (error: unknown) { + if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; + this.formError = 'Não foi possível confirmar o envio do pedido. Consulte o status antes de tentar novamente.'; + this.requestId = safeRequestId(error); + } finally { + if (generation === this.accountGeneration && assetId === this.selectedAssetId) this.isCreating = false; + } + } + + async checkJobStatus(): Promise { + if (!this.jobId || this.isCheckingJob) return; + const generation = this.accountGeneration; + const jobId = this.jobId; + this.isCheckingJob = true; + this.jobStatusError = ''; + try { + const status = await this.service.job(jobId); + if (generation !== this.accountGeneration || jobId !== this.jobId) return; + this.jobStatus = status; + if (this.jobStatus.status === 'SUCCEEDED') { + this.statusMessage = 'O pedido foi encaminhado ao provedor. O estado do trabalho não confirma aprovação do modelo pela Meta.'; + } else if (this.jobStatus.status === 'UNKNOWN') { + this.statusMessage = 'O resultado ainda não está confirmado. Atualize a lista de modelos antes de fazer outro pedido.'; + } else if (this.jobStatus.status === 'FAILED') { + this.statusMessage = 'O pedido não foi concluído. Consulte os detalhes seguros do trabalho antes de tentar novamente.'; + } else { + this.statusMessage = 'O pedido continua em processamento.'; + } + this.requestId = this.jobStatus.requestId; + } catch (error: unknown) { + if (generation !== this.accountGeneration || jobId !== this.jobId) return; + this.jobStatusError = 'Não foi possível consultar o status do pedido.'; + this.requestId = safeRequestId(error); + } finally { + if (generation === this.accountGeneration && jobId === this.jobId) this.isCheckingJob = false; + } + } + + trackByAsset(_index: number, asset: TemplateAsset): string { return asset.id; } + trackByTemplate(_index: number, item: TemplateRecord): string { return `${item.id}:${item.language}`; } + + private async loadAccounts(): Promise { + const generation = ++this.accountGeneration; + this.isLoadingAccounts = true; + this.accountError = ''; + this.statusMessage = ''; + this.accounts = []; + this.templates = []; + this.selectedAssetId = ''; + try { + this.accounts = await this.service.accounts(); + if (generation !== this.accountGeneration) return; + const first = this.accounts[0]; + if (first) { + this.selectedAssetId = first.id; + if (this.accountCanManageTemplates) await this.loadTemplates(); + } + } catch (error: unknown) { + if (generation !== this.accountGeneration) return; + this.accountError = 'Não foi possível carregar as contas WhatsApp desta empresa.'; + this.requestId = safeRequestId(error); + } finally { + if (generation === this.accountGeneration) this.isLoadingAccounts = false; + } + } + + private async loadTemplates(): Promise { + const generation = ++this.templateGeneration; + this.isLoadingTemplates = true; + this.listError = ''; + this.statusMessage = ''; + this.requestId = null; + try { + const page = await this.service.list(this.selectedAssetId, this.cursorHistory[this.pageIndex]); + if (generation !== this.templateGeneration) return; + if (!this.isTemplatePage(page)) throw new Error('The template list response was not valid.'); + this.templates = page.items; + this.nextCursor = page.nextCursor; + this.requestId = page.requestId; + } catch (error: unknown) { + if (generation !== this.templateGeneration) return; + this.templates = []; + this.nextCursor = null; + this.listError = 'Não foi possível consultar os modelos desta conta.'; + this.requestId = safeRequestId(error); + } finally { + if (generation === this.templateGeneration) this.isLoadingTemplates = false; + } + } + + private async reloadForCurrentContext(): Promise { + this.accountGeneration += 1; + this.templateGeneration += 1; + this.isLoadingAccounts = true; + this.isLoadingTemplates = false; + this.accounts = []; + this.templates = []; + this.selectedAssetId = ''; + this.nextCursor = null; + this.cursorHistory.splice(0, this.cursorHistory.length, undefined); + this.pageIndex = 0; + this.requestId = null; + this.jobId = null; + this.jobStatus = null; + this.jobStatusError = ''; + this.isCheckingJob = false; + this.isCreating = false; + this.lastCreateFingerprint = ''; + this.createIdempotencyKey = ''; + this.form = { name: '', language: '', category: 'UTILITY', bodyText: '' }; + this.examplesText = ''; + this.listError = ''; + this.accountError = ''; + this.formError = ''; + this.statusMessage = ''; + if (!await this.sessions.ensureCurrentSession(true)) { + this.accountError = 'A sessão não está disponível para este contexto. Entre novamente.'; + this.isLoadingAccounts = false; + return; + } + await this.loadAccounts(); + } + + private clearForExpiredSession(): void { + this.accountGeneration += 1; + this.templateGeneration += 1; + this.accounts = []; + this.templates = []; + this.selectedAssetId = ''; + this.nextCursor = null; + this.isLoadingAccounts = false; + this.isLoadingTemplates = false; + this.isCreating = false; + this.isCheckingJob = false; + this.jobId = null; + this.jobStatus = null; + this.jobStatusError = ''; + this.requestId = null; + this.lastCreateFingerprint = ''; + this.createIdempotencyKey = ''; + this.form = { name: '', language: '', category: 'UTILITY', bodyText: '' }; + this.examplesText = ''; + this.formError = ''; + this.statusMessage = ''; + this.accountError = 'Sua sessão terminou. Entre novamente para consultar os modelos.'; + } + + private parseExamples(bodyText: string, rawExamples: string): readonly string[] | null { + const values = rawExamples.split(/\r?\n/).map((value) => value.trim()).filter((value) => value.length > 0); + const placeholderPattern = /\{\{([1-9][0-9]{0,2})\}\}/g; + const found = new Set(); + for (const match of bodyText.matchAll(placeholderPattern)) { + const index = Number(match[1]); + if (index > 100 || (!found.has(index) && index !== found.size + 1)) return null; + found.add(index); + } + const stripped = bodyText.replace(new RegExp(placeholderPattern.source, 'g'), ''); + if (stripped.includes('{{') || stripped.includes('}}') || found.size !== values.length) return null; + return Object.freeze(values); + } + + private validRequest(request: TemplateCreateRequest): boolean { + return /^[a-z0-9_]{1,128}$/.test(request.name) && /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8}){0,2}$/.test(request.language) && + request.bodyText.trim().length > 0 && request.bodyText.length <= 1024 && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(request.bodyText) && + (request.exampleValues?.length ?? 0) <= 100 && request.exampleValues?.every((value) => value.trim().length > 0 && + value.length <= 1024 && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) !== false; + } + + private isAcceptedJob(value: unknown): value is components['schemas']['JobAccepted'] { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const body = value as Record; + return body['status'] === 'PENDING' && typeof body['jobId'] === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(body['jobId']) && + body['location'] === `/api/v1/jobs/${body['jobId']}` && typeof body['requestId'] === 'string' && body['requestId'].length > 0; + } + + private isTemplatePage(value: unknown): value is TemplateListPage { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const page = value as Record; + return Array.isArray(page['items']) && page['items'].length <= 100 && + (page['nextCursor'] === null || typeof page['nextCursor'] === 'string') && typeof page['requestId'] === 'string'; + } +} diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md new file mode 100644 index 00000000..109d27de --- /dev/null +++ b/docs/delivery/M5/M5.04.md @@ -0,0 +1,43 @@ +# M5.04 — WhatsApp BODY template request surface + +## Scope and status + +Issue [#58](https://github.com/costaapps/ca_meta_app/issues/58) adds tenant-scoped listing and request creation for WhatsApp EXCLUSIVE templates. Browser requests use the internal WABA asset UUID from the authorized asset catalog. The API resolves the provider WABA ID only from the same tenant's active, verified EXCLUSIVE asset and a granted candidate `whatsapp_business_management` asset grant. It never accepts a provider WABA ID, `cUUID`, `company_uuid`, token, or global credential from the browser. + +The current API provider and worker adapter registrations are disabled. The service rejects calls with `503` until an explicitly reviewed provider list adapter is configured; template creation also stays disabled until both API admission and worker dispatch are enabled together. The Graph adapter is an injection point using the existing M3.08 credential resolver and M4.01 Graph client. A future worker submission is limited to one attempt; null, thrown, timed out, or ambiguous results get at most one read-only list observation. A same-name/language candidate may predate this job and cannot establish causation, so the outcome remains `UNKNOWN` until a current provider correlation mechanism is confirmed. An accepted job means provider submission was accepted or observed, not that Meta approved the template. No Meta call or Sandbox operation was run. + +No database migration or schema-version change was needed. `TEMPLATE_CREATE` uses the existing durable jobs table, transactional tenant-scoped idempotency, one attempt, and `AMBIGUOUS` effect policy. + +## Local BODY request semantics + +This package deliberately implements only one BODY component. The closed request accepts name, language, category, body text, and optional `exampleValues`; extra provider IDs or credentials are rejected. The local contract allows placeholders `{{1}}` through `{{N}}` in ascending first-occurrence order, allows a previously-seen placeholder to repeat, and requires one non-empty example for each distinct placeholder in order. A body without placeholders may omit examples or pass `[]`. This mapping from example values to BODY placeholders is **our local contract rule**, not an assertion about current Meta requirements. Current provider limits and accepted language/category values have not been confirmed; these conservative input bounds exist for this versioned API and must be reviewed against verified capability evidence before enabling dispatch. + +The UI labels a `202` as a request accepted for processing and explicitly says that this does not confirm Meta approval. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result advises the operator to refresh the list before another request. + +## Provider evidence collected 2026-11-11 + +- [Meta: Get all templates (default fields)](https://www.postman.com/meta/whatsapp-business-platform/request/qtgr0i7/get-all-templates-default-fields) documents a WABA-scoped template-list request. +- [Meta: Create a text template with BODY](https://www.postman.com/meta/whatsapp-business-platform/request/uzphwqw/create-template-w-text-header-text-body-text-footer-and-2-quick-reply-buttons) provides an official text-template request example. +- [Meta: Fetch message templates for an assigned WABA](https://www.postman.com/meta/whatsapp-business-platform/request/f7o759z/fetch-message-templates) provides an official assigned-WABA list example. +- [WhatsApp Business Messaging Policy](https://whatsappbusiness.com/policy/) applies to use of the feature. + +Graph API version is configurable and **Not confirmed** here. Current exact provider enums/status meanings, template-name/language/category rules, placeholder/example constraints, active grants and account eligibility remain **Not confirmed**. The current Meta Developers component/overview pages returned HTTP 429 during discovery. No historic v13 enum, `ALERT_UPDATE`, `TICKET_UPDATE`, or approval behavior is copied into this contract. The API normalizes unknown provider status/category strings to local `UNKNOWN` and shows those values as observations only. + +## Security and delivery gates + +- Portal session, current tenant context, and `CONNECT` authorization are checked before account resolution. The internal asset selector also requires a tenant-matched WABA, active verified asset authorization, WhatsApp EXCLUSIVE / embedded-signup journey, and the candidate management grant. Exact Meta permission mapping remains **Not confirmed**. +- List cursors and page size are bounded; results are normalized and returned with `Cache-Control: no-store`. Listing has no `channel`, number, provider ID, or caller-selected account filter beyond the internal WABA asset UUID. +- The POST body is schema/size bounded. Its idempotency key is scoped by tenant and operation, then hashed server-side; the durable job additionally hashes the canonical payload. A repeated key with different payload returns `409`. +- The worker's verified job context and current account grant are rechecked before credential resolution. A missing/unverified profile, credential, account, capability, or provider result cannot authorize a new submission. No automatic retry follows `UNKNOWN`. +- M1.08 tenant context, M3.08 credential resolution, M4.04/M4.06 Graph operation/capability evidence, and M3.12 verified asset-grant projection remain prerequisites for enabling real adapters. Adapter admission requires a reviewed change that enables the API provider and worker handler together and supplies current evidence for operation capability and tenant eligibility. + +## Focused synthetic harness + +Prerequisites: Node 24 and npm 11, with root workspace dependencies installed from the committed `package-lock.json` (`npm ci`). The harnesses compile/run only the M5.04 API spec and M5.04 plus route-map Angular specs, write outputs under private `.test-runs/m5-04-*` directories, and remove those outputs after the run. They make no network or Meta requests and require no database schema. + +```powershell +npm run test:m5-04 --workspace=@ca-meta/api +npm run test:m5-04 --workspace=@ca-meta/web +``` + +Other targeted gates for the package are API/web typecheck, contract generation/check/lint because OpenAPI changed, the M1.07 generated compatibility/client checks, and `git diff --check`. Exact commands and actual results belong in the SHA-specific handoff; no full suite is implied. diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml index 265e89bc..d6cdd8f7 100644 --- a/docs/meta-v2/contracts/openapi.yaml +++ b/docs/meta-v2/contracts/openapi.yaml @@ -2040,27 +2040,27 @@ paths: - name: assetId in: path required: true - description: Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. + description: Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. + The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied + provider IDs, cUUID, company_uuid and tokens are not accepted. schema: type: string - minLength: 1 - maxLength: 128 - pattern: ^[A-Za-z0-9_-]+$ + format: uuid get: operationId: listTemplates tags: - Templates summary: List templates for an authorized WhatsApp account - description: Returns a tenant/account-scoped template projection from the selected eligible grant. - Provider eligibility and actual grant semantics are Not confirmed; no global credential is - selected. + description: Returns a tenant/account-scoped template projection after current membership, WABA + ownership, WhatsApp EXCLUSIVE flow, verified asset and candidate MANAGE_TEMPLATES grant checks. + The provider adapter is disabled by default until current eligibility and capability evidence is + reviewed. Unrecognized provider states are normalized to UNKNOWN; exact provider fields and status + enumeration are Not confirmed. No root/global credential is selected. security: *a10 - parameters: &a25 + parameters: - $ref: "#/components/parameters/TenantContext" - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/AssetCatalogChannel" - - $ref: "#/components/parameters/AssetCatalogNumber" responses: "200": description: Sanitized success example @@ -2076,8 +2076,8 @@ paths: - id: template_example_01 name: order_update language: en - status: APPROVED - category: UTILITY + status: UNKNOWN + category: UNKNOWN updatedAt: 2026-10-29T10:00:00Z nextCursor: null requestId: req_example_36 @@ -2098,7 +2098,10 @@ paths: - Templates summary: Request creation of a template description: Validates an allowlisted payload and selected account grant before dispatch. Provider - template review/approval is asynchronous and is not inferred from request acceptance. + template review/approval is asynchronous and is not inferred from request acceptance. The real + dispatch adapter remains disabled until current provider capability and tenant eligibility are + confirmed. Body and category constraints are local request-contract limits, not a claim about the + current Meta validation rules. security: *a10 parameters: *a23 requestBody: @@ -2116,6 +2119,7 @@ paths: language: en category: UTILITY bodyText: Your order is ready for pickup. + exampleValues: [] responses: "202": description: Sanitized success example @@ -2143,11 +2147,14 @@ paths: "429": *a19 "503": *a20 default: *a21 - x-external-effect: Submits template creation to the provider for review. + x-external-effect: The installed worker adapter submits template creation to the provider for review. + The API and worker adapters are disabled by default until current capability evidence is reviewed. x-idempotency-scope: tenant + operation + Idempotency-Key and canonical request payload hash. - x-retry-policy: At most one automatic provider submission, including the initial request; zero - automatic retries without verified provider idempotency. If the result is ambiguous, reconcile by - provider template identity before any new explicit request. + x-retry-policy: At most one provider submission, including the initial request. The durable job is + configured with one attempt and AMBIGUOUS effect mode. If the outcome is ambiguous, status is + UNKNOWN and a read-only reconciliation checks the same internal account for a name/language + candidate. A candidate can predate the request and does not prove causation; without confirmed + provider correlation the outcome remains UNKNOWN. No blind retry. /api/v1/notifications: get: operationId: listNotifications @@ -2157,7 +2164,12 @@ paths: description: Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. security: *a10 - parameters: *a25 + parameters: + - $ref: "#/components/parameters/TenantContext" + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AssetCatalogChannel" + - $ref: "#/components/parameters/AssetCatalogNumber" responses: "200": description: Sanitized success example @@ -4266,6 +4278,8 @@ components: maxLength: 16 status: type: string + description: Local normalized vocabulary. Do not map a provider value until its current status and approval + semantics have been confirmed; current provider values and meanings are Not confirmed. enum: - PENDING - APPROVED @@ -4274,6 +4288,8 @@ components: - UNKNOWN category: type: string + description: Local normalized vocabulary. Do not map a provider value until its current category mapping + has been confirmed; current provider values and meanings are Not confirmed. enum: - AUTHENTICATION - MARKETING @@ -4307,6 +4323,10 @@ components: TemplateCreateRequest: type: object additionalProperties: false + description: Local BODY-only request shape. Name/language/body limits and the local create-category + allowlist are compatibility constraints of this API and are not asserted as current Meta rules. `exampleValues` has one entry + per distinct BODY placeholder in first-occurrence order; repeated placeholders reuse that entry. + This correspondence is a local contract rule, not a claim about Meta's current examples validation. required: - name - language @@ -4322,6 +4342,7 @@ components: type: string minLength: 2 maxLength: 16 + pattern: '^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8}){0,2}$' category: type: string enum: @@ -4332,6 +4353,17 @@ components: type: string minLength: 1 maxLength: 1024 + description: BODY text. Matched placeholders use {{1}} through {{N}} with first occurrence in + ascending order; repeats are allowed by this local request contract. + exampleValues: + type: array + maxItems: 100 + description: Local BODY example values, one non-empty string for each distinct placeholder index. + Omit or pass an empty array when the body has no placeholders. + items: + type: string + minLength: 1 + maxLength: 1024 Notification: type: object additionalProperties: false diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index d0ed2a0c..b4585611 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -1203,19 +1203,19 @@ paths: schema: type: string const: no-store - "400": &a30 + "400": &a29 $ref: "#/components/responses/BadRequest" - "404": &a31 + "404": &a30 $ref: "#/components/responses/NotFound" - "409": &a32 + "409": &a31 $ref: "#/components/responses/Conflict" - "413": &a33 + "413": &a32 $ref: "#/components/responses/PayloadTooLarge" - "429": &a34 + "429": &a33 $ref: "#/components/responses/TooManyRequests" - "503": &a35 + "503": &a34 $ref: "#/components/responses/Unavailable" - default: &a36 + default: &a35 $ref: "#/components/responses/UnexpectedError" x-external-effect: Consumes one-time state and records provider return for a server-side job. @@ -2186,28 +2186,30 @@ paths: - name: assetId in: path required: true - description: Opaque account asset ID; the server verifies tenant and - MANAGE_TEMPLATES capability. + description: Internal WABA asset UUID selected from this tenant's verified + WhatsApp EXCLUSIVE assets. The server resolves the provider account + identifier and MANAGE_TEMPLATES credential; browser-supplied provider + IDs, cUUID, company_uuid and tokens are not accepted. schema: type: string - minLength: 1 - maxLength: 128 - pattern: ^[A-Za-z0-9_-]+$ + format: uuid get: operationId: listTemplates tags: - Templates summary: List templates for an authorized WhatsApp account - description: Returns a tenant/account-scoped template projection from the - selected eligible grant. Provider eligibility and actual grant semantics - are Not confirmed; no global credential is selected. + description: Returns a tenant/account-scoped template projection after current + membership, WABA ownership, WhatsApp EXCLUSIVE flow, verified asset and + candidate MANAGE_TEMPLATES grant checks. The provider adapter is + disabled by default until current eligibility and capability evidence is + reviewed. Unrecognized provider states are normalized to UNKNOWN; exact + provider fields and status enumeration are Not confirmed. No root/global + credential is selected. security: *a10 - parameters: &a29 + parameters: - $ref: "#/components/parameters/TenantContext" - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/AssetCatalogChannel" - - $ref: "#/components/parameters/AssetCatalogNumber" responses: "200": description: Sanitized success example @@ -2223,8 +2225,8 @@ paths: - id: template_example_01 name: order_update language: en - status: APPROVED - category: UTILITY + status: UNKNOWN + category: UNKNOWN updatedAt: 2026-10-29T10:00:00Z nextCursor: null requestId: req_example_36 @@ -2246,7 +2248,10 @@ paths: summary: Request creation of a template description: Validates an allowlisted payload and selected account grant before dispatch. Provider template review/approval is asynchronous and is not - inferred from request acceptance. + inferred from request acceptance. The real dispatch adapter remains + disabled until current provider capability and tenant eligibility are + confirmed. Body and category constraints are local request-contract + limits, not a claim about the current Meta validation rules. security: *a10 parameters: *a23 requestBody: @@ -2264,6 +2269,7 @@ paths: language: en category: UTILITY bodyText: Your order is ready for pickup. + exampleValues: [] responses: "202": description: Sanitized success example @@ -2291,12 +2297,17 @@ paths: "429": *a19 "503": *a20 default: *a21 - x-external-effect: Submits template creation to the provider for review. + x-external-effect: The installed worker adapter submits template creation to the + provider for review. The API and worker adapters are disabled by default + until current capability evidence is reviewed. x-idempotency-scope: tenant + operation + Idempotency-Key and canonical request payload hash. - x-retry-policy: At most one automatic provider submission, including the initial - request; zero automatic retries without verified provider idempotency. - If the result is ambiguous, reconcile by provider template identity - before any new explicit request. + x-retry-policy: At most one provider submission, including the initial request. + The durable job is configured with one attempt and AMBIGUOUS effect + mode. If the outcome is ambiguous, status is UNKNOWN and a read-only + reconciliation checks the same internal account for a name/language + candidate. A candidate can predate the request and does not prove + causation; without confirmed provider correlation the outcome remains + UNKNOWN. No blind retry. /api/v1/notifications: get: operationId: listNotifications @@ -2306,7 +2317,12 @@ paths: description: Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. security: *a10 - parameters: *a29 + parameters: + - $ref: "#/components/parameters/TenantContext" + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AssetCatalogChannel" + - $ref: "#/components/parameters/AssetCatalogNumber" responses: "200": description: Sanitized success example @@ -2448,13 +2464,13 @@ paths: summary: Sanitized success example value: challenge: synthetic-webhook-challenge - "400": *a30 - "404": *a31 - "409": *a32 - "413": *a33 - "429": *a34 - "503": *a35 - default: *a36 + "400": *a29 + "404": *a30 + "409": *a31 + "413": *a32 + "429": *a33 + "503": *a34 + default: *a35 x-external-effect: None beyond a challenge response after configured verification. post: operationId: receiveMetaWebhook @@ -2495,13 +2511,13 @@ paths: value: accepted: true requestId: req_example_41 - "400": *a30 - "404": *a31 - "409": *a32 - "413": *a33 - "429": *a34 - "503": *a35 - default: *a36 + "400": *a29 + "404": *a30 + "409": *a31 + "413": *a32 + "429": *a33 + "503": *a34 + default: *a35 x-external-effect: Stores a bounded signed inbox envelope for asynchronous routing. x-idempotency-scope: Configured app + provider delivery ID when available; otherwise a bounded raw-body digest with signature-verification @@ -2529,11 +2545,11 @@ paths: summary: Sanitized success example value: status: ok - "405": &a37 + "405": &a36 $ref: "#/components/responses/MethodNotAllowed" - "503": &a38 + "503": &a37 $ref: "#/components/responses/Unavailable" - default: &a39 + default: &a38 $ref: "#/components/responses/UnexpectedError" /ready: get: @@ -2557,9 +2573,9 @@ paths: summary: Sanitized success example value: status: ok - "405": *a37 - "503": *a38 - default: *a39 + "405": *a36 + "503": *a37 + default: *a38 components: securitySchemes: portalSession: @@ -4333,6 +4349,9 @@ components: maxLength: 16 status: type: string + description: Local normalized vocabulary. Do not map a provider value until its + current status and approval semantics have been confirmed; current + provider values and meanings are Not confirmed. enum: - PENDING - APPROVED @@ -4341,6 +4360,9 @@ components: - UNKNOWN category: type: string + description: Local normalized vocabulary. Do not map a provider value until its + current category mapping has been confirmed; current provider values + and meanings are Not confirmed. enum: - AUTHENTICATION - MARKETING @@ -4374,6 +4396,12 @@ components: TemplateCreateRequest: type: object additionalProperties: false + description: Local BODY-only request shape. Name/language/body limits and the + local create-category allowlist are compatibility constraints of this + API and are not asserted as current Meta rules. `exampleValues` has one + entry per distinct BODY placeholder in first-occurrence order; repeated + placeholders reuse that entry. This correspondence is a local contract + rule, not a claim about Meta's current examples validation. required: - name - language @@ -4389,6 +4417,7 @@ components: type: string minLength: 2 maxLength: 16 + pattern: ^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8}){0,2}$ category: type: string enum: @@ -4399,6 +4428,19 @@ components: type: string minLength: 1 maxLength: 1024 + description: BODY text. Matched placeholders use {{1}} through {{N}} with first + occurrence in ascending order; repeats are allowed by this local + request contract. + exampleValues: + type: array + maxItems: 100 + description: Local BODY example values, one non-empty string for each distinct + placeholder index. Omit or pass an empty array when the body has no + placeholders. + items: + type: string + minLength: 1 + maxLength: 1024 Notification: type: object additionalProperties: false @@ -4567,7 +4609,7 @@ components: schema: $ref: "#/components/schemas/ErrorEnvelope" examples: - sanitized: &a40 + sanitized: &a39 summary: Invalid input, redacted value: code: VALIDATION_FAILED @@ -4679,7 +4721,7 @@ components: schema: $ref: "#/components/schemas/ErrorEnvelope" examples: - sanitized: *a40 + sanitized: *a39 PayloadTooLarge: description: The payload exceeds the endpoint limit. content: diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 9b0ad823..e3f688ad 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -655,20 +655,20 @@ export interface paths { readonly query?: never; readonly header?: never; readonly path: { - /** @description Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; }; /** * List templates for an authorized WhatsApp account - * @description Returns a tenant/account-scoped template projection from the selected eligible grant. Provider eligibility and actual grant semantics are Not confirmed; no global credential is selected. + * @description Returns a tenant/account-scoped template projection after current membership, WABA ownership, WhatsApp EXCLUSIVE flow, verified asset and candidate MANAGE_TEMPLATES grant checks. The provider adapter is disabled by default until current eligibility and capability evidence is reviewed. Unrecognized provider states are normalized to UNKNOWN; exact provider fields and status enumeration are Not confirmed. No root/global credential is selected. */ readonly get: operations["listTemplates"]; readonly put?: never; /** * Request creation of a template - * @description Validates an allowlisted payload and selected account grant before dispatch. Provider template review/approval is asynchronous and is not inferred from request acceptance. + * @description Validates an allowlisted payload and selected account grant before dispatch. Provider template review/approval is asynchronous and is not inferred from request acceptance. The real dispatch adapter remains disabled until current provider capability and tenant eligibility are confirmed. Body and category constraints are local request-contract limits, not a claim about the current Meta validation rules. */ readonly post: operations["createTemplate"]; readonly delete?: never; @@ -1233,9 +1233,15 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** @enum {string} */ + /** + * @description Local normalized vocabulary. Do not map a provider value until its current status and approval semantics have been confirmed; current provider values and meanings are Not confirmed. + * @enum {string} + */ readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; - /** @enum {string} */ + /** + * @description Local normalized vocabulary. Do not map a provider value until its current category mapping has been confirmed; current provider values and meanings are Not confirmed. + * @enum {string} + */ readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; /** Format: date-time */ readonly updatedAt: string; @@ -1245,12 +1251,16 @@ export interface components { readonly nextCursor: string | null; readonly requestId: string; }; + /** @description Local BODY-only request shape. Name/language/body limits and the local create-category allowlist are compatibility constraints of this API and are not asserted as current Meta rules. `exampleValues` has one entry per distinct BODY placeholder in first-occurrence order; repeated placeholders reuse that entry. This correspondence is a local contract rule, not a claim about Meta's current examples validation. */ readonly TemplateCreateRequest: { readonly name: string; readonly language: string; /** @enum {string} */ readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY"; + /** @description BODY text. Matched placeholders use {{1}} through {{N}} with first occurrence in ascending order; repeats are allowed by this local request contract. */ readonly bodyText: string; + /** @description Local BODY example values, one non-empty string for each distinct placeholder index. Omit or pass an empty array when the body has no placeholders. */ + readonly exampleValues?: readonly string[]; }; readonly Notification: { readonly id: string; @@ -2865,16 +2875,13 @@ export interface operations { readonly cursor?: components["parameters"]["Cursor"]; /** @description Page size, default 50, maximum 100. */ readonly limit?: components["parameters"]["Limit"]; - readonly channel?: components["parameters"]["AssetCatalogChannel"]; - /** @description Exact opaque WhatsApp phone number filter. */ - readonly number?: components["parameters"]["AssetCatalogNumber"]; }; 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 account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; @@ -2925,7 +2932,7 @@ export interface operations { readonly "Idempotency-Key": components["parameters"]["IdempotencyKey"]; }; readonly path: { - /** @description Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts index 9b0ad823..35a25e0e 100644 --- a/tools/m1-07/src/generated/openapi.d.ts +++ b/tools/m1-07/src/generated/openapi.d.ts @@ -655,20 +655,20 @@ export interface paths { readonly query?: never; readonly header?: never; readonly path: { - /** @description Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; }; /** * List templates for an authorized WhatsApp account - * @description Returns a tenant/account-scoped template projection from the selected eligible grant. Provider eligibility and actual grant semantics are Not confirmed; no global credential is selected. + * @description Returns a tenant/account-scoped template projection after current membership, WABA ownership, WhatsApp EXCLUSIVE flow, verified asset and candidate MANAGE_TEMPLATES grant checks. The provider adapter is disabled by default until current eligibility and capability evidence is reviewed. Unrecognized provider states are normalized to UNKNOWN; exact provider fields and status enumeration are Not confirmed. No root/global credential is selected. */ readonly get: operations["listTemplates"]; readonly put?: never; /** * Request creation of a template - * @description Validates an allowlisted payload and selected account grant before dispatch. Provider template review/approval is asynchronous and is not inferred from request acceptance. + * @description Validates an allowlisted payload and selected account grant before dispatch. Provider template review/approval is asynchronous and is not inferred from request acceptance. The real dispatch adapter remains disabled until current provider capability and tenant eligibility are confirmed. Body and category constraints are local request-contract limits, not a claim about the current Meta validation rules. */ readonly post: operations["createTemplate"]; readonly delete?: never; @@ -1233,9 +1233,15 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** @enum {string} */ + /** + * @description Local normalized vocabulary. Do not map a provider value until its current status and approval semantics have been confirmed; current provider values and meanings are Not confirmed. + * @enum {string} + */ readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; - /** @enum {string} */ + /** + * @description Local normalized vocabulary. Do not map a provider value until its current category mapping has been confirmed; current provider values and meanings are Not confirmed. + * @enum {string} + */ readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; /** Format: date-time */ readonly updatedAt: string; @@ -1245,12 +1251,16 @@ export interface components { readonly nextCursor: string | null; readonly requestId: string; }; + /** @description Local BODY-only request shape. Name/language/body limits and the local create-category allowlist are compatibility constraints of this API and are not asserted as current Meta rules. `exampleValues` has one entry per distinct BODY placeholder in first-occurrence order; repeated placeholders reuse that entry. This correspondence is a local contract rule, not a claim about Meta's current examples validation. */ readonly TemplateCreateRequest: { readonly name: string; readonly language: string; /** @enum {string} */ readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY"; + /** @description BODY text. Matched placeholders use {{1}} through {{N}} with first occurrence in ascending order; repeats are allowed by this local request contract. */ readonly bodyText: string; + /** @description Local BODY example values, one non-empty string for each distinct placeholder index. Omit or pass an empty array when the body has no placeholders. */ + readonly exampleValues?: readonly string[]; }; readonly Notification: { readonly id: string; @@ -2874,7 +2884,7 @@ export interface operations { readonly "X-Tenant-Context": components["parameters"]["TenantContext"]; }; readonly path: { - /** @description Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; @@ -2925,7 +2935,7 @@ export interface operations { readonly "Idempotency-Key": components["parameters"]["IdempotencyKey"]; }; readonly path: { - /** @description Opaque account asset ID; the server verifies tenant and MANAGE_TEMPLATES capability. */ + /** @description Internal WABA asset UUID selected from this tenant's verified WhatsApp EXCLUSIVE assets. The server resolves the provider account identifier and MANAGE_TEMPLATES credential; browser-supplied provider IDs, cUUID, company_uuid and tokens are not accepted. */ readonly assetId: string; }; readonly cookie?: never; diff --git a/tools/m5-04/README.md b/tools/m5-04/README.md new file mode 100644 index 00000000..2bb36269 --- /dev/null +++ b/tools/m5-04/README.md @@ -0,0 +1,12 @@ +# M5.04 focused validation + +Prerequisites: use Node 24 and npm 11 and install the repository workspace dependencies from the existing root `package-lock.json` (`npm ci`). No package or lockfile dependency was added. The worktree requires its own `@ca-meta/*` aliases to point to local packages; the shared root `node_modules` junction is not changed by these scripts. + +Run from the repository root: + +```powershell +npm run test:m5-04 --workspace=@ca-meta/api +npm run test:m5-04 --workspace=@ca-meta/web +``` + +The API harness compiles only `test/m5-04-whatsapp-templates.spec.ts` plus API sources to `apps/api/.test-runs/m5-04-*`, then runs Node's built-in test runner. The Angular harness includes only the template component spec and route-map spec, writes JUnit beneath `apps/web/.test-runs/m5-04-*`, and verifies a non-empty, failure-free result. Each harness validates its output path and removes only its own private output directory. Both suites use synthetic fixtures; they do not call Meta, need no database schema, and do not inspect or modify shared service state. diff --git a/tools/m5-04/run-focused-api-test.mjs b/tools/m5-04/run-focused-api-test.mjs new file mode 100644 index 00000000..70fa5ada --- /dev/null +++ b/tools/m5-04/run-focused-api-test.mjs @@ -0,0 +1,38 @@ +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.04 focused API validation requires Node 24; received ${process.version}.`); +} +const apiRoot = join(repositoryRoot, 'apps', 'api'); +const compiler = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'); +const project = join(apiRoot, 'tsconfig.m5-04-test.json'); +const outputRoot = join(apiRoot, '.test-runs'); +await mkdir(outputRoot, { recursive: true }); +const temporaryOutput = join(outputRoot, `m5-04-${process.pid}-${Date.now()}`); +await mkdir(temporaryOutput, { recursive: false }); +const safePrefix = `${resolve(outputRoot)}${sep}`; + +function run(args) { + const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit', windowsHide: true }); + if (result.error) throw result.error; + return result.status === 0; +} + +try { + process.stdout.write(`M5.04 synthetic WhatsApp BODY template tests — Node ${process.version}\n`); + if (!run([compiler, '--project', project, '--outDir', temporaryOutput, '--pretty', 'false'])) { + process.exitCode = 1; + } else if (!run(['--test', '--test-concurrency=1', join(temporaryOutput, 'test', 'm5-04-whatsapp-templates.spec.js')])) { + process.exitCode = 1; + } +} finally { + const resolvedOutput = resolve(temporaryOutput); + if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-04-`)) { + throw new Error('M5.04 harness refused to remove an output directory outside its private workspace.'); + } + await rm(resolvedOutput, { recursive: true, force: true }); +} diff --git a/tools/m5-04/run-focused-web-tests.mjs b/tools/m5-04/run-focused-web-tests.mjs new file mode 100644 index 00000000..2c90cfca --- /dev/null +++ b/tools/m5-04/run-focused-web-tests.mjs @@ -0,0 +1,66 @@ +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.04 focused web validation requires Node 24; 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-04-${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.04 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/features/portal/whatsapp-templates.component.spec.ts', + '--include=src/app/app.routes.spec.ts', + ]; + const commandSucceeded = run(args); + if (!commandSucceeded) process.exitCode = 1; + 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.04 Angular JUnit result: ${tests} test(s), ${failures} failure(s).\n`); + const failedCases = [...report.matchAll(/]*)>([\s\S]*?)<\/testcase>/g)] + .filter((match) => /<(failure|error)\b/.test(match[2] ?? '')); + for (const failed of failedCases) { + const name = failed[1]?.match(/\bname="([^"]+)"/)?.[1] ?? 'unknown test'; + const detail = (failed[2] ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1200); + process.stderr.write(` ${name}: ${detail}\n`); + } + process.exitCode = 1; + } else { + process.stdout.write(`M5.04 Angular specs: ${tests} test(s), ${failures} failure(s).\n`); + } + } catch (error) { + if (commandSucceeded) process.stderr.write(`M5.04 Angular JUnit report could not be read: ${error instanceof Error ? error.message : 'unknown error'}\n`); + process.exitCode = 1; + } +} finally { + const resolvedOutput = resolve(temporaryOutput); + if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-04-`)) { + throw new Error('M5.04 harness refused to remove an output directory outside its private workspace.'); + } + await rm(resolvedOutput, { recursive: true, force: true }); +} From f694595d1bd67ce5870f696a3843c8bc8bc1f4e2 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 16:53:50 -0300 Subject: [PATCH 02/17] fix(web): retain template idempotency across reloads --- .../pending-whatsapp-template-attempts.ts | 98 +++++++++++++++++++ .../whatsapp-templates.component.spec.ts | 60 +++++++++++- .../portal/whatsapp-templates.component.ts | 77 +++++++++++---- docs/delivery/M5/M5.04.md | 2 + 4 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts diff --git a/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts new file mode 100644 index 00000000..2438f088 --- /dev/null +++ b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts @@ -0,0 +1,98 @@ +import type { TemplateCreateRequest } from './whatsapp-templates.service'; + +export const PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY = 'ca.meta.portal.whatsapp-template-attempts.v1'; +const MAX_PENDING_ATTEMPTS = 100; +const IDEMPOTENCY_KEY = /^[A-Za-z0-9._~-]{16,128}$/; +const JOB_ID = /^[A-Za-z0-9_-]{1,128}$/; +const SHA256 = /^[a-f0-9]{64}$/; + +export interface PendingWhatsAppTemplateAttempt { + readonly idempotencyKey: string; + readonly jobId?: string; +} + +/** Persists only hashes, idempotency keys and opaque job IDs; request text/examples never reach storage. */ +export class PendingWhatsAppTemplateAttempts { + constructor(private readonly storage: () => Storage = () => globalThis.sessionStorage) {} + + async scope(contextRef: string, assetId: string, request: TemplateCreateRequest): Promise { + if (!contextRef || !assetId) throw new Error('A verified portal context and internal asset are required.'); + const material = JSON.stringify([contextRef, assetId, request]); + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(material)); + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join(''); + } + + reserve(scope: string, makeIdempotencyKey: () => string): PendingWhatsAppTemplateAttempt { + if (!SHA256.test(scope)) throw new Error('Invalid pending template attempt scope.'); + const attempts = this.read(); + const existing = attempts.get(scope); + if (existing) return existing; + if (attempts.size >= MAX_PENDING_ATTEMPTS) throw new Error('Pending template attempt capacity reached.'); + const idempotencyKey = makeIdempotencyKey(); + if (!IDEMPOTENCY_KEY.test(idempotencyKey)) throw new Error('Invalid pending template idempotency key.'); + const attempt = Object.freeze({ idempotencyKey }); + attempts.set(scope, attempt); + this.write(attempts); + return attempt; + } + + find(scope: string): PendingWhatsAppTemplateAttempt | undefined { + if (!SHA256.test(scope)) throw new Error('Invalid pending template attempt scope.'); + return this.read().get(scope); + } + + recordJob(scope: string, jobId: string): void { + if (!SHA256.test(scope) || !JOB_ID.test(jobId)) throw new Error('Invalid pending template job reference.'); + const attempts = this.read(); + const current = attempts.get(scope); + if (!current) return; + attempts.set(scope, Object.freeze({ idempotencyKey: current.idempotencyKey, jobId })); + this.write(attempts); + } + + resolve(scope: string): void { + if (!SHA256.test(scope)) return; + const attempts = this.read(); + if (!attempts.delete(scope)) return; + this.write(attempts); + } + + clear(): void { + this.storage().removeItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY); + } + + private read(): Map { + const raw = this.storage().getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY); + if (raw === null) return new Map(); + const parsed: unknown = JSON.parse(raw); + if (!record(parsed) || Object.keys(parsed).length > MAX_PENDING_ATTEMPTS) { + throw new Error('Pending template attempt storage is invalid.'); + } + const attempts = new Map(); + for (const [scope, value] of Object.entries(parsed)) { + if (!SHA256.test(scope) || !record(value) || typeof value['idempotencyKey'] !== 'string' || + !IDEMPOTENCY_KEY.test(value['idempotencyKey'])) { + throw new Error('Pending template attempt storage is invalid.'); + } + const jobId = value['jobId']; + if (jobId !== undefined && (typeof jobId !== 'string' || !JOB_ID.test(jobId))) { + throw new Error('Pending template attempt storage is invalid.'); + } + attempts.set(scope, Object.freeze({ idempotencyKey: value['idempotencyKey'], + ...(typeof jobId === 'string' ? { jobId } : {}) })); + } + return attempts; + } + + private write(attempts: ReadonlyMap): void { + if (attempts.size === 0) { + this.clear(); + return; + } + this.storage().setItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(attempts))); + } +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts index 45617f93..814f1374 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -1,8 +1,10 @@ +import { beforeEach } from 'vitest'; import { Subject } from 'rxjs'; import type { components } from '@ca-meta/contracts'; import { PortalContextStore } from '../../core/portal/portal-context.store'; import { PortalSessionService } from '../../core/portal/portal-session.service'; import { WhatsAppTemplatesService, type TemplateAsset } from '../../core/portal/whatsapp-templates.service'; +import { PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY } from '../../core/portal/pending-whatsapp-template-attempts'; import { WhatsAppTemplatesComponent } from './whatsapp-templates.component'; const asset = { @@ -17,11 +19,20 @@ const asset = { stale: false, capabilities: { canSend: false, canManageTemplates: true, canSubscribe: false, canReauthorize: false, canDisconnect: false }, } as TemplateAsset; +const tenantContextRef = 'synthetic-template-context-reference-0001'; + +beforeEach(() => sessionStorage.removeItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)); function acceptedJob(jobId = 'job_template_01'): components['schemas']['JobAccepted'] { return { jobId, status: 'PENDING', location: `/api/v1/jobs/${jobId}`, requestId: 'req-template-create-01' }; } +function jobStatus(status: components['schemas']['JobStatus']['status']): components['schemas']['JobStatus'] { + return { id: 'job_template_01', status, operation: 'TEMPLATE_CREATE', createdAt: '2026-11-10T12:00:00.000Z', + updatedAt: '2026-11-10T12:01:00.000Z', nextAction: status === 'UNKNOWN' || status === 'FAILED' ? 'CHECK_STATUS' : 'NONE', + requestId: 'req-template-job-01' }; +} + async function settle(): Promise { for (let index = 0; index < 6; index += 1) await Promise.resolve(); } @@ -46,7 +57,7 @@ async function createFixture(options: { const sessionExpired$ = new Subject(); const component = new WhatsAppTemplatesComponent(service, { ensureCurrentSession: vi.fn().mockResolvedValue({}) } as unknown as PortalSessionService, - { contextChanged$: contextChanged$.asObservable(), sessionExpired$: sessionExpired$.asObservable() } as unknown as PortalContextStore); + { contextRef: tenantContextRef, contextChanged$: contextChanged$.asObservable(), sessionExpired$: sessionExpired$.asObservable() } as unknown as PortalContextStore); component.ngOnInit(); await settle(); return { component, service, list, create, job, contextChanged$, sessionExpired$ }; @@ -89,6 +100,7 @@ describe('WhatsAppTemplatesComponent', () => { await state.component.checkJobStatus(); expect(state.component.jobStatus?.status).toBe('UNKNOWN'); expect(state.component.statusMessage).toContain('resultado ainda não está confirmado'); + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).not.toBeNull(); state.component.ngOnDestroy(); }); @@ -108,9 +120,10 @@ describe('WhatsAppTemplatesComponent', () => { const state = await createFixture({ create: create as never }); state.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Private draft.' }; const pendingCreate = state.component.createTemplate(); - expect(create).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(create).toHaveBeenCalledOnce()); state.contextChanged$.next(); await settle(); + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).toBeNull(); expect(state.component.form.name).toBe(''); expect(state.component.jobId).toBeNull(); resolveCreate(acceptedJob()); @@ -119,4 +132,47 @@ describe('WhatsAppTemplatesComponent', () => { expect(state.component.statusMessage).not.toContain('Pedido enviado'); state.component.ngOnDestroy(); }); + + it('reuses the persisted key after remount when the first accepted response was lost', async () => { + const jobs = new Map(); + let calls = 0; + const create = vi.fn(async (_assetId: string, _request: components['schemas']['TemplateCreateRequest'], key: string) => { + let jobId = jobs.get(key); + if (!jobId) { + jobId = 'job_template_01'; + jobs.set(key, jobId); + } + calls += 1; + if (calls === 1) throw new Error('synthetic lost response after durable enqueue'); + return acceptedJob(jobId); + }); + + const firstMount = await createFixture({ create: create as never }); + firstMount.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Ready.' }; + await firstMount.component.createTemplate(); + const firstKey = create.mock.calls[0]?.[2]; + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).not.toBeNull(); + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).not.toContain('Ready.'); + firstMount.sessionExpired$.next(); + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).not.toBeNull(); + firstMount.component.ngOnDestroy(); + + const secondMount = await createFixture({ create: create as never, + job: async () => jobStatus('SUCCEEDED') }); + secondMount.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Ready.' }; + await secondMount.component.createTemplate(); + + expect(create.mock.calls[1]?.[2]).toBe(firstKey); + expect(jobs.size).toBe(1); + expect(secondMount.component.jobId).toBe('job_template_01'); + const persisted = sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY); + expect(persisted).not.toBeNull(); + const currentScope = (secondMount.component as unknown as { currentAttemptScope: string | null }).currentAttemptScope; + expect(currentScope).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.parse(persisted ?? '{}')[currentScope ?? '']?.jobId).toBe('job_template_01'); + await secondMount.component.checkJobStatus(); + expect(secondMount.component.jobStatus?.status).toBe('SUCCEEDED'); + expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).toBeNull(); + secondMount.component.ngOnDestroy(); + }); }); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.ts index 805e3ccf..b5492ef7 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.ts @@ -1,4 +1,5 @@ import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Subject, takeUntil } from 'rxjs'; @@ -7,6 +8,7 @@ import { PortalContextStore } from '../../core/portal/portal-context.store'; import { PortalSessionService } from '../../core/portal/portal-session.service'; import { safeRequestId } from '../../core/portal/safe-api-error'; import { TemplateAsset, TemplateCreateRequest, TemplateJobStatus, TemplateListPage, TemplateRecord, WhatsAppTemplatesService } from '../../core/portal/whatsapp-templates.service'; +import { PendingWhatsAppTemplateAttempts } from '../../core/portal/pending-whatsapp-template-attempts'; type TemplateCategory = TemplateCreateRequest['category']; type TemplateForm = { name: string; language: string; category: TemplateCategory; bodyText: string }; @@ -41,8 +43,9 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { private readonly destroy$ = new Subject(); private accountGeneration = 0; private templateGeneration = 0; - private lastCreateFingerprint = ''; + private currentAttemptScope: string | null = null; private createIdempotencyKey = ''; + private readonly pendingAttempts = new PendingWhatsAppTemplateAttempts(); private readonly cursorHistory: Array = [undefined]; constructor( @@ -53,8 +56,13 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { ngOnInit(): void { void this.loadAccounts(); - this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => { void this.reloadForCurrentContext(); }); - this.context.sessionExpired$.pipe(takeUntil(this.destroy$)).subscribe(() => this.clearForExpiredSession()); + this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.clearPendingAttemptsForContextChange(); + void this.reloadForCurrentContext(); + }); + this.context.sessionExpired$.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.clearForExpiredSession(); + }); } ngOnDestroy(): void { @@ -80,7 +88,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.jobId = null; this.jobStatus = null; this.jobStatusError = ''; - this.lastCreateFingerprint = ''; + this.currentAttemptScope = null; this.createIdempotencyKey = ''; } this.selectedAssetId = assetId; @@ -130,23 +138,27 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { return; } - const fingerprint = JSON.stringify(request); - if (fingerprint !== this.lastCreateFingerprint) { - const key = globalThis.crypto?.randomUUID?.(); - if (!key) { - this.formError = 'Este navegador não conseguiu criar uma chave segura para o pedido.'; - return; - } - this.lastCreateFingerprint = fingerprint; - this.createIdempotencyKey = key; - } - this.isCreating = true; this.requestId = null; + const contextRef = this.context.contextRef; + let attemptScope: string | null = null; + let dispatched = false; try { + if (!contextRef) throw new Error('The portal context is unavailable.'); + attemptScope = await this.pendingAttempts.scope(contextRef, assetId, request); + if (generation !== this.accountGeneration || assetId !== this.selectedAssetId || contextRef !== this.context.contextRef) return; + const attempt = this.pendingAttempts.reserve(attemptScope, () => { + const key = globalThis.crypto?.randomUUID?.(); + if (!key) throw new Error('A secure idempotency key is unavailable.'); + return key; + }); + this.currentAttemptScope = attemptScope; + this.createIdempotencyKey = attempt.idempotencyKey; + dispatched = true; const accepted = await this.service.create(assetId, request, this.createIdempotencyKey); if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; if (!this.isAcceptedJob(accepted)) throw new Error('The template request was not accepted.'); + try { this.pendingAttempts.recordJob(attemptScope, accepted.jobId); } catch { /* The key was persisted before the POST. */ } this.requestId = accepted.requestId; this.jobId = accepted.jobId; this.jobStatus = null; @@ -154,6 +166,12 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.statusMessage = 'Pedido enviado para processamento. A aceitação da solicitação não confirma aprovação do modelo pela Meta.'; } catch (error: unknown) { if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; + if (!dispatched) { + this.formError = 'Não foi possível registrar a tentativa pendente neste navegador. O pedido não foi enviado.'; + this.requestId = safeRequestId(error); + return; + } + if (attemptScope && definitiveNoEffect(error)) this.resolveTemplateAttempt(attemptScope); this.formError = 'Não foi possível confirmar o envio do pedido. Consulte o status antes de tentar novamente.'; this.requestId = safeRequestId(error); } finally { @@ -180,6 +198,9 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { } else { this.statusMessage = 'O pedido continua em processamento.'; } + if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(this.jobStatus.status) && this.currentAttemptScope) { + this.resolveTemplateAttempt(this.currentAttemptScope); + } this.requestId = this.jobStatus.requestId; } catch (error: unknown) { if (generation !== this.accountGeneration || jobId !== this.jobId) return; @@ -259,7 +280,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.jobStatusError = ''; this.isCheckingJob = false; this.isCreating = false; - this.lastCreateFingerprint = ''; + this.currentAttemptScope = null; this.createIdempotencyKey = ''; this.form = { name: '', language: '', category: 'UTILITY', bodyText: '' }; this.examplesText = ''; @@ -290,7 +311,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.jobStatus = null; this.jobStatusError = ''; this.requestId = null; - this.lastCreateFingerprint = ''; + this.currentAttemptScope = null; this.createIdempotencyKey = ''; this.form = { name: '', language: '', category: 'UTILITY', bodyText: '' }; this.examplesText = ''; @@ -299,6 +320,24 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.accountError = 'Sua sessão terminou. Entre novamente para consultar os modelos.'; } + private clearPendingAttemptsForContextChange(): void { + try { this.pendingAttempts.clear(); } catch { /* The new context hash still fences any old record. */ } + this.currentAttemptScope = null; + this.createIdempotencyKey = ''; + } + + private resolveTemplateAttempt(scope: string): void { + try { + this.pendingAttempts.resolve(scope); + if (this.currentAttemptScope === scope) { + this.currentAttemptScope = null; + this.createIdempotencyKey = ''; + } + } catch { + // Retain the key if storage cannot confirm removal; that is safer than generating a new POST key. + } + } + private parseExamples(bodyText: string, rawExamples: string): readonly string[] | null { const values = rawExamples.split(/\r?\n/).map((value) => value.trim()).filter((value) => value.length > 0); const placeholderPattern = /\{\{([1-9][0-9]{0,2})\}\}/g; @@ -335,3 +374,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { (page['nextCursor'] === null || typeof page['nextCursor'] === 'string') && typeof page['requestId'] === 'string'; } } + +function definitiveNoEffect(error: unknown): boolean { + return error instanceof HttpErrorResponse && [400, 403, 404, 422].includes(error.status); +} diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 109d27de..7a42b1cb 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -14,6 +14,8 @@ This package deliberately implements only one BODY component. The closed request The UI labels a `202` as a request accepted for processing and explicitly says that this does not confirm Meta approval. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result advises the operator to refresh the list before another request. +For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, and an accepted job ID when available, never template text or examples. Re-entering the same request in the same tab reuses the pending key until a terminal job result is observed or the tenant context changes. Session expiry keeps the pending key so a later session cannot accidentally enqueue a duplicate. Storage failures block the POST. A focused regression simulates a lost `202` after durable enqueue, remounts the component, and verifies the retry uses the same key and creates only one synthetic job. + ## Provider evidence collected 2026-11-11 - [Meta: Get all templates (default fields)](https://www.postman.com/meta/whatsapp-business-platform/request/qtgr0i7/get-all-templates-default-fields) documents a WABA-scoped template-list request. From 0a3f4d90125af2993d993bacc089880e2f66a2e4 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 17:09:15 -0300 Subject: [PATCH 03/17] fix(m5-04): prevent duplicate successful template requests --- .../pending-whatsapp-template-attempts.ts | 27 ++++++++++++++++--- .../whatsapp-templates.component.spec.ts | 23 ++++++++++++++-- .../portal/whatsapp-templates.component.ts | 9 ++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts index 2438f088..86c0bddb 100644 --- a/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts +++ b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts @@ -9,6 +9,7 @@ const SHA256 = /^[a-f0-9]{64}$/; export interface PendingWhatsAppTemplateAttempt { readonly idempotencyKey: string; readonly jobId?: string; + readonly succeeded?: boolean; } /** Persists only hashes, idempotency keys and opaque job IDs; request text/examples never reach storage. */ @@ -46,7 +47,21 @@ export class PendingWhatsAppTemplateAttempts { const attempts = this.read(); const current = attempts.get(scope); if (!current) return; - attempts.set(scope, Object.freeze({ idempotencyKey: current.idempotencyKey, jobId })); + attempts.set(scope, Object.freeze({ idempotencyKey: current.idempotencyKey, jobId, + ...(current.succeeded ? { succeeded: true } : {}) })); + this.write(attempts); + } + + markSucceeded(scope: string): void { + if (!SHA256.test(scope)) return; + const attempts = this.read(); + const current = attempts.get(scope); + if (!current) return; + attempts.set(scope, Object.freeze({ + idempotencyKey: current.idempotencyKey, + ...(current.jobId ? { jobId: current.jobId } : {}), + succeeded: true, + })); this.write(attempts); } @@ -70,16 +85,20 @@ export class PendingWhatsAppTemplateAttempts { } const attempts = new Map(); for (const [scope, value] of Object.entries(parsed)) { - if (!SHA256.test(scope) || !record(value) || typeof value['idempotencyKey'] !== 'string' || + if (!SHA256.test(scope) || !record(value) || Object.keys(value).some((key) => + !['idempotencyKey', 'jobId', 'succeeded'].includes(key)) || typeof value['idempotencyKey'] !== 'string' || !IDEMPOTENCY_KEY.test(value['idempotencyKey'])) { throw new Error('Pending template attempt storage is invalid.'); } const jobId = value['jobId']; - if (jobId !== undefined && (typeof jobId !== 'string' || !JOB_ID.test(jobId))) { + const succeeded = value['succeeded']; + if ((jobId !== undefined && (typeof jobId !== 'string' || !JOB_ID.test(jobId))) || + (succeeded !== undefined && typeof succeeded !== 'boolean')) { throw new Error('Pending template attempt storage is invalid.'); } attempts.set(scope, Object.freeze({ idempotencyKey: value['idempotencyKey'], - ...(typeof jobId === 'string' ? { jobId } : {}) })); + ...(typeof jobId === 'string' ? { jobId } : {}), + ...(succeeded === true ? { succeeded: true } : {}) })); } return attempts; } diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts index 814f1374..6874c05a 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -139,7 +139,7 @@ describe('WhatsAppTemplatesComponent', () => { const create = vi.fn(async (_assetId: string, _request: components['schemas']['TemplateCreateRequest'], key: string) => { let jobId = jobs.get(key); if (!jobId) { - jobId = 'job_template_01'; + jobId = `job_template_${String(jobs.size + 1).padStart(2, '0')}`; jobs.set(key, jobId); } calls += 1; @@ -172,7 +172,26 @@ describe('WhatsAppTemplatesComponent', () => { expect(JSON.parse(persisted ?? '{}')[currentScope ?? '']?.jobId).toBe('job_template_01'); await secondMount.component.checkJobStatus(); expect(secondMount.component.jobStatus?.status).toBe('SUCCEEDED'); - expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).toBeNull(); + const succeededRecord = JSON.parse(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY) ?? '{}')[currentScope ?? '']; + expect(succeededRecord).toMatchObject({ jobId: 'job_template_01', succeeded: true }); + await secondMount.component.createTemplate(); + expect(create).toHaveBeenCalledTimes(2); + expect(secondMount.component.jobStatus?.status).toBe('SUCCEEDED'); secondMount.component.ngOnDestroy(); + + const thirdMount = await createFixture({ create: create as never }); + thirdMount.component.form = { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Ready.' }; + await thirdMount.component.createTemplate(); + expect(create).toHaveBeenCalledTimes(2); + expect(thirdMount.component.jobId).toBe('job_template_01'); + expect(thirdMount.component.statusMessage).toContain('já foi encaminhado'); + + thirdMount.component.form.bodyText = 'Ready for {{1}}.'; + thirdMount.component.examplesText = 'Ana'; + await thirdMount.component.createTemplate(); + expect(create).toHaveBeenCalledTimes(3); + expect(create.mock.calls[2]?.[2]).not.toBe(firstKey); + expect(jobs.size).toBe(2); + thirdMount.component.ngOnDestroy(); }); }); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.ts index b5492ef7..aa7df378 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.ts @@ -154,6 +154,11 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { }); this.currentAttemptScope = attemptScope; this.createIdempotencyKey = attempt.idempotencyKey; + if (attempt.succeeded) { + this.jobId = attempt.jobId ?? null; + this.statusMessage = 'Este pedido já foi encaminhado ao provedor. Edite o modelo para iniciar um novo pedido.'; + return; + } dispatched = true; const accepted = await this.service.create(assetId, request, this.createIdempotencyKey); if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; @@ -198,7 +203,9 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { } else { this.statusMessage = 'O pedido continua em processamento.'; } - if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(this.jobStatus.status) && this.currentAttemptScope) { + if (this.jobStatus.status === 'SUCCEEDED' && this.currentAttemptScope) { + try { this.pendingAttempts.markSucceeded(this.currentAttemptScope); } catch { /* Keep the existing key if storage is unavailable. */ } + } else if (['FAILED', 'CANCELLED'].includes(this.jobStatus.status) && this.currentAttemptScope) { this.resolveTemplateAttempt(this.currentAttemptScope); } this.requestId = this.jobStatus.requestId; From f5daacb1a35df303cb32f2ad2543079f28f734e8 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 17:09:59 -0300 Subject: [PATCH 04/17] docs(m5-04): record success dedupe correction --- docs/delivery/M5/M5.04.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 7a42b1cb..b1abfe77 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -14,7 +14,7 @@ This package deliberately implements only one BODY component. The closed request The UI labels a `202` as a request accepted for processing and explicitly says that this does not confirm Meta approval. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result advises the operator to refresh the list before another request. -For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, and an accepted job ID when available, never template text or examples. Re-entering the same request in the same tab reuses the pending key until a terminal job result is observed or the tenant context changes. Session expiry keeps the pending key so a later session cannot accidentally enqueue a duplicate. Storage failures block the POST. A focused regression simulates a lost `202` after durable enqueue, remounts the component, and verifies the retry uses the same key and creates only one synthetic job. +For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. ## Provider evidence collected 2026-11-11 @@ -43,3 +43,7 @@ npm run test:m5-04 --workspace=@ca-meta/web ``` Other targeted gates for the package are API/web typecheck, contract generation/check/lint because OpenAPI changed, the M1.07 generated compatibility/client checks, and `git diff --check`. Exact commands and actual results belong in the SHA-specific handoff; no full suite is implied. + +## Latest focused correction + +An independent review found that a request with the same fingerprint could be sent again after a local job reached `SUCCEEDED`. The UI now retains a terminal success marker without storing request text. On SHA `8470a9f9892e2f4729a4db84c9d138c0c96af253`, `npm run test:m5-04 --workspace=@ca-meta/web` passed 8/8 focused Angular specs and `npm run typecheck --workspace=@ca-meta/web` passed; `git diff --check` passed. The regression verifies that submitting the identical request after success, including after remount, makes no additional POST, while an edited request creates a new job with a different key. API code did not change in this correction, so API gates were not repeated. From 5347d332517d73cdd07f62f59345ccdb2fdd8b29 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 17:12:44 -0300 Subject: [PATCH 05/17] fix(m5-04): bound successful template dedupe markers --- .../pending-whatsapp-template-attempts.ts | 7 +++++++ .../whatsapp-templates.component.spec.ts | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts index 86c0bddb..23c975b2 100644 --- a/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts +++ b/apps/web/src/app/core/portal/pending-whatsapp-template-attempts.ts @@ -28,6 +28,10 @@ export class PendingWhatsAppTemplateAttempts { const attempts = this.read(); const existing = attempts.get(scope); if (existing) return existing; + // A newly submitted fingerprint is an explicit new request; keep only unresolved attempts plus the latest success marker. + for (const [priorScope, prior] of attempts) { + if (prior.succeeded) attempts.delete(priorScope); + } if (attempts.size >= MAX_PENDING_ATTEMPTS) throw new Error('Pending template attempt capacity reached.'); const idempotencyKey = makeIdempotencyKey(); if (!IDEMPOTENCY_KEY.test(idempotencyKey)) throw new Error('Invalid pending template idempotency key.'); @@ -57,6 +61,9 @@ export class PendingWhatsAppTemplateAttempts { const attempts = this.read(); const current = attempts.get(scope); if (!current) return; + for (const [priorScope, prior] of attempts) { + if (priorScope !== scope && prior.succeeded) attempts.delete(priorScope); + } attempts.set(scope, Object.freeze({ idempotencyKey: current.idempotencyKey, ...(current.jobId ? { jobId: current.jobId } : {}), diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts index 6874c05a..2aeeff2a 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -4,7 +4,7 @@ import type { components } from '@ca-meta/contracts'; import { PortalContextStore } from '../../core/portal/portal-context.store'; import { PortalSessionService } from '../../core/portal/portal-session.service'; import { WhatsAppTemplatesService, type TemplateAsset } from '../../core/portal/whatsapp-templates.service'; -import { PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY } from '../../core/portal/pending-whatsapp-template-attempts'; +import { PendingWhatsAppTemplateAttempts, PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY } from '../../core/portal/pending-whatsapp-template-attempts'; import { WhatsAppTemplatesComponent } from './whatsapp-templates.component'; const asset = { @@ -64,6 +64,24 @@ async function createFixture(options: { } describe('WhatsAppTemplatesComponent', () => { + it('keeps successful dedupe markers bounded as new template requests are submitted', () => { + const attempts = new PendingWhatsAppTemplateAttempts(() => sessionStorage); + let lastScope = ''; + let lastKey = ''; + for (let index = 0; index < 101; index += 1) { + lastScope = index.toString(16).padStart(64, '0'); + lastKey = `synthetic-idempotency-key-${index}`; + attempts.reserve(lastScope, () => lastKey); + attempts.recordJob(lastScope, `job_template_${index}`); + attempts.markSucceeded(lastScope); + } + + const persisted = JSON.parse(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY) ?? '{}'); + expect(Object.keys(persisted)).toEqual([lastScope]); + expect(persisted[lastScope]).toMatchObject({ idempotencyKey: lastKey, succeeded: true }); + expect(attempts.reserve(lastScope, () => 'synthetic-different-idempotency-key')).toMatchObject({ idempotencyKey: lastKey }); + }); + it('keeps list and create unavailable when local capability evidence is absent', async () => { const state = await createFixture({ capabilities: false }); expect(state.list).not.toHaveBeenCalled(); From bb34f565cccfa7e9fa39e3aa3716e9f93ee8b5ca Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Wed, 11 Nov 2026 17:13:08 -0300 Subject: [PATCH 06/17] docs(m5-04): record bounded success marker evidence --- docs/delivery/M5/M5.04.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index b1abfe77..4dcd9a2b 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -14,7 +14,7 @@ This package deliberately implements only one BODY component. The closed request The UI labels a `202` as a request accepted for processing and explicitly says that this does not confirm Meta approval. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result advises the operator to refresh the list before another request. -For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. +For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the latest success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job, which prunes the prior success marker. At most one succeeded marker is retained, and up to 100 unresolved attempts remain bounded by the store limit. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. ## Provider evidence collected 2026-11-11 @@ -46,4 +46,6 @@ Other targeted gates for the package are API/web typecheck, contract generation/ ## Latest focused correction -An independent review found that a request with the same fingerprint could be sent again after a local job reached `SUCCEEDED`. The UI now retains a terminal success marker without storing request text. On SHA `8470a9f9892e2f4729a4db84c9d138c0c96af253`, `npm run test:m5-04 --workspace=@ca-meta/web` passed 8/8 focused Angular specs and `npm run typecheck --workspace=@ca-meta/web` passed; `git diff --check` passed. The regression verifies that submitting the identical request after success, including after remount, makes no additional POST, while an edited request creates a new job with a different key. API code did not change in this correction, so API gates were not repeated. +An independent review found that a request with the same fingerprint could be sent again after a local job reached `SUCCEEDED`. The UI now retains a terminal success marker without storing request text. On SHA `8470a9f9892e2f4729a4db84c9d138c0c96af253`, the focused web specs passed 8/8 and the web typecheck passed; `git diff --check` passed. The regression verifies that submitting the identical request after success, including after remount, makes no additional POST, while an edited request creates a new job with a different key. API code did not change in this correction, so API gates were not repeated. + +Review then identified that unbounded success markers would consume the 100-attempt capacity. SHA `a0e8d101d8cb0fd8a942645368d93f44aa1fa0d9` keeps only the latest success marker and prunes it when a different request is explicitly reserved, while preserving unresolved attempts. The same focused web harness passed 9/9 specs, including 101 sequential synthetic successful requests with one terminal marker retained; web typecheck and `git diff --check` passed. From f1c6e19a0bcb171c977434840118a2b7b8201b3a Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 15:02:29 -0300 Subject: [PATCH 07/17] fix(m5-04): regenerate M1.07 client for merged contract --- tools/m1-07/src/generated/openapi.d.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts index 35a25e0e..e3f688ad 100644 --- a/tools/m1-07/src/generated/openapi.d.ts +++ b/tools/m1-07/src/generated/openapi.d.ts @@ -2875,9 +2875,6 @@ export interface operations { readonly cursor?: components["parameters"]["Cursor"]; /** @description Page size, default 50, maximum 100. */ readonly limit?: components["parameters"]["Limit"]; - readonly channel?: components["parameters"]["AssetCatalogChannel"]; - /** @description Exact opaque WhatsApp phone number filter. */ - readonly number?: components["parameters"]["AssetCatalogNumber"]; }; readonly header: { /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */ From 760ec09dd70d09114338d849093fd7dd8817e1eb Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 17:54:36 -0300 Subject: [PATCH 08/17] docs(m5): record M5.03 integration and M5.04 rebase --- docs/delivery/M5/M5.03.md | 4 ++-- docs/delivery/M5/M5.04.md | 6 ++++++ docs/delivery/M5/handoff.md | 14 +++++++------- docs/delivery/M5/progress.md | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/delivery/M5/M5.03.md b/docs/delivery/M5/M5.03.md index b782e66d..4a7c9094 100644 --- a/docs/delivery/M5/M5.03.md +++ b/docs/delivery/M5/M5.03.md @@ -61,9 +61,9 @@ Os gates foram executados de forma focada; não foi executada suíte ampla de AP | Staged migration MariaDB integration | Base `c32f01fd029bbff800b85bacfe21b32619cb9ad8` plus source-diff fingerprint `b46e2fe7589de1aa73b790355cd534156ad4465c` for `migration-runner.ts`, `migration-unit.spec.ts`, and `migrations.integration.spec.ts` | Windows 10; Docker 28.3.0, Node 24.22.2/npm 11.19.1, MariaDB 11.8.8; isolated project `m503-3255e362b049` and schema `ca_meta_m503_3255e362b049`. A temporary wrapper compiled `tsconfig.test.json` and ran `node --test --test-concurrency=1 /test/migrations.integration.spec.js`. Reproducible committed gate: `pwsh -NoLogo -NoProfile -File tools/m2-03/run-mariadb-tests.ps1`. | PASS — 1/1, 0 failures, 0 skipped; test 32.3 s, runner 47.2 s. Fresh-schema migration/crash recovery, pause at v22 without table DELETE, exact table-only grant via disposable root, resume through ledger 1–22 APPLIED and trigger present. The unique container, network, and volume were removed. | | Diff check (staged-grant working diff) | Base SHA `c32f01fd029bbff800b85bacfe21b32619cb9ad8` plus current uncommitted diff | `git diff --check` | PASS | | Independent security review | Base SHA `c32f01fd029bbff800b85bacfe21b32619cb9ad8` plus current source diff | Read-only review of grant validation, staged recovery, synthetic root use, and CLI error handling | PASS — no blocker. Advisory P2: the existing trigger verifier checks action substrings but not the `DEFINER`; strengthening the trigger shape check is recommended as follow-up. This requires privileged schema change and is not a runtime/API bypass. | -| M2.05 fixture compatibility bootstrap | Working diff after the v22 CI failure on `a0e241fb4a886b6a73924f8a83e49969cf56ae7d` | Node 24.22.2; `node --check tools/m2-05/prepare-current-schema.mjs`; `git diff --check` | PASS — syntax/diff checks only. The script applies v1–v21, lets v22 create the lock table and pause, then grants `DELETE` only on that table through synthetic root. Full M2.08 MariaDB CI on the next PR head is the behavioral gate. | +| M2.05 and M3.10 staged-grant fixture compatibility | `141836a145be045d20f27a0c7746c0c7bf96086f` | Node 24.22.2/npm 11.19.1; `node --check` on both `prepare-current-schema.mjs` helpers, PowerShell parser on `tools/m3-10/run-retention-tests.ps1`, `git diff --check`, then M2.08 MariaDB workflow run `43303353328` | PASS — M2.03 migration/backup-restore, M2.05 integration fixtures and M3.10 retention fixtures passed. Both M2.05 and M3.10 start from a fresh disposable schema, stage the grant at version 22, and resume through the full compiled registry. The pause point remains v22 when later migrations are added. | -Validation is bound to the listed source SHAs and diff fingerprint: the pre-reindex API/MariaDB harness and web typecheck ran at `03b6b87cd1691f05c870e8c466fe4eafce8bc00c`; web specs ran at `71336c94ca02f483e3a355c73dd6e6b60592db60`; API typecheck and the recomposed v22 API/MariaDB harness ran at `cae3d97c7f838999a95e79e7f999b4fcb2d93953`; staged migration recovery ran against the three-source-file fingerprint above. The next PR head and CI checks provide the exact committed SHA gate. Real provider activation remains disabled pending M4.14/M3.05 evidence. +Validation is bound to the listed source SHAs and diff fingerprint: the pre-reindex API/MariaDB harness and web typecheck ran at `03b6b87cd1691f05c870e8c466fe4eafce8bc00c`; web specs ran at `71336c94ca02f483e3a355c73dd6e6b60592db60`; API typecheck and the recomposed v22 API/MariaDB harness ran at `cae3d97c7f838999a95e79e7f999b4fcb2d93953`; staged migration recovery ran against the three-source-file fingerprint above; and the committed M2.03/M2.05/M3.10 staged-profile behavior passed in M2.08 workflow `43303353328` on `141836a145be045d20f27a0c7746c0c7bf96086f`. Real provider activation remains disabled pending M4.14/M3.05 evidence. ## Integração de schema diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 4dcd9a2b..855ec453 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -49,3 +49,9 @@ Other targeted gates for the package are API/web typecheck, contract generation/ An independent review found that a request with the same fingerprint could be sent again after a local job reached `SUCCEEDED`. The UI now retains a terminal success marker without storing request text. On SHA `8470a9f9892e2f4729a4db84c9d138c0c96af253`, the focused web specs passed 8/8 and the web typecheck passed; `git diff --check` passed. The regression verifies that submitting the identical request after success, including after remount, makes no additional POST, while an edited request creates a new job with a different key. API code did not change in this correction, so API gates were not repeated. Review then identified that unbounded success markers would consume the 100-attempt capacity. SHA `a0e8d101d8cb0fd8a942645368d93f44aa1fa0d9` keeps only the latest success marker and prunes it when a different request is explicitly reserved, while preserving unresolved attempts. The same focused web harness passed 9/9 specs, including 101 sequential synthetic successful requests with one terminal marker retained; web typecheck and `git diff --check` passed. + +## Recomposition on the M5.03 migration base + +After M5.03/#60 merged into `desktop/milestone-m5`, this package was rebased onto milestone commit `41280f0f6aa61a14a985c76d499ce58d714cda0c`. The route-map and generated OpenAPI files were merged with the M5.03 contract; the API and Web package manifests retain both M5.03 and M5.04 focused scripts. No M5.04 migration was added. + +At source SHA `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, Node 24.22.2/npm 11.19.1 validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (7/7) and `npm run test:m5-04 --workspace=@ca-meta/web` (10/10, 0 failures). These local runs exercised the recomposed API/component/routes. The PR head's workspace typecheck, full test/build, contract gates, security and image checks remain bound to its GitHub Actions SHA. diff --git a/docs/delivery/M5/handoff.md b/docs/delivery/M5/handoff.md index a69355cb..f7a3237a 100644 --- a/docs/delivery/M5/handoff.md +++ b/docs/delivery/M5/handoff.md @@ -13,8 +13,8 @@ |---|---|---|---| | M5.01 / #43 | `desktop/m5-01` | `C:\Users\user\.codex\worktrees\m5-01-portal\ca_meta_app` | PR #153 squash-integrado em `ecc84b7299c5348ace015c9db7e71b48d6893c37`; review funcional/security PASS. Actions candidate `43080169376` e orchestrator `43080169281` SUCCESS. Ações tenant-scoped fail-closed sem identidade segura. Browser `/access` viu layout/foco, mas sessão não autenticada via proxy local. Issue permanece aberta. | | M5.02 / #56 | `desktop/m5-02` | `C:\Users\user\.codex\worktrees\m5-02-connections\ca_meta_app` | Recomposta sobre `58a7052c6c2d3c278a08fe371db350f4fdd896c9`; head candidato `841e2cbd1eadbd241909a64ab936155c4d081dfe`; PR #154 squash em `ab1c17581487775ca0ec86e7ae3fd82f7d9d2798`. API/Web focados 13/13 cada, Node 24.22.2/npm 11.19.1 em worktree de validação isolada. Review independente não encontrou bloqueio de isolamento/estado; refresh/job permanece PENDING. Actions candidate `43273173302`, planning `43273173611`, M1.07 `43273173142`, orchestrator `43273173516` SUCCESS. O detalhe da API `POST /assets/:id/refresh` falha 503 sem adapter, e a UI não chama refresh/job; identidade M1.08 também permanece ausente. Issue aberta; não habilitar efeito externo. -| M5.03 / #60 | `desktop/m5-03` | `C:\Users\user\.codex\worktrees\m5-03-onboarding\ca_meta_app` | HEAD `865645380c1e37021875564b24b7ecde2d815a1e`; review independente PASS. Web focal 31/31 + typecheck; API/MariaDB focal 11/11 + typecheck no commit funcional. Migration precisa de reindex no empilhamento. | -| M5.04 / #58 | `desktop/m5-04` | `C:\Users\user\.codex\worktrees\m5-04-templates\ca_meta_app` | HEAD `018651339da408fbd70df83d5d929fcc2cfdbf3d`; review independente PASS. Web focal 9/9, typecheck e diff-check no commit de código; idempotência cobre reload, sucesso e limite de markers. | +| M5.03 / #60 | `desktop/m5-03` | `C:\Users\user\.codex\worktrees\m5-03-onboarding\ca_meta_app` | PR #155 em `141836a145be045d20f27a0c7746c0c7bf96086f` foi squash-integrado como `41280f0f6aa61a14a985c76d499ce58d714cda0c`. Planning `43303353426`, orchestrator `43303353552`, M1.07 `43303353309`, Desktop candidate `43303353328` SUCCESS; M2.03/M2.05/M3.10 MariaDB, candidate, security e imagens passaram. Issue #60 aberta por M3.05/M4.14, identidade e provider gates Not confirmed. | +| M5.04 / #58 | `desktop/m5-04` | `C:\Users\user\.codex\worktrees\m5-04-templates\ca_meta_app` | HEAD `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, rebaseado sobre milestone após #60. API focal 7/7 e Web/routes 10/10 em Node 24.22.2/npm 11.19.1; manifest conflicts resolvidos preservando os scripts M5.03/M5.04. Sem migration própria. Review do SHA recombinado e PR/Actions pendentes. | | M5.05 / #59 | `desktop/m5-05` | `C:\Users\user\.codex\worktrees\m5-05-messages\ca_meta_app` | HEAD `c74a35de8117808d983b4993abdde9b663cee8b1`; review independente PASS, incluindo rejeição tardia após troca de tenant. Web focal 9/9, typecheck/diff-check; API/MariaDB já validados. Migration precisa ser reindexada ao empilhar. | | M5.07 / #62 | `desktop/m5-07` | `C:\Users\user\.codex\worktrees\m5-07-service-bridge\ca_meta_app` | PR #152 squash-integrado em `58a7052c6c2d3c278a08fe371db350f4fdd896c9`; review funcional/security PASS. Build/API, 16 focados, M5.07 32/32, M2.03/MariaDB e backup/restore passaram. Actions rerun SUCCESS: candidate `43063578817`, planning `43063578837`, M1.07 `43063578825`, orchestrator `43063578813`. Consumidores PHP permanecem `NOT_MIGRATED`; issue aberta. | | M5.06 / #61 | `desktop/m5-06` | `C:\Users\user\.codex\worktrees\m5-06-notifications\ca_meta_app` | HEAD `87b6e456b96dbd4c083c7d1ce7ac047c645953e6`; security review PASS no código anterior sem efeito desta correção. O bug P2 do cursor `DATETIME(6)` foi corrigido e re-revisado independentemente PASS; harness MariaDB focal 5/5, API typecheck e diff-check PASS no SHA. Adapter externo permanece desabilitado. | @@ -35,12 +35,12 @@ M5.01 e M5.07 começaram em `c3e9baa`; M5.02 foi criada a partir do candidato M5 ## Etapa e próxima ação -- PR #153/#43 e #152/#62 foram squash-integrados; os cinco reruns autorizados passaram e estão registrados por run ID nas linhas das packages. PR #154/#56 também foi squash-integrado após os quatro workflows próprios passarem. Branch da milestone antes desta atualização documental: `ab1c17581487775ca0ec86e7ae3fd82f7d9d2798`; os três merge commits remotos verificados são #153 `ecc84b7`, #152 `58a7052` e #154 `ab1c175`. Branch list informa `protected:false`; API de rulesets retorna 403, regras adicionais **Not confirmed**. Os PRs não fecharam as issues. +- PRs #153/#43, #152/#62, #154/#56 e #155/#60 foram squash-integrados. PR #155 trouxe o milestone de `c5cdab7` para `41280f0f6aa61a14a985c76d499ce58d714cda0c`; no head final, planning `43303353426`, orchestrator `43303353552`, M1.07 `43303353309` e Desktop candidate `43303353328` SUCCESS. O candidate incluiu M2.03/M2.05/M3.10 MariaDB, segurança, imagens e suites/build/typecheck. Branch list informa `protected:false`; API de rulesets retorna 403, regras adicionais **Not confirmed**. As issues permanecem abertas até seus critérios externos ou restantes serem atendidos. - M5.02/#56 está integrada como implementação parcial. A candidata foi recomposta em `841e2cb` e squash em `ab1c175`; seus testes focados API/Web e os workflows Actions passaram. A atualização manual e o acompanhamento do job seguem pendentes: a tela não chama `refresh()`/`job()`, o adapter de M4.12 não está registrado e a API retorna 503. A identidade empresarial segura de M1.08 também falta; mantenha ações indisponíveis e #56 aberta. -- M5.03/#60, #58 e #59 têm candidatos e reviews descritos em `progress.md`; #60 migration precisa de slot v22 no empilhamento. Não marcar como entregue em develop. -- As branches #58/#59/#60 partem de bases históricas empilhadas. Não abrir PR diretamente contra milestone antes da recomposição após dependências: criar branch limpa sobre base milestone atualizada e cherry-pick somente commits do pacote; revisar SHA e repetir gates afetados. -- Colisões de migration: reservar v21 para M5.07/#62, v22 para #60 e v23 para #59 após conferir ledger/checksums e reexecutar somente migrations/harnesses afetados. -- Próximas ações: recompose #60/#58/#59 sobre o milestone atualizado; preservar/reindexar migrations na ordem v22 (#60), v23 (#59), v24 (#61), v25 (#63), v26 (#64). Criar PRs depois dos testes focalizados/reviews do SHA recombinado; aguardar Actions e squash por pacote, verificando o SHA remoto antes de liberar dependentes. #56 permanece aberta pelo critério refresh/job e gates de identidade/adapter; as outras issues não são fechadas antes do aceite final em `develop`. Concluir #61/#63/#64/#65 na ordem técnica, atualizar a tabela de todas as issues e preparar o PR consolidado para `develop`. Nenhuma importação real, piloto, envio ou subscription foi executada. +- #60 está integrada na milestone em v22. #58 foi rebaseada em `f1c6e19`; os focados API 7/7 e Web 10/10 passaram, e nenhum schema foi adicionado. Revisão independente e PR/CI são as próximas etapas imediatas. +- #59 depende funcionalmente de #58/#56 e reserva v23; seu candidato local `c74a35d` ainda não foi recombinado com milestone atual. Atualizar os helpers M2.05/M3.10 já garante que a pausa table-grant continue ancorada na v22 quando v23+ entrarem. +- Colisões de migration: v21 pertence a M5.07/#62, v22 a #60, v23 a #59, v24 a #61, v25 a #63 e v26 a #64. Preservar ledger/checksums e repetir somente gates afetados após reindexamento. +- Próximas ações: concluir review e PR/Actions/squash de #58; então recompor #59 e seguir em ordem #61/#63/#64/#65, atualizando a tabela de issues e validando cada SHA antes de liberar dependentes. #56 permanece aberta por refresh/job e gates de identidade/adapter; #60 permanece aberta pelos gates externos indicados. Não fechar issues antes do aceite final em `develop`. Nenhuma importação real, piloto, envio ou subscription foi executada. - Project #12 não está acessível pelo conector e segue **Not confirmed**. Não fechar issues nem marcar piloto/shadow real. Manter progress.md sincronizado e salvar cada gate no SHA exato. - Worktrees reutilizam `node_modules` do checkout base; o runtime preparado é Node 24.22.2/npm 11.19.1 em `C:\Users\user\AppData\Local\Temp\ca-meta-m5-node-runtime-20261111`; o M5.09 fix focal também passou com Node 24.22.3/npm 11.19.1 via npx. Os previews sintéticos M5.02/M5.10 estão parados. Evitar repetir suítes já aprovadas; reexecutar apenas checks afetados por SHA/reindex/integração. diff --git a/docs/delivery/M5/progress.md b/docs/delivery/M5/progress.md index 5f890c20..a3158b1d 100644 --- a/docs/delivery/M5/progress.md +++ b/docs/delivery/M5/progress.md @@ -16,8 +16,8 @@ |---|---|---|---|---|---| | M5.01 / #43 | integrada por squash na milestone | `d2ccb6227c50b00c5bdfc55642f42381eb5831b1` | PR #153 squash em `ecc84b7299c5348ace015c9db7e71b48d6893c37`. Review funcional e segurança PASS no candidato. Desktop candidate `43080169376` e orchestrator `43080169281` SUCCESS. Visual `/access` desktop/mobile confirmou layout/foco, mas autenticação não foi confirmada pelo proxy local (`api` DNS). Identidade empresarial/M1.08 permanece não confirmada. | [#153](https://github.com/costaapps/ca_meta_app/pull/153) | manter issue aberta até gates de identidade/fluxo real; sem declarar completa | | M5.02 / #56 | integrada por squash com critério pendente | candidato recomposicionado `841e2cbd1eadbd241909a64ab936155c4d081dfe`; squash `ab1c17581487775ca0ec86e7ae3fd82f7d9d2798` | PR #154; API e Web focados 13/13 cada em Node 24.22.2/npm 11.19.1, worktree isolada. Review independente no SHA exato: isolamento, IDs, filtros, UNKNOWN/stale e invalidação de sessão sem bloqueador; refresh/job permanece PENDING. Actions planning `43273173611`, M1.07 `43273173142`, orchestrator `43273173516` e Desktop candidate `43273173302` SUCCESS. Walkthrough visual/teclado/mobile anterior em `d4fbffb`, não repetido após recomposição. | [#154](https://github.com/costaapps/ca_meta_app/pull/154) | implementar refresh/job quando identidade M1.08 e adapter M4.12 puderem ser habilitados; manter #56 aberta | -| M5.03 / #60 | candidata local revisada | `865645380c1e37021875564b24b7ecde2d815a1e` (código em `71336c94ca02f483e3a355c73dd6e6b60592db60`) | Review independente PASS. API/MariaDB 11/11 + API typecheck e web 31/31 + typecheck no SHA de código; cobre callbacks fora de ordem e abas concorrentes. Reserva/reindex de migration segue necessário no empilhamento; M3.05/M4.14 e grants Meta reais Not confirmed. | não criado | preservar candidato e integrar somente após dependências e checks/Actions ficarem disponíveis | -| M5.04 / #58 | candidata local revisada | `018651339da408fbd70df83d5d929fcc2cfdbf3d` (código em `a0e8d101d8cb0fd8a942645368d93f44aa1fa0d9`) | Review independente PASS. Web focal 9/9, typecheck e diff-check no SHA de código; retém chave após resposta 202 perdida, bloqueia POST repetido após sucesso/remount e mantém bounded success marker. Eligibility e chamadas Meta reais permanecem Not confirmed. | não criado | integrar após #56 e checks/Actions; manter adapters externos fail-closed | +| M5.03 / #60 | integrada por squash na milestone; issue aberta por gates externos | PR head `141836a145be045d20f27a0c7746c0c7bf96086f`; squash `41280f0f6aa61a14a985c76d499ce58d714cda0c` | Planning `43303353426`, orchestrator `43303353552`, M1.07 `43303353309` e Desktop candidate `43303353328` SUCCESS. MariaDB M2.03/M2.05/M3.10, desktop-candidate, security e imagens passaram. API/MariaDB focal 11/11, API typecheck, Web 31/31/typecheck permanecem evidência dos SHAs registrados acima. M3.05/M4.14, grants Meta reais e identidade da empresa Not confirmed. | [#155](https://github.com/costaapps/ca_meta_app/pull/155) | manter #60 aberta até evidência externa de happy path/recuperação e identidade; trabalhar #58/#59 | +| M5.04 / #58 | rebaseado; validação focal concluída, review/PR pendentes | `f1c6e19a0bcb171c977434840118a2b7b8201b3a` sobre squash #60 `41280f0f6aa61a14a985c76d499ce58d714cda0c` | Node 24.22.2/npm 11.19.1: API focal 7/7 e Web/routes 10/10 após recomposição; route map e OpenAPI gerado foram combinados com M5.03, scripts dos dois pacotes preservados. Nenhuma migration M5.04. Revisão independente do SHA recombinado e CI ficam pendentes. Eligibility e chamadas Meta reais permanecem Not confirmed. | não criado | revisão focal no SHA final, abrir PR para milestone e acompanhar workflows; manter adapters fail-closed | | M5.05 / #59 | candidata local revisada | `c74a35de8117808d983b4993abdde9b663cee8b1` (código/testes em `7369c9a1174b829df58a538cd237c3168dffeaf2`) | Review independente PASS, incluindo sucesso e rejeição de PATCH tardios após troca de tenant. Web focal 9/9, typecheck e diff-check; API/MariaDB anterior validada. DDL/consumidores externos permanecem Not confirmed; migration precisa de reindexamento no empilhamento. | não criado | usar como dependência local de #61/#63; recompor migration antes do PR | | M5.06 / #61 | fix e re-review PASS | `87b6e456b96dbd4c083c7d1ce7ac047c645953e6` | Re-review independente apontou perda de microssegundos no cursor de `DATETIME(6)`. Fix preserva seis dígitos ao serializar/validar/consultar cursor e mantém cursores antigos em milissegundos. Teste focal MariaDB usa linhas separadas por 1 μs e confirma que página 2 não repete a primeira; harness 5/5, API typecheck e diff-check passaram no SHA. Re-review independente da correção PASS. Segurança previamente PASS em `f09fe39`; runtime externo segue desabilitado. | não criado | aguardar dependências e gates de integração; sem novo código de segurança em relação ao SHA revisto | | M5.07 / #62 | integrada por squash na milestone | `9314a002707d29e98dd343b9cb8775fa5a7edcce` | PR #152 squash em `58a7052c6c2d3c278a08fe371db350f4fdd896c9`. v1 preservado; bridge tipada v2 + ADR-021. Review funcional/segurança PASS. 16 focados, harness 32/32 e M2.03/MariaDB 11.8.8 + backup/restore passaram. Actions rerun: Desktop candidate `43063578817`, planning `43063578837`, M1.07 `43063578825`, orchestrator `43063578813` SUCCESS. Consumidores PHP permanecem `NOT_MIGRATED`; evidência real Not confirmed. | [#152](https://github.com/costaapps/ca_meta_app/pull/152) | manter issue aberta até consumidores e gates externos; sem declarar completa | From 3513cb25be7df734024fef8baa50dcd726150dee Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:27:38 -0300 Subject: [PATCH 09/17] feat(m5-04): preserve template listing and dedupe creates --- .../whatsapp-templates.service.ts | 83 +++++++++++++++---- .../api/test/m5-04-whatsapp-templates.spec.ts | 69 +++++++++++++-- .../portal/whatsapp-templates.component.html | 8 +- .../portal/whatsapp-templates.component.scss | 3 + .../whatsapp-templates.component.spec.ts | 19 ++++- .../portal/whatsapp-templates.component.ts | 9 +- docs/delivery/M5/M5.04.md | 10 ++- docs/meta-v2/contracts/openapi.yaml | 28 +++---- packages/contracts/openapi.yaml | 34 ++++---- packages/contracts/src/generated/openapi.d.ts | 16 ++-- tools/m1-07/src/generated/openapi.d.ts | 16 ++-- 11 files changed, 213 insertions(+), 82 deletions(-) diff --git a/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts index e406bf84..5dc28762 100644 --- a/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts +++ b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts @@ -8,6 +8,7 @@ import { isVerifiedServiceTenantScope, TenantAuthorizationService, TenantContext import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js'; import type { DurableWorkDatabase } from '../jobs/durable-work-database.js'; import type { DurableJobStore } from '../jobs/job-store.js'; +import { canonicalJson, parseJobPayload } from '../jobs/event-validation.js'; import { TemplateInputError, parseTemplateCreateRequest, type TemplateCreateRequest } from './template-body.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; @@ -31,7 +32,7 @@ export interface WhatsAppTemplatesAccountDirectory { export interface WhatsAppTemplatesProvider { list(account: WhatsAppTemplatesAccount, input: { readonly cursor?: string; readonly limit: number; readonly requestId: string }): - Promise; + Promise; } export const META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY = Symbol('META_WHATSAPP_TEMPLATES_ACCOUNT_DIRECTORY'); @@ -135,22 +136,57 @@ export class WhatsAppTemplatesService { const { scope, account } = await this.authorizedAccount(session, tenantContextRef, assetId, 'CONNECT'); if (!this.createEnabled) throw new WhatsAppTemplatesUnavailableError(); - const digest = createHash('sha256').update(idempotencyKey, 'utf8').digest('hex'); - const correlationId = stableCorrelationId(scope.tenantId, digest); + const keyDigest = createHash('sha256').update(idempotencyKey, 'utf8').digest('hex'); + const idempotencyRef = `templates:${keyDigest}`; + const correlationId = stableCorrelationId(scope.tenantId, keyDigest); + const payload = parseJobPayload('TEMPLATE_CREATE', { kind: 'TEMPLATE_CREATE', assetId: account.assetId, + appConfigurationId: account.appConfigurationId, environment: account.environment, request }); + const payloadHash = createHash('sha256').update(canonicalJson(payload), 'utf8').digest(); const dataSource = await this.database.getDataSource(); let jobId: string; try { - const queued = await dataSource.transaction((manager) => this.jobs.enqueueWithin(manager, { - tenantId: scope.tenantId, - type: 'TEMPLATE_CREATE', - idempotencyKey: `templates:${digest}`, - correlationId, - resourceScopeRef: account.assetId, - payload: { kind: 'TEMPLATE_CREATE', assetId: account.assetId, - appConfigurationId: account.appConfigurationId, environment: account.environment, request }, - })); - jobId = queued.id; + jobId = await dataSource.transaction(async (manager) => { + // Serialize all template-create submissions for this tenant before checking key and payload indexes. + const tenantRows = await manager.query('SELECT id FROM ca_meta_tenants WHERE id = ? FOR UPDATE', [scope.tenantId]) as Array<{ id: string }>; + if (tenantRows.length !== 1) throw new Error('Internal tenant reference does not exist.'); + + // A caller must not evade same-key/different-payload conflicts through semantic deduplication. + const keyed = await manager.query( + `SELECT id, HEX(payload_hash) AS payload_hash, correlation_id, causation_id, resource_scope_ref + FROM ca_meta_jobs WHERE tenant_id = ? AND job_type = 'TEMPLATE_CREATE' AND idempotency_key = ? FOR UPDATE`, + [scope.tenantId, idempotencyRef], + ) as Array<{ id: string; payload_hash: string; correlation_id: string; causation_id: string | null; resource_scope_ref: string }>; + const keyedJob = keyed[0]; + if (keyedJob) { + if (keyedJob.payload_hash.toLowerCase() !== payloadHash.toString('hex') || + keyedJob.correlation_id !== correlationId || keyedJob.causation_id !== null || + keyedJob.resource_scope_ref !== account.assetId) throw new WhatsAppTemplateIdempotencyConflictError(); + return keyedJob.id; + } + + // Same-body requests from separate tabs can have different keys. Reuse jobs whose effects may + // still be pending, ambiguous, or known accepted; confirmed failures may be explicitly retried. + const semantic = await manager.query( + `SELECT id FROM ca_meta_jobs + WHERE tenant_id = ? AND job_type = 'TEMPLATE_CREATE' AND resource_scope_ref = ? AND payload_hash = ? + AND status IN ('QUEUED', 'RUNNING', 'RETRY_PENDING', 'UNKNOWN', 'SUCCEEDED') + ORDER BY created_at DESC, id DESC LIMIT 1 FOR UPDATE`, + [scope.tenantId, account.assetId, payloadHash], + ) as Array<{ id: string }>; + if (semantic[0]) return semantic[0].id; + + const queued = await this.jobs.enqueueWithin(manager, { + tenantId: scope.tenantId, + type: 'TEMPLATE_CREATE', + idempotencyKey: idempotencyRef, + correlationId, + resourceScopeRef: account.assetId, + payload, + }); + return queued.id; + }); } catch (error) { + if (error instanceof WhatsAppTemplateIdempotencyConflictError) throw error; if (error instanceof Error && error.message === 'Idempotency key conflicts with an existing job.') { throw new WhatsAppTemplateIdempotencyConflictError(); } @@ -197,13 +233,30 @@ function sanitizeTemplatePage(value: unknown, requestId: string): components['sc typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt))) { throw new WhatsAppTemplatesUnavailableError(); } - // Exact current provider status/category meanings are Not confirmed; do not infer approval. - items.push(Object.freeze({ id: item.id, name: item.name, language: item.language, status: 'UNKNOWN', category: 'UNKNOWN', + const status = observedLabel(item.status); + const category = observedLabel(item.category); + const bodyText = observedBodyText(item); + // Keep provider labels as observations. They do not establish approval or local category semantics. + items.push(Object.freeze({ id: item.id, name: item.name, language: item.language, status, category, bodyText, updatedAt: new Date(item.updatedAt).toISOString() })); } return Object.freeze({ items: Object.freeze(items), nextCursor: value.nextCursor as string | null, requestId }); } +function observedLabel(value: unknown): string { + return typeof value === 'string' && value.trim().length > 0 && value.length <= 64 && + !/[\u0000-\u001f\u007f]/.test(value) ? value : 'UNKNOWN'; +} + +function observedBodyText(item: Record): string | null { + const direct = item.bodyText; + const components = Array.isArray(item.components) ? item.components : []; + const body = components.find((component) => record(component) && component.type === 'BODY'); + const value = typeof direct === 'string' ? direct : record(body) ? body.text : undefined; + return typeof value === 'string' && value.length >= 1 && value.length <= 1024 && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ? value : null; +} + function stableCorrelationId(tenantId: string, digest: string): string { const bytes = createHash('sha256').update(`TEMPLATE_CREATE\0${tenantId}\0${digest}`).digest(); const hex = bytes.toString('hex').slice(0, 32).split(''); diff --git a/apps/api/test/m5-04-whatsapp-templates.spec.ts b/apps/api/test/m5-04-whatsapp-templates.spec.ts index 2beda09f..f6adee86 100644 --- a/apps/api/test/m5-04-whatsapp-templates.spec.ts +++ b/apps/api/test/m5-04-whatsapp-templates.spec.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { test } from 'node:test'; import { IdentityDeniedError } from '../src/identity/identity.errors.js'; import { PortalSessionVerifier, type VerifiedPortalSession } from '../src/identity/identity.trust.js'; @@ -11,6 +12,7 @@ import { submitAndReconcileTemplateCreate, type WhatsAppTemplateDispatchAdapter, import type { AuthorizedTenantScope } from '../src/identity/identity.types.js'; import type { DurableWorkDatabase } from '../src/jobs/durable-work-database.js'; import type { DurableJobStore, EnqueueJobInput } from '../src/jobs/job-store.js'; +import { canonicalJson, parseJobPayload } from '../src/jobs/event-validation.js'; import type { WhatsAppTemplatesAccount } from '../src/whatsapp-templates/whatsapp-templates.service.js'; const tenantId = '00000000-0000-4000-8000-000000000001'; @@ -44,9 +46,36 @@ function setup(options: { readonly currentTenant?: string; readonly allow?: bool readonly createEnabled?: boolean; readonly accountFor?: (tenant: string, assetRef: string) => WhatsAppTemplatesAccount | undefined } = {}) { const enqueued = new Map(); + const jobStatuses = new Map(); + const queryLog: string[] = []; let insertionCount = 0; let transactionTail = Promise.resolve(); - const manager = {}; + const payloadHash = (input: EnqueueJobInput): string => createHash('sha256') + .update(canonicalJson(parseJobPayload(input.type, input.payload)), 'utf8').digest('hex'); + const rowFor = (job: { readonly id: string; readonly input: EnqueueJobInput }) => ({ id: job.id, + payload_hash: payloadHash(job.input), correlation_id: job.input.correlationId, + causation_id: job.input.causationId ?? null, resource_scope_ref: job.input.resourceScopeRef ?? '' }); + const manager = { + async query(statement: string, parameters: readonly unknown[] = []): Promise { + queryLog.push(statement); + if (statement.includes('SELECT id FROM ca_meta_tenants')) return [{ id: parameters[0] }]; + if (statement.includes('AND idempotency_key = ? FOR UPDATE')) { + const [tenant, idempotencyKey] = parameters; + const found = [...enqueued.values()].find(({ input }) => input.tenantId === tenant && input.type === 'TEMPLATE_CREATE' && + input.idempotencyKey === idempotencyKey); + return found ? [rowFor(found)] : []; + } + if (statement.includes('payload_hash = ?') && statement.includes('status IN')) { + const [tenant, resource, hash] = parameters; + const hashHex = Buffer.isBuffer(hash) ? hash.toString('hex') : String(hash); + return [...enqueued.values()] + .filter(({ id, input }) => input.tenantId === tenant && input.type === 'TEMPLATE_CREATE' && input.resourceScopeRef === resource && + payloadHash(input) === hashHex && ['QUEUED', 'RUNNING', 'RETRY_PENDING', 'UNKNOWN', 'SUCCEEDED'].includes(jobStatuses.get(id) ?? 'QUEUED')) + .map(({ id }) => ({ id })); + } + throw new Error(`Unexpected synthetic query: ${statement}`); + }, + }; const databaseSource = { async transaction(work: (transactionManager: object) => Promise): Promise { const result = transactionTail.then(() => work(manager)); @@ -67,6 +96,7 @@ function setup(options: { readonly currentTenant?: string; readonly allow?: bool } const id = `job_template_${++insertionCount}`; enqueued.set(key, { id, input }); + jobStatuses.set(id, 'QUEUED'); return { id, duplicate: false }; }, } as unknown as DurableJobStore; @@ -75,7 +105,8 @@ function setup(options: { readonly currentTenant?: string; readonly allow?: bool async list(target: WhatsAppTemplatesAccount, input: { readonly cursor?: string; readonly limit: number }) { calls.push({ account: target, ...input }); return { items: [{ id: 'provider-template-1', name: 'order_update', language: 'en', status: 'APPROVED', - category: 'UTILITY', updatedAt: '2026-11-10T12:00:00Z' }], nextCursor: null, requestId: 'discarded' }; + category: 'UTILITY', components: [{ type: 'BODY', text: 'Hello {{1}}, code {{2}} and again {{1}}', + example: { body_text: [['PRIVATE PROVIDER EXAMPLE']] } }], updatedAt: '2026-11-10T12:00:00Z' }], nextCursor: null, requestId: 'discarded' }; }, }; const tenant = options.currentTenant ?? tenantId; @@ -93,7 +124,7 @@ function setup(options: { readonly currentTenant?: string; readonly allow?: bool authorizedScope.tenantId === tenantId && assetRef === assetId ? account : undefined; }, async resolveForWorker() { return undefined; } }; const service = new WhatsAppTemplatesService(contexts, authorization, accounts, provider as never, options.createEnabled ?? true, database, jobs); - return { service, calls, enqueued, get insertions() { return insertionCount; } }; + return { service, calls, enqueued, queryLog, jobStatuses, get insertions() { return insertionCount; } }; } const baseBody = Object.freeze({ name: 'order_update', language: 'en', category: 'UTILITY', @@ -131,13 +162,16 @@ test('M5.04 account directory constrains lookup to the tenant WABA and active ve assert.match(sql, /g\.grant_name = 'whatsapp_business_management' AND g\.status = 'GRANTED'/); }); -test('M5.04 listing scopes the internal WABA, forwards pagination and maps unknown provider fields safely', async () => { +test('M5.04 listing preserves observed labels and sanitized BODY text while omitting examples', async () => { const fixture = setup(); const page = await fixture.service.list(await sessionFor(), contextRef, assetId, { cursor: 'opaque/+cursor=', limit: 23 }, 'req-list-01'); assert.deepEqual(fixture.calls[0], { account, cursor: 'opaque/+cursor=', limit: 23, requestId: 'req-list-01' }); assert.equal(page.requestId, 'req-list-01'); - assert.equal(page.items[0]?.status, 'UNKNOWN'); - assert.equal(page.items[0]?.category, 'UNKNOWN'); + assert.equal(page.items[0]?.status, 'APPROVED'); + assert.equal(page.items[0]?.category, 'UTILITY'); + assert.equal(page.items[0]?.bodyText, 'Hello {{1}}, code {{2}} and again {{1}}'); + assert.equal('components' in (page.items[0] ?? {}), false); + assert.equal(JSON.stringify(page.items).includes('PRIVATE PROVIDER EXAMPLE'), false); const session = await sessionFor(); await assert.rejects(() => fixture.service.list(session, contextRef, assetId, { cursor: 'bad cursor', limit: 23 }, 'req-list-02'), TemplateInputError); assert.equal(fixture.calls.length, 1); @@ -173,14 +207,37 @@ test('M5.04 create uses tenant-scoped durable idempotency sequentially and concu const repeated = await submit(); const [concurrentA, concurrentB] = await Promise.all([submit(), submit()]); assert.deepEqual([first.jobId, repeated.jobId, concurrentA.jobId, concurrentB.jobId], Array(4).fill(first.jobId)); + const otherTab = await fixture.service.create(session, contextRef, assetId, 'different-tab-template-key-0002', baseBody, 'req-create-tab-02'); + assert.equal(otherTab.jobId, first.jobId); assert.equal(fixture.insertions, 1); assert.equal(fixture.enqueued.values().next().value?.input.type, 'TEMPLATE_CREATE'); + fixture.queryLog.length = 0; await assert.rejects(() => fixture.service.create(session, contextRef, assetId, 'stable-template-create-key-0001', { ...baseBody, bodyText: 'Different {{1}} body {{2}}' }, 'req-create-02'), WhatsAppTemplateIdempotencyConflictError); + assert.equal(fixture.queryLog.some((sql) => sql.includes('payload_hash = ?') && sql.includes('status IN')), false, + 'same-key payload conflicts must be resolved before semantic deduplication'); await assert.rejects(() => fixture.service.create(session, contextRef, assetId, 'stable-template-create-key-0001', { ...baseBody, cUUID: 'forbidden' }, 'req-create-03'), TemplateInputError); }); +test('M5.04 semantic create dedupe is atomic across tabs for active, UNKNOWN and SUCCEEDED jobs', async () => { + for (const status of ['QUEUED', 'RUNNING', 'RETRY_PENDING', 'UNKNOWN', 'SUCCEEDED']) { + const fixture = setup(); + const session = await sessionFor(); + const original = await fixture.service.create(session, contextRef, assetId, `template-key-original-${status.toLowerCase()}`, baseBody, + `req-create-${status.toLowerCase()}-01`); + fixture.jobStatuses.set(original.jobId, status); + const [tabA, tabB] = await Promise.all([ + fixture.service.create(session, contextRef, assetId, `template-key-tab-a-${status.toLowerCase()}`, baseBody, + `req-create-${status.toLowerCase()}-02`), + fixture.service.create(session, contextRef, assetId, `template-key-tab-b-${status.toLowerCase()}`, baseBody, + `req-create-${status.toLowerCase()}-03`), + ]); + assert.deepEqual([tabA.jobId, tabB.jobId], [original.jobId, original.jobId], `${status} must reuse the existing job`); + assert.equal(fixture.insertions, 1, `${status} must not enqueue another durable job`); + } +}); + test('M5.04 read-only reconciliation handles accepted, rejected, null and ambiguous outcomes without retry', async () => { const input: WhatsAppTemplateDispatchInput = { tenantId, assetId, appConfigurationId, environment: 'test', request: parseTemplateCreateRequest(baseBody), serviceScope: {} as WhatsAppTemplateDispatchInput['serviceScope'], correlationId: '00000000-0000-4000-8000-000000000006' }; diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.html b/apps/web/src/app/features/portal/whatsapp-templates.component.html index 15292299..6f6f3dc6 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.html +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.html @@ -36,17 +36,23 @@

Modelos existentes

Consultando modelos…

Esta conta não retornou modelos nesta página.

+

Estado e categoria são valores informados pelo provedor; o estado não confirma aprovação do modelo.

{{ item.name }}

- {{ item.status }} + Estado informado: {{ item.status }}
Idioma
{{ item.language }}
Categoria informada
{{ item.category }}
Última atualização observada
{{ item.updatedAt }}
+
Texto BODY
{{ item.bodyText ?? 'Texto BODY não disponível.' }}
+
+
Marcadores do BODY
+
{{ getBodyPlaceholders(item.bodyText).join(', ') }}
+
diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.scss b/apps/web/src/app/features/portal/whatsapp-templates.component.scss index 1602c0e7..c012fe46 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.scss +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.scss @@ -46,10 +46,13 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus- .template-list { display: grid; gap: .75rem; } .template-card { padding: 1rem; border: 1px solid #e1e6ea; border-radius: 6px; } .status { padding: .2rem .5rem; border-radius: 20px; background: #f1f4f6; color: #3a4650; font-size: .8rem; } +.observation-note { color: #63717c; font-size: .9rem; } dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; margin: .8rem 0 0; } dl div { min-width: 0; } +.template-body-field { grid-column: 1 / -1; } dt { color: #63717c; font-size: .78rem; } dd { overflow-wrap: anywhere; margin: .15rem 0 0; } +.template-body { white-space: pre-wrap; } .pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin: 1rem 0; } .support-details { margin: .8rem 0; color: #63717c; } diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts index 2aeeff2a..5b607652 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -100,7 +100,21 @@ describe('WhatsAppTemplatesComponent', () => { expect(state.create).toHaveBeenCalledWith(asset.id, { name: 'order_update', language: 'en', category: 'UTILITY', bodyText: 'Hello {{1}}, code {{2}} and again {{1}}', exampleValues: ['Ana', '1234'], }, expect.stringMatching(/^[A-Za-z0-9._~-]{16,128}$/)); - expect(state.component.statusMessage).toContain('não confirma aprovação do modelo pela Meta'); + expect(state.job).toHaveBeenCalledWith('job_template_01'); + expect(state.component.jobStatus?.status).toBe('UNKNOWN'); + expect(state.component.statusMessage).toContain('não libera outro pedido igual'); + expect(state.component.statusMessage).toContain('não confirma aprovação da Meta'); + state.component.ngOnDestroy(); + }); + + it('keeps the BODY text and unique placeholders available to the list view', async () => { + const state = await createFixture(); + const bodyText = 'Hello {{1}}, code {{2}} and again {{1}}'; + state.component.templates = [{ id: 'provider-template-1', name: 'order_update', language: 'en', status: 'APPROVED', + category: 'UTILITY', bodyText, updatedAt: '2026-11-10T12:00:00.000Z' }]; + expect(state.component.templates[0]?.bodyText).toBe(bodyText); + expect(state.component.getBodyPlaceholders(state.component.templates[0]?.bodyText ?? null)).toEqual(['{{1}}', '{{2}}']); + expect(state.component.getBodyPlaceholders(null)).toEqual([]); state.component.ngOnDestroy(); }); @@ -115,9 +129,8 @@ describe('WhatsAppTemplatesComponent', () => { expect(state.component.formError).toContain('Consulte o status'); await state.component.createTemplate(); expect(create.mock.calls[0]?.[2]).toBe(create.mock.calls[1]?.[2]); - await state.component.checkJobStatus(); expect(state.component.jobStatus?.status).toBe('UNKNOWN'); - expect(state.component.statusMessage).toContain('resultado ainda não está confirmado'); + expect(state.component.statusMessage).toContain('não libera outro pedido igual'); expect(sessionStorage.getItem(PENDING_WHATSAPP_TEMPLATE_ATTEMPTS_STORAGE_KEY)).not.toBeNull(); state.component.ngOnDestroy(); }); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.ts index aa7df378..90559077 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.ts @@ -169,6 +169,9 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { this.jobStatus = null; this.jobStatusError = ''; this.statusMessage = 'Pedido enviado para processamento. A aceitação da solicitação não confirma aprovação do modelo pela Meta.'; + // A 202 response acknowledges the durable request; the existing job may already be terminal + // when this call reused an idempotent or semantically equivalent submission. + await this.checkJobStatus(); } catch (error: unknown) { if (generation !== this.accountGeneration || assetId !== this.selectedAssetId) return; if (!dispatched) { @@ -197,7 +200,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { if (this.jobStatus.status === 'SUCCEEDED') { this.statusMessage = 'O pedido foi encaminhado ao provedor. O estado do trabalho não confirma aprovação do modelo pela Meta.'; } else if (this.jobStatus.status === 'UNKNOWN') { - this.statusMessage = 'O resultado ainda não está confirmado. Atualize a lista de modelos antes de fazer outro pedido.'; + this.statusMessage = 'O resultado continua inconclusivo e este estado não confirma aprovação da Meta. Atualizar a lista pode ajudar a verificar se o modelo apareceu, mas não libera outro pedido igual. Não reenvie este modelo; contate o suporte com o identificador do pedido para uma reconciliação segura.'; } else if (this.jobStatus.status === 'FAILED') { this.statusMessage = 'O pedido não foi concluído. Consulte os detalhes seguros do trabalho antes de tentar novamente.'; } else { @@ -221,6 +224,10 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { trackByAsset(_index: number, asset: TemplateAsset): string { return asset.id; } trackByTemplate(_index: number, item: TemplateRecord): string { return `${item.id}:${item.language}`; } + getBodyPlaceholders(bodyText: string | null): readonly string[] { + return Object.freeze([...new Set(bodyText?.match(/\{\{[1-9][0-9]{0,2}\}\}/g) ?? [])]); + } + private async loadAccounts(): Promise { const generation = ++this.accountGeneration; this.isLoadingAccounts = true; diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 855ec453..d655af58 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -12,9 +12,11 @@ No database migration or schema-version change was needed. `TEMPLATE_CREATE` use This package deliberately implements only one BODY component. The closed request accepts name, language, category, body text, and optional `exampleValues`; extra provider IDs or credentials are rejected. The local contract allows placeholders `{{1}}` through `{{N}}` in ascending first-occurrence order, allows a previously-seen placeholder to repeat, and requires one non-empty example for each distinct placeholder in order. A body without placeholders may omit examples or pass `[]`. This mapping from example values to BODY placeholders is **our local contract rule**, not an assertion about current Meta requirements. Current provider limits and accepted language/category values have not been confirmed; these conservative input bounds exist for this versioned API and must be reviewed against verified capability evidence before enabling dispatch. -The UI labels a `202` as a request accepted for processing and explicitly says that this does not confirm Meta approval. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result advises the operator to refresh the list before another request. +The template list preserves the bounded status and category labels returned by the provider as observations. The UI labels them as provider-reported values and says the status does not confirm approval. It returns sanitized BODY text, including literal placeholders, while omitting provider examples and other components. The UI labels a `202` as an acknowledgement that the durable request was accepted or an existing job reused; it immediately queries the job location to show its current local state. `PENDING` in the acknowledgement is not the job's current state. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result says refreshing the list may help check whether the model appeared, but does not release duplicate protection; the operator must contact support with the request identifier for safe reconciliation instead of resubmitting the same model. -For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the latest success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job, which prunes the prior success marker. At most one succeeded marker is retained, and up to 100 unresolved attempts remain bounded by the store limit. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. +For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the latest success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job, which prunes the prior success marker. At most one succeeded marker is retained, and up to 100 unresolved attempts remain bounded by the store limit. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. After every acknowledged POST the UI reads the job location so a cross-tab semantic match that is already `SUCCEEDED` or `UNKNOWN` is presented with its current state, not as newly pending. + +The API also serializes template-create submissions on the tenant row and semantically deduplicates the canonical payload within the same tenant and internal WABA. Requests with different browser keys reuse an existing `QUEUED`, `RUNNING`, `RETRY_PENDING`, `UNKNOWN`, or `SUCCEEDED` job; a confirmed terminal failure can be retried with a new key. A successful identical payload remains deduplicated, so creating a distinct template requires changing its payload (normally its name or BODY). This server-side rule protects separate tabs and callers; it is a local duplicate guard, not a Meta idempotency guarantee. The same-key/different-payload conflict still returns `409` before semantic deduplication. ## Provider evidence collected 2026-11-11 @@ -23,13 +25,13 @@ For retry safety across a page reload, the UI reserves the idempotency key in `s - [Meta: Fetch message templates for an assigned WABA](https://www.postman.com/meta/whatsapp-business-platform/request/f7o759z/fetch-message-templates) provides an official assigned-WABA list example. - [WhatsApp Business Messaging Policy](https://whatsappbusiness.com/policy/) applies to use of the feature. -Graph API version is configurable and **Not confirmed** here. Current exact provider enums/status meanings, template-name/language/category rules, placeholder/example constraints, active grants and account eligibility remain **Not confirmed**. The current Meta Developers component/overview pages returned HTTP 429 during discovery. No historic v13 enum, `ALERT_UPDATE`, `TICKET_UPDATE`, or approval behavior is copied into this contract. The API normalizes unknown provider status/category strings to local `UNKNOWN` and shows those values as observations only. +Graph API version is configurable and **Not confirmed** here. Current exact provider enums/status meanings, template-name/language/category rules, placeholder/example constraints, active grants and account eligibility remain **Not confirmed**. The current Meta Developers component/overview pages returned HTTP 429 during discovery. No historic v13 enum, `ALERT_UPDATE`, `TICKET_UPDATE`, or approval behavior is copied into this contract. Status and category are bounded provider-observed strings; the API does not map them to local Meta semantics or treat a status value as proof of approval. ## Security and delivery gates - Portal session, current tenant context, and `CONNECT` authorization are checked before account resolution. The internal asset selector also requires a tenant-matched WABA, active verified asset authorization, WhatsApp EXCLUSIVE / embedded-signup journey, and the candidate management grant. Exact Meta permission mapping remains **Not confirmed**. - List cursors and page size are bounded; results are normalized and returned with `Cache-Control: no-store`. Listing has no `channel`, number, provider ID, or caller-selected account filter beyond the internal WABA asset UUID. -- The POST body is schema/size bounded. Its idempotency key is scoped by tenant and operation, then hashed server-side; the durable job additionally hashes the canonical payload. A repeated key with different payload returns `409`. +- The POST body is schema/size bounded. Its idempotency key is scoped by tenant and operation, then hashed server-side; the durable job additionally hashes the canonical payload. A repeated key with different payload returns `409`. Under the tenant row lock, a matching key is checked before cross-key semantic dedupe; an identical resource and canonical payload reuses jobs in `QUEUED`, `RUNNING`, `RETRY_PENDING`, `UNKNOWN`, or `SUCCEEDED` state. - The worker's verified job context and current account grant are rechecked before credential resolution. A missing/unverified profile, credential, account, capability, or provider result cannot authorize a new submission. No automatic retry follows `UNKNOWN`. - M1.08 tenant context, M3.08 credential resolution, M4.04/M4.06 Graph operation/capability evidence, and M3.12 verified asset-grant projection remain prerequisites for enabling real adapters. Adapter admission requires a reviewed change that enables the API provider and worker handler together and supplies current evidence for operation capability and tenant eligibility. diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml index d6cdd8f7..4d284dff 100644 --- a/docs/meta-v2/contracts/openapi.yaml +++ b/docs/meta-v2/contracts/openapi.yaml @@ -4262,6 +4262,7 @@ components: - language - status - category + - bodyText - updatedAt properties: id: @@ -4278,23 +4279,20 @@ components: maxLength: 16 status: type: string - description: Local normalized vocabulary. Do not map a provider value until its current status and approval - semantics have been confirmed; current provider values and meanings are Not confirmed. - enum: - - PENDING - - APPROVED - - REJECTED - - PAUSED - - UNKNOWN + minLength: 1 + maxLength: 64 + description: Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. category: type: string - description: Local normalized vocabulary. Do not map a provider value until its current category mapping - has been confirmed; current provider values and meanings are Not confirmed. - enum: - - AUTHENTICATION - - MARKETING - - UTILITY - - UNKNOWN + minLength: 1 + maxLength: 64 + description: Bounded provider-observed label. It is returned without mapping to local category semantics. + bodyText: + type: + - string + - "null" + maxLength: 1024 + description: Sanitized BODY text, including any literal placeholders. Provider examples and other components are omitted; null means no safe BODY text was available. updatedAt: type: string format: date-time diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index b4585611..d4b8d034 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -4333,6 +4333,7 @@ components: - language - status - category + - bodyText - updatedAt properties: id: @@ -4349,25 +4350,24 @@ components: maxLength: 16 status: type: string - description: Local normalized vocabulary. Do not map a provider value until its - current status and approval semantics have been confirmed; current - provider values and meanings are Not confirmed. - enum: - - PENDING - - APPROVED - - REJECTED - - PAUSED - - UNKNOWN + minLength: 1 + maxLength: 64 + description: Bounded provider-observed label. It is returned without mapping to + local status semantics and does not establish approval. category: type: string - description: Local normalized vocabulary. Do not map a provider value until its - current category mapping has been confirmed; current provider values - and meanings are Not confirmed. - enum: - - AUTHENTICATION - - MARKETING - - UTILITY - - UNKNOWN + minLength: 1 + maxLength: 64 + description: Bounded provider-observed label. It is returned without mapping to + local category semantics. + bodyText: + type: + - string + - "null" + maxLength: 1024 + description: Sanitized BODY text, including any literal placeholders. Provider + examples and other components are omitted; null means no safe BODY + text was available. updatedAt: type: string format: date-time diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index e3f688ad..54f77788 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -1233,16 +1233,12 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** - * @description Local normalized vocabulary. Do not map a provider value until its current status and approval semantics have been confirmed; current provider values and meanings are Not confirmed. - * @enum {string} - */ - readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; - /** - * @description Local normalized vocabulary. Do not map a provider value until its current category mapping has been confirmed; current provider values and meanings are Not confirmed. - * @enum {string} - */ - readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; + /** @description Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. */ + readonly status: string; + /** @description Bounded provider-observed label. It is returned without mapping to local category semantics. */ + readonly category: string; + /** @description Sanitized BODY text, including any literal placeholders. Provider examples and other components are omitted; null means no safe BODY text was available. */ + readonly bodyText: string | null; /** Format: date-time */ readonly updatedAt: string; }; diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts index e3f688ad..54f77788 100644 --- a/tools/m1-07/src/generated/openapi.d.ts +++ b/tools/m1-07/src/generated/openapi.d.ts @@ -1233,16 +1233,12 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** - * @description Local normalized vocabulary. Do not map a provider value until its current status and approval semantics have been confirmed; current provider values and meanings are Not confirmed. - * @enum {string} - */ - readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; - /** - * @description Local normalized vocabulary. Do not map a provider value until its current category mapping has been confirmed; current provider values and meanings are Not confirmed. - * @enum {string} - */ - readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; + /** @description Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. */ + readonly status: string; + /** @description Bounded provider-observed label. It is returned without mapping to local category semantics. */ + readonly category: string; + /** @description Sanitized BODY text, including any literal placeholders. Provider examples and other components are omitted; null means no safe BODY text was available. */ + readonly bodyText: string | null; /** Format: date-time */ readonly updatedAt: string; }; From 5559ac7349e5df8bb45fedbcf91d64ed71c51292 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:28:12 -0300 Subject: [PATCH 10/17] docs(m5-04): record focused review and validation --- docs/delivery/M5/M5.04.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index d655af58..2cad1abe 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -57,3 +57,7 @@ Review then identified that unbounded success markers would consume the 100-atte After M5.03/#60 merged into `desktop/milestone-m5`, this package was rebased onto milestone commit `41280f0f6aa61a14a985c76d499ce58d714cda0c`. The route-map and generated OpenAPI files were merged with the M5.03 contract; the API and Web package manifests retain both M5.03 and M5.04 focused scripts. No M5.04 migration was added. At source SHA `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, Node 24.22.2/npm 11.19.1 validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (7/7) and `npm run test:m5-04 --workspace=@ca-meta/web` (10/10, 0 failures). These local runs exercised the recomposed API/component/routes. The PR head's workspace typecheck, full test/build, contract gates, security and image checks remain bound to its GitHub Actions SHA. + +## Final focused review and validation — 2026-11-12 + +At source SHA `3513cb25be7df734024fef8baa50dcd726150dee`, Node 24.22.2/npm 11.19.1 focused validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (8/8) and `npm run test:m5-04 --workspace=@ca-meta/web` (11/11, 0 failures); `git diff --check` passed. Functional review closed the UNKNOWN recovery and stale-PENDING display findings: the UI reads the job location after each accepted response, accurately presents an existing terminal state, and tells the operator that refreshing the provider list does not unlock another identical submission. No Meta call was made. Contract generation/client compatibility, workspace typechecks, full build/security/image validation are delegated to the final package PR workflows at their exact head SHA. From f02779f4fd2177c7210f9ab6e6a5f5413eb5efc9 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:36:50 -0300 Subject: [PATCH 11/17] fix(m5-04): keep template response contract additive --- .../whatsapp-templates.service.ts | 10 ++++-- .../api/test/m5-04-whatsapp-templates.spec.ts | 19 +++++++++-- .../portal/whatsapp-templates.component.html | 4 +-- .../whatsapp-templates.component.spec.ts | 3 +- .../portal/whatsapp-templates.component.ts | 2 +- docs/delivery/M5/M5.04.md | 4 +-- docs/meta-v2/contracts/openapi.yaml | 27 ++++++++++++--- packages/contracts/openapi.yaml | 33 +++++++++++++++---- packages/contracts/src/generated/openapi.d.ts | 20 ++++++++--- tools/m1-07/src/generated/openapi.d.ts | 20 ++++++++--- 10 files changed, 112 insertions(+), 30 deletions(-) diff --git a/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts index 5dc28762..2abf44fd 100644 --- a/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts +++ b/apps/api/src/whatsapp-templates/whatsapp-templates.service.ts @@ -14,6 +14,8 @@ import { TemplateInputError, parseTemplateCreateRequest, type TemplateCreateRequ 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 CURSOR = /^[A-Za-z0-9._~+/%=-]{1,512}$/; const PROVIDER_ID = /^\d{1,32}$/; +const TEMPLATE_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'PAUSED', 'UNKNOWN'] as const; +const TEMPLATE_CATEGORIES = ['AUTHENTICATION', 'MARKETING', 'UTILITY', 'UNKNOWN'] as const; export interface WhatsAppTemplatesAccount { readonly tenantId: string; @@ -236,8 +238,12 @@ function sanitizeTemplatePage(value: unknown, requestId: string): components['sc const status = observedLabel(item.status); const category = observedLabel(item.category); const bodyText = observedBodyText(item); - // Keep provider labels as observations. They do not establish approval or local category semantics. - items.push(Object.freeze({ id: item.id, name: item.name, language: item.language, status, category, bodyText, + // Keep the v1 enums stable for older consumers; optional labels preserve provider observations. + const normalizedStatus = TEMPLATE_STATUSES.find((known) => known === status) ?? 'UNKNOWN'; + const normalizedCategory = TEMPLATE_CATEGORIES.find((known) => known === category) ?? 'UNKNOWN'; + items.push(Object.freeze({ id: item.id, name: item.name, language: item.language, + status: normalizedStatus, category: normalizedCategory, providerStatusLabel: status, + providerCategoryLabel: category, bodyText, updatedAt: new Date(item.updatedAt).toISOString() })); } return Object.freeze({ items: Object.freeze(items), nextCursor: value.nextCursor as string | null, requestId }); diff --git a/apps/api/test/m5-04-whatsapp-templates.spec.ts b/apps/api/test/m5-04-whatsapp-templates.spec.ts index f6adee86..e446dd24 100644 --- a/apps/api/test/m5-04-whatsapp-templates.spec.ts +++ b/apps/api/test/m5-04-whatsapp-templates.spec.ts @@ -44,6 +44,8 @@ function scope(tenant: string = tenantId): AuthorizedTenantScope { function setup(options: { readonly currentTenant?: string; readonly allow?: boolean; readonly provider?: object | null; readonly createEnabled?: boolean; + readonly providerStatus?: unknown; + readonly providerCategory?: unknown; readonly accountFor?: (tenant: string, assetRef: string) => WhatsAppTemplatesAccount | undefined } = {}) { const enqueued = new Map(); const jobStatuses = new Map(); @@ -104,8 +106,10 @@ function setup(options: { readonly currentTenant?: string; readonly allow?: bool const provider = options.provider === null ? null : options.provider ?? { async list(target: WhatsAppTemplatesAccount, input: { readonly cursor?: string; readonly limit: number }) { calls.push({ account: target, ...input }); - return { items: [{ id: 'provider-template-1', name: 'order_update', language: 'en', status: 'APPROVED', - category: 'UTILITY', components: [{ type: 'BODY', text: 'Hello {{1}}, code {{2}} and again {{1}}', + return { items: [{ id: 'provider-template-1', name: 'order_update', language: 'en', + status: options.providerStatus === undefined ? 'APPROVED' : options.providerStatus, + category: options.providerCategory === undefined ? 'UTILITY' : options.providerCategory, + components: [{ type: 'BODY', text: 'Hello {{1}}, code {{2}} and again {{1}}', example: { body_text: [['PRIVATE PROVIDER EXAMPLE']] } }], updatedAt: '2026-11-10T12:00:00Z' }], nextCursor: null, requestId: 'discarded' }; }, }; @@ -169,6 +173,8 @@ test('M5.04 listing preserves observed labels and sanitized BODY text while omit assert.equal(page.requestId, 'req-list-01'); assert.equal(page.items[0]?.status, 'APPROVED'); assert.equal(page.items[0]?.category, 'UTILITY'); + assert.equal(page.items[0]?.providerStatusLabel, 'APPROVED'); + assert.equal(page.items[0]?.providerCategoryLabel, 'UTILITY'); assert.equal(page.items[0]?.bodyText, 'Hello {{1}}, code {{2}} and again {{1}}'); assert.equal('components' in (page.items[0] ?? {}), false); assert.equal(JSON.stringify(page.items).includes('PRIVATE PROVIDER EXAMPLE'), false); @@ -177,6 +183,15 @@ test('M5.04 listing preserves observed labels and sanitized BODY text while omit assert.equal(fixture.calls.length, 1); }); +test('M5.04 keeps v1 enum compatibility and preserves bounded unrecognized provider labels', async () => { + const fixture = setup({ providerStatus: 'IN_REVIEW', providerCategory: 'PROMOTIONAL' }); + const page = await fixture.service.list(await sessionFor(), contextRef, assetId, { limit: 50 }, 'req-list-unknown'); + assert.equal(page.items[0]?.status, 'UNKNOWN'); + assert.equal(page.items[0]?.category, 'UNKNOWN'); + assert.equal(page.items[0]?.providerStatusLabel, 'IN_REVIEW'); + assert.equal(page.items[0]?.providerCategoryLabel, 'PROMOTIONAL'); +}); + test('M5.04 prevents cross-tenant and non-granted asset list access', async () => { const fixture = setup({ accountFor: () => undefined }); const otherTenantSession = await sessionFor(otherTenantId); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.html b/apps/web/src/app/features/portal/whatsapp-templates.component.html index 6f6f3dc6..fb056f7b 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.html +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.html @@ -42,11 +42,11 @@

Modelos existentes

{{ item.name }}

- Estado informado: {{ item.status }} + Estado informado: {{ item.providerStatusLabel ?? item.status }}
Idioma
{{ item.language }}
-
Categoria informada
{{ item.category }}
+
Categoria informada
{{ item.providerCategoryLabel ?? item.category }}
Última atualização observada
{{ item.updatedAt }}
Texto BODY
{{ item.bodyText ?? 'Texto BODY não disponível.' }}
diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts index 5b607652..1b220623 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.spec.ts @@ -111,7 +111,8 @@ describe('WhatsAppTemplatesComponent', () => { const state = await createFixture(); const bodyText = 'Hello {{1}}, code {{2}} and again {{1}}'; state.component.templates = [{ id: 'provider-template-1', name: 'order_update', language: 'en', status: 'APPROVED', - category: 'UTILITY', bodyText, updatedAt: '2026-11-10T12:00:00.000Z' }]; + category: 'UTILITY', providerStatusLabel: 'APPROVED', providerCategoryLabel: 'UTILITY', bodyText, + updatedAt: '2026-11-10T12:00:00.000Z' }]; expect(state.component.templates[0]?.bodyText).toBe(bodyText); expect(state.component.getBodyPlaceholders(state.component.templates[0]?.bodyText ?? null)).toEqual(['{{1}}', '{{2}}']); expect(state.component.getBodyPlaceholders(null)).toEqual([]); diff --git a/apps/web/src/app/features/portal/whatsapp-templates.component.ts b/apps/web/src/app/features/portal/whatsapp-templates.component.ts index 90559077..1b013c77 100644 --- a/apps/web/src/app/features/portal/whatsapp-templates.component.ts +++ b/apps/web/src/app/features/portal/whatsapp-templates.component.ts @@ -224,7 +224,7 @@ export class WhatsAppTemplatesComponent implements OnInit, OnDestroy { trackByAsset(_index: number, asset: TemplateAsset): string { return asset.id; } trackByTemplate(_index: number, item: TemplateRecord): string { return `${item.id}:${item.language}`; } - getBodyPlaceholders(bodyText: string | null): readonly string[] { + getBodyPlaceholders(bodyText: string | null | undefined): readonly string[] { return Object.freeze([...new Set(bodyText?.match(/\{\{[1-9][0-9]{0,2}\}\}/g) ?? [])]); } diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 2cad1abe..a6936c39 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -12,7 +12,7 @@ No database migration or schema-version change was needed. `TEMPLATE_CREATE` use This package deliberately implements only one BODY component. The closed request accepts name, language, category, body text, and optional `exampleValues`; extra provider IDs or credentials are rejected. The local contract allows placeholders `{{1}}` through `{{N}}` in ascending first-occurrence order, allows a previously-seen placeholder to repeat, and requires one non-empty example for each distinct placeholder in order. A body without placeholders may omit examples or pass `[]`. This mapping from example values to BODY placeholders is **our local contract rule**, not an assertion about current Meta requirements. Current provider limits and accepted language/category values have not been confirmed; these conservative input bounds exist for this versioned API and must be reviewed against verified capability evidence before enabling dispatch. -The template list preserves the bounded status and category labels returned by the provider as observations. The UI labels them as provider-reported values and says the status does not confirm approval. It returns sanitized BODY text, including literal placeholders, while omitting provider examples and other components. The UI labels a `202` as an acknowledgement that the durable request was accepted or an existing job reused; it immediately queries the job location to show its current local state. `PENDING` in the acknowledgement is not the job's current state. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result says refreshing the list may help check whether the model appeared, but does not release duplicate protection; the operator must contact support with the request identifier for safe reconciliation instead of resubmitting the same model. +To preserve the existing v1 response shape, `status` and `category` keep their enum values; an unrecognized provider value maps to `UNKNOWN`. New optional `providerStatusLabel` and `providerCategoryLabel` fields preserve the bounded sanitized labels as observations. The UI displays those provider-reported labels and says the status does not confirm approval. It returns sanitized BODY text, including literal placeholders, while omitting provider examples and other components. The UI labels a `202` as an acknowledgement that the durable request was accepted or an existing job reused; it immediately queries the job location to show its current local state. `PENDING` in the acknowledgement is not the job's current state. A `SUCCEEDED` local job is described as forwarded to the provider, not approved. An `UNKNOWN` result says refreshing the list may help check whether the model appeared, but does not release duplicate protection; the operator must contact support with the request identifier for safe reconciliation instead of resubmitting the same model. For retry safety across a page reload, the UI reserves the idempotency key in `sessionStorage` before sending the request. The record is scoped by the current tenant-context reference, internal WABA asset UUID, and a SHA-256 fingerprint of the request; it stores only that fingerprint, the key, an accepted job ID when available, and a success marker, never template text or examples. Re-entering the same request in the same tab reuses the pending key. After `SUCCEEDED`, the latest success marker prevents another POST for that identical fingerprint across remounts; an edited request has a different fingerprint and can start a new job, which prunes the prior success marker. At most one succeeded marker is retained, and up to 100 unresolved attempts remain bounded by the store limit. `FAILED`/`CANCELLED` and definitive no-effect responses clear the attempt, while `UNKNOWN` and session expiry retain its key. Context changes clear attempts. Storage failures block the POST. Focused regressions simulate a lost `202` after durable enqueue and a successful job followed by same-request submissions before and after remount; the key prevents duplicate synthetic jobs. After every acknowledged POST the UI reads the job location so a cross-tab semantic match that is already `SUCCEEDED` or `UNKNOWN` is presented with its current state, not as newly pending. @@ -31,7 +31,7 @@ Graph API version is configurable and **Not confirmed** here. Current exact prov - Portal session, current tenant context, and `CONNECT` authorization are checked before account resolution. The internal asset selector also requires a tenant-matched WABA, active verified asset authorization, WhatsApp EXCLUSIVE / embedded-signup journey, and the candidate management grant. Exact Meta permission mapping remains **Not confirmed**. - List cursors and page size are bounded; results are normalized and returned with `Cache-Control: no-store`. Listing has no `channel`, number, provider ID, or caller-selected account filter beyond the internal WABA asset UUID. -- The POST body is schema/size bounded. Its idempotency key is scoped by tenant and operation, then hashed server-side; the durable job additionally hashes the canonical payload. A repeated key with different payload returns `409`. Under the tenant row lock, a matching key is checked before cross-key semantic dedupe; an identical resource and canonical payload reuses jobs in `QUEUED`, `RUNNING`, `RETRY_PENDING`, `UNKNOWN`, or `SUCCEEDED` state. +- The POST body is schema/size bounded. Its idempotency key is scoped by tenant and operation, then hashed server-side; the durable job additionally hashes the canonical payload. A repeated key with different payload returns `409`. Under the tenant row lock, a matching key is checked before cross-key semantic dedupe; an identical resource and canonical payload reuses jobs in `QUEUED`, `RUNNING`, `RETRY_PENDING`, `UNKNOWN`, or `SUCCEEDED` state. Response additions are optional, the existing status/category enums remain, and BODY is optional in the response for compatibility with the frozen v1 contract. - The worker's verified job context and current account grant are rechecked before credential resolution. A missing/unverified profile, credential, account, capability, or provider result cannot authorize a new submission. No automatic retry follows `UNKNOWN`. - M1.08 tenant context, M3.08 credential resolution, M4.04/M4.06 Graph operation/capability evidence, and M3.12 verified asset-grant projection remain prerequisites for enabling real adapters. Adapter admission requires a reviewed change that enables the API provider and worker handler together and supplies current evidence for operation capability and tenant eligibility. diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml index 4d284dff..52a58b25 100644 --- a/docs/meta-v2/contracts/openapi.yaml +++ b/docs/meta-v2/contracts/openapi.yaml @@ -2078,6 +2078,9 @@ paths: language: en status: UNKNOWN category: UNKNOWN + providerStatusLabel: EXTERNALLY_REVIEWED + providerCategoryLabel: UTILITY + bodyText: Your order is ready for pickup. updatedAt: 2026-10-29T10:00:00Z nextCursor: null requestId: req_example_36 @@ -4262,7 +4265,6 @@ components: - language - status - category - - bodyText - updatedAt properties: id: @@ -4278,15 +4280,32 @@ components: minLength: 2 maxLength: 16 status: + type: string + enum: + - PENDING + - APPROVED + - REJECTED + - PAUSED + - UNKNOWN + description: Normalized status using the existing v1 enum; UNKNOWN covers other provider labels. This value does not establish approval. + category: + type: string + enum: + - AUTHENTICATION + - MARKETING + - UTILITY + - UNKNOWN + description: Normalized category using the existing v1 enum; UNKNOWN covers other provider labels. + providerStatusLabel: type: string minLength: 1 maxLength: 64 - description: Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. - category: + description: Bounded, sanitized provider-reported status label, preserved for display without assigning approval semantics. + providerCategoryLabel: type: string minLength: 1 maxLength: 64 - description: Bounded provider-observed label. It is returned without mapping to local category semantics. + description: Bounded, sanitized provider-reported category label, preserved without assigning local category semantics. bodyText: type: - string diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index d4b8d034..fb8be0f2 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -2227,6 +2227,9 @@ paths: language: en status: UNKNOWN category: UNKNOWN + providerStatusLabel: EXTERNALLY_REVIEWED + providerCategoryLabel: UTILITY + bodyText: Your order is ready for pickup. updatedAt: 2026-10-29T10:00:00Z nextCursor: null requestId: req_example_36 @@ -4333,7 +4336,6 @@ components: - language - status - category - - bodyText - updatedAt properties: id: @@ -4349,17 +4351,36 @@ components: minLength: 2 maxLength: 16 status: + type: string + enum: + - PENDING + - APPROVED + - REJECTED + - PAUSED + - UNKNOWN + description: Normalized status using the existing v1 enum; UNKNOWN covers other + provider labels. This value does not establish approval. + category: + type: string + enum: + - AUTHENTICATION + - MARKETING + - UTILITY + - UNKNOWN + description: Normalized category using the existing v1 enum; UNKNOWN covers + other provider labels. + providerStatusLabel: type: string minLength: 1 maxLength: 64 - description: Bounded provider-observed label. It is returned without mapping to - local status semantics and does not establish approval. - category: + description: Bounded, sanitized provider-reported status label, preserved for + display without assigning approval semantics. + providerCategoryLabel: type: string minLength: 1 maxLength: 64 - description: Bounded provider-observed label. It is returned without mapping to - local category semantics. + description: Bounded, sanitized provider-reported category label, preserved + without assigning local category semantics. bodyText: type: - string diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 54f77788..53390b08 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -1233,12 +1233,22 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** @description Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. */ - readonly status: string; - /** @description Bounded provider-observed label. It is returned without mapping to local category semantics. */ - readonly category: string; + /** + * @description Normalized status using the existing v1 enum; UNKNOWN covers other provider labels. This value does not establish approval. + * @enum {string} + */ + readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; + /** + * @description Normalized category using the existing v1 enum; UNKNOWN covers other provider labels. + * @enum {string} + */ + readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; + /** @description Bounded, sanitized provider-reported status label, preserved for display without assigning approval semantics. */ + readonly providerStatusLabel?: string; + /** @description Bounded, sanitized provider-reported category label, preserved without assigning local category semantics. */ + readonly providerCategoryLabel?: string; /** @description Sanitized BODY text, including any literal placeholders. Provider examples and other components are omitted; null means no safe BODY text was available. */ - readonly bodyText: string | null; + readonly bodyText?: string | null; /** Format: date-time */ readonly updatedAt: string; }; diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts index 54f77788..53390b08 100644 --- a/tools/m1-07/src/generated/openapi.d.ts +++ b/tools/m1-07/src/generated/openapi.d.ts @@ -1233,12 +1233,22 @@ export interface components { readonly id: string; readonly name: string; readonly language: string; - /** @description Bounded provider-observed label. It is returned without mapping to local status semantics and does not establish approval. */ - readonly status: string; - /** @description Bounded provider-observed label. It is returned without mapping to local category semantics. */ - readonly category: string; + /** + * @description Normalized status using the existing v1 enum; UNKNOWN covers other provider labels. This value does not establish approval. + * @enum {string} + */ + readonly status: "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "UNKNOWN"; + /** + * @description Normalized category using the existing v1 enum; UNKNOWN covers other provider labels. + * @enum {string} + */ + readonly category: "AUTHENTICATION" | "MARKETING" | "UTILITY" | "UNKNOWN"; + /** @description Bounded, sanitized provider-reported status label, preserved for display without assigning approval semantics. */ + readonly providerStatusLabel?: string; + /** @description Bounded, sanitized provider-reported category label, preserved without assigning local category semantics. */ + readonly providerCategoryLabel?: string; /** @description Sanitized BODY text, including any literal placeholders. Provider examples and other components are omitted; null means no safe BODY text was available. */ - readonly bodyText: string | null; + readonly bodyText?: string | null; /** Format: date-time */ readonly updatedAt: string; }; From e4b9f91308ad124346e8431a382efbb12e515417 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:37:46 -0300 Subject: [PATCH 12/17] docs(m5-04): record additive contract validation --- docs/delivery/M5/M5.04.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index a6936c39..8ebe0001 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -60,4 +60,4 @@ At source SHA `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, Node 24.22.2/npm 11.19 ## Final focused review and validation — 2026-11-12 -At source SHA `3513cb25be7df734024fef8baa50dcd726150dee`, Node 24.22.2/npm 11.19.1 focused validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (8/8) and `npm run test:m5-04 --workspace=@ca-meta/web` (11/11, 0 failures); `git diff --check` passed. Functional review closed the UNKNOWN recovery and stale-PENDING display findings: the UI reads the job location after each accepted response, accurately presents an existing terminal state, and tells the operator that refreshing the provider list does not unlock another identical submission. No Meta call was made. Contract generation/client compatibility, workspace typechecks, full build/security/image validation are delegated to the final package PR workflows at their exact head SHA. +At source SHA `f02779f4fd2177c7210f9ab6e6a5f5413eb5efc9`, Node 24.22.2/npm 11.19.1 focused validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (9/9) and `npm run test:m5-04 --workspace=@ca-meta/web` (11/11, 0 failures). `npm test --workspace=@ca-meta/contracts` passed generated-artifact checks, lint, breaking-change checks against the frozen v1 baseline, sensitivity/determinism/contract/runtime tests and typecheck; it reports the four existing magic-link response warnings. `git diff --check` passed. The M1.07 full local runner could not execute its check-breaking subprocess because the optional native `@pb33f/openapi-changes` binary is absent from this Windows worktree; the contracts package's same baseline check passed, and M1.07 Actions remains the exact-head gate. Functional review closed UNKNOWN recovery and stale-PENDING display findings: the UI reads the job location after each accepted response and accurately presents existing terminal state. No Meta call was made. Full candidate/build/security/image workflows remain tied to the updated PR head. From c8bfddb810734d489fe6f7437943156184aaf538 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:42:28 -0300 Subject: [PATCH 13/17] test(m5-04): constrain sanitized BODY contract exception --- docs/meta-v2/contracts/openapi.yaml | 11 +++++----- packages/contracts/openapi.yaml | 14 ++++++------ tools/m1-07/src/contract.test.mjs | 34 +++++++++++++++++++---------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml index 52a58b25..18359e37 100644 --- a/docs/meta-v2/contracts/openapi.yaml +++ b/docs/meta-v2/contracts/openapi.yaml @@ -2153,11 +2153,12 @@ paths: x-external-effect: The installed worker adapter submits template creation to the provider for review. The API and worker adapters are disabled by default until current capability evidence is reviewed. x-idempotency-scope: tenant + operation + Idempotency-Key and canonical request payload hash. - x-retry-policy: At most one provider submission, including the initial request. The durable job is - configured with one attempt and AMBIGUOUS effect mode. If the outcome is ambiguous, status is - UNKNOWN and a read-only reconciliation checks the same internal account for a name/language - candidate. A candidate can predate the request and does not prove causation; without confirmed - provider correlation the outcome remains UNKNOWN. No blind retry. + x-retry-policy: At most one automatic provider submission, including the initial request; zero + automatic retries without verified provider idempotency. The durable job has one attempt and + AMBIGUOUS effect mode. If the outcome is ambiguous, status is UNKNOWN and a read-only + reconciliation checks the same internal account for a name/language candidate. A candidate can + predate the request and does not prove causation; without confirmed provider correlation the + outcome remains UNKNOWN. No blind retry. /api/v1/notifications: get: operationId: listNotifications diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index fb8be0f2..c549fdda 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -2304,13 +2304,13 @@ paths: provider for review. The API and worker adapters are disabled by default until current capability evidence is reviewed. x-idempotency-scope: tenant + operation + Idempotency-Key and canonical request payload hash. - x-retry-policy: At most one provider submission, including the initial request. - The durable job is configured with one attempt and AMBIGUOUS effect - mode. If the outcome is ambiguous, status is UNKNOWN and a read-only - reconciliation checks the same internal account for a name/language - candidate. A candidate can predate the request and does not prove - causation; without confirmed provider correlation the outcome remains - UNKNOWN. No blind retry. + x-retry-policy: At most one automatic provider submission, including the initial + request; zero automatic retries without verified provider idempotency. + The durable job has one attempt and AMBIGUOUS effect mode. If the outcome + is ambiguous, status is UNKNOWN and a read-only reconciliation checks + the same internal account for a name/language candidate. A candidate can + predate the request and does not prove causation; without confirmed + provider correlation the outcome remains UNKNOWN. No blind retry. /api/v1/notifications: get: operationId: listNotifications diff --git a/tools/m1-07/src/contract.test.mjs b/tools/m1-07/src/contract.test.mjs index 8412f1a0..6fc27b5f 100644 --- a/tools/m1-07/src/contract.test.mjs +++ b/tools/m1-07/src/contract.test.mjs @@ -54,24 +54,24 @@ function operationList() { .map(([method, operation]) => ({ route, method, operation }))); } -function collectSchemaPropertyNames(schema, result = new Set(), seen = new Set()) { +function collectSchemaPropertyPaths(schema, result = new Set(), seen = new Set(), owner = 'inline') { if (!schema || typeof schema !== 'object') return result; if (typeof schema.$ref === 'string') { if (seen.has(schema.$ref)) return result; seen.add(schema.$ref); const match = schema.$ref.match(/^#\/components\/schemas\/([^/]+)$/); - if (match) collectSchemaPropertyNames(contract.components.schemas[match[1]], result, seen); + if (match) collectSchemaPropertyPaths(contract.components.schemas[match[1]], result, seen, match[1]); return result; } if (schema.properties && typeof schema.properties === 'object') { for (const [name, child] of Object.entries(schema.properties)) { - result.add(name); - collectSchemaPropertyNames(child, result, seen); + result.add(`${owner}.${name}`); + collectSchemaPropertyPaths(child, result, seen, owner); } } - if (schema.items) collectSchemaPropertyNames(schema.items, result, seen); + if (schema.items) collectSchemaPropertyPaths(schema.items, result, seen, owner); for (const branch of [...(schema.oneOf ?? []), ...(schema.anyOf ?? []), ...(schema.allOf ?? [])]) { - collectSchemaPropertyNames(branch, result, seen); + collectSchemaPropertyPaths(branch, result, seen, owner); } return result; } @@ -203,26 +203,38 @@ test('every request, success and reusable error example validates against its JS assert.ok(checked >= 60, `expected a broad request/success/error fixture set, checked ${checked}`); }); -test('public success DTOs and event envelopes contain no credential or raw message fields', async () => { - const forbiddenProperty = /^(?:accessToken|refreshToken|token|secret|clientSecret|appSecret|ciphertext|authorizationCode|sessionCookie|providerPayload|rawPayload|messageText|messageBody|bodyText)$/i; +test('public success DTOs and event envelopes contain no credentials or raw messages outside sanitized template BODY', async () => { + const forbiddenProperty = /^(?:accessToken|refreshToken|token|secret|clientSecret|appSecret|ciphertext|authorizationCode|sessionCookie|providerPayload|rawPayload|messageText|messageBody)$/i; + const forbiddenEventProperty = /^(?:accessToken|refreshToken|token|secret|clientSecret|appSecret|ciphertext|authorizationCode|sessionCookie|providerPayload|rawPayload|messageText|messageBody|bodyText)$/i; const checkedProperties = new Set(); for (const { route, method, operation } of operationList()) { for (const [status, responseRef] of Object.entries(operation.responses)) { const response = resolveResponse(responseRef); for (const content of Object.values(response.content ?? {})) { if (!content.schema) continue; - for (const name of collectSchemaPropertyNames(content.schema)) checkedProperties.add(name); + for (const name of collectSchemaPropertyPaths(content.schema)) checkedProperties.add(name); } } } - for (const name of checkedProperties) assert.doesNotMatch(name, forbiddenProperty, `unsafe response DTO field: ${name}`); + for (const fieldPath of checkedProperties) { + const name = fieldPath.slice(fieldPath.lastIndexOf('.') + 1); + assert.doesNotMatch(name, forbiddenProperty, `unsafe response DTO field: ${fieldPath}`); + if (name.toLowerCase() === 'bodytext') assert.equal(fieldPath, 'Template.bodyText', 'raw body fields are only allowed in the sanitized Template DTO'); + } + const templateBody = contract.components.schemas.Template?.properties?.bodyText; + assert.ok(templateBody, 'Template exposes BODY text only through its sanitized response field'); + assert.equal(contract.components.schemas.Template.required.includes('bodyText'), false, 'BODY text remains optional for v1 consumers'); + assert.deepEqual(templateBody.type, ['string', 'null']); + assert.equal(templateBody.maxLength, 1024); + assert.match(templateBody.description, /Sanitized BODY text/); + assert.match(templateBody.description, /examples and other components are omitted/); for (const eventPath of eventPaths) { const schema = JSON.parse(await readFile(eventPath, 'utf8')); const visit = (node) => { if (!node || typeof node !== 'object') return; for (const [key, value] of Object.entries(node)) { - assert.doesNotMatch(key, forbiddenProperty, `unsafe event field: ${key}`); + assert.doesNotMatch(key, forbiddenEventProperty, `unsafe event field: ${key}`); visit(value); } }; From e07694f9f88151130a199fbb61e10018bb9e9a98 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:43:24 -0300 Subject: [PATCH 14/17] docs(m5-04): record contract consumer validation --- docs/delivery/M5/M5.04.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index 8ebe0001..d17a1024 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -60,4 +60,4 @@ At source SHA `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, Node 24.22.2/npm 11.19 ## Final focused review and validation — 2026-11-12 -At source SHA `f02779f4fd2177c7210f9ab6e6a5f5413eb5efc9`, Node 24.22.2/npm 11.19.1 focused validation passed: `npm run test:m5-04 --workspace=@ca-meta/api` (9/9) and `npm run test:m5-04 --workspace=@ca-meta/web` (11/11, 0 failures). `npm test --workspace=@ca-meta/contracts` passed generated-artifact checks, lint, breaking-change checks against the frozen v1 baseline, sensitivity/determinism/contract/runtime tests and typecheck; it reports the four existing magic-link response warnings. `git diff --check` passed. The M1.07 full local runner could not execute its check-breaking subprocess because the optional native `@pb33f/openapi-changes` binary is absent from this Windows worktree; the contracts package's same baseline check passed, and M1.07 Actions remains the exact-head gate. Functional review closed UNKNOWN recovery and stale-PENDING display findings: the UI reads the job location after each accepted response and accurately presents existing terminal state. No Meta call was made. Full candidate/build/security/image workflows remain tied to the updated PR head. +At source SHA `c8bfddb810734d489fe6f7437943156184aaf538`, Node 24.22.2/npm 11.19.1 focused validation passed: API 9/9; Web 11/11; `npm test --workspace=@ca-meta/contracts` passed generated-artifact checks, lint, frozen-v1 breaking checks, sensitivity/determinism/contract/runtime tests and typecheck; M1.07 contract tests passed 11/11, behavior tests 6/6, and generated-client/compatibility check passed. Contract lint reports the four existing magic-link response warnings. `git diff --check` passed. The full M1.07 local runner could not execute its breaking-check subprocess because the optional native `@pb33f/openapi-changes` binary is absent from this Windows worktree; the contracts package's baseline check passed, and M1.07 Actions remains the exact-head gate. Functional review closed UNKNOWN recovery and stale-PENDING display findings; the contract test permits `Template.bodyText` only when bounded, optional, nullable and explicitly sanitized, and continues to reject that field in events. No Meta call was made. Full candidate/build/security/image workflows remain tied to the updated PR head. From e1157cfc359937977627ecdff78f67785caaa187 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:44:44 -0300 Subject: [PATCH 15/17] test(m1-07): constrain template body exception path --- tools/m1-07/src/contract.test.mjs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tools/m1-07/src/contract.test.mjs b/tools/m1-07/src/contract.test.mjs index 6fc27b5f..5ec45953 100644 --- a/tools/m1-07/src/contract.test.mjs +++ b/tools/m1-07/src/contract.test.mjs @@ -54,24 +54,28 @@ function operationList() { .map(([method, operation]) => ({ route, method, operation }))); } -function collectSchemaPropertyPaths(schema, result = new Set(), seen = new Set(), owner = 'inline') { +function collectSchemaPropertyPaths(schema, result = new Set(), seen = new Set(), owner = 'inline', prefix = '') { if (!schema || typeof schema !== 'object') return result; if (typeof schema.$ref === 'string') { if (seen.has(schema.$ref)) return result; - seen.add(schema.$ref); const match = schema.$ref.match(/^#\/components\/schemas\/([^/]+)$/); - if (match) collectSchemaPropertyPaths(contract.components.schemas[match[1]], result, seen, match[1]); + if (match) { + const nextSeen = new Set(seen); + nextSeen.add(schema.$ref); + collectSchemaPropertyPaths(contract.components.schemas[match[1]], result, nextSeen, match[1]); + } return result; } if (schema.properties && typeof schema.properties === 'object') { for (const [name, child] of Object.entries(schema.properties)) { - result.add(`${owner}.${name}`); - collectSchemaPropertyPaths(child, result, seen, owner); + const path = prefix ? `${prefix}.${name}` : name; + result.add(`${owner}.${path}`); + collectSchemaPropertyPaths(child, result, seen, owner, path); } } - if (schema.items) collectSchemaPropertyPaths(schema.items, result, seen, owner); + if (schema.items) collectSchemaPropertyPaths(schema.items, result, seen, owner, prefix); for (const branch of [...(schema.oneOf ?? []), ...(schema.anyOf ?? []), ...(schema.allOf ?? [])]) { - collectSchemaPropertyPaths(branch, result, seen, owner); + collectSchemaPropertyPaths(branch, result, seen, owner, prefix); } return result; } @@ -228,6 +232,9 @@ test('public success DTOs and event envelopes contain no credentials or raw mess assert.equal(templateBody.maxLength, 1024); assert.match(templateBody.description, /Sanitized BODY text/); assert.match(templateBody.description, /examples and other components are omitted/); + const nestedBodyPaths = collectSchemaPropertyPaths({ properties: { nested: { type: 'object', properties: { bodyText: { type: 'string' } } } } }, + new Set(), new Set(), 'Template'); + assert.ok(nestedBodyPaths.has('Template.nested.bodyText'), 'a nested raw body must not collapse to the Template.bodyText exception'); for (const eventPath of eventPaths) { const schema = JSON.parse(await readFile(eventPath, 'utf8')); From fe613e28d341b110e113b28c16954df9facff040 Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:45:25 -0300 Subject: [PATCH 16/17] chore(m5-04): sync generated contract mirror --- packages/contracts/openapi.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index c549fdda..189bf6bc 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -2306,11 +2306,12 @@ paths: x-idempotency-scope: tenant + operation + Idempotency-Key and canonical request payload hash. x-retry-policy: At most one automatic provider submission, including the initial request; zero automatic retries without verified provider idempotency. - The durable job has one attempt and AMBIGUOUS effect mode. If the outcome - is ambiguous, status is UNKNOWN and a read-only reconciliation checks - the same internal account for a name/language candidate. A candidate can - predate the request and does not prove causation; without confirmed - provider correlation the outcome remains UNKNOWN. No blind retry. + The durable job has one attempt and AMBIGUOUS effect mode. If the + outcome is ambiguous, status is UNKNOWN and a read-only reconciliation + checks the same internal account for a name/language candidate. A + candidate can predate the request and does not prove causation; without + confirmed provider correlation the outcome remains UNKNOWN. No blind + retry. /api/v1/notifications: get: operationId: listNotifications From 3da1eb749abb0f0af7ebf98ea9350d29095538cf Mon Sep 17 00:00:00 2001 From: Eduardo Matuella Date: Thu, 12 Nov 2026 18:46:27 -0300 Subject: [PATCH 17/17] docs(m5-04): record final compatibility evidence --- docs/delivery/M5/M5.04.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/delivery/M5/M5.04.md b/docs/delivery/M5/M5.04.md index d17a1024..319cad40 100644 --- a/docs/delivery/M5/M5.04.md +++ b/docs/delivery/M5/M5.04.md @@ -60,4 +60,4 @@ At source SHA `f1c6e19a0bcb171c977434840118a2b7b8201b3a`, Node 24.22.2/npm 11.19 ## Final focused review and validation — 2026-11-12 -At source SHA `c8bfddb810734d489fe6f7437943156184aaf538`, Node 24.22.2/npm 11.19.1 focused validation passed: API 9/9; Web 11/11; `npm test --workspace=@ca-meta/contracts` passed generated-artifact checks, lint, frozen-v1 breaking checks, sensitivity/determinism/contract/runtime tests and typecheck; M1.07 contract tests passed 11/11, behavior tests 6/6, and generated-client/compatibility check passed. Contract lint reports the four existing magic-link response warnings. `git diff --check` passed. The full M1.07 local runner could not execute its breaking-check subprocess because the optional native `@pb33f/openapi-changes` binary is absent from this Windows worktree; the contracts package's baseline check passed, and M1.07 Actions remains the exact-head gate. Functional review closed UNKNOWN recovery and stale-PENDING display findings; the contract test permits `Template.bodyText` only when bounded, optional, nullable and explicitly sanitized, and continues to reject that field in events. No Meta call was made. Full candidate/build/security/image workflows remain tied to the updated PR head. +At source SHA `fe613e28d341b110e113b28c16954df9facff040`, Node 24.22.2/npm 11.19.1 focused validation passed: API 9/9 and Web 11/11 on source `f02779f`; the contracts suite passed generated checks, lint, frozen-v1 breaking checks, sensitivity/determinism/contract/runtime tests and typecheck; after the final retry wording and mirror sync, frozen-v1 `check:breaking` and contract `check:generated` both passed. M1.07 contract tests passed 11/11, behavior tests 6/6 and generated-client/compatibility check passed. Contract lint reports the four existing magic-link response warnings. `git diff --check` passed. The full M1.07 local runner could not execute its breaking-check subprocess because the optional native `@pb33f/openapi-changes` binary is absent from this Windows worktree; the contracts package's same baseline check passed. Actions at the updated PR head remain the final exact-head gate. Functional review closed UNKNOWN recovery, stale-PENDING display and v1 compatibility findings; the consumer guard permits `Template.bodyText` only as bounded, optional, nullable sanitized text, rejects nested body fields in response DTOs, and continues to reject bodyText in events. No Meta call was made.