From c391154fa2ec4f8aad5f83b496eb3f3fe96d48a8 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 23:47:50 +1000 Subject: [PATCH 1/3] fix: persist Function App CORS and document contact consumer onboarding Encode platform CORS origins in bicep so redeploys keep marketing contact forms working, and add the dual allowlist checklist plus sync:skills wiring. Co-authored-by: Cursor --- apps/api/src/host-profiles.spec.ts | 16 ++++++++++ docs/README.md | 2 ++ docs/integrations/inkads-marketing.md | 6 ++++ docs/onboarding/contact-consumer.md | 42 +++++++++++++++++++++++++++ infra/function-app.bicep | 12 ++++++++ package.json | 2 +- 6 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/onboarding/contact-consumer.md diff --git a/apps/api/src/host-profiles.spec.ts b/apps/api/src/host-profiles.spec.ts index b9a154a..a5cabc1 100644 --- a/apps/api/src/host-profiles.spec.ts +++ b/apps/api/src/host-profiles.spec.ts @@ -17,4 +17,20 @@ describe('Function App host profiles', () => { assert.ok(seed['app:email:profilesByHost']?.includes('inkads.poc.singletonsd.com')); assert.equal(seed['app:email:validation:domain'], 'mail.plattform-kit.poc.singletonsd.com'); }); + + it('persists platform CORS exact origins alongside App Config ORIGINS globs', () => { + const root = path.resolve(__dirname, '../../..'); + const bicep = readFileSync(path.join(root, 'infra/function-app.bicep'), 'utf8'); + const seed = JSON.parse( + readFileSync(path.join(root, 'infra/appconfig-seed.json'), 'utf8'), + ) as Record; + + assert.match(bicep, /cors:\s*\{/); + assert.match(bicep, /supportCredentials:\s*false/); + assert.match(bicep, /'https:\/\/inkads\.poc\.singletonsd\.com'/); + assert.match(bicep, /'https:\/\/plattform-kit\.poc\.singletonsd\.com'/); + assert.match(bicep, /'http:\/\/localhost:4321'/); + assert.ok(seed['app:email:origins']?.includes('*.poc.singletonsd.com')); + assert.ok(seed['app:email:origins']?.includes('localhost:4321')); + }); }); diff --git a/docs/README.md b/docs/README.md index 7435004..acb3e43 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ docs/ │ └── inkads-marketing.md (InkAds PoC site → POST /contact) ├── onboarding/ │ ├── tenant-onboarding.md (new tenant → first email) +│ ├── contact-consumer.md (new marketing/contact consumer checklist) │ └── environments.md (dev/staging/prod separation, local dev) ├── operations/ │ ├── troubleshooting.md (send endpoint triage, correlation IDs, runbooks) @@ -71,6 +72,7 @@ docs/ | [`guides/editor-integration.md`](./guides/editor-integration.md) | Embed `@singleton-sd/post-kit-editor`: install, `onSave` persistence, preview vs send, access control | | [`examples/publish-email-templates.yml`](./examples/publish-email-templates.yml) | Sample consumer-repository publish workflow (not installed in this repo) | | [`onboarding/tenant-onboarding.md`](./onboarding/tenant-onboarding.md) | New tenant from nothing configured to first email: identifier, credential, sender, templates, publish, test send, triage | +| [`onboarding/contact-consumer.md`](./onboarding/contact-consumer.md) | New marketing/contact consumer: App Config origins + host profile, platform CORS, optional mail domain, publicBaseUrl, preview header, smoke tests | | [`onboarding/environments.md`](./onboarding/environments.md) | development/staging/production separation, local development without real delivery | | [`architecture/request-lifecycle.md`](./architecture/request-lifecycle.md) | `POST /emails/send` runtime sequence, correlation IDs, error-code → HTTP-status table | | [`architecture/template-lifecycle.md`](./architecture/template-lifecycle.md) | Template source → compiler → publisher → Blob layout → send-time load | diff --git a/docs/integrations/inkads-marketing.md b/docs/integrations/inkads-marketing.md index d59c4c0..a71dbf2 100644 --- a/docs/integrations/inkads-marketing.md +++ b/docs/integrations/inkads-marketing.md @@ -40,6 +40,11 @@ Seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json): `inkads.poc.singletonsd.com/pr-preview/pr-*` previews share the same allowed origin host. +**Also required:** Function App **platform CORS** exact origins (see +[`infra/function-app.bicep`](../../infra/function-app.bicep) `siteConfig.cors`). +App Config ORIGINS alone does not satisfy Linux Consumption browser preflight. +Full checklist: [`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md). + ## Request shape `POST /contact` body (browser → PostKit): @@ -115,6 +120,7 @@ only required for authenticated `SendRequest` flows. ## Related docs +- [`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md) - [`docs/guides/public-forms.md`](../guides/public-forms.md) - [`docs/email-forward-email.md`](../email-forward-email.md) - Platform Kit reference: `plattform-kit` `docs/marketing-astro-decap.md` diff --git a/docs/onboarding/contact-consumer.md b/docs/onboarding/contact-consumer.md new file mode 100644 index 0000000..f3d1007 --- /dev/null +++ b/docs/onboarding/contact-consumer.md @@ -0,0 +1,42 @@ +# Onboarding a marketing / contact consumer + +Checklist for adding a new public marketing site (or similar) that posts to +PostKit `POST /contact`. Agents: also follow the +`postkit-contact-consumer` skill from +[`singleton-sd/ai-plattform-skills`](https://github.com/singleton-sd/ai-plattform-skills) +(installed via `pnpm sync:skills`). + +Do **not** treat App Config `app:email:origins` alone as sufficient — Linux +Consumption handles `OPTIONS` at the **platform**. Browser preflight needs +Function App CORS **exact** origins **and** the App Config hostname allowlist. + +## Dual allowlists (required) + +| Surface | What to set | Notes | +| --- | --- | --- | +| **Platform CORS** | Exact origin URLs on the Function App (`siteConfig.cors.allowedOrigins` in [`infra/function-app.bicep`](../../infra/function-app.bicep)) | e.g. `https://example.poc.singletonsd.com`, `http://localhost:4321`. No `*.poc…` globs. `supportCredentials: false`. Redeploy or `az functionapp cors add` so live matches bicep. | +| **App Config ORIGINS** | `app:email:origins` (seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json)) | Hostname allowlist / globs for reflecting `Access-Control-Allow-Origin` and resolving host profiles (e.g. `*.poc.singletonsd.com,localhost:4321`). | + +Keep both in sync when adding a consumer. + +## Checklist + +1. **Origins (App Config)** — Extend `app:email:origins` (and the seed JSON) with the consumer hostname or a safe glob. Apply to store `ssd-postkit-appcs-prod-ae`. +2. **Host profile** — Add an entry under `app:email:profilesByHost` for the production host: + - `fromAddress` / `fromName` / `contactInboxAddress` + - Prefer a dedicated sending subdomain (`noreply@mail.…`) when branding requires it. +3. **Platform CORS** — Add the exact `https://…` (and local `http://localhost:…` if needed) origins to `infra/function-app.bicep` `siteConfig.cors.allowedOrigins`. Apply live if bicep is not redeployed yet. +4. **Sending domain (optional but required for new `fromAddress` domains)** — If the profile uses a new mail subdomain, add it to [`packages/post-kit-email/config/email-domains.json`](../../packages/post-kit-email/config/email-domains.json) and run `pnpm email:provision -- --domain ` (see [`docs/email-forward-email.md`](../email-forward-email.md)). Forward Email returns **400 Domain does not exist** until the domain exists on the account — that surfaces as HTTP **500** on `/contact` with `We could not send your message`. +5. **Public API base URL** — Consumers resolve `app:api:publicBaseUrl` from App Configuration at build time (see [`docs/integrations/inkads-marketing.md`](../integrations/inkads-marketing.md)). Do not invent a second source of truth. +6. **Preview header** — Same-host PR previews must send `X-PostKit-Contact-Preview: true` so PostKit uses the development sink instead of live delivery. See contact preview behaviour in the InkAds integration doc. +7. **Smoke tests** + - `OPTIONS /contact` with `Origin: https://` → `Access-Control-Allow-Origin` reflected (platform + app). + - `POST /contact` with a valid body and that Origin → **202** (or intentional **4xx**), never unexplained **500**. + - `GET /health` on the public API base URL. + +## Related docs + +- [`docs/integrations/inkads-marketing.md`](../integrations/inkads-marketing.md) — reference consumer +- [`docs/guides/public-forms.md`](../guides/public-forms.md) — trusted-server pattern for public forms +- [`docs/email-forward-email.md`](../email-forward-email.md) — Forward Email + DNS provision +- [`docs/onboarding/tenant-onboarding.md`](./tenant-onboarding.md) — authenticated tenant / template send (not this checklist) diff --git a/infra/function-app.bicep b/infra/function-app.bicep index 743bbe5..46d38cb 100644 --- a/infra/function-app.bicep +++ b/infra/function-app.bicep @@ -115,6 +115,18 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { linuxFxVersion: 'Node|22' ftpsState: 'Disabled' minTlsVersion: '1.2' + // Linux Consumption handles OPTIONS at the platform. App-level CORS helpers + // and App Config ORIGINS alone do not satisfy browser preflight — keep these + // exact origins in sync with app:email:origins (hostname globs) when adding + // a marketing/contact consumer. Azure platform CORS does not support globs. + cors: { + allowedOrigins: [ + 'https://inkads.poc.singletonsd.com' + 'https://plattform-kit.poc.singletonsd.com' + 'http://localhost:4321' + ] + supportCredentials: false + } appSettings: [ { name: 'AzureWebJobsStorage' diff --git a/package.json b/package.json index b4e345b..bb1e01d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "tenant:register-key": "./scripts/register-tenant-api-key.sh", "email:provision": "pnpm --filter @singleton-sd/post-kit-email provision", "validate:email-domain-branding": "pnpm --filter @singleton-sd/post-kit-email validate:domain-branding", - "sync:skills": "npx --yes skills add singleton-sd/ai-plattform-skills --skill task-driven-development --skill \"Task-Driven Development\" --skill backend --skill frontend -a cursor -a claude-code -a grok -a codex --copy -y", + "sync:skills": "npx --yes skills add singleton-sd/ai-plattform-skills --skill task-driven-development --skill \"Task-Driven Development\" --skill backend --skill frontend --skill postkit-contact-consumer -a cursor -a claude-code -a grok -a codex --copy -y", "bootstrap:worktree": "node ./scripts/invoke-bootstrap-worktree.mjs", "bootstrap:worktree:quick": "node ./scripts/invoke-bootstrap-worktree.mjs --quick-check", "worktree:add": "node ./scripts/invoke-worktree-add.mjs", From 6414126f55faeff11c9555ae379e16da399dbb54 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 23:49:21 +1000 Subject: [PATCH 2/3] docs: capture contact CORS and custom-domain agent learnings Record dual-CORS, Forward Email domain 500s, hostname/TLS sequence, and handoff pitfalls so future agents do not stop at live ops without a PR. Co-authored-by: Cursor --- docs/README.md | 3 +- .../learnings-contact-cors-custom-domain.md | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/operations/learnings-contact-cors-custom-domain.md diff --git a/docs/README.md b/docs/README.md index acb3e43..296eff2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,7 +50,8 @@ docs/ ├── operations/ │ ├── troubleshooting.md (send endpoint triage, correlation IDs, runbooks) │ ├── send-metrics-queries.md (Kusto queries for send telemetry) -│ └── releasing.md (release.yml bump + OIDC npm publish checklist) +│ ├── releasing.md (release.yml bump + OIDC npm publish checklist) +│ └── learnings-contact-cors-custom-domain.md (CORS dual-allowlist, contact 500, custom domain, agent handoff) └── examples/ └── publish-email-templates.yml (sample consumer publish workflow) ``` diff --git a/docs/operations/learnings-contact-cors-custom-domain.md b/docs/operations/learnings-contact-cors-custom-domain.md new file mode 100644 index 0000000..e85e101 --- /dev/null +++ b/docs/operations/learnings-contact-cors-custom-domain.md @@ -0,0 +1,64 @@ +# Learnings — contact CORS, custom domain, agent handoff (2026-09) + +Hard-won notes from the InkAds contact outage and `postkit.singletonsd.com` +work ([#145](https://github.com/singleton-sd/post-kit/issues/145), +[#146](https://github.com/singleton-sd/post-kit/issues/146)). Keep this short; +durable checklists live in +[`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md) and +the `postkit-contact-consumer` skill. + +## Product / platform + +1. **Dual CORS is mandatory on Linux Consumption.** The Functions host answers + browser `OPTIONS` before worker code runs. App Config `app:email:origins` + + `contactCorsHeaders` only affect requests that reach the function (e.g. + `POST`). Platform CORS (`siteConfig.cors.allowedOrigins` / `az functionapp + cors`) needs **exact** `https://…` origins — no `*.poc…` globs. Persist + them in [`infra/function-app.bicep`](../../infra/function-app.bicep). +2. **Symptom split:** missing platform CORS → browser reports no + `Access-Control-Allow-Origin` on preflight (often a bare `204`). Direct + `POST` may still return app CORS headers. +3. **Contact HTTP 500 after CORS is fixed** often means Forward Email rejected + the send (e.g. **400 Domain does not exist** for a new + `mail.…` from-address). Provision the domain before chasing App + Config / Key Vault. See + [`docs/email-forward-email.md`](../email-forward-email.md). +4. **Custom API hostname sequence that worked on Y1 Linux Consumption:** + Route53 `asuid.` TXT (verification id) → CNAME → + `az functionapp config hostname add` → + `az functionapp config ssl create` → wait until cert exists → + `az functionapp config ssl bind --ssl-type SNI` → only then flip live + `app:api:publicBaseUrl`. Keep `*.azurewebsites.net` as fallback. +5. **CLI noise:** managed-cert create may print a deserialization warning and + still succeed; poll `az webapp config ssl show` until thumbprint appears. + `az functionapp show` / bind responses may show `siteConfig.cors: null` + even when CORS is still set — verify with `az functionapp cors show`. + +## Agent / tooling + +1. **Handoff = pushed PR + issue comment.** Live Azure/DNS fixes without a PR + (or with only an uncommitted worktree) look like “nothing happened.” Finish + with `Closes #N`, verification curls, and no secrets. +2. **Parallel agents need exclusive file ownership** (stated in the issue). + `#145` owned DNS/hostname/`publicBaseUrl`; `#146` owned bicep CORS + + contact delivery + skill/docs. Shared hubs without ownership collide. +3. **Route53:** prefer `~/.config/pc-provision/route53.zones.map` (e.g. + `singletonsd.com=Z2PHDBJIVYBXRT`). The `resolve-route53-zone` shim may + point at a missing `/mnt/c/…/pc-provision` path on WSL — do not block on + that wrapper if the map + AWS creds from `company.secrets.env` work. +4. **WSL often lacks `dig` / `nslookup`.** Use `aws route53 …`, `getent + hosts`, or `curl` for checks. +5. **Skills source of truth is `ai-plattform` skills (GitLab), not post-kit.** + The GitHub `ai-plattform-skills` mirror may be archived/read-only. Open the + skill MR on GitLab, then wire `pnpm sync:skills` in post-kit. Capture + product ops rules in post-kit `docs/**`; capture reusable agent procedure + in the skill. + +## Related + +| Artifact | Link | +| --- | --- | +| Custom domain PR | [#147](https://github.com/singleton-sd/post-kit/pull/147) | +| CORS / consumer docs PR | [#148](https://github.com/singleton-sd/post-kit/pull/148) | +| Contact consumer checklist | [`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md) | +| Skills MR | [ai-plattform/skills !13](https://gitlab.com/singleton-sd/ai-plattform/skills/-/merge_requests/13) | From 35326e6e1e8a612d82612a18682b63c0f2b07f6c Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 23:57:49 +1000 Subject: [PATCH 3/3] fix: sync Function App platform CORS from App Config Derive exact CORS origins from app:email:origins (non-glob) and profilesByHost keys via pnpm cors:sync; run on Deploy API. Drop the hardcoded bicep list so App Config stays the single source of truth. Co-authored-by: Cursor --- .github/workflows/deploy-api.yml | 8 + apps/api/src/host-profiles.spec.ts | 17 ++- docs/integrations/inkads-marketing.md | 9 +- docs/onboarding/contact-consumer.md | 23 ++- .../learnings-contact-cors-custom-domain.md | 9 +- infra/README.md | 5 + infra/function-app.bicep | 17 +-- package.json | 3 +- scripts/platform-cors-origins.mjs | 72 +++++++++ scripts/platform-cors-origins.test.mjs | 75 ++++++++++ scripts/sync-function-cors-from-appconfig.sh | 141 ++++++++++++++++++ 11 files changed, 345 insertions(+), 34 deletions(-) create mode 100644 scripts/platform-cors-origins.mjs create mode 100644 scripts/platform-cors-origins.test.mjs create mode 100755 scripts/sync-function-cors-from-appconfig.sh diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 4d6d654..d5dcb11 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -14,6 +14,8 @@ on: - 'infra/function-app.bicep' - 'infra/appconfig-seed.json' - 'scripts/seed-appconfig.sh' + - 'scripts/sync-function-cors-from-appconfig.sh' + - 'scripts/platform-cors-origins.mjs' - '.github/workflows/deploy-api.yml' workflow_dispatch: @@ -138,6 +140,12 @@ jobs: chmod +x scripts/seed-appconfig.sh ./scripts/seed-appconfig.sh infra/appconfig-seed.json + - name: Sync Function App platform CORS from App Configuration + run: | + set -euo pipefail + chmod +x scripts/sync-function-cors-from-appconfig.sh + ./scripts/sync-function-cors-from-appconfig.sh + - name: Zip deploy Function App run: | set -euo pipefail diff --git a/apps/api/src/host-profiles.spec.ts b/apps/api/src/host-profiles.spec.ts index a5cabc1..2eebaec 100644 --- a/apps/api/src/host-profiles.spec.ts +++ b/apps/api/src/host-profiles.spec.ts @@ -18,19 +18,24 @@ describe('Function App host profiles', () => { assert.equal(seed['app:email:validation:domain'], 'mail.plattform-kit.poc.singletonsd.com'); }); - it('persists platform CORS exact origins alongside App Config ORIGINS globs', () => { + it('owns platform CORS via App Config sync script, not hardcoded bicep origins', () => { const root = path.resolve(__dirname, '../../..'); const bicep = readFileSync(path.join(root, 'infra/function-app.bicep'), 'utf8'); const seed = JSON.parse( readFileSync(path.join(root, 'infra/appconfig-seed.json'), 'utf8'), ) as Record; + const deploy = readFileSync(path.join(root, '.github/workflows/deploy-api.yml'), 'utf8'); + const syncScript = readFileSync( + path.join(root, 'scripts/sync-function-cors-from-appconfig.sh'), + 'utf8', + ); - assert.match(bicep, /cors:\s*\{/); - assert.match(bicep, /supportCredentials:\s*false/); - assert.match(bicep, /'https:\/\/inkads\.poc\.singletonsd\.com'/); - assert.match(bicep, /'https:\/\/plattform-kit\.poc\.singletonsd\.com'/); - assert.match(bicep, /'http:\/\/localhost:4321'/); + assert.doesNotMatch(bicep, /cors:\s*\{/); + assert.doesNotMatch(bicep, /allowedOrigins:/); + assert.match(syncScript, /platformCorsOriginsFromAppConfig/); + assert.match(deploy, /sync-function-cors-from-appconfig\.sh/); assert.ok(seed['app:email:origins']?.includes('*.poc.singletonsd.com')); assert.ok(seed['app:email:origins']?.includes('localhost:4321')); + assert.ok(seed['app:email:profilesByHost']?.includes('inkads.poc.singletonsd.com')); }); }); diff --git a/docs/integrations/inkads-marketing.md b/docs/integrations/inkads-marketing.md index 07047b8..e15a176 100644 --- a/docs/integrations/inkads-marketing.md +++ b/docs/integrations/inkads-marketing.md @@ -40,10 +40,11 @@ Seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json): `inkads.poc.singletonsd.com/pr-preview/pr-*` previews share the same allowed origin host. -**Also required:** Function App **platform CORS** exact origins (see -[`infra/function-app.bicep`](../../infra/function-app.bicep) `siteConfig.cors`). -App Config ORIGINS alone does not satisfy Linux Consumption browser preflight. -Full checklist: [`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md). +Platform CORS on the Function App is **synced from App Config** (profile hosts ++ exact ORIGINS entries) via `pnpm cors:sync` / +[`scripts/sync-function-cors-from-appconfig.sh`](../../scripts/sync-function-cors-from-appconfig.sh) +— not a second hardcoded list. Full checklist: +[`docs/onboarding/contact-consumer.md`](../onboarding/contact-consumer.md). ## Request shape diff --git a/docs/onboarding/contact-consumer.md b/docs/onboarding/contact-consumer.md index f3d1007..5f7fc08 100644 --- a/docs/onboarding/contact-consumer.md +++ b/docs/onboarding/contact-consumer.md @@ -6,18 +6,23 @@ PostKit `POST /contact`. Agents: also follow the [`singleton-sd/ai-plattform-skills`](https://github.com/singleton-sd/ai-plattform-skills) (installed via `pnpm sync:skills`). -Do **not** treat App Config `app:email:origins` alone as sufficient — Linux -Consumption handles `OPTIONS` at the **platform**. Browser preflight needs -Function App CORS **exact** origins **and** the App Config hostname allowlist. +**Source of truth for allowed hosts is App Configuration.** Platform CORS on +the Function App is **derived** from that config by +[`scripts/sync-function-cors-from-appconfig.sh`](../../scripts/sync-function-cors-from-appconfig.sh) +(`pnpm cors:sync`, also run by Deploy API). Linux Consumption still needs +platform CORS for browser `OPTIONS` — you do not edit a second hardcoded list +in bicep. -## Dual allowlists (required) +## How allowlists work | Surface | What to set | Notes | | --- | --- | --- | -| **Platform CORS** | Exact origin URLs on the Function App (`siteConfig.cors.allowedOrigins` in [`infra/function-app.bicep`](../../infra/function-app.bicep)) | e.g. `https://example.poc.singletonsd.com`, `http://localhost:4321`. No `*.poc…` globs. `supportCredentials: false`. Redeploy or `az functionapp cors add` so live matches bicep. | -| **App Config ORIGINS** | `app:email:origins` (seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json)) | Hostname allowlist / globs for reflecting `Access-Control-Allow-Origin` and resolving host profiles (e.g. `*.poc.singletonsd.com,localhost:4321`). | +| **App Config ORIGINS** | `app:email:origins` (+ seed) | Hostname allowlist / globs for app-layer `contactCorsHeaders` and trusted host resolution (e.g. `*.poc.singletonsd.com,localhost:4321`). | +| **App Config host profiles** | `app:email:profilesByHost` (+ seed) | Map of production host → from/inbox. **Every profile host is also pushed as an exact `https://…` platform CORS origin.** | +| **Platform CORS** | Synced automatically | Exact origin URLs = exact (non-glob) ORIGINS entries + every `profilesByHost` key. `localhost*` → `http://…`; other hosts → `https://…`. Globs cannot be expressed on the platform. | -Keep both in sync when adding a consumer. +After editing App Config (or the seed + portal), run `pnpm cors:sync` (or wait +for Deploy API) so the Function App preflight list matches. ## Checklist @@ -25,7 +30,8 @@ Keep both in sync when adding a consumer. 2. **Host profile** — Add an entry under `app:email:profilesByHost` for the production host: - `fromAddress` / `fromName` / `contactInboxAddress` - Prefer a dedicated sending subdomain (`noreply@mail.…`) when branding requires it. -3. **Platform CORS** — Add the exact `https://…` (and local `http://localhost:…` if needed) origins to `infra/function-app.bicep` `siteConfig.cors.allowedOrigins`. Apply live if bicep is not redeployed yet. + - This host is what platform CORS will allow as `https://`. +3. **Sync platform CORS** — `pnpm cors:sync` (or Deploy API). Confirm with `az functionapp cors show`. 4. **Sending domain (optional but required for new `fromAddress` domains)** — If the profile uses a new mail subdomain, add it to [`packages/post-kit-email/config/email-domains.json`](../../packages/post-kit-email/config/email-domains.json) and run `pnpm email:provision -- --domain ` (see [`docs/email-forward-email.md`](../email-forward-email.md)). Forward Email returns **400 Domain does not exist** until the domain exists on the account — that surfaces as HTTP **500** on `/contact` with `We could not send your message`. 5. **Public API base URL** — Consumers resolve `app:api:publicBaseUrl` from App Configuration at build time (see [`docs/integrations/inkads-marketing.md`](../integrations/inkads-marketing.md)). Do not invent a second source of truth. 6. **Preview header** — Same-host PR previews must send `X-PostKit-Contact-Preview: true` so PostKit uses the development sink instead of live delivery. See contact preview behaviour in the InkAds integration doc. @@ -40,3 +46,4 @@ Keep both in sync when adding a consumer. - [`docs/guides/public-forms.md`](../guides/public-forms.md) — trusted-server pattern for public forms - [`docs/email-forward-email.md`](../email-forward-email.md) — Forward Email + DNS provision - [`docs/onboarding/tenant-onboarding.md`](./tenant-onboarding.md) — authenticated tenant / template send (not this checklist) +- [`docs/operations/learnings-contact-cors-custom-domain.md`](../operations/learnings-contact-cors-custom-domain.md) — why platform CORS exists diff --git a/docs/operations/learnings-contact-cors-custom-domain.md b/docs/operations/learnings-contact-cors-custom-domain.md index e85e101..0646f54 100644 --- a/docs/operations/learnings-contact-cors-custom-domain.md +++ b/docs/operations/learnings-contact-cors-custom-domain.md @@ -12,9 +12,12 @@ the `postkit-contact-consumer` skill. 1. **Dual CORS is mandatory on Linux Consumption.** The Functions host answers browser `OPTIONS` before worker code runs. App Config `app:email:origins` + `contactCorsHeaders` only affect requests that reach the function (e.g. - `POST`). Platform CORS (`siteConfig.cors.allowedOrigins` / `az functionapp - cors`) needs **exact** `https://…` origins — no `*.poc…` globs. Persist - them in [`infra/function-app.bicep`](../../infra/function-app.bicep). + `POST`). Platform CORS still needs **exact** `https://…` origins — no + `*.poc…` globs. **Own the list in App Config** (`origins` exact hosts + + `profilesByHost` keys) and run + [`scripts/sync-function-cors-from-appconfig.sh`](../../scripts/sync-function-cors-from-appconfig.sh) + (`pnpm cors:sync`, Deploy API). Do not maintain a parallel hardcoded list + in bicep. 2. **Symptom split:** missing platform CORS → browser reports no `Access-Control-Allow-Origin` on preflight (often a bare `204`). Direct `POST` may still return app CORS headers. diff --git a/infra/README.md b/infra/README.md index cdb6eb9..938df7e 100644 --- a/infra/README.md +++ b/infra/README.md @@ -41,6 +41,11 @@ is first-run only — `scripts/seed-appconfig.sh` does **not** overwrite keys th already exist, so ops can edit in the portal. The Forward Email token is a Key Vault reference (`secret:forwardemail-api-key`), not a value in the store. +After seed, Deploy API runs +`scripts/sync-function-cors-from-appconfig.sh` so Function App **platform CORS** +matches App Config (exact ORIGINS hosts + `profilesByHost` keys). Local: +`pnpm cors:sync`. + The Function App only needs `AZURE_APPCONFIGURATION_ENDPOINT` plus host plumbing. It loads keys at request time via managed identity. diff --git a/infra/function-app.bicep b/infra/function-app.bicep index 46d38cb..499d6dc 100644 --- a/infra/function-app.bicep +++ b/infra/function-app.bicep @@ -115,18 +115,11 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { linuxFxVersion: 'Node|22' ftpsState: 'Disabled' minTlsVersion: '1.2' - // Linux Consumption handles OPTIONS at the platform. App-level CORS helpers - // and App Config ORIGINS alone do not satisfy browser preflight — keep these - // exact origins in sync with app:email:origins (hostname globs) when adding - // a marketing/contact consumer. Azure platform CORS does not support globs. - cors: { - allowedOrigins: [ - 'https://inkads.poc.singletonsd.com' - 'https://plattform-kit.poc.singletonsd.com' - 'http://localhost:4321' - ] - supportCredentials: false - } + // Platform CORS is owned by App Config → scripts/sync-function-cors-from-appconfig.sh + // (deploy-api runs it after seed). Do not hardcode allowedOrigins here — a stale + // list in bicep would fight the sync on infra redeploy. Linux Consumption still + // requires platform CORS for OPTIONS; the sync derives exact URLs from + // app:email:origins (exact hosts) + app:email:profilesByHost keys. appSettings: [ { name: 'AzureWebJobsStorage' diff --git a/package.json b/package.json index bb1e01d..7bc02bd 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "release:ci": "node ./scripts/release-changed.mjs --ci", "prepare": "husky", "pr:gate": "node scripts/pr-handoff-gate.mjs", - "test:pr-automation": "node scripts/pr-handoff-gate.test.mjs && node --test scripts/invoke-ps1.test.mjs && node --test scripts/github-releases.test.mjs && node --test scripts/publish-npm.test.mjs && node --test scripts/tenant-key-map.test.mjs", + "test:pr-automation": "node scripts/pr-handoff-gate.test.mjs && node --test scripts/invoke-ps1.test.mjs && node --test scripts/github-releases.test.mjs && node --test scripts/publish-npm.test.mjs && node --test scripts/tenant-key-map.test.mjs && node --test scripts/platform-cors-origins.test.mjs", "tenant:register-key": "./scripts/register-tenant-api-key.sh", "email:provision": "pnpm --filter @singleton-sd/post-kit-email provision", "validate:email-domain-branding": "pnpm --filter @singleton-sd/post-kit-email validate:domain-branding", + "cors:sync": "./scripts/sync-function-cors-from-appconfig.sh", "sync:skills": "npx --yes skills add singleton-sd/ai-plattform-skills --skill task-driven-development --skill \"Task-Driven Development\" --skill backend --skill frontend --skill postkit-contact-consumer -a cursor -a claude-code -a grok -a codex --copy -y", "bootstrap:worktree": "node ./scripts/invoke-bootstrap-worktree.mjs", "bootstrap:worktree:quick": "node ./scripts/invoke-bootstrap-worktree.mjs --quick-check", diff --git a/scripts/platform-cors-origins.mjs b/scripts/platform-cors-origins.mjs new file mode 100644 index 0000000..2bd21df --- /dev/null +++ b/scripts/platform-cors-origins.mjs @@ -0,0 +1,72 @@ +/** + * Derive Function App platform CORS allowedOrigins from App Config values. + * + * Azure platform CORS requires exact origin URLs (scheme + host[:port]). + * It does not support hostname globs like `*.poc.singletonsd.com`. + * + * Source of truth for *which* consumers exist is App Config: + * - exact entries in `app:email:origins` (no `*`) + * - every host key in `app:email:profilesByHost` + * + * Globs in ORIGINS still apply at the function layer (`contactCorsHeaders`) + * once the request reaches the worker; platform preflight uses this list. + */ + +/** + * @param {string | undefined} raw + * @returns {string[]} + */ +export function parseOriginsList(raw) { + if (!raw?.trim()) return []; + return raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean); +} + +/** + * @param {string} host hostname or host:port (no scheme) + * @returns {string} origin URL + */ +export function originUrlForHost(host) { + const normalized = host.trim().toLowerCase(); + if (!normalized) { + throw new Error('host must be non-empty'); + } + if (normalized === 'localhost' || normalized.startsWith('localhost:')) { + return `http://${normalized}`; + } + return `https://${normalized}`; +} + +/** + * @param {{ originsRaw?: string, profilesByHostRaw?: string }} input + * @returns {string[]} sorted unique origin URLs + */ +export function platformCorsOriginsFromAppConfig(input) { + const hosts = new Set(); + + for (const entry of parseOriginsList(input.originsRaw)) { + if (entry.includes('*')) continue; + hosts.add(entry.toLowerCase()); + } + + const profilesRaw = input.profilesByHostRaw?.trim(); + if (profilesRaw) { + let profiles; + try { + profiles = JSON.parse(profilesRaw); + } catch { + throw new Error('app:email:profilesByHost must be valid JSON'); + } + if (profiles === null || typeof profiles !== 'object' || Array.isArray(profiles)) { + throw new Error('app:email:profilesByHost must be a JSON object map by host'); + } + for (const host of Object.keys(profiles)) { + const h = host.trim().toLowerCase(); + if (h) hosts.add(h); + } + } + + return [...hosts].map(originUrlForHost).sort(); +} diff --git a/scripts/platform-cors-origins.test.mjs b/scripts/platform-cors-origins.test.mjs new file mode 100644 index 0000000..a507a96 --- /dev/null +++ b/scripts/platform-cors-origins.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + originUrlForHost, + parseOriginsList, + platformCorsOriginsFromAppConfig, +} from './platform-cors-origins.mjs'; + +describe('platformCorsOriginsFromAppConfig', () => { + it('maps profile hosts to https origins and exact ORIGINS localhost to http', () => { + const origins = platformCorsOriginsFromAppConfig({ + originsRaw: '*.poc.singletonsd.com,localhost:4321', + profilesByHostRaw: JSON.stringify({ + 'inkads.poc.singletonsd.com': { fromAddress: 'noreply@mail.inkads.poc.singletonsd.com' }, + 'plattform-kit.poc.singletonsd.com': { + fromAddress: 'noreply@mail.plattform-kit.poc.singletonsd.com', + }, + }), + }); + assert.deepEqual(origins, [ + 'http://localhost:4321', + 'https://inkads.poc.singletonsd.com', + 'https://plattform-kit.poc.singletonsd.com', + ]); + }); + + it('skips ORIGINS globs (platform CORS cannot express them)', () => { + const origins = platformCorsOriginsFromAppConfig({ + originsRaw: '*.poc.singletonsd.com,*.azurestaticapps.net', + profilesByHostRaw: '{}', + }); + assert.deepEqual(origins, []); + }); + + it('includes exact ORIGINS hosts even without a profile', () => { + const origins = platformCorsOriginsFromAppConfig({ + originsRaw: 'demo.example.com,localhost:3000', + profilesByHostRaw: undefined, + }); + assert.deepEqual(origins, ['http://localhost:3000', 'https://demo.example.com']); + }); + + it('dedupes and lowercases hosts', () => { + const origins = platformCorsOriginsFromAppConfig({ + originsRaw: 'InkAds.poc.singletonsd.com', + profilesByHostRaw: JSON.stringify({ + 'inkads.poc.singletonsd.com': { fromName: 'InkAds' }, + }), + }); + assert.deepEqual(origins, ['https://inkads.poc.singletonsd.com']); + }); + + it('rejects invalid profiles JSON', () => { + assert.throws( + () => + platformCorsOriginsFromAppConfig({ + originsRaw: 'localhost:4321', + profilesByHostRaw: '{bad', + }), + /profilesByHost must be valid JSON/, + ); + }); +}); + +describe('parseOriginsList / originUrlForHost', () => { + it('parses comma lists', () => { + assert.deepEqual(parseOriginsList(' a ,b,'), ['a', 'b']); + assert.deepEqual(parseOriginsList(''), []); + }); + + it('uses http only for localhost', () => { + assert.equal(originUrlForHost('localhost:4321'), 'http://localhost:4321'); + assert.equal(originUrlForHost('example.com'), 'https://example.com'); + }); +}); diff --git a/scripts/sync-function-cors-from-appconfig.sh b/scripts/sync-function-cors-from-appconfig.sh new file mode 100755 index 0000000..1acb475 --- /dev/null +++ b/scripts/sync-function-cors-from-appconfig.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Sync Function App platform CORS from App Configuration. +# +# Linux Consumption answers OPTIONS at the platform. App Config ORIGINS alone +# does not fix browser preflight — this script pushes exact origin URLs derived +# from App Config onto the Function App. +# +# Derivation (see scripts/platform-cors-origins.mjs): +# - exact (non-glob) entries in app:email:origins +# - every host key in app:email:profilesByHost +# - localhost* → http://… ; other hosts → https://… +# +# Usage: +# ./scripts/sync-function-cors-from-appconfig.sh +# ./scripts/sync-function-cors-from-appconfig.sh --dry-run +# ./scripts/sync-function-cors-from-appconfig.sh --from-seed infra/appconfig-seed.json +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STORE="${APP_CONFIG_NAME:-ssd-postkit-appcs-prod-ae}" +RG="${AZURE_RESOURCE_GROUP:-rg-postkit-prod-ae}" +APP="${AZURE_FUNCTIONAPP_NAME:-ssd-postkit-api-prod-ae}" +DRY_RUN=0 +SEED="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --from-seed) + SEED="${2:-}" + [[ -n "$SEED" ]] || { echo "error: --from-seed requires a path" >&2; exit 1; } + shift 2 + ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -n "$SEED" ]]; then + [[ -f "$SEED" ]] || { echo "error: seed file not found: $SEED" >&2; exit 1; } + ORIGINS_RAW="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1],encoding="utf-8")).get("app:email:origins",""))' "$SEED")" + PROFILES_RAW="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1],encoding="utf-8")).get("app:email:profilesByHost",""))' "$SEED")" + echo "source=seed file=$SEED" +else + ORIGINS_RAW="$(az appconfig kv show --name "$STORE" --key app:email:origins --query value -o tsv)" + PROFILES_RAW="$(az appconfig kv show --name "$STORE" --key app:email:profilesByHost --query value -o tsv)" + echo "source=appconfig store=$STORE" +fi + +DESIRED_JSON="$( + cd "$ROOT" + ORIGINS_RAW="$ORIGINS_RAW" PROFILES_RAW="$PROFILES_RAW" node --input-type=module <<'EOF' +import { platformCorsOriginsFromAppConfig } from './scripts/platform-cors-origins.mjs'; +const origins = platformCorsOriginsFromAppConfig({ + originsRaw: process.env.ORIGINS_RAW, + profilesByHostRaw: process.env.PROFILES_RAW, +}); +process.stdout.write(JSON.stringify(origins)); +EOF +)" + +echo "desired=$DESIRED_JSON" + +CURRENT_JSON="$(az functionapp cors show --name "$APP" --resource-group "$RG" --query allowedOrigins -o json 2>/dev/null || echo '[]')" +echo "current=$CURRENT_JSON" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "dry-run: no changes applied" + exit 0 +fi + +# Idempotent replace via ARM web config (avoids add/remove races). +python3 - "$APP" "$RG" "$DESIRED_JSON" <<'PY' +import json +import subprocess +import sys + +app, rg, desired_json = sys.argv[1:] +desired = json.loads(desired_json) + +show = subprocess.run( + [ + "az", + "functionapp", + "show", + "--name", + app, + "--resource-group", + rg, + "--query", + "id", + "-o", + "tsv", + ], + check=True, + capture_output=True, + text=True, +) +site_id = show.stdout.strip() +config_id = f"{site_id}/config/web" + +get = subprocess.run( + ["az", "rest", "--method", "get", "--url", f"{config_id}?api-version=2023-12-01"], + check=True, + capture_output=True, + text=True, +) +body = json.loads(get.stdout) +props = body.setdefault("properties", {}) +cors = props.setdefault("cors", {}) +cors["allowedOrigins"] = desired +cors["supportCredentials"] = False + +put = subprocess.run( + [ + "az", + "rest", + "--method", + "put", + "--url", + f"{config_id}?api-version=2023-12-01", + "--body", + json.dumps({"properties": props}), + ], + check=False, + capture_output=True, + text=True, +) +if put.returncode != 0: + sys.stderr.write(put.stderr or put.stdout) + raise SystemExit(put.returncode) +print(f"applied platform CORS ({len(desired)} origin(s)) on {app}") +PY + +az functionapp cors show --name "$APP" --resource-group "$RG" -o json