Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/host-profiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,25 @@ 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('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<string, string>;
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.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'));
});
});
5 changes: 4 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ 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)
│ ├── 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)
```
Expand All @@ -71,6 +73,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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/integrations/inkads-marketing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ Seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json):
`inkads.poc.singletonsd.com/pr-preview/pr-*` previews share the same allowed
origin host.

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

`POST /contact` body (browser → PostKit):
Expand Down Expand Up @@ -115,6 +121,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`
49 changes: 49 additions & 0 deletions docs/onboarding/contact-consumer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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`).

**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.

## How allowlists work

| Surface | What to set | Notes |
| --- | --- | --- |
| **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. |

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

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.<consumer>…`) when branding requires it.
- This host is what platform CORS will allow as `https://<host>`.
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 <mail-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://<consumer-host>` → `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)
- [`docs/operations/learnings-contact-cors-custom-domain.md`](../operations/learnings-contact-cors-custom-domain.md) — why platform CORS exists
67 changes: 67 additions & 0 deletions docs/operations/learnings-contact-cors-custom-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 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 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.
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.<consumer>…` 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.<host>` 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) |
5 changes: 5 additions & 0 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions infra/function-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
linuxFxVersion: 'Node|22'
ftpsState: 'Disabled'
minTlsVersion: '1.2'
// 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'
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
"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",
"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",
"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",
"worktree:add": "node ./scripts/invoke-worktree-add.mjs",
Expand Down
72 changes: 72 additions & 0 deletions scripts/platform-cors-origins.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading