Secrets & Config — the one map for the scattered platform plumbing
Why this file exists. Four kinds of "platform plumbing" — site-domain provisioning, the email-services topology, the secrets / KMS / API-key surface, and the env vars / hub
.envkeys an app actually needs — were each documented in one place buried in a much larger SoT. An agent that hits "Missing site configuration for email", or doesn't know which four ports email needs, or passes anaccessLevelthecreateApiKeyproc rejects, had to know exactly which file + line to open. This doc is the single discoverable index: it states the load-bearing fact inline and then links back to the canonical SoT for the full detail. It does not fork detail — when the SoT changes, this file points at it; it doesn't duplicate it.Read this when: you are wiring an app's
.env, standing up email delivery, exercising any secret/API-key flow, or you hit one of the config errors named below. It is a reference, not a phase — nothing here is on the four-phase critical path for a basic no-email app.This file is consume-only over the upstream runbooks it links. The local-email-services Docker-Compose runbook lives in
constructive-io/constructive(a different repo) — this file points at it, it does not re-host it.
0. Map (jump table)
| # | Area | One-line fact | Canonical SoT (full detail) |
|---|---|---|---|
| 1 | Site-domain provisioning | Email links resolve their site from services_public.domains; the per-DB provisioner makes API hosts but no site-domain row → "Missing site configuration for email". The toolkit now backfills it hands-free. |
scripts/templates/provision/provision.ts (the live backfill, §1 below) + troubleshooting.md → "Post-Provision: Missing site configuration for email" (by-hand fallback) |
| 2 | Email-services topology | Four services must all listen: Mailpit 8025, Admin GraphQL 3002, send-email-link 8082, job-service (no HTTP port). SEND_EMAIL_LINK_DRY_RUN must be false. |
SKILL.md Optional-Extensions "Email services" row + troubleshooting.md → the four Post-Provision (Email Services) sections. Upstream runbook: constructive-io/constructive (Docker-Compose method). |
| 3 | Secrets / KMS / API keys | Four durable stores, chosen by secret vs config × internal (no namespace) vs infra (projected into Kubernetes) — see §3.0 + references/secret-stores.md. Secret values never come back out of the SDK/ORM: encrypted at rest, decryptable only through the trusted runtime secret-resolution path. createApiKey is step-up-gated server-side and accepts only accessLevel ∈ {read_only, full_access} — other values raise INVALID_ACCESS_LEVEL. Reveal is one-time, step-up first. |
constructive-principals (API-key lifecycle) + constructive-auth (step-up verification) |
| 4 | Env vars / hub .env keys |
App .env points blocks at the per-DB endpoints (blocks read the _GRAPHQL_ names). Query hostnames by DATABASE_ID (§4.3) — never string-build them. The shared hub server needs API_IS_PUBLIC / API_ANON_ROLE / API_ROLE_NAME. |
SKILL.md S0/S3 (hub + app env) + gotchas.md BLOCKS-001 (the _GRAPHQL_ name trap) + §4.3 below |
| 5 | Realms | Optional nullable realm field: one name holds many values (per region/tenant-app/user/channel), and an instance (resource/functionDeployment) selects a lane. null = the default lane; reads fall back exact→null. Never synthesize a realm (realm ?? 'default'). |
references/realms.md (SDK/ORM view) + constructive-db docs/architecture/realms.md (internals) |
Known-defect status for these areas is tracked internally; harness deployments layer a private known-gaps overlay on top of this skill with the current list. If a documented flow fails after the steps here are green, check that overlay (or ask the platform team) before assuming your wiring is wrong.
1. Site-domain provisioning (the "Missing site configuration for email" fix)
The fact. send-email-link resolves the outgoing link's site from
services_public.domains JOIN services_public.sites. The per-DB provisioner creates the API domains
(the api-<sub>.localhost data host) but does not create a services_public site-domain row.
With no row, every email send aborts with "Missing site configuration for email" — making the
email flows (magic-link / verify / reset / invite) un-exercisable.
The fix is now baked in (hands-free). scripts/templates/provision/provision.ts backfills the app's
own site-domain (subdomain = <db-name>, domain = 'localhost'), idempotently, gated on
SITE_DOMAIN_NEEDED (set by scripts/scaffold-provision.mjs needsSiteDomain() — true for any
non-minimal auth preset or any brief listing an email-sending flow). It then verifies the join returns a
row and warns loudly if it can't. So for any email-capable app the row provisions automatically — you should
not normally need the manual INSERT.
Gotcha baked into the template (don't regress it): the
$1param must carry explicit::textcasts on both occurrences (theSELECT-listsubdomainvalue and theWHERE db.namecomparison). Thepgdriver sends the param untyped; Postgres deduces$1's type independently at each position and aborts with "inconsistent types deduced for parameter $1" if they disagree. This is themail2FLOW-QA fix — see the comment atscripts/templates/provision/provision.ts(≈L148–158).
By-hand fallback (only if the backfill warns/fails, or for a DB provisioned outside the toolkit) — the
canonical SQL lives in troubleshooting.md → "Post-Provision: Missing site configuration for email":
-- Replace <your-db-name> with your database name. Idempotent (ON CONFLICT DO NOTHING).
INSERT INTO services_public.domains (database_id, site_id, subdomain, domain)
SELECT db.id, s.id, '<your-db-name>'::text, 'localhost'
FROM metaschema_public.database db
JOIN services_public.sites s ON s.database_id = db.id
WHERE db.name = '<your-db-name>'::text
ON CONFLICT (subdomain, domain) DO NOTHING;
Verify it landed (the join the email service relies on must return a row):
SELECT d.subdomain, d.domain
FROM services_public.domains d
JOIN services_public.sites s ON d.site_id = s.id
JOIN metaschema_public.database db ON d.database_id = db.id
WHERE db.name = '<your-db-name>'; -- should return a row
If the verify returns zero rows even after the INSERT, the app DB has no
services_public.sitesrow (the provisioner didn't create one) — email sends will still fail. That's a provisioning problem, not a missing-domain problem; confirm the DB was provisioned with a site.
2. Email-services topology (which processes must be listening)
Email delivery is an Optional Extension (was "Phase 2.4"), not a mainline phase — add it only when the
brief exercises email (verification, password reset, invitations). The canonical pointer is the
SKILL.md Optional-Extensions "Email services" row; the failure-mode detail is in troubleshooting.md
under the Post-Provision (Email Services) sections.
2.1 The four moving parts (all must be up)
| Service | Port | Check it's up | Role |
|---|---|---|---|
| Mailpit | 8025 (UI) / 1025 (SMTP) | curl -s http://localhost:8025 | head -5 |
Local SMTP sink + web inbox — where delivered mail lands |
| Admin GraphQL | 3002 | lsof -i:3002 | grep LISTEN |
The private admin endpoint the email path calls (see start-command caveat below) |
| send-email-link | 8082 | lsof -i:8082 | grep LISTEN |
Renders + sends the actual email link |
| job-service | (no HTTP port) | pgrep -f "knative-job-service" |
Drains the queue and calls send-email-link |
All four must be listening. A missing piece is a silent failure — mail just never appears in Mailpit. The diagnostic ladder (which one is down, log paths, env) is in
troubleshooting.md→ "Post-Provision: send-email-link not sending emails" and "…job-service not processing jobs".
2.2 The three settings people forget
SEND_EMAIL_LINK_DRY_RUN=false— iftrue, emails are logged but not sent. (troubleshooting.mdstep 3 of the send-email-link section.)INTERNAL_GATEWAY_DEVELOPMENT_MAP— job-service must know where send-email-link lives:export INTERNAL_GATEWAY_DEVELOPMENT_MAP='{"send-email-link":"http://localhost:8082"}'(troubleshooting.md, job-service section).- Admin GraphQL
--origin "*"—constructive server --port 3002without--originhangs waiting for an interactive CORS prompt (no TTY in agent/CI). Always pass--origin "*". Full env-laden start command is introubleshooting.md→ "Admin GraphQL Server (3002) hangs on startup".
Note the Admin server's env differs from the public hub: it runs
API_IS_PUBLIC=false,API_ANON_ROLE=administrator,API_ROLE_NAME=administrator, plusAPI_ENABLE_SERVICES=trueand theAPI_EXPOSED_SCHEMAS/API_META_SCHEMASadmin schema lists (see §4 andtroubleshooting.md).
2.3 Don't re-host the upstream runbook
The full local-email-services standup (Docker-Compose method, image set, wiring) lives in the
constructive-io/constructive repo — a different repo the build flow is consume-only over. Point at it;
do not copy it here. The SKILL.md "Email services" row already names it as the source.
2.4 Email defects that aren't yours
The site-domain prerequisite (§1) is deterministic; the email send path can additionally be affected by platform-side issues outside the app's control. If email "doesn't work" after §1+§2 are green, check the private known-gaps overlay (internal harness deployments) or ask the platform team before treating it as an app/build-flow bug.
3. Secrets / KMS / API-key surface
Where it comes from. Every auth-carrying preset (auth:hardened and up) provisions the
encrypted-at-rest secret store (internal_secrets_module at the app scope), its plaintext counterpart
(internal_config_module), and the per-user credential store (user_credentials_module) that API-key and
password hashes live in. There is no extra module to add for the API-key surface beyond a basic auth
module list.
3.0 Which store, and what the SDK can see
Four durable stores, chosen on two questions — is the value secret, and does Kubernetes have to see it?
| secret (encrypted at rest) | config (plaintext by design) | |
|---|---|---|
| internal — in the database, no namespace | internal_secrets_module |
internal_config_module |
| infra — namespace-backed, projected into the cluster | infra_secrets_module |
infra_config_module |
Internal is the default: a value your code reads while it runs never needs a Kubernetes namespace. Reach for an infra store only when the value must be mounted by the cluster (an env var or file a container reads at boot, image-pull credentials) — that write projects it into a Kubernetes Secret/ConfigMap.
Secret values are not retrievable through the SDK/ORM. They are encrypted at rest, may only be
decrypted through the trusted runtime secret-resolution path, and are
never part of a readable row: what you get is the metadata surface (name, realm, provider, labels,
rotation/retirement timestamps) plus set/rotate/retire/delete. There is no getSecret. A value is
resolved only by trusted code running inside the platform — a deployed function reading it at invocation
time for the tenant it is serving — so it never reaches an API response or a client bundle. Config values
are deliberately the opposite: plaintext, readable and editable through the ordinary ORM surface.
If a flow appears to need a plaintext secret in the client, it is the wrong flow — move the work into a function. The one-time reveal on API-key creation (§3.2) is the deliberate, step-up-gated exception.
Full detail, including the per-user credential and session-secret stores: references/secret-stores.md.
3.1 The createApiKey contract (get this wrong → runtime rejection)
Source of truth: references/flow-catalog.md → "API keys" (api-keys) + the usage/wire notes in
references/flows.json (the api-keys flow entry). The deployed proc enforces:
accessLevel ∈ { 'read_only', 'full_access' }— ONLY. Any other value (e.g.read/write/admin/required) fails at runtime withINVALID_ACCESS_LEVEL.- ⚠️ Block↔backend mismatch: the shipped
auth-api-key-create-dialogblock presents anaccessLevelOptionslist ofread/write/admin, which does not match the proc. Constrain the UI to the two valid values (read_only,full_access), or every create rejects.
- ⚠️ Block↔backend mismatch: the shipped
mfaLevel ∈ { 'none', 'verified' }.STEP_UP_REQUIREDserver-side (defense-in-depth beyond the client gate). Theauth-api-key-create-dialogruns the step-up first; if you callcreateApiKeydirectly, use the verify-and-retry pattern inconstructive-principals→ api-keys.md § Step-up, which also owns theverifyPasswordresult semantics.
3.2 Reveal is one-time + step-up-gated
The API-key / secret value is shown via a one-time reveal modal (auth-api-key-created-modal), and the
sensitive read is step-up-gated — the same requireStepUp / verifyPassword / verifyTotp surface the
step-up flow exposes (references/flow-catalog.md → "Step-up verification"). Mount the StepUpProvider
once at the app root (the api-keys flow's wire snippet shows this). Treat the revealed value as
show-once; there is no second reveal.
3.3 Verify revocation, don't just trust the return value
After revokeApiKey, verify the key is actually rejected before presenting "revoked" as a terminal,
enforced state in the UI — treat the mutation's true as "request recorded" and confirm enforcement
with a follow-up call using the key.
4. Env vars / hub .env keys that matter
Two distinct env scopes. Don't conflate them.
4.1 The shared hub server (:3000) — API_* knobs
The warm public server (api/auth/modules on :3000) is launched with (SKILL.md S0):
API_IS_PUBLIC=true API_ANON_ROLE=anonymous API_ROLE_NAME=authenticated + the hub .env
API_IS_PUBLIC—truefor the public hub;falsefor the private Admin server (§2.2).API_ANON_ROLE— the Postgres role for unauthenticated requests (anonymouson the hub;administratoron the admin server).API_ROLE_NAME— the authenticated role (authenticatedon the hub;administratoron admin).- If
:3000OOMs under multi-DB load, relaunch withNODE_OPTIONS=--max-old-space-size=8192(S0) — a mitigation for per-DB handler-cache memory growth, not a fix.
4.2 The generated app's .env / .env.local — per-DB endpoints
Source of truth: SKILL.md S3 (and scripts/wire-app.mjs, which writes both files). Write env to
BOTH <app>/.env (codegen reads it) and <app>/.env.local (pnpm dev reads it), kept identical.
The key names are the contract; the hostname values come from §4.3 — <sub> is whatever the
platform assigned, never the app slug by assumption:
NEXT_PUBLIC_DB_NAME=<sub>
NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT=http://auth-<sub>.localhost:3000/graphql # users / auth
NEXT_PUBLIC_ADMIN_GRAPHQL_ENDPOINT=http://admin-<sub>.localhost:3000/graphql # orgs / members
NEXT_PUBLIC_APP_ENDPOINT=http://api-<sub>.localhost:3000/graphql # runtime app DATA = api-<sub>
CODEGEN_APP_ENDPOINT=http://api-<sub>.localhost:3000/graphql # codegen app DATA URL
CODEGEN_APP_HOST=api-<sub>.localhost # codegen Host header
🚨 Two traps that silently no-op the whole app:
api-<sub>, NOTapp-public-<sub>. App data reads/writes route onapi-<sub>; theapp-public-<sub>host is dead. Routing is by the request'sHostheader — the URL alone is necessary but not sufficient. (SKILL.md≈L264–273,gotchas.mdSUBDOMAIN-001 / F2.)- Blocks read the
_GRAPHQL_-infix names.blocks-runtime.tsxreadsNEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT/NEXT_PUBLIC_ADMIN_GRAPHQL_ENDPOINT— different names from the template'sNEXT_PUBLIC_AUTH_ENDPOINT/NEXT_PUBLIC_ADMIN_ENDPOINT. Set the_GRAPHQL_names too, or every block request no-ops withMissing NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT. (gotchas.mdBLOCKS-001.)
4.3 Query your endpoints — never derive hostnames
Databases may have random pool-assigned names (e.g. b16-fatal-rose-mosquito), so never string-build
hostnames from the app name. Query them by the .env DATABASE_ID instead:
// Registered hostnames for this database
const domains = await db.domain.findMany({
where: { databaseId: process.env.DATABASE_ID },
select: { hostname: true, isPublished: true }
}).execute();
// The API surfaces they bind to
const apis = await db.api.findMany({
where: { databaseId: process.env.DATABASE_ID },
select: { id: true, name: true, isPublished: true }
}).execute();
// Verify a hostname resolves the way the server will
const route = await db.query.resolveRoute({
requestHost: hostname, requestPath: '/', requestMethod: null
}).execute();
Then write the returned hostnames into the §4.2 .env keys.
Auth is per-DB, not the platform token. A
schema-builder/ platform token does not authenticate per-DB data calls. Sign up / sign in against the tenantauth-<sub>.localhostendpoint (not baseauth.localhost) and send that session token toapi-<sub>. (gotchas.md≈L314–329; FK prereq for owner-scoped tables: the authed user must exist in-tenant — RLS-POLICY-001.)
Realms (the realm discriminator)
A realm is an optional nullable field that lets one logical name hold many
values (storage discriminator) and lets an instance choose which value it consumes
(consumption selector). null is the default lane, not "missing" — reads resolve
an exact realm match first, then fall back to the null-realm value. There is no
default realm string; omit it for the ordinary single-value case, and never
synthesize one (realm ?? 'default'). Two consumption modes: projection
(set realm on a resource/functionDeployment, values baked in at deploy) and
runtime query (leave it null, fetch the realm of each entity on demand — the
push worker). Full SDK/ORM detail in references/realms.md; the DB-level mechanics
(uniqueness with NULLS NOT DISTINCT, getter fallback, requirements gate,
register_push_channel, cloud-function runtime lookup) live in constructive-db
docs/architecture/realms.md.
See also (canonical SoTs — full detail lives in these, not here)
scripts/templates/provision/provision.ts— the live site-domain backfill (§1) + the$1::textcast.troubleshooting.md— Post-Provision (Email Services): Mailpit / Admin-3002 / send-email-link / "Missing site configuration for email" / job-service (§1 fallback + §2).SKILL.md— S0/S3 env (§4) + the Optional-Extensions "Email services" row (§2).references/flow-catalog.md+references/flows.json— theapi-keys+step-upflow contracts (§3): exact ops, blocks,accessLevel/mfaLevel/step-up rules.gotchas.md— BLOCKS-001 (_GRAPHQL_names), SUBDOMAIN-001 / F2 (api-<sub>notapp-public-), RLS-POLICY-001 (tenant-endpoint FK prereq).- Upstream (different repo):
constructive-io/constructive— the local-email-services Docker-Compose runbook (§2.3). Not re-hosted here. references/realms.md— the SDK/ORM view of realms (§5); DB-level internals in constructive-dbdocs/architecture/realms.md.references/secret-stores.md— the four durable stores (§3.0): internal vs infra, secret vs config, scopes, and why a secret value never leaves the platform through the SDK. DB-level internals in constructive-db.agents/skills/constructive-db-secrets/.