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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 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, the deploy job is skipped (workflow succeeds).
name: Deploy API (Function)

on:
push:
branches: [main]
paths:
- '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:

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
APP_CONFIG_NAME: ssd-postkit-appcs-prod-ae

jobs:
build:
runs-on: ubuntu-latest
name: Build ZIP
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.15.0

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Test API
run: pnpm --filter @singleton-sd/post-kit-api run test

- name: Stage zip
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)
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)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- 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}" \
--parameters githubOidcPrincipalId="$PRINCIPAL_ID"

- name: Assert KV secret exists
run: |
set -euo pipefail
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
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
Comment thread
patoperpetua marked this conversation as resolved.

- 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: |
set -euo pipefail
az functionapp deployment source config-zip \
--resource-group "$AZURE_RESOURCE_GROUP" \
--name "$AZURE_FUNCTIONAPP_NAME" \
--src post-kit-api.zip
7 changes: 5 additions & 2 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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):**

Expand All @@ -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)

Expand Down
7 changes: 3 additions & 4 deletions apps/README.md
Original file line number Diff line number Diff line change
@@ -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` |
2 changes: 2 additions & 0 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
local.settings.json
26 changes: 26 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# `@singleton-sd/post-kit-api`

Azure Functions (anonymous contact + health). Trusted marketing sites POST
`/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`,
`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
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).
20 changes: 20 additions & 0 deletions apps/api/host.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
}
}
8 changes: 8 additions & 0 deletions apps/api/local.settings.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",
"AZURE_APPCONFIGURATION_ENDPOINT": "https://ssd-postkit-appcs-prod-ae.azconfig.io"
}
}
27 changes: 27 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"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/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": {
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22"
}
}
135 changes: 135 additions & 0 deletions apps/api/src/config/app-configuration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { describe, it, beforeEach, afterEach } from 'node:test';
import type { ConfigurationSetting } from '@azure/app-configuration';
import {
ensureAppConfiguration,
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<string, string | undefined>();

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('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';

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;
}
Loading
Loading