Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f6b8605
feat(m5-04): add scoped WhatsApp template surface
eduardomatuella-costaapps Nov 11, 2026
f694595
fix(web): retain template idempotency across reloads
eduardomatuella-costaapps Nov 11, 2026
0a3f4d9
fix(m5-04): prevent duplicate successful template requests
eduardomatuella-costaapps Nov 11, 2026
f5daacb
docs(m5-04): record success dedupe correction
eduardomatuella-costaapps Nov 11, 2026
5347d33
fix(m5-04): bound successful template dedupe markers
eduardomatuella-costaapps Nov 11, 2026
bb34f56
docs(m5-04): record bounded success marker evidence
eduardomatuella-costaapps Nov 11, 2026
f1c6e19
fix(m5-04): regenerate M1.07 client for merged contract
eduardomatuella-costaapps Nov 12, 2026
760ec09
docs(m5): record M5.03 integration and M5.04 rebase
eduardomatuella-costaapps Nov 12, 2026
3513cb2
feat(m5-04): preserve template listing and dedupe creates
eduardomatuella-costaapps Nov 12, 2026
5559ac7
docs(m5-04): record focused review and validation
eduardomatuella-costaapps Nov 12, 2026
f02779f
fix(m5-04): keep template response contract additive
eduardomatuella-costaapps Nov 12, 2026
e4b9f91
docs(m5-04): record additive contract validation
eduardomatuella-costaapps Nov 12, 2026
c8bfddb
test(m5-04): constrain sanitized BODY contract exception
eduardomatuella-costaapps Nov 12, 2026
e07694f
docs(m5-04): record contract consumer validation
eduardomatuella-costaapps Nov 12, 2026
e1157cf
test(m1-07): constrain template body exception path
eduardomatuella-costaapps Nov 12, 2026
fe613e2
chore(m5-04): sync generated contract mirror
eduardomatuella-costaapps Nov 12, 2026
3da1eb7
docs(m5-04): record final compatibility evidence
eduardomatuella-costaapps Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion apps/api/src/jobs/event-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,7 +37,7 @@ export interface OutboxEventEnvelope {
readonly data: Readonly<Record<string, unknown>>;
}

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 }
Expand All @@ -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';
Expand Down Expand Up @@ -102,6 +107,11 @@ export const JOB_POLICIES: Readonly<Record<JobType, JobPolicy>> = 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;
Expand Down Expand Up @@ -172,6 +182,24 @@ const jobPayloadSchemas: Readonly<Record<JobType, object>> = {
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<JobType, ReturnType<typeof ajv.compile>>(
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/jobs/job-execution-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const REQUIRED_JOB_SCOPES: Readonly<Partial<Record<JobType, ServiceScope>>> = Ob
HISTORY_SYNC: 'TENANT_RESOURCE_READ',
CREDENTIAL_INSPECTION: 'CREDENTIAL_USE',
ASSET_REFRESH: 'META_CONNECTION_EXECUTE',
TEMPLATE_CREATE: 'CREDENTIAL_USE',
} as const satisfies Readonly<Partial<Record<JobType, ServiceScope>>>);

function resourceReference(type: JobType, payload: JobPayload): string | undefined {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
}
}
}
1 change: 1 addition & 0 deletions apps/api/src/jobs/job-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
25 changes: 24 additions & 1 deletion apps/api/src/meta-assets/meta-assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ 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 {
static register(identityModule: DynamicModule, environment: ApiEnvironment): DynamicModule {
return {
module: MetaAssetsModule,
imports: [identityModule, JobsModule.register(environment), PrivacyModule.register(environment)],
controllers: [AssetCatalogController],
controllers: [AssetCatalogController, WhatsAppTemplatesController],
providers: [
{
provide: PortalSessionGuard,
Expand All @@ -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<FlowCatalogVariant> => new Set() },
// No real Meta adapter ships with this runtime; tests may inject an isolated fake.
Expand Down
68 changes: 68 additions & 0 deletions apps/api/src/whatsapp-templates/template-body.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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<Record<string, unknown>> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Loading
Loading