From baa2af1e664583611a4236b1ae13a44edd76c9a3 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 03:48:43 +1000 Subject: [PATCH 1/5] feat: #8 Add contact Function App with host profiles Azure Functions POST /contact and GET /health on Consumption in rg-ssd-global. CONTACT_EMAIL_PROFILES_BY_HOST is a Function app setting so allowlisted Origins pick the right sender and inbox. --- .github/workflows/deploy-api.yml | 117 +++++++++++++++++ apps/README.md | 7 +- apps/api/.gitignore | 2 + apps/api/README.md | 13 ++ apps/api/host.json | 20 +++ apps/api/local.settings.json.example | 17 +++ apps/api/package.json | 24 ++++ apps/api/src/contact-rate-limit.ts | 63 +++++++++ apps/api/src/contact.spec.ts | 183 +++++++++++++++++++++++++++ apps/api/src/contact.ts | 114 +++++++++++++++++ apps/api/src/functions/contact.ts | 80 ++++++++++++ apps/api/src/functions/health.ts | 11 ++ apps/api/src/host-profiles.spec.ts | 16 +++ apps/api/src/index.ts | 2 + apps/api/src/origins.spec.ts | 49 +++++++ apps/api/src/origins.ts | 47 +++++++ apps/api/tsconfig.json | 16 +++ docs/README.md | 2 +- docs/architecture/overview.md | 4 +- docs/email-forward-email.md | 4 +- docs/pr-pipelines.md | 1 + infra/README.md | 19 +++ infra/function-app.bicep | 174 +++++++++++++++++++++++++ pnpm-lock.yaml | 47 +++++++ 24 files changed, 1023 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/deploy-api.yml create mode 100644 apps/api/.gitignore create mode 100644 apps/api/README.md create mode 100644 apps/api/host.json create mode 100644 apps/api/local.settings.json.example create mode 100644 apps/api/package.json create mode 100644 apps/api/src/contact-rate-limit.ts create mode 100644 apps/api/src/contact.spec.ts create mode 100644 apps/api/src/contact.ts create mode 100644 apps/api/src/functions/contact.ts create mode 100644 apps/api/src/functions/health.ts create mode 100644 apps/api/src/host-profiles.spec.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/origins.spec.ts create mode 100644 apps/api/src/origins.ts create mode 100644 apps/api/tsconfig.json create mode 100644 infra/README.md create mode 100644 infra/function-app.bicep diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..c57f295 --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,117 @@ +# PostKit Function App — Linux Consumption in rg-ssd-global. +# Secrets: NEVER in GitHub Secrets. OIDC → Key Vault `forwardemail-api-key`. +# Required Variables: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID. +# If OIDC Variables are missing, deploy steps SKIP (job succeeds). +name: Deploy API (Function) + +on: + push: + branches: [main] + paths: + - 'apps/api/**' + - 'packages/post-kit-email/**' + - 'infra/function-app.bicep' + - '.github/workflows/deploy-api.yml' + workflow_dispatch: + +concurrency: + group: deploy-api-production + cancel-in-progress: false + +env: + AZURE_RESOURCE_GROUP: rg-ssd-global + AZURE_FUNCTIONAPP_NAME: ssd-postkit-api-prod-ae + AZURE_KEY_VAULT_NAME: ssd-global-kv-prod-ae + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + name: Build + Function App + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Test API + run: pnpm --filter @singleton-sd/post-kit-api run test + + - name: Check OIDC + id: cfg + run: | + set -euo pipefail + if [ -z "${{ vars.AZURE_CLIENT_ID }}" ] || [ -z "${{ vars.AZURE_TENANT_ID }}" ] || [ -z "${{ vars.AZURE_SUBSCRIPTION_ID }}" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "OIDC Variables not set — skipping Function App deploy." + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Azure login (OIDC) + if: steps.cfg.outputs.configured == 'true' + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Assert KV secret exists + if: steps.cfg.outputs.configured == 'true' + run: | + set -euo pipefail + az keyvault secret show \ + --vault-name "$AZURE_KEY_VAULT_NAME" \ + --name forwardemail-api-key \ + --query name -o tsv >/dev/null + + - name: Deploy Function App infra + if: steps.cfg.outputs.configured == 'true' + run: | + set -euo pipefail + az deployment group create \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --template-file infra/function-app.bicep \ + --name "postkit-api-${GITHUB_RUN_ID}" + + - name: Stage zip + if: steps.cfg.outputs.configured == 'true' + run: | + set -euo pipefail + pnpm --filter @singleton-sd/post-kit-email run build + pnpm --filter @singleton-sd/post-kit-api run build + STAGE=$(mktemp -d) + cp apps/api/host.json "$STAGE/" + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('apps/api/package.json','utf8')); + delete pkg.dependencies['@singleton-sd/post-kit-email']; + fs.writeFileSync(process.argv[1] + '/package.json', JSON.stringify(pkg, null, 2)); + " "$STAGE" + cp -r apps/api/dist "$STAGE/dist" + (cd "$STAGE" && npm install --omit=dev --package-lock=false) + mkdir -p "$STAGE/node_modules/@singleton-sd/post-kit-email" + cp packages/post-kit-email/package.json "$STAGE/node_modules/@singleton-sd/post-kit-email/" + cp -r packages/post-kit-email/dist "$STAGE/node_modules/@singleton-sd/post-kit-email/dist" + test -f "$STAGE/node_modules/@singleton-sd/post-kit-email/dist/index.js" + (cd "$STAGE" && zip -r "$GITHUB_WORKSPACE/post-kit-api.zip" .) + + - name: Zip deploy Function App + if: steps.cfg.outputs.configured == 'true' + run: | + set -euo pipefail + az functionapp deployment source config-zip \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --name "$AZURE_FUNCTIONAPP_NAME" \ + --src post-kit-api.zip diff --git a/apps/README.md b/apps/README.md index 50ef41d..e4c879e 100644 --- a/apps/README.md +++ b/apps/README.md @@ -1,6 +1,5 @@ # Apps -Azure Functions API will live here as `apps/api`. - -That app is not created in this bootstrap PR. Later epics add the contact/send -Function App that trusted consumers call. +| App | Package | Role | +| --- | --- | --- | +| [`api`](./api/) | `@singleton-sd/post-kit-api` | Azure Functions: `POST /contact`, `GET /health` | diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..c6290c2 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,2 @@ +dist/ +local.settings.json diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..02ad7a3 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,13 @@ +# `@singleton-sd/post-kit-api` + +Azure Functions (anonymous contact + health). Trusted marketing sites POST +`/contact` with an allowlisted `Origin`. Host-specific sender/inbox comes from +`CONTACT_EMAIL_PROFILES_BY_HOST`. + +```bash +pnpm --filter @singleton-sd/post-kit-api test +pnpm --filter @singleton-sd/post-kit-api start +``` + +See [`docs/email-forward-email.md`](../../docs/email-forward-email.md) and +[`infra/README.md`](../../infra/README.md). diff --git a/apps/api/host.json b/apps/api/host.json new file mode 100644 index 0000000..1c14b6a --- /dev/null +++ b/apps/api/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "http": { + "routePrefix": "" + } + } +} diff --git a/apps/api/local.settings.json.example b/apps/api/local.settings.json.example new file mode 100644 index 0000000..a026e5b --- /dev/null +++ b/apps/api/local.settings.json.example @@ -0,0 +1,17 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", + "ORIGINS": "*.poc.singletonsd.com,localhost:4321", + "EMAIL_PROVIDER": "development", + "EMAIL_FROM_ADDRESS": "noreply@mail.plattform-kit.poc.singletonsd.com", + "EMAIL_FROM_NAME": "Plattform Kit", + "CONTACT_INBOX_ADDRESS": "hello@singletonsd.com", + "CONTACT_EMAIL_PROFILES_BY_HOST": "{\"inkads.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.inkads.poc.singletonsd.com\",\"fromName\":\"InkAds\",\"contactInboxAddress\":\"inkads-support@singletonsd.com\"},\"plattform-kit.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.plattform-kit.poc.singletonsd.com\",\"fromName\":\"Plattform Kit\",\"contactInboxAddress\":\"hello@singletonsd.com\"}}", + "FORWARD_EMAIL_TOKEN": "", + "FORWARD_EMAIL_BASE_URL": "https://api.forwardemail.net", + "EMAIL_ALLOW_PRODUCTION_SEND": "", + "CONTACT_RATE_LIMIT_PER_MIN": "5" + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..70f2895 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,24 @@ +{ + "name": "@singleton-sd/post-kit-api", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", + "lint": "echo \"lint:api — covered by root eslint on staged files\"", + "test": "pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test src/**/*.spec.ts", + "start": "func start" + }, + "dependencies": { + "@azure/functions": "^4.6.0", + "@singleton-sd/post-kit-email": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts new file mode 100644 index 0000000..0276a97 --- /dev/null +++ b/apps/api/src/contact-rate-limit.ts @@ -0,0 +1,63 @@ +/** In-memory sliding-window limiter for anonymous Contact (PoC). */ + +export interface RateLimitResult { + allowed: boolean; + retryAfterSec: number; +} + +export class SlidingWindowRateLimiter { + private readonly hits = new Map(); + + constructor( + private readonly maxHits: number, + private readonly windowMs: number, + ) {} + + tryConsume(key: string, now = Date.now()): RateLimitResult { + const cutoff = now - this.windowMs; + const recent = (this.hits.get(key) ?? []).filter((t) => t > cutoff); + if (recent.length >= this.maxHits) { + const oldest = recent[0] ?? now; + const retryAfterSec = Math.max(1, Math.ceil((oldest + this.windowMs - now) / 1000)); + this.hits.set(key, recent); + return { allowed: false, retryAfterSec }; + } + recent.push(now); + this.hits.set(key, recent); + return { allowed: true, retryAfterSec: 0 }; + } + + /** Test helper — clear all buckets. */ + reset(): void { + this.hits.clear(); + } +} + +const DEFAULT_MAX = 5; +const DEFAULT_WINDOW_MS = 60_000; + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (!raw?.trim()) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** Shared limiter for the Function process (resets on cold start / scale-out). */ +export const contactRateLimiter = new SlidingWindowRateLimiter( + parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_MAX), + parsePositiveInt(process.env.CONTACT_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), +); + +export function clientIpFromHeaders(headers: { get(name: string): string | null }): string { + const xff = headers.get('x-forwarded-for'); + if (xff) { + const first = xff.split(',')[0]?.trim(); + if (first) return first; + } + return ( + headers.get('x-client-ip')?.trim() || + headers.get('x-real-ip')?.trim() || + headers.get('x-azure-clientip')?.trim() || + 'unknown' + ); +} diff --git a/apps/api/src/contact.spec.ts b/apps/api/src/contact.spec.ts new file mode 100644 index 0000000..4ee5b61 --- /dev/null +++ b/apps/api/src/contact.spec.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { DevelopmentEmailProvider } from '@singleton-sd/post-kit-email'; +import { + buildContactEmailRequest, + contactCorsHeaders, + resolveContactEmailProvider, + resolveTrustedContactHost, + submitContactInquiry, + validateContactInquiry, +} from './contact'; +import { SlidingWindowRateLimiter, clientIpFromHeaders } from './contact-rate-limit'; + +function withEnv(keys: string[], run: () => void | Promise): Promise { + const prior = new Map(); + for (const key of keys) { + prior.set(key, process.env[key]); + } + return Promise.resolve() + .then(run) + .finally(() => { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +} + +describe('validateContactInquiry', () => { + it('accepts a valid payload', () => { + const result = validateContactInquiry({ + name: 'Jane Doe', + email: 'jane@acme.com', + subject: 'sales', + message: 'I would like a demo of Platform Kit.', + }); + assert.equal(result.ok, true); + }); + + it('rejects CR/LF/NUL in name (header-injection surface)', () => { + const withCrLf = validateContactInquiry({ + name: 'Alice\r\nBcc: victim@evil.com', + email: 'jane@acme.com', + subject: 'sales', + message: 'I would like a demo of Platform Kit.', + }); + assert.equal(withCrLf.ok, false); + }); +}); + +describe('contact send', () => { + it('forces development provider for SWA preview origins', () => { + const provider = resolveContactEmailProvider('https://nice-wave-123.azurestaticapps.net', { + EMAIL_PROVIDER: 'forward-email', + FORWARD_EMAIL_TOKEN: 'secret', + EMAIL_ALLOW_PRODUCTION_SEND: 'true', + }); + assert.equal(provider.name, 'development'); + }); + + it('applies host-based sender profile override when configured', async () => { + const email = new DevelopmentEmailProvider({ logMetadata: false }); + const result = await submitContactInquiry( + { + name: 'Jane Doe', + email: 'jane@acme.com', + subject: 'support', + message: 'Please help with setup details for this PoC.', + }, + { + requestOrigin: 'https://inkads.poc.singletonsd.com', + email, + env: { + ORIGINS: 'inkads.poc.singletonsd.com', + EMAIL_FROM_ADDRESS: 'noreply@mail.plattform-kit.poc.singletonsd.com', + EMAIL_FROM_NAME: 'Plattform Kit', + CONTACT_INBOX_ADDRESS: 'hello@singletonsd.com', + CONTACT_EMAIL_PROFILES_BY_HOST: JSON.stringify({ + 'inkads.poc.singletonsd.com': { + fromAddress: 'noreply@mail.inkads.poc.singletonsd.com', + fromName: 'InkAds', + contactInboxAddress: 'inkads-support@singletonsd.com', + }, + }), + }, + }, + ); + assert.equal(result.status, 'sent'); + assert.equal(email.sent[0]?.to, 'inkads-support@singletonsd.com'); + assert.match(String(email.sent[0]?.from), /noreply@mail\.inkads\.poc\.singletonsd\.com/); + }); + + it('ignores host profile overrides for untrusted Origin hosts', async () => { + const email = new DevelopmentEmailProvider({ logMetadata: false }); + const result = await submitContactInquiry( + { + name: 'Jane Doe', + email: 'jane@acme.com', + subject: 'support', + message: 'Please help with setup details for this PoC.', + }, + { + requestOrigin: 'https://evil.example.com', + email, + env: { + ORIGINS: 'inkads.poc.singletonsd.com', + EMAIL_FROM_ADDRESS: 'noreply@mail.plattform-kit.poc.singletonsd.com', + CONTACT_INBOX_ADDRESS: 'hello@singletonsd.com', + CONTACT_EMAIL_PROFILES_BY_HOST: JSON.stringify({ + 'evil.example.com': { + fromAddress: 'noreply@mail.inkads.poc.singletonsd.com', + contactInboxAddress: 'inkads-support@singletonsd.com', + }, + }), + }, + }, + ); + assert.equal(result.status, 'sent'); + assert.equal(email.sent[0]?.to, 'hello@singletonsd.com'); + }); +}); + +describe('resolveTrustedContactHost', () => { + it('returns the host when Origin matches ORIGINS allowlist', () => { + assert.equal( + resolveTrustedContactHost('https://inkads.poc.singletonsd.com', { + ORIGINS: 'inkads.poc.singletonsd.com', + }), + 'inkads.poc.singletonsd.com', + ); + }); + + it('returns null for hosts outside the allowlist', () => { + assert.equal( + resolveTrustedContactHost('https://evil.example.com', { + ORIGINS: 'inkads.poc.singletonsd.com', + }), + null, + ); + }); +}); + +describe('contactCorsHeaders', () => { + it('reflects allowed marketing origins', async () => { + await withEnv(['ORIGINS'], () => { + process.env.ORIGINS = 'plattform-kit.poc.singletonsd.com,localhost:4321'; + const headers = contactCorsHeaders('https://plattform-kit.poc.singletonsd.com'); + assert.equal( + headers['Access-Control-Allow-Origin'], + 'https://plattform-kit.poc.singletonsd.com', + ); + }); + }); + + it('omits Allow-Origin for unknown hosts', async () => { + await withEnv(['ORIGINS'], () => { + process.env.ORIGINS = 'plattform-kit.poc.singletonsd.com'; + const headers = contactCorsHeaders('https://evil.example'); + assert.equal(headers['Access-Control-Allow-Origin'], undefined); + }); + }); +}); + +describe('SlidingWindowRateLimiter', () => { + it('allows up to max then returns 429-style deny', () => { + const limiter = new SlidingWindowRateLimiter(2, 60_000); + const t0 = 1_000_000; + assert.equal(limiter.tryConsume('1.1.1.1', t0).allowed, true); + assert.equal(limiter.tryConsume('1.1.1.1', t0 + 1).allowed, true); + const denied = limiter.tryConsume('1.1.1.1', t0 + 2); + assert.equal(denied.allowed, false); + assert.ok(denied.retryAfterSec >= 1); + }); + + it('isolates keys and reads first X-Forwarded-For hop', () => { + const limiter = new SlidingWindowRateLimiter(1, 60_000); + assert.equal(limiter.tryConsume('a', 1).allowed, true); + assert.equal(limiter.tryConsume('b', 1).allowed, true); + + const headers = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1']]); + assert.equal(clientIpFromHeaders({ get: (n) => headers.get(n) ?? null }), '203.0.113.9'); + }); +}); diff --git a/apps/api/src/contact.ts b/apps/api/src/contact.ts new file mode 100644 index 0000000..40e1dbb --- /dev/null +++ b/apps/api/src/contact.ts @@ -0,0 +1,114 @@ +import { + createEmailProvider, + DevelopmentEmailProvider, + sendContactInquiryEmail, + validateContactInquiry, + type EmailProvider, +} from '@singleton-sd/post-kit-email'; +import { isAllowedHostname, parseOrigins } from './origins'; + +export { + buildContactEmailRequest, + CONTACT_SUBJECTS, + hasForbiddenControls, + validateContactInquiry, + type ContactInquiryInput, + type ContactSubject, +} from '@singleton-sd/post-kit-email'; + +/** + * Resolve a trusted marketing-site host from Origin using the ORIGINS allowlist. + * Untrusted or missing Origin values return null so host-profile overrides are skipped. + */ +export function resolveTrustedContactHost( + requestOrigin: string | null | undefined, + env: NodeJS.ProcessEnv = process.env, +): string | null { + if (!requestOrigin) return null; + + let allowlist: string[]; + try { + allowlist = parseOrigins(env.ORIGINS); + } catch { + return null; + } + + try { + const host = new URL(requestOrigin).host; + return isAllowedHostname(host, allowlist) ? host.toLowerCase() : null; + } catch { + return null; + } +} + +/** + * Preview SWA hosts must not trigger real outbound email against the shared + * Function App unless EMAIL_ALLOW_PREVIEW_SEND=true. + */ +export function resolveContactEmailProvider( + requestOrigin: string | null, + env: NodeJS.ProcessEnv = process.env, +): EmailProvider { + if (requestOrigin && env.EMAIL_ALLOW_PREVIEW_SEND !== 'true') { + try { + const host = new URL(requestOrigin).host.toLowerCase(); + if (host.endsWith('.azurestaticapps.net') || host.startsWith('localhost')) { + return new DevelopmentEmailProvider({ logMetadata: true }); + } + } catch { + // fall through to configured provider + } + } + return createEmailProvider(env); +} + +export async function submitContactInquiry( + body: unknown, + options: { + requestOrigin?: string | null; + email?: EmailProvider; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise<{ id: string; status: 'sent' }> { + const validated = validateContactInquiry(body); + if (!validated.ok) { + const error = new Error(validated.error); + (error as Error & { status: number }).status = validated.status; + throw error; + } + + const env = options.env ?? process.env; + const email = options.email ?? resolveContactEmailProvider(options.requestOrigin ?? null, env); + const result = await sendContactInquiryEmail(validated.value, email, env, { + trustedRequestHost: resolveTrustedContactHost(options.requestOrigin ?? null, env), + }); + return { id: result.id, status: result.status }; +} + +/** CORS: reflect Origin when it matches the ORIGINS allowlist. */ +export function contactCorsHeaders(requestOrigin: string | null): Record { + const headers: Record = { + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept', + 'Access-Control-Max-Age': '86400', + }; + if (!requestOrigin) return headers; + + let allowlist: string[]; + try { + allowlist = parseOrigins(process.env.ORIGINS); + } catch { + return headers; + } + + try { + const host = new URL(requestOrigin).host; + if (isAllowedHostname(host, allowlist)) { + headers['Access-Control-Allow-Origin'] = requestOrigin; + headers.Vary = 'Origin'; + } + } catch { + return headers; + } + return headers; +} diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts new file mode 100644 index 0000000..14b2dda --- /dev/null +++ b/apps/api/src/functions/contact.ts @@ -0,0 +1,80 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { EmailProviderError } from '@singleton-sd/post-kit-email'; +import { contactCorsHeaders, submitContactInquiry } from '../contact'; +import { clientIpFromHeaders, contactRateLimiter } from '../contact-rate-limit'; + +export async function contactHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const origin = request.headers.get('origin'); + const cors = contactCorsHeaders(origin); + + if (request.method === 'OPTIONS') { + return { status: 204, headers: cors }; + } + + const ip = clientIpFromHeaders(request.headers); + const limit = contactRateLimiter.tryConsume(ip); + if (!limit.allowed) { + return { + status: 429, + headers: { + ...cors, + 'Content-Type': 'application/json', + 'Retry-After': String(limit.retryAfterSec), + }, + jsonBody: { error: 'Too many messages were sent. Please wait a minute and try again.' }, + }; + } + + try { + const body = await request.json().catch(() => null); + const result = await submitContactInquiry(body, { requestOrigin: origin }); + return { + status: 202, + headers: { ...cors, 'Content-Type': 'application/json' }, + jsonBody: result, + }; + } catch (error) { + context.error('contact failed', { + name: error instanceof Error ? error.name : 'Error', + kind: error instanceof EmailProviderError ? error.kind : undefined, + statusCode: error instanceof EmailProviderError ? error.statusCode : undefined, + correlationId: error instanceof EmailProviderError ? error.correlationId : undefined, + }); + + const statusFromValidation = + error instanceof Error && 'status' in error + ? Number((error as Error & { status?: number }).status) + : undefined; + if (statusFromValidation === 400) { + return { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + jsonBody: { error: error instanceof Error ? error.message : 'Invalid request' }, + }; + } + + const unavailable = + error instanceof EmailProviderError && + (error.kind === 'configuration' || error.kind === 'rate_limit' || error.kind === 'transient'); + + return { + status: unavailable ? 503 : 500, + headers: { ...cors, 'Content-Type': 'application/json' }, + jsonBody: { + error: unavailable + ? 'Contact delivery is temporarily unavailable. Please try again later.' + : 'We could not send your message. Please try again shortly.', + }, + }; + } +} + +app.http('contact', { + methods: ['POST', 'OPTIONS'], + authLevel: 'anonymous', + route: 'contact', + handler: contactHandler, +}); diff --git a/apps/api/src/functions/health.ts b/apps/api/src/functions/health.ts new file mode 100644 index 0000000..4ed3908 --- /dev/null +++ b/apps/api/src/functions/health.ts @@ -0,0 +1,11 @@ +import { app, HttpResponseInit } from '@azure/functions'; + +app.http('health', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'health', + handler: async (): Promise => ({ + status: 200, + jsonBody: { status: 'ok', service: 'post-kit-api' }, + }), +}); diff --git a/apps/api/src/host-profiles.spec.ts b/apps/api/src/host-profiles.spec.ts new file mode 100644 index 0000000..c1f87f4 --- /dev/null +++ b/apps/api/src/host-profiles.spec.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +describe('Function App host profiles', () => { + it('wires CONTACT_EMAIL_PROFILES_BY_HOST in bicep app settings', () => { + const bicep = readFileSync( + path.resolve(__dirname, '../../../infra/function-app.bicep'), + 'utf8', + ); + assert.match(bicep, /name: 'CONTACT_EMAIL_PROFILES_BY_HOST'/); + assert.match(bicep, /param contactEmailProfilesByHost string/); + assert.match(bicep, /inkads\.poc\.singletonsd\.com/); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..afa58ca --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,2 @@ +import './functions/contact'; +import './functions/health'; diff --git a/apps/api/src/origins.spec.ts b/apps/api/src/origins.spec.ts new file mode 100644 index 0000000..5dd153a --- /dev/null +++ b/apps/api/src/origins.spec.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isAllowedHostname, parseOrigins } from './origins'; + +describe('parseOrigins', () => { + it('splits and trims hostnames', () => { + assert.deepEqual(parseOrigins('a.example.com, localhost:4321 '), [ + 'a.example.com', + 'localhost:4321', + ]); + }); + + it('rejects empty ORIGINS', () => { + assert.throws(() => parseOrigins(''), /ORIGINS/); + assert.throws(() => parseOrigins(undefined), /ORIGINS/); + }); +}); + +describe('isAllowedHostname', () => { + const marketingSwa = 'purple-field-05048bf00*.azurestaticapps.net'; + const allowlist = ['plattform-kit.poc.singletonsd.com', marketingSwa, 'localhost:4321']; + + it('allows exact custom-domain and localhost hosts', () => { + assert.equal(isAllowedHostname('plattform-kit.poc.singletonsd.com', allowlist), true); + assert.equal(isAllowedHostname('localhost:4321', allowlist), true); + }); + + it('allows marketing SWA default and PR preview hosts', () => { + assert.equal( + isAllowedHostname('purple-field-05048bf00.7.azurestaticapps.net', allowlist), + true, + ); + assert.equal( + isAllowedHostname('purple-field-05048bf00-91.eastasia.7.azurestaticapps.net', allowlist), + true, + ); + }); + + it('rejects other SWA instances and open multi-tenant wildcards', () => { + assert.equal( + isAllowedHostname('kind-rock-0f409fe00-57.eastasia.7.azurestaticapps.net', allowlist), + false, + ); + assert.equal( + isAllowedHostname('attacker.7.azurestaticapps.net', ['*.azurestaticapps.net']), + false, + ); + }); +}); diff --git a/apps/api/src/origins.ts b/apps/api/src/origins.ts new file mode 100644 index 0000000..9a1cf5e --- /dev/null +++ b/apps/api/src/origins.ts @@ -0,0 +1,47 @@ +const AZURE_SWA_ROOT = 'azurestaticapps.net'; +const SWA_INSTANCE_SUFFIX = `*.${AZURE_SWA_ROOT}`; + +export function parseOrigins(raw: string | undefined): string[] { + if (!raw?.trim()) { + throw new Error('ORIGINS must be a comma-separated list of allowed hostnames'); + } + return raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean); +} + +/** + * Allow exact hostnames, generic `*` globs, and marketing SWA instance prefixes + * (`purple-field-05048bf00*.azurestaticapps.net`) for default + PR preview hosts. + * Do not use open `*.azurestaticapps.net` (any Azure customer’s SWA). + */ +export function isAllowedHostname(host: string, origins: readonly string[]): boolean { + for (const entry of origins) { + if (entry === host) { + return true; + } + + if (entry.endsWith(SWA_INSTANCE_SUFFIX)) { + const swaName = entry.slice(0, -SWA_INSTANCE_SUFFIX.length); + if (!swaName || swaName.includes('*')) { + continue; + } + if (!host.endsWith(`.${AZURE_SWA_ROOT}`)) { + continue; + } + if (host.startsWith(`${swaName}.`) || host.startsWith(`${swaName}-`)) { + return true; + } + continue; + } + + if (entry.includes('*')) { + const regex = new RegExp(`^${entry.replace(/\./g, '\\.').replace(/\*/g, '[\\w_.-]+')}$`); + if (regex.test(host)) { + return true; + } + } + } + return false; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..63a5d56 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/docs/README.md b/docs/README.md index fe0f025..dd3dbaf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,4 +40,4 @@ docs/ | [`github-project.md`](./github-project.md) | PostKit Engineering project fields, views, labels | | [`pr-pipelines.md`](./pr-pipelines.md) | PR CI, release, secrets policy | | [`architecture/overview.md`](./architecture/overview.md) | Phase-1 architecture: Functions API, EmailProvider, consumers | -| [`email-forward-email.md`](./email-forward-email.md) | Forward Email provider, DNS, `pnpm email:provision` | +| [`email-forward-email.md`](./email-forward-email.md) | Forward Email provider, DNS, `pnpm email:provision`, Function contact | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 373c148..7f15ce8 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -17,7 +17,7 @@ technical knowledge. For the full list of docs, see Consumer app (trusted server) | v -PostKit Functions API (apps/api) -- later epic +PostKit Functions API (apps/api) | v @singleton-sd/post-kit-email @@ -30,7 +30,7 @@ Forward Email → per-tenant mail domains | Piece | Role | Status | | --- | --- | --- | -| **Functions API** (`apps/api`) | Contact/send HTTP surface on Azure Functions Consumption | Planned | +| **Functions API** (`apps/api`) | Contact/send HTTP surface on Azure Functions Consumption | `ssd-postkit-api-prod-ae` | | **EmailProvider** | Swap development logging vs Forward Email production send | `@singleton-sd/post-kit-email` | | **Forward Email** | Production delivery + per-tenant mail domain provisioning | `pnpm email:provision` | | **Public npm packages** | `@singleton-sd/post-kit-*` consumed by trusted apps | First package: `post-kit-email` | diff --git a/docs/email-forward-email.md b/docs/email-forward-email.md index 0a53685..aef58c0 100644 --- a/docs/email-forward-email.md +++ b/docs/email-forward-email.md @@ -31,7 +31,7 @@ Trusted consumer / contact form | Domain / alias / verify | `ForwardEmailManagementClient` + `pnpm email:provision` | | DNS (MX / SPF / DKIM / DMARC / Return-Path) | AWS Route53; credentials from **pc-provision**, not this repo | | Secret storage | Azure Key Vault `ssd-global-kv-prod-ae` name **`forwardemail-api-key`** | -| Runtime Function App | Later epic (`apps/api`); this package is the library only | +| Runtime Function App | `apps/api` on `ssd-postkit-api-prod-ae`; `CONTACT_EMAIL_PROFILES_BY_HOST` is an app setting | ## Configuration @@ -43,7 +43,7 @@ Trusted consumer / contact form | `EMAIL_ALLOW_PRODUCTION_SEND` | Must be `true` with `EMAIL_PROVIDER=forward-email` | | `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` | Default sender | | `CONTACT_INBOX_ADDRESS` | Contact form destination | -| `CONTACT_EMAIL_PROFILES_BY_HOST` | Optional JSON map of host → sender/inbox | +| `CONTACT_EMAIL_PROFILES_BY_HOST` | JSON map of host → sender/inbox; set on the Function App (bicep param `contactEmailProfilesByHost`) | AWS credentials for DNS are **not** stored here. Load them from pc-provision Key Vault `ssd-devtools-kv-prod-ae` (`aws-access-key-id` / diff --git a/docs/pr-pipelines.md b/docs/pr-pipelines.md index b7fb144..b4ea4ed 100644 --- a/docs/pr-pipelines.md +++ b/docs/pr-pipelines.md @@ -6,6 +6,7 @@ | --- | --- | --- | | `ci.yml` | every pull request; every push to `main` | prettier check, eslint, worktree-path tests, PR automation tests, recursive package test/build | | `release.yml` | push to **`main`** (skipped for `chore: Release` commits) | Path-aware bumps; commit + tags for `@singleton-sd/post-kit-*` packages | +| `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep` | OIDC → bicep + zip deploy; skips if `AZURE_*` Variables are missing | There is **no** `pr-hygiene.yml` or `bootstrap-issue-labels.yml`. Do not add label-only GitHub Actions for this repository. diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..7bff136 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,19 @@ +# Infra + +Bicep for the PostKit Function App in `rg-ssd-global` (subscription +`01c0bb8b-3770-4765-979a-cb13ae7e3dd2`). + +| Resource | Name | SKU | +| --- | --- | --- | +| Plan | `ssd-postkit-plan-prod-ae` | Y1 Linux Consumption | +| Storage | `ssdpostkitstprodae` | Standard_LRS | +| Function App | `ssd-postkit-api-prod-ae` | Node 24 | +| Key Vault | existing `ssd-global-kv-prod-ae` | secret `forwardemail-api-key` | + +`CONTACT_EMAIL_PROFILES_BY_HOST` is an app setting (JSON). Update the +`contactEmailProfilesByHost` parameter when onboarding a PoC host. + +Deploy is `.github/workflows/deploy-api.yml` (OIDC). If GitHub Variables are +missing, the workflow skips Azure steps so CI is not blocked. + +Do not put tokens in git or GitHub Secrets. diff --git a/infra/function-app.bicep b/infra/function-app.bicep new file mode 100644 index 0000000..2e58c35 --- /dev/null +++ b/infra/function-app.bicep @@ -0,0 +1,174 @@ +// PostKit contact/send Azure Functions — Linux Consumption in rg-ssd-global. +// Secrets: FORWARD_EMAIL_TOKEN from existing Key Vault (never GitHub Secrets). +// CAF: ssd-postkit-api-prod-ae + +@description('Azure region') +param location string = resourceGroup().location + +@description('Function App name (CAF)') +param functionAppName string = 'ssd-postkit-api-prod-ae' + +@description('Storage account for Functions (3-24 lowercase alphanumeric)') +param storageAccountName string = 'ssdpostkitstprodae' + +@description('App Service plan name (Y1 Linux Consumption)') +param planName string = 'ssd-postkit-plan-prod-ae' + +@description('Existing Key Vault name in this resource group') +param keyVaultName string = 'ssd-global-kv-prod-ae' + +@description('Comma-separated allowed Origin hostnames (no scheme)') +param origins string = '*.poc.singletonsd.com,localhost:4321' + +@description('Contact inbox destination') +param contactInboxAddress string = 'hello@singletonsd.com' + +@description('Transactional From address (Forward Email alias)') +param emailFromAddress string = 'noreply@mail.plattform-kit.poc.singletonsd.com' + +@description('From display name') +param emailFromName string = 'Plattform Kit' + +@description('JSON map of marketing host → sender/inbox (CONTACT_EMAIL_PROFILES_BY_HOST)') +param contactEmailProfilesByHost string = '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com","fromName":"InkAds","contactInboxAddress":"inkads-support@singletonsd.com"},"plattform-kit.poc.singletonsd.com":{"fromAddress":"noreply@mail.plattform-kit.poc.singletonsd.com","fromName":"Plattform Kit","contactInboxAddress":"hello@singletonsd.com"}}' + +@description('KV secret name for Forward Email API token') +param forwardEmailSecretName string = 'forwardemail-api-key' + +var roleKeyVaultSecretsUser = '4633458b-17de-408a-b874-0445c86b69e6' + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + } +} + +resource plan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: planName + location: location + sku: { + name: 'Y1' + tier: 'Dynamic' + } + properties: { + reserved: true + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +var storageConnection = 'DefaultEndpointsProtocol=https;AccountName=${storage.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storage.listKeys().keys[0].value}' + +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: functionAppName + location: location + kind: 'functionapp,linux' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: plan.id + reserved: true + httpsOnly: true + siteConfig: { + linuxFxVersion: 'Node|24' + ftpsState: 'Disabled' + minTlsVersion: '1.2' + appSettings: [ + { + name: 'AzureWebJobsStorage' + value: storageConnection + } + { + name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + value: storageConnection + } + { + name: 'WEBSITE_CONTENTSHARE' + value: toLower(functionAppName) + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'node' + } + { + name: 'AzureWebJobsFeatureFlags' + value: 'EnableWorkerIndexing' + } + { + name: 'WEBSITE_NODE_DEFAULT_VERSION' + value: '~24' + } + { + name: 'ORIGINS' + value: origins + } + { + name: 'FORWARD_EMAIL_TOKEN' + value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/${forwardEmailSecretName}/)' + } + { + name: 'FORWARD_EMAIL_BASE_URL' + value: 'https://api.forwardemail.net' + } + { + name: 'EMAIL_PROVIDER' + value: 'forward-email' + } + { + name: 'EMAIL_ALLOW_PRODUCTION_SEND' + value: 'true' + } + { + name: 'EMAIL_FROM_ADDRESS' + value: emailFromAddress + } + { + name: 'EMAIL_FROM_NAME' + value: emailFromName + } + { + name: 'CONTACT_INBOX_ADDRESS' + value: contactInboxAddress + } + { + name: 'CONTACT_EMAIL_PROFILES_BY_HOST' + value: contactEmailProfilesByHost + } + { + name: 'CONTACT_RATE_LIMIT_PER_MIN' + value: '5' + } + ] + } + } +} + +resource kvFunctionSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, functionApp.id, roleKeyVaultSecretsUser) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleKeyVaultSecretsUser) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +output functionAppName string = functionApp.name +output functionAppHostname string = functionApp.properties.defaultHostName +output functionAppPrincipalId string = functionApp.identity.principalId +output baseUrl string = 'https://${functionApp.properties.defaultHostName}' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58c6a01..0a1881b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,25 @@ importers: specifier: ^7.7.0 version: 7.8.5 + apps/api: + dependencies: + '@azure/functions': + specifier: ^4.6.0 + version: 4.16.2 + '@singleton-sd/post-kit-email': + specifier: workspace:* + version: link:../../packages/post-kit-email + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.1 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/post-kit-email: devDependencies: '@types/node': @@ -50,6 +69,14 @@ importers: packages: + '@azure/functions-extensions-base@0.3.0': + resolution: {integrity: sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==} + engines: {node: '>=18.0'} + + '@azure/functions@4.16.2': + resolution: {integrity: sha512-6uq0Z7e3njy8fHpgCVIJWDtbkZz+BYXc6L8fQjisf8+hPdnauM5yZ70j9YC8xas6zvL1dFA+EfLuZcNYyuWLTg==} + engines: {node: '>=20.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -544,6 +571,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -737,6 +767,10 @@ packages: engines: {node: '>=18'} hasBin: true + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -1793,6 +1827,13 @@ packages: snapshots: + '@azure/functions-extensions-base@0.3.0': {} + + '@azure/functions@4.16.2': + dependencies: + '@azure/functions-extensions-base': 0.3.0 + cookie: 0.7.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2207,6 +2248,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -2403,6 +2448,8 @@ snapshots: conventional-commits-parser: 6.4.0 meow: 13.2.0 + cookie@0.7.2: {} + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 From d1418d578d816942545b1ce6da6238607035f869 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 03:58:17 +1000 Subject: [PATCH 2/5] fix: #8 Address CodeRabbit review on contact Function App Split OIDC deploy from ZIP build, pin ZIP deps with pnpm deploy, use Node 22 on Y1 Linux Consumption, and evict stale rate-limit keys. --- .github/workflows/deploy-api.yml | 73 ++++++++++++++---------------- apps/api/package.json | 4 +- apps/api/src/contact-rate-limit.ts | 26 ++++++++++- apps/api/src/contact.spec.ts | 8 ++++ apps/api/src/contact.ts | 6 ++- docs/architecture/overview.md | 2 +- docs/pr-pipelines.md | 2 +- infra/README.md | 2 +- infra/function-app.bicep | 4 +- 9 files changed, 79 insertions(+), 48 deletions(-) diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index c57f295..6b11794 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -1,7 +1,7 @@ # PostKit Function App — Linux Consumption in rg-ssd-global. # Secrets: NEVER in GitHub Secrets. OIDC → Key Vault `forwardemail-api-key`. # Required Variables: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID. -# If OIDC Variables are missing, deploy steps SKIP (job succeeds). +# If OIDC Variables are missing, the deploy job is skipped (workflow succeeds). name: Deploy API (Function) on: @@ -24,12 +24,11 @@ env: AZURE_KEY_VAULT_NAME: ssd-global-kv-prod-ae jobs: - build_and_deploy: + build: runs-on: ubuntu-latest - name: Build + Function App + name: Build ZIP permissions: contents: read - id-token: write steps: - uses: actions/checkout@v4 @@ -39,7 +38,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 22 cache: pnpm - name: Install @@ -48,19 +47,40 @@ jobs: - name: Test API run: pnpm --filter @singleton-sd/post-kit-api run test - - name: Check OIDC - id: cfg + - name: Stage zip run: | set -euo pipefail - if [ -z "${{ vars.AZURE_CLIENT_ID }}" ] || [ -z "${{ vars.AZURE_TENANT_ID }}" ] || [ -z "${{ vars.AZURE_SUBSCRIPTION_ID }}" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - echo "OIDC Variables not set — skipping Function App deploy." - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi + pnpm --filter @singleton-sd/post-kit-email run build + pnpm --filter @singleton-sd/post-kit-api run build + STAGE=$(mktemp -d) + pnpm --filter @singleton-sd/post-kit-api deploy --prod "$STAGE" + cp apps/api/host.json "$STAGE/" + test -f "$STAGE/dist/index.js" + test -d "$STAGE/node_modules/@singleton-sd/post-kit-email" + (cd "$STAGE" && zip -r "$GITHUB_WORKSPACE/post-kit-api.zip" .) + + - uses: actions/upload-artifact@v4 + with: + name: post-kit-api-zip + path: post-kit-api.zip + if-no-files-found: error + + deploy: + needs: build + if: ${{ vars.AZURE_CLIENT_ID != '' && vars.AZURE_TENANT_ID != '' && vars.AZURE_SUBSCRIPTION_ID != '' }} + runs-on: ubuntu-latest + name: Function App + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: post-kit-api-zip - name: Azure login (OIDC) - if: steps.cfg.outputs.configured == 'true' uses: azure/login@v2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} @@ -68,7 +88,6 @@ jobs: subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - name: Assert KV secret exists - if: steps.cfg.outputs.configured == 'true' run: | set -euo pipefail az keyvault secret show \ @@ -77,7 +96,6 @@ jobs: --query name -o tsv >/dev/null - name: Deploy Function App infra - if: steps.cfg.outputs.configured == 'true' run: | set -euo pipefail az deployment group create \ @@ -85,30 +103,7 @@ jobs: --template-file infra/function-app.bicep \ --name "postkit-api-${GITHUB_RUN_ID}" - - name: Stage zip - if: steps.cfg.outputs.configured == 'true' - run: | - set -euo pipefail - pnpm --filter @singleton-sd/post-kit-email run build - pnpm --filter @singleton-sd/post-kit-api run build - STAGE=$(mktemp -d) - cp apps/api/host.json "$STAGE/" - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('apps/api/package.json','utf8')); - delete pkg.dependencies['@singleton-sd/post-kit-email']; - fs.writeFileSync(process.argv[1] + '/package.json', JSON.stringify(pkg, null, 2)); - " "$STAGE" - cp -r apps/api/dist "$STAGE/dist" - (cd "$STAGE" && npm install --omit=dev --package-lock=false) - mkdir -p "$STAGE/node_modules/@singleton-sd/post-kit-email" - cp packages/post-kit-email/package.json "$STAGE/node_modules/@singleton-sd/post-kit-email/" - cp -r packages/post-kit-email/dist "$STAGE/node_modules/@singleton-sd/post-kit-email/dist" - test -f "$STAGE/node_modules/@singleton-sd/post-kit-email/dist/index.js" - (cd "$STAGE" && zip -r "$GITHUB_WORKSPACE/post-kit-api.zip" .) - - name: Zip deploy Function App - if: steps.cfg.outputs.configured == 'true' run: | set -euo pipefail az functionapp deployment source config-zip \ diff --git a/apps/api/package.json b/apps/api/package.json index 70f2895..be0b35b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", "lint": "echo \"lint:api — covered by root eslint on staged files\"", - "test": "pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test src/**/*.spec.ts", + "test": "pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", "start": "func start" }, "dependencies": { @@ -19,6 +19,6 @@ "typescript": "^5.7.2" }, "engines": { - "node": ">=20" + "node": ">=22" } } diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts index 0276a97..c94f464 100644 --- a/apps/api/src/contact-rate-limit.ts +++ b/apps/api/src/contact-rate-limit.ts @@ -5,6 +5,8 @@ export interface RateLimitResult { retryAfterSec: number; } +const MAX_KEYS = 10_000; + export class SlidingWindowRateLimiter { private readonly hits = new Map(); @@ -14,6 +16,7 @@ export class SlidingWindowRateLimiter { ) {} tryConsume(key: string, now = Date.now()): RateLimitResult { + this.pruneExpired(now); const cutoff = now - this.windowMs; const recent = (this.hits.get(key) ?? []).filter((t) => t > cutoff); if (recent.length >= this.maxHits) { @@ -22,6 +25,10 @@ export class SlidingWindowRateLimiter { this.hits.set(key, recent); return { allowed: false, retryAfterSec }; } + if (!this.hits.has(key) && this.hits.size >= MAX_KEYS) { + const oldestKey = this.hits.keys().next().value; + if (oldestKey !== undefined) this.hits.delete(oldestKey); + } recent.push(now); this.hits.set(key, recent); return { allowed: true, retryAfterSec: 0 }; @@ -31,6 +38,20 @@ export class SlidingWindowRateLimiter { reset(): void { this.hits.clear(); } + + /** Test helper — number of tracked keys after prune. */ + get size(): number { + return this.hits.size; + } + + private pruneExpired(now: number): void { + const cutoff = now - this.windowMs; + for (const [tracked, times] of this.hits) { + const recent = times.filter((t) => t > cutoff); + if (recent.length === 0) this.hits.delete(tracked); + else this.hits.set(tracked, recent); + } + } } const DEFAULT_MAX = 5; @@ -42,7 +63,10 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } -/** Shared limiter for the Function process (resets on cold start / scale-out). */ +/** + * Process-local limiter (resets on cold start / scale-out). A shared store + * is out of scope for this Y1 PoC; CONTACT_RATE_LIMIT_PER_MIN is best-effort. + */ export const contactRateLimiter = new SlidingWindowRateLimiter( parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_MAX), parsePositiveInt(process.env.CONTACT_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), diff --git a/apps/api/src/contact.spec.ts b/apps/api/src/contact.spec.ts index 4ee5b61..de8f259 100644 --- a/apps/api/src/contact.spec.ts +++ b/apps/api/src/contact.spec.ts @@ -180,4 +180,12 @@ describe('SlidingWindowRateLimiter', () => { const headers = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1']]); assert.equal(clientIpFromHeaders({ get: (n) => headers.get(n) ?? null }), '203.0.113.9'); }); + + it('evicts inactive buckets after the window', () => { + const limiter = new SlidingWindowRateLimiter(1, 1_000); + assert.equal(limiter.tryConsume('stale', 1).allowed, true); + assert.equal(limiter.size, 1); + assert.equal(limiter.tryConsume('fresh', 2_000).allowed, true); + assert.equal(limiter.size, 1); + }); }); diff --git a/apps/api/src/contact.ts b/apps/api/src/contact.ts index 40e1dbb..ee64328 100644 --- a/apps/api/src/contact.ts +++ b/apps/api/src/contact.ts @@ -17,8 +17,12 @@ export { } from '@singleton-sd/post-kit-email'; /** - * Resolve a trusted marketing-site host from Origin using the ORIGINS allowlist. + * Resolve a marketing-site host from Origin using the ORIGINS allowlist. * Untrusted or missing Origin values return null so host-profile overrides are skipped. + * + * Phase 1 tenant routing is this host-profile map (issue #8); authenticated + * tenant resolution is later work on epic #2. Origin is a routing hint, not proof + * of the caller — CORS does not stop a direct request that spoofs an allowlisted host. */ export function resolveTrustedContactHost( requestOrigin: string | null | undefined, diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7f15ce8..4a0ca25 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -56,7 +56,7 @@ Not in this bootstrap: - **Client** — trusted-consumer SDK (`post-kit-client`) - **Editor** — EmailBuilder.js wrapper (`post-kit-editor`) -## Deployment (planned) +## Deployment | Component | Host | Notes | | --- | --- | --- | diff --git a/docs/pr-pipelines.md b/docs/pr-pipelines.md index b4ea4ed..4ce7b2c 100644 --- a/docs/pr-pipelines.md +++ b/docs/pr-pipelines.md @@ -6,7 +6,7 @@ | --- | --- | --- | | `ci.yml` | every pull request; every push to `main` | prettier check, eslint, worktree-path tests, PR automation tests, recursive package test/build | | `release.yml` | push to **`main`** (skipped for `chore: Release` commits) | Path-aware bumps; commit + tags for `@singleton-sd/post-kit-*` packages | -| `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep` | OIDC → bicep + zip deploy; skips if `AZURE_*` Variables are missing | +| `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep`, `.github/workflows/deploy-api.yml`; also `workflow_dispatch` | OIDC → bicep + zip deploy; skips Azure if `AZURE_*` Variables are missing | There is **no** `pr-hygiene.yml` or `bootstrap-issue-labels.yml`. Do not add label-only GitHub Actions for this repository. diff --git a/infra/README.md b/infra/README.md index 7bff136..ffbf9fc 100644 --- a/infra/README.md +++ b/infra/README.md @@ -7,7 +7,7 @@ Bicep for the PostKit Function App in `rg-ssd-global` (subscription | --- | --- | --- | | Plan | `ssd-postkit-plan-prod-ae` | Y1 Linux Consumption | | Storage | `ssdpostkitstprodae` | Standard_LRS | -| Function App | `ssd-postkit-api-prod-ae` | Node 24 | +| Function App | `ssd-postkit-api-prod-ae` | Node 22 | | Key Vault | existing `ssd-global-kv-prod-ae` | secret `forwardemail-api-key` | `CONTACT_EMAIL_PROFILES_BY_HOST` is an app setting (JSON). Update the diff --git a/infra/function-app.bicep b/infra/function-app.bicep index 2e58c35..534f5e0 100644 --- a/infra/function-app.bicep +++ b/infra/function-app.bicep @@ -81,7 +81,7 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { reserved: true httpsOnly: true siteConfig: { - linuxFxVersion: 'Node|24' + linuxFxVersion: 'Node|22' ftpsState: 'Disabled' minTlsVersion: '1.2' appSettings: [ @@ -111,7 +111,7 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { } { name: 'WEBSITE_NODE_DEFAULT_VERSION' - value: '~24' + value: '~22' } { name: 'ORIGINS' From c7d44afbde5e9f5dd7330f2cba8a6173faab9d80 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 04:22:47 +1000 Subject: [PATCH 3/5] feat: #8 Load Function settings from App Config Add a Free store in rg-ssd-global. Seed missing keys only. Keep the Forward Email token as a Key Vault reference, not a store value. --- .github/workflows/deploy-api.yml | 13 +- SETUP.md | 7 +- apps/api/README.md | 10 +- apps/api/local.settings.json.example | 11 +- apps/api/package.json | 3 + apps/api/src/config/app-configuration.spec.ts | 115 ++++++ apps/api/src/config/app-configuration.ts | 104 ++++++ apps/api/src/contact-rate-limit.ts | 18 +- apps/api/src/functions/contact.ts | 6 +- apps/api/src/host-profiles.spec.ts | 20 +- docs/architecture/overview.md | 1 + docs/email-forward-email.md | 30 +- docs/pr-pipelines.md | 2 +- infra/README.md | 15 +- infra/appconfig-seed.json | 17 + infra/function-app.bicep | 114 +++--- pnpm-lock.yaml | 345 ++++++++++++++++++ scripts/seed-appconfig.sh | 79 ++++ 18 files changed, 812 insertions(+), 98 deletions(-) create mode 100644 apps/api/src/config/app-configuration.spec.ts create mode 100644 apps/api/src/config/app-configuration.ts create mode 100644 infra/appconfig-seed.json create mode 100755 scripts/seed-appconfig.sh diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 6b11794..732c379 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -11,6 +11,8 @@ on: - 'apps/api/**' - 'packages/post-kit-email/**' - 'infra/function-app.bicep' + - 'infra/appconfig-seed.json' + - 'scripts/seed-appconfig.sh' - '.github/workflows/deploy-api.yml' workflow_dispatch: @@ -22,6 +24,7 @@ env: AZURE_RESOURCE_GROUP: rg-ssd-global AZURE_FUNCTIONAPP_NAME: ssd-postkit-api-prod-ae AZURE_KEY_VAULT_NAME: ssd-global-kv-prod-ae + APP_CONFIG_NAME: ssd-postkit-appcs-prod-ae jobs: build: @@ -98,10 +101,18 @@ jobs: - name: Deploy Function App infra run: | set -euo pipefail + PRINCIPAL_ID=$(az ad sp show --id "${{ vars.AZURE_CLIENT_ID }}" --query id -o tsv) az deployment group create \ --resource-group "$AZURE_RESOURCE_GROUP" \ --template-file infra/function-app.bicep \ - --name "postkit-api-${GITHUB_RUN_ID}" + --name "postkit-api-${GITHUB_RUN_ID}" \ + --parameters githubOidcPrincipalId="$PRINCIPAL_ID" + + - name: Seed App Configuration (missing keys only) + run: | + set -euo pipefail + chmod +x scripts/seed-appconfig.sh + ./scripts/seed-appconfig.sh infra/appconfig-seed.json - name: Zip deploy Function App run: | diff --git a/SETUP.md b/SETUP.md index 17b3f85..e4adf34 100644 --- a/SETUP.md +++ b/SETUP.md @@ -126,13 +126,15 @@ npx skills add singleton-sd/ai-plattform-skills \ | Function App | `ssd-postkit-api-prod-ae` | Contact/send API | | App Service Plan | `ssd-postkit-plan-prod-ae` | Y1 Consumption | | Storage | `ssdpostkitstprodae` | Function App storage | +| App Configuration | `ssd-postkit-appcs-prod-ae` | **Free** (this subscription has no other Free store) | ### Secrets + configuration (locked) | Layer | Store | Rule | | --- | --- | --- | | **Secrets** | Azure Key Vault `ssd-global-kv-prod-ae` | Tokens, connection strings. Never in git or GitHub Actions secrets. | -| **CI/CD** | GitHub Actions **OIDC** → Azure | Workflows log in with federated creds, then at job runtime: `SECRET_VALUE=$(az keyvault secret show --name --vault-name ssd-global-kv-prod-ae --query value -o tsv)`, immediately `echo "::add-mask::$SECRET_VALUE"`, never print the raw value. | +| **App configuration** | Azure App Configuration `ssd-postkit-appcs-prod-ae` | Non-secret settings + **Key Vault references** for secret values. | +| **CI/CD** | GitHub Actions **OIDC** → Azure | Workflows log in with federated creds, then at job runtime: `az appconfig kv show` / `az keyvault secret show`. Mask secret values; never print them. | **GitHub Actions — allowed identifiers only (repository Variables, not Secrets):** @@ -150,7 +152,8 @@ npx skills add singleton-sd/ai-plattform-skills \ - [ ] OIDC app registration + federated credentials for this repo - [ ] GitHub Variables `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_SUBSCRIPTION_ID` - [ ] Copy required secrets into Key Vault `ssd-global-kv-prod-ae` (names only in git) -- [ ] Provision Function App / plan / storage when the API epic lands +- [ ] Provision Function App / plan / storage / App Configuration when the API epic lands +- [ ] Grant the GitHub OIDC app App Configuration Data Owner (bicep param `githubOidcPrincipalId`) ## 6. npmjs (public packages) diff --git a/apps/api/README.md b/apps/api/README.md index 02ad7a3..6be5085 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,8 +1,14 @@ # `@singleton-sd/post-kit-api` Azure Functions (anonymous contact + health). Trusted marketing sites POST -`/contact` with an allowlisted `Origin`. Host-specific sender/inbox comes from -`CONTACT_EMAIL_PROFILES_BY_HOST`. +`/contact` with an allowlisted `Origin`. Host-specific sender/inbox and other +non-secret settings come from Azure App Configuration +(`ssd-postkit-appcs-prod-ae`). `FORWARD_EMAIL_TOKEN` is a Key Vault reference +in that store. + +Local `func start` needs `az login` and +`AZURE_APPCONFIGURATION_ENDPOINT` in `local.settings.json` (see the example). +Do not put tenant profiles or tokens in `local.settings.json`. ```bash pnpm --filter @singleton-sd/post-kit-api test diff --git a/apps/api/local.settings.json.example b/apps/api/local.settings.json.example index a026e5b..044ac60 100644 --- a/apps/api/local.settings.json.example +++ b/apps/api/local.settings.json.example @@ -3,15 +3,6 @@ "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "node", - "ORIGINS": "*.poc.singletonsd.com,localhost:4321", - "EMAIL_PROVIDER": "development", - "EMAIL_FROM_ADDRESS": "noreply@mail.plattform-kit.poc.singletonsd.com", - "EMAIL_FROM_NAME": "Plattform Kit", - "CONTACT_INBOX_ADDRESS": "hello@singletonsd.com", - "CONTACT_EMAIL_PROFILES_BY_HOST": "{\"inkads.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.inkads.poc.singletonsd.com\",\"fromName\":\"InkAds\",\"contactInboxAddress\":\"inkads-support@singletonsd.com\"},\"plattform-kit.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.plattform-kit.poc.singletonsd.com\",\"fromName\":\"Plattform Kit\",\"contactInboxAddress\":\"hello@singletonsd.com\"}}", - "FORWARD_EMAIL_TOKEN": "", - "FORWARD_EMAIL_BASE_URL": "https://api.forwardemail.net", - "EMAIL_ALLOW_PRODUCTION_SEND": "", - "CONTACT_RATE_LIMIT_PER_MIN": "5" + "AZURE_APPCONFIGURATION_ENDPOINT": "https://ssd-postkit-appcs-prod-ae.azconfig.io" } } diff --git a/apps/api/package.json b/apps/api/package.json index be0b35b..b1b236b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,10 @@ "start": "func start" }, "dependencies": { + "@azure/app-configuration": "^1.12.1", "@azure/functions": "^4.6.0", + "@azure/identity": "^4.13.1", + "@azure/keyvault-secrets": "^4.11.2", "@singleton-sd/post-kit-email": "workspace:*" }, "devDependencies": { diff --git a/apps/api/src/config/app-configuration.spec.ts b/apps/api/src/config/app-configuration.spec.ts new file mode 100644 index 0000000..df22118 --- /dev/null +++ b/apps/api/src/config/app-configuration.spec.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import type { ConfigurationSetting } from '@azure/app-configuration'; +import { loadAppConfiguration, resetAppConfigurationCache } from './app-configuration'; + +describe('loadAppConfiguration', () => { + const touched = [ + 'AZURE_APPCONFIGURATION_ENDPOINT', + 'ORIGINS', + 'CONTACT_EMAIL_PROFILES_BY_HOST', + 'FORWARD_EMAIL_TOKEN', + 'EMAIL_FROM_ADDRESS', + ]; + const prior = new Map(); + + beforeEach(() => { + for (const key of touched) { + prior.set(key, process.env[key]); + delete process.env[key]; + } + resetAppConfigurationCache(); + }); + + afterEach(() => { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetAppConfigurationCache(); + }); + + it('does nothing when no endpoint is configured', async () => { + let listed = false; + await loadAppConfiguration({ + listSettings: () => { + listed = true; + return settings(); + }, + }); + assert.equal(listed, false); + }); + + it('maps plain settings and Key Vault references to environment variables', async () => { + process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; + const getSecret = async (secretUri: string) => { + assert.match(secretUri, /forwardemail-api-key/); + return { value: 'token-from-kv' }; + }; + + await loadAppConfiguration({ + listSettings: () => + settings( + setting('app:email:origins', '*.poc.singletonsd.com'), + setting( + 'app:email:profilesByHost', + '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com"}}', + ), + setting( + 'secret:forwardemail-api-key', + JSON.stringify({ + uri: 'https://ssd-global-kv-prod-ae.vault.azure.net/secrets/forwardemail-api-key', + }), + 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8', + ), + setting('unmapped:key', 'ignored'), + ), + getSecret, + }); + + assert.equal(process.env.ORIGINS, '*.poc.singletonsd.com'); + assert.equal( + process.env.CONTACT_EMAIL_PROFILES_BY_HOST, + '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com"}}', + ); + assert.equal(process.env.FORWARD_EMAIL_TOKEN, 'token-from-kv'); + assert.equal(process.env.UNMAPPED_KEY, undefined); + }); + + it('preserves explicitly configured environment variables', async () => { + process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; + process.env.ORIGINS = 'localhost:4321'; + + await loadAppConfiguration({ + listSettings: () => settings(setting('app:email:origins', 'from-store')), + }); + + assert.equal(process.env.ORIGINS, 'localhost:4321'); + }); + + it('rejects malformed Key Vault references', async () => { + process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; + + await assert.rejects( + loadAppConfiguration({ + listSettings: () => + settings( + setting( + 'secret:forwardemail-api-key', + '{}', + 'application/vnd.microsoft.appconfig.keyvaultref+json', + ), + ), + }), + /Invalid Key Vault reference for secret:forwardemail-api-key/, + ); + }); +}); + +function setting(key: string, value: string, contentType?: string): ConfigurationSetting { + return { key, value, contentType } as ConfigurationSetting; +} + +async function* settings(...values: ConfigurationSetting[]) { + yield* values; +} diff --git a/apps/api/src/config/app-configuration.ts b/apps/api/src/config/app-configuration.ts new file mode 100644 index 0000000..8818ded --- /dev/null +++ b/apps/api/src/config/app-configuration.ts @@ -0,0 +1,104 @@ +import { AppConfigurationClient, type ConfigurationSetting } from '@azure/app-configuration'; +import { DefaultAzureCredential } from '@azure/identity'; +import { SecretClient } from '@azure/keyvault-secrets'; + +const keyVaultReferenceContentType = 'application/vnd.microsoft.appconfig.keyvaultref+json'; + +/** App Configuration key → process.env name. Explicit env always wins. */ +export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly> = { + 'app:email:origins': 'ORIGINS', + 'app:email:provider': 'EMAIL_PROVIDER', + 'app:email:allowProductionSend': 'EMAIL_ALLOW_PRODUCTION_SEND', + 'app:email:fromAddress': 'EMAIL_FROM_ADDRESS', + 'app:email:fromName': 'EMAIL_FROM_NAME', + 'app:email:contactInboxAddress': 'CONTACT_INBOX_ADDRESS', + 'app:email:profilesByHost': 'CONTACT_EMAIL_PROFILES_BY_HOST', + 'app:email:rateLimitPerMin': 'CONTACT_RATE_LIMIT_PER_MIN', + 'app:email:forwardEmailBaseUrl': 'FORWARD_EMAIL_BASE_URL', + 'app:email:validation:domain': 'EMAIL_VALIDATION_DOMAIN', + 'app:email:validation:dkimSelector': 'EMAIL_VALIDATION_DKIM_SELECTOR', + 'app:email:validation:dmarcPolicy': 'EMAIL_VALIDATION_DMARC_POLICY', + 'app:email:validation:bimiSelector': 'EMAIL_VALIDATION_BIMI_SELECTOR', + 'app:email:validation:bimiLogoUrl': 'EMAIL_VALIDATION_BIMI_LOGO_URL', + 'app:email:validation:requireBimiSvg': 'EMAIL_VALIDATION_REQUIRE_BIMI_SVG', + 'secret:forwardemail-api-key': 'FORWARD_EMAIL_TOKEN', +}; + +type AppConfigurationDependencies = { + listSettings?: () => AsyncIterable; + getSecret?: (secretUri: string) => Promise<{ value?: string }>; +}; + +let loadOnce: Promise | undefined; + +/** + * Populate process.env from App Configuration. Missing endpoint is a no-op + * (unit tests and local overrides). Explicit environment variables win. + */ +export async function loadAppConfiguration( + dependencies: AppConfigurationDependencies = {}, +): Promise { + const endpoint = process.env.AZURE_APPCONFIGURATION_ENDPOINT; + if (!endpoint) return; + + const credential = new DefaultAzureCredential(); + const listSettings = + dependencies.listSettings ?? + (() => new AppConfigurationClient(endpoint, credential).listConfigurationSettings()); + const getSecret = + dependencies.getSecret ?? + (async (secretUri: string) => { + const url = new URL(secretUri); + const secretName = url.pathname.split('/').filter(Boolean)[1]; + if (!secretName) throw new Error(`Invalid Key Vault secret URI: ${secretUri}`); + const client = new SecretClient(url.origin, credential); + return client.getSecret(secretName); + }); + + for await (const setting of listSettings()) { + const environmentKey = APP_CONFIGURATION_ENVIRONMENT_KEYS[setting.key]; + if (!environmentKey || process.env[environmentKey] !== undefined) continue; + + const value = isKeyVaultReference(setting) + ? await resolveKeyVaultReference(setting, getSecret) + : setting.value; + + if (value !== undefined) process.env[environmentKey] = value; + } +} + +/** Load once per worker. Safe to call from every Function invocation. */ +export function ensureAppConfiguration(): Promise { + loadOnce ??= loadAppConfiguration(); + return loadOnce; +} + +export function resetAppConfigurationCache(): void { + loadOnce = undefined; +} + +function isKeyVaultReference(setting: ConfigurationSetting): boolean { + return setting.contentType?.toLowerCase().startsWith(keyVaultReferenceContentType) ?? false; +} + +async function resolveKeyVaultReference( + setting: ConfigurationSetting, + getSecret: (secretUri: string) => Promise<{ value?: string }>, +): Promise { + let uri: string | undefined; + try { + uri = JSON.parse(setting.value ?? '').uri; + } catch { + // Message includes the App Configuration key, never a secret value. + } + + if (!uri) { + throw new Error(`Invalid Key Vault reference for ${setting.key}`); + } + + const secret = await getSecret(uri); + if (secret.value === undefined) { + throw new Error(`Key Vault reference has no value for ${setting.key}`); + } + return secret.value; +} diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts index c94f464..2479e46 100644 --- a/apps/api/src/contact-rate-limit.ts +++ b/apps/api/src/contact-rate-limit.ts @@ -66,11 +66,21 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number { /** * Process-local limiter (resets on cold start / scale-out). A shared store * is out of scope for this Y1 PoC; CONTACT_RATE_LIMIT_PER_MIN is best-effort. + * Constructed lazily so App Configuration can populate env first. */ -export const contactRateLimiter = new SlidingWindowRateLimiter( - parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_MAX), - parsePositiveInt(process.env.CONTACT_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), -); +let contactRateLimiter: SlidingWindowRateLimiter | undefined; + +export function getContactRateLimiter(): SlidingWindowRateLimiter { + contactRateLimiter ??= new SlidingWindowRateLimiter( + parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_MAX), + parsePositiveInt(process.env.CONTACT_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), + ); + return contactRateLimiter; +} + +export function resetContactRateLimiter(): void { + contactRateLimiter = undefined; +} export function clientIpFromHeaders(headers: { get(name: string): string | null }): string { const xff = headers.get('x-forwarded-for'); diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index 14b2dda..44cc5f7 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -1,12 +1,14 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { EmailProviderError } from '@singleton-sd/post-kit-email'; +import { ensureAppConfiguration } from '../config/app-configuration'; import { contactCorsHeaders, submitContactInquiry } from '../contact'; -import { clientIpFromHeaders, contactRateLimiter } from '../contact-rate-limit'; +import { clientIpFromHeaders, getContactRateLimiter } from '../contact-rate-limit'; export async function contactHandler( request: HttpRequest, context: InvocationContext, ): Promise { + await ensureAppConfiguration(); const origin = request.headers.get('origin'); const cors = contactCorsHeaders(origin); @@ -15,7 +17,7 @@ export async function contactHandler( } const ip = clientIpFromHeaders(request.headers); - const limit = contactRateLimiter.tryConsume(ip); + const limit = getContactRateLimiter().tryConsume(ip); if (!limit.allowed) { return { status: 429, diff --git a/apps/api/src/host-profiles.spec.ts b/apps/api/src/host-profiles.spec.ts index c1f87f4..b9a154a 100644 --- a/apps/api/src/host-profiles.spec.ts +++ b/apps/api/src/host-profiles.spec.ts @@ -4,13 +4,17 @@ import path from 'node:path'; import { describe, it } from 'node:test'; describe('Function App host profiles', () => { - it('wires CONTACT_EMAIL_PROFILES_BY_HOST in bicep app settings', () => { - const bicep = readFileSync( - path.resolve(__dirname, '../../../infra/function-app.bicep'), - 'utf8', - ); - assert.match(bicep, /name: 'CONTACT_EMAIL_PROFILES_BY_HOST'/); - assert.match(bicep, /param contactEmailProfilesByHost string/); - assert.match(bicep, /inkads\.poc\.singletonsd\.com/); + it('seeds CONTACT host profiles in App Configuration, not Function app settings', () => { + 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, /ssd-postkit-appcs-prod-ae/); + assert.match(bicep, /AZURE_APPCONFIGURATION_ENDPOINT/); + assert.doesNotMatch(bicep, /name: 'CONTACT_EMAIL_PROFILES_BY_HOST'/); + assert.ok(seed['app:email:profilesByHost']?.includes('inkads.poc.singletonsd.com')); + assert.equal(seed['app:email:validation:domain'], 'mail.plattform-kit.poc.singletonsd.com'); }); }); diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 4a0ca25..7d2fe34 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -62,6 +62,7 @@ Not in this bootstrap: | --- | --- | --- | | API | Azure Function App `ssd-postkit-api-prod-ae` | Plan `ssd-postkit-plan-prod-ae` (Y1 Consumption) | | Storage | `ssdpostkitstprodae` | Function App storage | +| App configuration | `ssd-postkit-appcs-prod-ae` | Free SKU; non-secret settings + KV refs | | Secrets | Key Vault `ssd-global-kv-prod-ae` | Subscription `01c0bb8b-3770-4765-979a-cb13ae7e3dd2`, RG `rg-ssd-global` | | Packages | npmjs public `@singleton-sd/post-kit-*` | Root workspace is private and not published | diff --git a/docs/email-forward-email.md b/docs/email-forward-email.md index aef58c0..b8939e5 100644 --- a/docs/email-forward-email.md +++ b/docs/email-forward-email.md @@ -31,19 +31,29 @@ Trusted consumer / contact form | Domain / alias / verify | `ForwardEmailManagementClient` + `pnpm email:provision` | | DNS (MX / SPF / DKIM / DMARC / Return-Path) | AWS Route53; credentials from **pc-provision**, not this repo | | Secret storage | Azure Key Vault `ssd-global-kv-prod-ae` name **`forwardemail-api-key`** | -| Runtime Function App | `apps/api` on `ssd-postkit-api-prod-ae`; `CONTACT_EMAIL_PROFILES_BY_HOST` is an app setting | +| App configuration | Azure App Configuration `ssd-postkit-appcs-prod-ae` (Free) | +| Runtime Function App | `apps/api` on `ssd-postkit-api-prod-ae`; loads env from App Config | ## Configuration -| Env | Notes | -| --- | --- | -| `FORWARD_EMAIL_TOKEN` | Required for live send / provision. KV secret `forwardemail-api-key` | -| `FORWARD_EMAIL_BASE_URL` | Default `https://api.forwardemail.net` | -| `EMAIL_PROVIDER` | `development` (safe default) or `forward-email` | -| `EMAIL_ALLOW_PRODUCTION_SEND` | Must be `true` with `EMAIL_PROVIDER=forward-email` | -| `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` | Default sender | -| `CONTACT_INBOX_ADDRESS` | Contact form destination | -| `CONTACT_EMAIL_PROFILES_BY_HOST` | JSON map of host → sender/inbox; set on the Function App (bicep param `contactEmailProfilesByHost`) | +Runtime env is filled from App Configuration (`AZURE_APPCONFIGURATION_ENDPOINT`). +Explicit process env always wins (local overrides / tests). + +| Env | App Config key | Notes | +| --- | --- | --- | +| `FORWARD_EMAIL_TOKEN` | `secret:forwardemail-api-key` | KV reference, never stored as a value | +| `FORWARD_EMAIL_BASE_URL` | `app:email:forwardEmailBaseUrl` | Default `https://api.forwardemail.net` | +| `EMAIL_PROVIDER` | `app:email:provider` | `development` locally; `forward-email` in prod | +| `EMAIL_ALLOW_PRODUCTION_SEND` | `app:email:allowProductionSend` | Must be `true` with Forward Email | +| `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` | `app:email:fromAddress` / `fromName` | Default sender | +| `CONTACT_INBOX_ADDRESS` | `app:email:contactInboxAddress` | Contact form destination | +| `CONTACT_EMAIL_PROFILES_BY_HOST` | `app:email:profilesByHost` | JSON map of host → sender/inbox | +| `ORIGINS` | `app:email:origins` | Allowlisted Origin hosts | +| `EMAIL_VALIDATION_*` | `app:email:validation:*` | Branding CI (see branding workflow) | + +First-run values are in `infra/appconfig-seed.json`. After that, edit the store +in Azure (seed will not overwrite existing keys). Onboard a new PoC host by +updating `app:email:profilesByHost` in the store, not Function App settings. AWS credentials for DNS are **not** stored here. Load them from pc-provision Key Vault `ssd-devtools-kv-prod-ae` (`aws-access-key-id` / diff --git a/docs/pr-pipelines.md b/docs/pr-pipelines.md index 4ce7b2c..1dd3f00 100644 --- a/docs/pr-pipelines.md +++ b/docs/pr-pipelines.md @@ -6,7 +6,7 @@ | --- | --- | --- | | `ci.yml` | every pull request; every push to `main` | prettier check, eslint, worktree-path tests, PR automation tests, recursive package test/build | | `release.yml` | push to **`main`** (skipped for `chore: Release` commits) | Path-aware bumps; commit + tags for `@singleton-sd/post-kit-*` packages | -| `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep`, `.github/workflows/deploy-api.yml`; also `workflow_dispatch` | OIDC → bicep + zip deploy; skips Azure if `AZURE_*` Variables are missing | +| `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep`, `infra/appconfig-seed.json`, `.github/workflows/deploy-api.yml`; also `workflow_dispatch` | OIDC → bicep + App Config seed-if-absent + zip deploy; skips Azure if `AZURE_*` Variables are missing | There is **no** `pr-hygiene.yml` or `bootstrap-issue-labels.yml`. Do not add label-only GitHub Actions for this repository. diff --git a/infra/README.md b/infra/README.md index ffbf9fc..ae6611e 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,17 +1,24 @@ # Infra -Bicep for the PostKit Function App in `rg-ssd-global` (subscription -`01c0bb8b-3770-4765-979a-cb13ae7e3dd2`). +Bicep for the PostKit Function App and App Configuration store in +`rg-ssd-global` (subscription `01c0bb8b-3770-4765-979a-cb13ae7e3dd2`). | Resource | Name | SKU | | --- | --- | --- | | Plan | `ssd-postkit-plan-prod-ae` | Y1 Linux Consumption | | Storage | `ssdpostkitstprodae` | Standard_LRS | | Function App | `ssd-postkit-api-prod-ae` | Node 22 | +| App Configuration | `ssd-postkit-appcs-prod-ae` | **Free** | | Key Vault | existing `ssd-global-kv-prod-ae` | secret `forwardemail-api-key` | -`CONTACT_EMAIL_PROFILES_BY_HOST` is an app setting (JSON). Update the -`contactEmailProfilesByHost` parameter when onboarding a PoC host. +Non-secret settings (origins, host profiles, branding validation, from/inbox) +live in App Configuration. `infra/appconfig-seed.json` is first-run only — +`scripts/seed-appconfig.sh` does **not** overwrite keys that 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. + +The Function App only needs `AZURE_APPCONFIGURATION_ENDPOINT` plus host +plumbing. It loads keys at request time via managed identity. Deploy is `.github/workflows/deploy-api.yml` (OIDC). If GitHub Variables are missing, the workflow skips Azure steps so CI is not blocked. diff --git a/infra/appconfig-seed.json b/infra/appconfig-seed.json new file mode 100644 index 0000000..4daafca --- /dev/null +++ b/infra/appconfig-seed.json @@ -0,0 +1,17 @@ +{ + "app:email:origins": "*.poc.singletonsd.com,localhost:4321", + "app:email:provider": "forward-email", + "app:email:allowProductionSend": "true", + "app:email:fromAddress": "noreply@mail.plattform-kit.poc.singletonsd.com", + "app:email:fromName": "Plattform Kit", + "app:email:contactInboxAddress": "hello@singletonsd.com", + "app:email:profilesByHost": "{\"inkads.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.inkads.poc.singletonsd.com\",\"fromName\":\"InkAds\",\"contactInboxAddress\":\"inkads-support@singletonsd.com\"},\"plattform-kit.poc.singletonsd.com\":{\"fromAddress\":\"noreply@mail.plattform-kit.poc.singletonsd.com\",\"fromName\":\"Plattform Kit\",\"contactInboxAddress\":\"hello@singletonsd.com\"}}", + "app:email:rateLimitPerMin": "5", + "app:email:forwardEmailBaseUrl": "https://api.forwardemail.net", + "app:email:validation:domain": "mail.plattform-kit.poc.singletonsd.com", + "app:email:validation:dkimSelector": "fe", + "app:email:validation:dmarcPolicy": "quarantine", + "app:email:validation:bimiSelector": "default", + "app:email:validation:bimiLogoUrl": "", + "app:email:validation:requireBimiSvg": "true" +} diff --git a/infra/function-app.bicep b/infra/function-app.bicep index 534f5e0..211405d 100644 --- a/infra/function-app.bicep +++ b/infra/function-app.bicep @@ -1,5 +1,6 @@ // PostKit contact/send Azure Functions — Linux Consumption in rg-ssd-global. -// Secrets: FORWARD_EMAIL_TOKEN from existing Key Vault (never GitHub Secrets). +// Secrets: FORWARD_EMAIL_TOKEN from Key Vault via App Configuration KV refs. +// Non-secret settings: Azure App Configuration (Free) ssd-postkit-appcs-prod-ae // CAF: ssd-postkit-api-prod-ae @description('Azure region') @@ -17,25 +18,19 @@ param planName string = 'ssd-postkit-plan-prod-ae' @description('Existing Key Vault name in this resource group') param keyVaultName string = 'ssd-global-kv-prod-ae' -@description('Comma-separated allowed Origin hostnames (no scheme)') -param origins string = '*.poc.singletonsd.com,localhost:4321' +@description('CAF App Configuration store name') +param appConfigName string = 'ssd-postkit-appcs-prod-ae' -@description('Contact inbox destination') -param contactInboxAddress string = 'hello@singletonsd.com' +@description('App Configuration SKU — Free is available in this subscription') +@allowed(['Free', 'Developer', 'Standard']) +param appConfigSku string = 'Free' -@description('Transactional From address (Forward Email alias)') -param emailFromAddress string = 'noreply@mail.plattform-kit.poc.singletonsd.com' - -@description('From display name') -param emailFromName string = 'Plattform Kit' - -@description('JSON map of marketing host → sender/inbox (CONTACT_EMAIL_PROFILES_BY_HOST)') -param contactEmailProfilesByHost string = '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com","fromName":"InkAds","contactInboxAddress":"inkads-support@singletonsd.com"},"plattform-kit.poc.singletonsd.com":{"fromAddress":"noreply@mail.plattform-kit.poc.singletonsd.com","fromName":"Plattform Kit","contactInboxAddress":"hello@singletonsd.com"}}' - -@description('KV secret name for Forward Email API token') -param forwardEmailSecretName string = 'forwardemail-api-key' +@description('Entra object id of the GitHub OIDC app (empty skips App Config RBAC for CI)') +param githubOidcPrincipalId string = '' var roleKeyVaultSecretsUser = '4633458b-17de-408a-b874-0445c86b69e6' +var roleAppConfigDataReader = '516239f1-63e1-4d78-a4de-a74fb236a071' +var roleAppConfigDataOwner = '5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b' resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { name: storageAccountName @@ -67,6 +62,21 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: keyVaultName } +resource appConfig 'Microsoft.AppConfiguration/configurationStores@2024-05-01' = { + name: appConfigName + location: location + sku: { + name: appConfigSku + } + identity: { + type: 'SystemAssigned' + } + properties: { + publicNetworkAccess: 'Enabled' + disableLocalAuth: false + } +} + var storageConnection = 'DefaultEndpointsProtocol=https;AccountName=${storage.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storage.listKeys().keys[0].value}' resource functionApp 'Microsoft.Web/sites@2023-12-01' = { @@ -114,44 +124,8 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { value: '~22' } { - name: 'ORIGINS' - value: origins - } - { - name: 'FORWARD_EMAIL_TOKEN' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/${forwardEmailSecretName}/)' - } - { - name: 'FORWARD_EMAIL_BASE_URL' - value: 'https://api.forwardemail.net' - } - { - name: 'EMAIL_PROVIDER' - value: 'forward-email' - } - { - name: 'EMAIL_ALLOW_PRODUCTION_SEND' - value: 'true' - } - { - name: 'EMAIL_FROM_ADDRESS' - value: emailFromAddress - } - { - name: 'EMAIL_FROM_NAME' - value: emailFromName - } - { - name: 'CONTACT_INBOX_ADDRESS' - value: contactInboxAddress - } - { - name: 'CONTACT_EMAIL_PROFILES_BY_HOST' - value: contactEmailProfilesByHost - } - { - name: 'CONTACT_RATE_LIMIT_PER_MIN' - value: '5' + name: 'AZURE_APPCONFIGURATION_ENDPOINT' + value: appConfig.properties.endpoint } ] } @@ -168,7 +142,39 @@ resource kvFunctionSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04- } } +resource kvAppConfigSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, appConfig.id, roleKeyVaultSecretsUser) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleKeyVaultSecretsUser) + principalId: appConfig.identity.principalId + principalType: 'ServicePrincipal' + } +} + +resource appConfigFunctionReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(appConfig.id, functionApp.id, roleAppConfigDataReader) + scope: appConfig + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleAppConfigDataReader) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +resource appConfigOidcOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(githubOidcPrincipalId)) { + name: guid(appConfig.id, githubOidcPrincipalId, roleAppConfigDataOwner) + scope: appConfig + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleAppConfigDataOwner) + principalId: githubOidcPrincipalId + principalType: 'ServicePrincipal' + } +} + output functionAppName string = functionApp.name output functionAppHostname string = functionApp.properties.defaultHostName output functionAppPrincipalId string = functionApp.identity.principalId output baseUrl string = 'https://${functionApp.properties.defaultHostName}' +output appConfigName string = appConfig.name +output appConfigEndpoint string = appConfig.properties.endpoint diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a1881b..da61cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,18 @@ importers: apps/api: dependencies: + '@azure/app-configuration': + specifier: ^1.12.1 + version: 1.12.1 '@azure/functions': specifier: ^4.6.0 version: 4.16.2 + '@azure/identity': + specifier: ^4.13.1 + version: 4.13.2 + '@azure/keyvault-secrets': + specifier: ^4.11.2 + version: 4.11.2 '@singleton-sd/post-kit-email': specifier: workspace:* version: link:../../packages/post-kit-email @@ -69,6 +78,61 @@ importers: packages: + '@azure-rest/core-client@2.8.0': + resolution: {integrity: sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==} + engines: {node: '>=22.0.0'} + + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/app-configuration@1.12.1': + resolution: {integrity: sha512-skUcJ8ca5X7RQU92svaGtpefp3ncI/LqFbc+IiQR0KCT//+TlwJN52nsloGsqwr8+b4fo+refWJp53iqqHEqbA==} + engines: {node: '>=20.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.0': + resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} + engines: {node: '>=22.0.0'} + + '@azure/core-http-compat@2.5.0': + resolution: {integrity: sha512-BoSmXPx2er1Ai+wKlDvj29jIQespCNBwEmKyZVHO2kEFsWbGjAjwMCGzug3DJM5/QYIV3vej0S1zcU5bq9fa8w==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-lro@3.4.0': + resolution: {integrity: sha512-y0uqcVFp5NHd7tkZcn8Nes6yIhVR05m4dd+L8foWiH1IsS75Z2BodJxwdErEF3bV+NSh6nkNnwPyXaLp0ma1Nw==} + engines: {node: '>=22.0.0'} + + '@azure/core-paging@1.7.0': + resolution: {integrity: sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==} + engines: {node: '>=22.0.0'} + + '@azure/core-process@1.0.0': + resolution: {integrity: sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + '@azure/functions-extensions-base@0.3.0': resolution: {integrity: sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==} engines: {node: '>=18.0'} @@ -77,6 +141,34 @@ packages: resolution: {integrity: sha512-6uq0Z7e3njy8fHpgCVIJWDtbkZz+BYXc6L8fQjisf8+hPdnauM5yZ70j9YC8xas6zvL1dFA+EfLuZcNYyuWLTg==} engines: {node: '>=20.0'} + '@azure/identity@4.13.2': + resolution: {integrity: sha512-NXL2/pCJctLxgw8bvrwwgge743kEq8LBT+O1pmV0vyUwetzFPH9auP6jhkU/cgZCPPtWoewAe3ncaGCgPo07fA==} + engines: {node: '>=22.0.0'} + + '@azure/keyvault-common@2.1.0': + resolution: {integrity: sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==} + engines: {node: '>=20.0.0'} + + '@azure/keyvault-secrets@4.11.2': + resolution: {integrity: sha512-ECj/kwZbZlQXj2kfWivSICbKwj6W3chmFhv8qUdauqYnjvZ0hWZBFSsZWux7W2nX3MP49PLUCusXk+hAg3pipg==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/msal-browser@5.19.0': + resolution: {integrity: sha512-DHe9iRcyByGJuLPkl0K31a1JjOdRY2zX38Q07mQpSbR8zOj1EIgsWfTXhSQVyyijUxlcofVy/br7qWJbrMwVXQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.13.0': + resolution: {integrity: sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.6.0': + resolution: {integrity: sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -584,6 +676,10 @@ packages: resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. + '@typespec/ts-http-runtime@0.3.8': + resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} + engines: {node: '>=22.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -661,6 +757,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -828,6 +927,9 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1224,6 +1326,16 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1257,6 +1369,18 @@ packages: lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -1266,6 +1390,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniqby@4.7.0: resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} @@ -1827,6 +1954,112 @@ packages: snapshots: + '@azure-rest/core-client@2.8.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/app-configuration@1.12.1': + dependencies: + '@azure-rest/core-client': 2.8.0 + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.0)(@azure/core-rest-pipeline@1.25.0) + '@azure/core-lro': 3.4.0 + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.0)(@azure/core-rest-pipeline@1.25.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-lro@3.4.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.7.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-process@1.0.0': {} + + '@azure/core-rest-pipeline@1.25.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/functions-extensions-base@0.3.0': {} '@azure/functions@4.16.2': @@ -1834,6 +2067,70 @@ snapshots: '@azure/functions-extensions-base': 0.3.0 cookie: 0.7.2 + '@azure/identity@4.13.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-process': 1.0.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@azure/msal-browser': 5.19.0 + '@azure/msal-node': 5.6.0 + open: 10.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-common@2.1.0': + dependencies: + '@azure-rest/core-client': 2.8.0 + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-secrets@4.11.2': + dependencies: + '@azure-rest/core-client': 2.8.0 + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/keyvault-common': 2.1.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.19.0': + dependencies: + '@azure/msal-common': 16.13.0 + + '@azure/msal-common@16.13.0': {} + + '@azure/msal-node@5.6.0': + dependencies: + '@azure/msal-common': 16.13.0 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2262,6 +2559,14 @@ snapshots: dependencies: parse-path: 7.1.0 + '@typespec/ts-http-runtime@0.3.8': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -2338,6 +2643,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -2498,6 +2805,10 @@ snapshots: dependencies: type-fest: 4.41.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -2922,6 +3233,30 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2955,12 +3290,22 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniqby@4.7.0: {} lodash@4.17.21: {} diff --git a/scripts/seed-appconfig.sh b/scripts/seed-appconfig.sh new file mode 100755 index 0000000..a07e73e --- /dev/null +++ b/scripts/seed-appconfig.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Seed App Configuration keys if missing. Does not overwrite portal edits. +# KV reference for forwardemail-api-key is idempotently (re)asserted — it is +# a URI pointer, not a secret value. +set -euo pipefail + +STORE="${APP_CONFIG_NAME:-ssd-postkit-appcs-prod-ae}" +SEED="${1:-infra/appconfig-seed.json}" +VAULT_URI="${KEY_VAULT_URI:-https://ssd-global-kv-prod-ae.vault.azure.net}" +SECRET_NAME="${FORWARD_EMAIL_SECRET_NAME:-forwardemail-api-key}" + +[[ -f "$SEED" ]] || { echo "error: seed file not found: $SEED" >&2; exit 1; } + +python3 - "$STORE" "$SEED" "$VAULT_URI" "$SECRET_NAME" <<'PY' +import json +import subprocess +import sys + +store, seed_path, vault_uri, secret_name = sys.argv[1:] +seed = json.load(open(seed_path, encoding="utf-8")) + + +def run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, check=False, capture_output=True, text=True) + + +def key_exists(key: str) -> bool: + result = run(["az", "appconfig", "kv", "show", "--name", store, "--key", key, "-o", "none"]) + return result.returncode == 0 + + +for key, value in seed.items(): + if key_exists(key): + print(f"keep {key}") + continue + result = run( + [ + "az", + "appconfig", + "kv", + "set", + "--name", + store, + "--key", + key, + "--value", + value, + "--yes", + ] + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + raise SystemExit(result.returncode) + print(f"set {key}") + +kv_key = f"secret:{secret_name}" +kv_value = json.dumps({"uri": f"{vault_uri.rstrip('/')}/secrets/{secret_name}"}) +result = run( + [ + "az", + "appconfig", + "kv", + "set", + "--name", + store, + "--key", + kv_key, + "--value", + kv_value, + "--content-type", + "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + "--yes", + ] +) +if result.returncode != 0: + sys.stderr.write(result.stderr) + raise SystemExit(result.returncode) +print(f"set {kv_key} (Key Vault reference)") +PY From a0ce75b7de2ac3da26ff50df674ee77b0dd259ce Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 04:31:05 +1000 Subject: [PATCH 4/5] fix: #8 Address CodeRabbit App Config review Retry App Config loads after failure, return 503 from contact, take the platform client IP, and grant OIDC Key Vault read after deploy. --- .github/workflows/deploy-api.yml | 24 ++++++++++++------- apps/api/README.md | 13 +++++++--- apps/api/src/config/app-configuration.spec.ts | 22 ++++++++++++++++- apps/api/src/config/app-configuration.ts | 9 +++++-- apps/api/src/contact-rate-limit.ts | 20 +++++++++------- apps/api/src/contact.spec.ts | 12 +++++++--- apps/api/src/functions/contact.ts | 18 +++++++++++++- infra/function-app.bicep | 10 ++++++++ 8 files changed, 102 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 732c379..977e3d9 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -90,14 +90,6 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - name: Assert KV secret exists - run: | - set -euo pipefail - az keyvault secret show \ - --vault-name "$AZURE_KEY_VAULT_NAME" \ - --name forwardemail-api-key \ - --query name -o tsv >/dev/null - - name: Deploy Function App infra run: | set -euo pipefail @@ -108,6 +100,22 @@ jobs: --name "postkit-api-${GITHUB_RUN_ID}" \ --parameters githubOidcPrincipalId="$PRINCIPAL_ID" + - name: Assert KV secret exists + run: | + set -euo pipefail + for i in 1 2 3 4 5; do + if az keyvault secret show \ + --vault-name "$AZURE_KEY_VAULT_NAME" \ + --name forwardemail-api-key \ + --query name -o tsv >/dev/null; then + exit 0 + fi + echo "Key Vault read not ready yet (attempt ${i}/5); waiting for RBAC." + sleep 5 + done + echo "forwardemail-api-key is missing or the OIDC principal cannot read it." + exit 1 + - name: Seed App Configuration (missing keys only) run: | set -euo pipefail diff --git a/apps/api/README.md b/apps/api/README.md index 6be5085..31d7474 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -6,9 +6,16 @@ non-secret settings come from Azure App Configuration (`ssd-postkit-appcs-prod-ae`). `FORWARD_EMAIL_TOKEN` is a Key Vault reference in that store. -Local `func start` needs `az login` and -`AZURE_APPCONFIGURATION_ENDPOINT` in `local.settings.json` (see the example). -Do not put tenant profiles or tokens in `local.settings.json`. +Local `func start` needs `az login`, +`AZURE_APPCONFIGURATION_ENDPOINT` in `local.settings.json` (see the example), +and these Azure RBAC roles on your user: + +- **App Configuration Data Reader** on `ssd-postkit-appcs-prod-ae` +- **Key Vault Secrets User** on `ssd-global-kv-prod-ae` + +`az login` only supplies a credential; without both roles the contact handler +cannot load configuration. Do not put tenant profiles or tokens in +`local.settings.json`. ```bash pnpm --filter @singleton-sd/post-kit-api test diff --git a/apps/api/src/config/app-configuration.spec.ts b/apps/api/src/config/app-configuration.spec.ts index df22118..d2fceff 100644 --- a/apps/api/src/config/app-configuration.spec.ts +++ b/apps/api/src/config/app-configuration.spec.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it, beforeEach, afterEach } from 'node:test'; import type { ConfigurationSetting } from '@azure/app-configuration'; -import { loadAppConfiguration, resetAppConfigurationCache } from './app-configuration'; +import { + ensureAppConfiguration, + loadAppConfiguration, + resetAppConfigurationCache, +} from './app-configuration'; describe('loadAppConfiguration', () => { const touched = [ @@ -87,6 +91,22 @@ describe('loadAppConfiguration', () => { assert.equal(process.env.ORIGINS, 'localhost:4321'); }); + it('retries after a failed load instead of caching the rejection', async () => { + process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; + let calls = 0; + const failing = { + listSettings: () => + (async function* () { + calls += 1; + throw new Error('store unavailable'); + })(), + }; + + await assert.rejects(ensureAppConfiguration(failing), /store unavailable/); + await assert.rejects(ensureAppConfiguration(failing), /store unavailable/); + assert.equal(calls, 2); + }); + it('rejects malformed Key Vault references', async () => { process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; diff --git a/apps/api/src/config/app-configuration.ts b/apps/api/src/config/app-configuration.ts index 8818ded..e4df31b 100644 --- a/apps/api/src/config/app-configuration.ts +++ b/apps/api/src/config/app-configuration.ts @@ -68,8 +68,13 @@ export async function loadAppConfiguration( } /** Load once per worker. Safe to call from every Function invocation. */ -export function ensureAppConfiguration(): Promise { - loadOnce ??= loadAppConfiguration(); +export function ensureAppConfiguration( + dependencies: AppConfigurationDependencies = {}, +): Promise { + loadOnce ??= loadAppConfiguration(dependencies).catch((error: unknown) => { + loadOnce = undefined; + throw error; + }); return loadOnce; } diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts index 2479e46..0eca0fa 100644 --- a/apps/api/src/contact-rate-limit.ts +++ b/apps/api/src/contact-rate-limit.ts @@ -83,15 +83,19 @@ export function resetContactRateLimiter(): void { } export function clientIpFromHeaders(headers: { get(name: string): string | null }): string { + const azureClient = headers.get('x-azure-clientip')?.trim(); + if (azureClient) return azureClient; + const xff = headers.get('x-forwarded-for'); if (xff) { - const first = xff.split(',')[0]?.trim(); - if (first) return first; + const hops = xff + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + // Azure appends the socket peer; take the last hop, not a caller-supplied prefix. + const last = hops.at(-1); + if (last) return last; } - return ( - headers.get('x-client-ip')?.trim() || - headers.get('x-real-ip')?.trim() || - headers.get('x-azure-clientip')?.trim() || - 'unknown' - ); + + return headers.get('x-real-ip')?.trim() || headers.get('x-client-ip')?.trim() || 'unknown'; } diff --git a/apps/api/src/contact.spec.ts b/apps/api/src/contact.spec.ts index de8f259..41e534a 100644 --- a/apps/api/src/contact.spec.ts +++ b/apps/api/src/contact.spec.ts @@ -172,13 +172,19 @@ describe('SlidingWindowRateLimiter', () => { assert.ok(denied.retryAfterSec >= 1); }); - it('isolates keys and reads first X-Forwarded-For hop', () => { + it('isolates keys and prefers the platform client address', () => { const limiter = new SlidingWindowRateLimiter(1, 60_000); assert.equal(limiter.tryConsume('a', 1).allowed, true); assert.equal(limiter.tryConsume('b', 1).allowed, true); - const headers = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1']]); - assert.equal(clientIpFromHeaders({ get: (n) => headers.get(n) ?? null }), '203.0.113.9'); + const xff = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1']]); + assert.equal(clientIpFromHeaders({ get: (n) => xff.get(n) ?? null }), '10.0.0.1'); + + const azure = new Map([ + ['x-forwarded-for', '203.0.113.9, 10.0.0.1'], + ['x-azure-clientip', '198.51.100.7'], + ]); + assert.equal(clientIpFromHeaders({ get: (n) => azure.get(n) ?? null }), '198.51.100.7'); }); it('evicts inactive buckets after the window', () => { diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index 44cc5f7..84f35d9 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -8,8 +8,24 @@ export async function contactHandler( request: HttpRequest, context: InvocationContext, ): Promise { - await ensureAppConfiguration(); const origin = request.headers.get('origin'); + try { + await ensureAppConfiguration(); + } catch (error) { + context.error('app configuration load failed', { + name: error instanceof Error ? error.name : 'Error', + }); + return { + status: 503, + headers: { + ...contactCorsHeaders(origin), + 'Content-Type': 'application/json', + }, + jsonBody: { + error: 'Contact delivery is temporarily unavailable. Please try again later.', + }, + }; + } const cors = contactCorsHeaders(origin); if (request.method === 'OPTIONS') { diff --git a/infra/function-app.bicep b/infra/function-app.bicep index 211405d..9ceabb5 100644 --- a/infra/function-app.bicep +++ b/infra/function-app.bicep @@ -172,6 +172,16 @@ resource appConfigOidcOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' } } +resource kvOidcSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(githubOidcPrincipalId)) { + name: guid(keyVault.id, githubOidcPrincipalId, roleKeyVaultSecretsUser) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleKeyVaultSecretsUser) + principalId: githubOidcPrincipalId + principalType: 'ServicePrincipal' + } +} + output functionAppName string = functionApp.name output functionAppHostname string = functionApp.properties.defaultHostName output functionAppPrincipalId string = functionApp.identity.principalId From 172578c629d34130e2b281303c2cd3e74c5b8a9f Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 04:38:13 +1000 Subject: [PATCH 5/5] fix: #8 Lengthen KV RBAC wait and strip XFF ports Retry Key Vault reads for 10 minutes after Bicep assigns OIDC access. Normalize App Service `ip:port` hops so rate-limit keys stay per client. --- .github/workflows/deploy-api.yml | 10 ++++++--- apps/api/src/contact-rate-limit.ts | 34 ++++++++++++++++++++++++++---- apps/api/src/contact.spec.ts | 6 ++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 977e3d9..d579b76 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -103,15 +103,19 @@ jobs: - name: Assert KV secret exists run: | set -euo pipefail - for i in 1 2 3 4 5; do + deadline=$((SECONDS + 600)) + attempt=0 + while [ "$SECONDS" -lt "$deadline" ]; do + attempt=$((attempt + 1)) if az keyvault secret show \ --vault-name "$AZURE_KEY_VAULT_NAME" \ --name forwardemail-api-key \ --query name -o tsv >/dev/null; then exit 0 fi - echo "Key Vault read not ready yet (attempt ${i}/5); waiting for RBAC." - sleep 5 + remaining=$((deadline - SECONDS)) + echo "Key Vault read not ready yet (attempt ${attempt}; ${remaining}s remaining); waiting for RBAC." + sleep 20 done echo "forwardemail-api-key is missing or the OIDC principal cannot read it." exit 1 diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts index 0eca0fa..b5367ae 100644 --- a/apps/api/src/contact-rate-limit.ts +++ b/apps/api/src/contact-rate-limit.ts @@ -1,3 +1,5 @@ +import { isIP } from 'node:net'; + /** In-memory sliding-window limiter for anonymous Contact (PoC). */ export interface RateLimitResult { @@ -82,8 +84,31 @@ export function resetContactRateLimiter(): void { contactRateLimiter = undefined; } +/** + * Host from a forwarded hop. App Service often appends `ipv4:port`; IPv6 + * ports use `[addr]:port`. Do not strip the last `:digits` group from bare + * IPv6. Untrusted `X-Client-IP` / `X-Real-IP` are ignored. + */ +function addressFromForwardedHop(hop: string): string | undefined { + const trimmed = hop.trim(); + if (!trimmed) return undefined; + + let host = trimmed; + if (host.startsWith('[')) { + const close = host.indexOf(']'); + if (close < 2) return undefined; + host = host.slice(1, close); + } else if ((host.match(/:/g) ?? []).length === 1) { + const colon = host.indexOf(':'); + const port = host.slice(colon + 1); + if (/^\d+$/.test(port)) host = host.slice(0, colon); + } + + return isIP(host) ? host : undefined; +} + export function clientIpFromHeaders(headers: { get(name: string): string | null }): string { - const azureClient = headers.get('x-azure-clientip')?.trim(); + const azureClient = addressFromForwardedHop(headers.get('x-azure-clientip') ?? ''); if (azureClient) return azureClient; const xff = headers.get('x-forwarded-for'); @@ -92,10 +117,11 @@ export function clientIpFromHeaders(headers: { get(name: string): string | null .split(',') .map((part) => part.trim()) .filter(Boolean); - // Azure appends the socket peer; take the last hop, not a caller-supplied prefix. + // Y1 Consumption / App Service: last hop is the socket peer. const last = hops.at(-1); - if (last) return last; + const address = last ? addressFromForwardedHop(last) : undefined; + if (address) return address; } - return headers.get('x-real-ip')?.trim() || headers.get('x-client-ip')?.trim() || 'unknown'; + return 'unknown'; } diff --git a/apps/api/src/contact.spec.ts b/apps/api/src/contact.spec.ts index 41e534a..5f41c0b 100644 --- a/apps/api/src/contact.spec.ts +++ b/apps/api/src/contact.spec.ts @@ -180,11 +180,17 @@ describe('SlidingWindowRateLimiter', () => { const xff = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1']]); assert.equal(clientIpFromHeaders({ get: (n) => xff.get(n) ?? null }), '10.0.0.1'); + const xffPort = new Map([['x-forwarded-for', '203.0.113.9, 10.0.0.1:49152']]); + assert.equal(clientIpFromHeaders({ get: (n) => xffPort.get(n) ?? null }), '10.0.0.1'); + const azure = new Map([ ['x-forwarded-for', '203.0.113.9, 10.0.0.1'], ['x-azure-clientip', '198.51.100.7'], ]); assert.equal(clientIpFromHeaders({ get: (n) => azure.get(n) ?? null }), '198.51.100.7'); + + const v6 = new Map([['x-forwarded-for', '[2001:db8::1]:49152']]); + assert.equal(clientIpFromHeaders({ get: (n) => v6.get(n) ?? null }), '2001:db8::1'); }); it('evicts inactive buckets after the window', () => {