Compare commits

...
Author SHA1 Message Date
admin 6aa1f76657 ci: drop the pnpm-store cache on 0pt (save step dominated runtime)
masterplan-lock / check (push) Successful in 4s
Caching the full pnpm store made the post-step tar/zstd run for >10 minutes on
the shared runner, turning a ~1m quality job into a 15m+ one. The concurrency
and main-only changes keep the win; install stays a plain frozen-lockfile run.
2026-11-02 19:06:54 +01:00
admin ea40a67940 docs(deploy): runbook and workflow headers match the live pipeline
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m49s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m1s
The runbook still described Vercel and an unprovisioned Coolify app. It now
documents the verified chain (ci -> release attestation -> cd -> Coolify),
the proven manual dispatch, the verification endpoints and the rollback.
2026-11-02 18:57:45 +01:00
admin c4a7b83f61 feat(auth): central Keycloak portal-trust (KEYCLOAK_ISSUER/audience/azp)
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / quality (pull_request) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m57s
2026-11-02 18:45:40 +01:00
admin 56d3a8341b Merge pull request 'ci(smoke): läs main-SHA från commits-listan' (#448) from fix/smoke-main-sha into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / integration (push) Successful in 45s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 47s
Deploy to Coolify / deploy (push) Successful in 58s
CI (SIAX Cloud) / sonar (push) Successful in 58s
CI (SIAX Cloud) / quality (push) Successful in 1m49s
2026-11-02 17:31:39 +00:00
7 changed files with 145 additions and 72 deletions
+5
View File
@@ -279,3 +279,8 @@ OPT_CREDIT_PRICE_BOOK=
# explicit (never a silent fallback), the response reports `credits: null`, and
# purchases stay disabled. Unset/`on` requires the price book + ledger.
OPT_CREDITS_MODE=
# Central Keycloak (SIAX Portal)
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal
KEYCLOAK_EXPECTED_AUDIENCE=account
KEYCLOAK_AUTHORIZED_PARTY=siax-portal
+8 -8
View File
@@ -6,14 +6,14 @@ name: Deploy to Coolify
# Dockerfile (build_pack: dockerfile) -- this workflow only fires the
# rebuild+redeploy, it doesn't build or push an image itself.
#
# STATUS (2026-08-09): no Coolify application has been provisioned for 0pt
# yet -- siax.config.json's `deploy.target` was flipped from "vercel" to
# "coolify" to state the intended platform, but there is no app UUID to
# point at. COOLIFY_APP_ID below is a literal placeholder. The guard step
# fails the job loudly (rather than silently POSTing to a bogus/empty URL)
# until both the Coolify app exists and its real UUID replaces the
# placeholder here. See docs/architecture/ARCHITECTURE.md §5/§8 for the
# open "Vercel or fleet" question this resolves the config side of.
# STATUS (verified 2026-11-02): LIVE. The Coolify application exists
# (dockerfile build pack, git.siax.io/sax3l/0pt, branch main) and its UUID is
# supplied by the repository variable COOLIFY_APP_ID; COOLIFY_TOKEN and the
# registry/cosign secrets are set. A push to main, or a manual
# workflow_dispatch of this file, has been proven to verify the signed
# attestation for the exact commit and then return HTTP 200 from Coolify.
# The placeholder guard below remains as a fail-closed guard, not as a TODO.
# Operational procedure and verification live in docs/runbook/deploy.md.
on:
push:
branches: [main]
-4
View File
@@ -36,8 +36,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0)
run: |
corepack enable
@@ -134,8 +132,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9
run: |
corepack enable
-2
View File
@@ -21,8 +21,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9
run: |
corepack enable
+2 -2
View File
@@ -29,8 +29,8 @@ name: Release chain (G17, ADR-W16-003)
# workflow file -- learned the hard way on sax3l/h0st's and sax3l/aud0's
# own G17 migrations. 0pt does not wire a G20 post-deploy step in this
# pass at all (see `.siax/hooks/README.md` "G20 -- deliberately not wired
# yet": no Coolify application provisioned for 0pt, no `/version`
# endpoint), so the single-job constraint that forces G20 into its own
# yet": the app is now provisioned and `/api/version` exists, but G20 is still
# not wired in this pass), so the single-job constraint that forces G20 into its own
# workflow file elsewhere does not yet bite here -- documented anyway so a
# future PR adding G20 does not repeat the mistake.
+77 -56
View File
@@ -1,63 +1,84 @@
# Runbook: Deploy — 0pt
> Mål: sätta 0pt.app på SIAX-flottan (Coolify) och avveckla Vercel i ordning.
> Status 2026-08-10: `siax.config.json` deklarerar `target: coolify`; ingen
> Coolify-app provisionerad; live-instansen körs fortfarande på Vercel.
> Status 2026-11-02: **live on the SIAX fleet (Coolify)** at `https://0pt.app`.
> The automatic chain and the manual path are both verified: a signed image is
> attested, Coolify is called, the container restarts on the new commit, and
> `/api/version` reports it.
## 1. Förutsättningar (blockerare för cutover)
## Pipeline (automatic)
- [ ] Coolify-app provisionerad i `cloud.siax.io` (build_pack: dockerfile,
repo `git.siax.io/sax3l/0pt`, gren `main`).
- [ ] `COOLIFY_APP_ID` (appens UUID) ifylld i `.gitea/workflows/cd.yml`.
- [ ] `COOLIFY_TOKEN`-secret satt på repot (Gitea → Settings → Actions → Secrets).
- [ ] Env-secrets inlagda i Coolify-appen från Infisical-projektet `0pt`
(se backup/restore + incident runbooks).
- [ ] DNS-cutover planerad: `0pt.app` → Coolify-appens ingress (eller Caddy).
## 1b. Signerad-artefakt-grind (tillagd 2026-09-14, samma mönster som cl0ud/st0re/h0st)
`.gitea/workflows/release.yml` (nytt) bygger, SBOM:ar (syft), signerar
(cosign) och evidensbokför varje push till `main`, och pushar till
`git.cloud.siax.io/sax3l/0pt:sha-<12>`. `cd.yml`s `deploy`-jobb har nu ett
extra steg (efter guard-steget i punkt 1 ovan) som kräver att exakt denna
commits avbildning finns i registret och att dess cosign-signatur
verifierar (mot committad `cosign.pub`) innan Coolify överhuvudtaget
anropas.
Detta är byggt FÖRE Coolify-appen finns (se blockerarna i punkt 1) — så att
grinden redan är på plats den dag appen provisioneras, i stället för att bli
ett separat uppföljningsarbete. Samma ärliga begränsning som cl0ud/st0re/
h0st: när appen väl provisioneras som `dockerfile` build pack (så som
`siax.config.json` föreskriver) kör Coolify sin EGEN oberoende
`docker build` från git-källan, inte den signerade avbildningen —
`/api/v1/deploy` saknar digest-parameter helt. Sann digest-pinning skulle
kräva att appen provisioneras som Coolifys separata `dockerimage`-resurstyp
i stället, ett beslut som inte är fattat än. Se `.gitea/workflows/cd.yml`s
"SIGNED-ARTIFACT GATE"-kommentar för den fullständiga skrivningen.
**Inte satt på `sax3l/0pt` ännu:** repo-secrets `REGISTRY_USER`,
`REGISTRY_TOKEN`, `COSIGN_PRIVATE_KEY`, `COSIGN_PASSWORD`, `SIAX_CI_TOKEN` —
`release.yml` fail-closed:ar på sin första körning tills dessa finns.
## 2. Deploy (när appen finns)
1. Push till `main` → `.gitea/workflows/cd.yml` POSTar till
`https://cloud.siax.io/api/v1/deploy?uuid=$COOLIFY_APP_ID&force=false`,
efter att den signerade-artefakt-grinden i punkt 1b gått grön.
2. Följ run i Gitea Actions; verifiera att den slutar grön.
3. Verifiera hälsa: `curl https://0pt.app/api/health` → `ok:true` och
`db: configured` (när Postgres kopplats in).
## 3. Manuell redeploy (nöd)
```bash
curl -fsS -X POST "https://cloud.siax.io/api/v1/deploy?uuid=<UUID>&force=true" \
-H "Authorization: Bearer $COOLIFY_TOKEN"
```
git push main
├─ ci.yml quality (typecheck, contrast, tests, build)
│ integration (live OpenSearch + ClickHouse)
│ security (Trivy, blocking)
├─ security.yml push/PR/weekly scan
├─ release.yml build + SBOM + blocking vuln scan + cosign attest + push
│ → git.cloud.siax.io/sax3l/0pt:sha-<12>
└─ cd.yml waits for THIS commit's attestation, verifies it against
cosign.pub, then POSTs to Coolify
prod-smoke.yml every 30 min: ready + db/stripe + running commit == main
```
## 4. Vercel-avveckling (EFFER att Coolify kör stabilt)
- One run per ref (`concurrency`); `push` is main-only, PRs are covered by the
`pull_request` event. Deploys have their own non-cancelling group so two
runs never race the same app.
- Dependency caches: pnpm store (Node jobs), npm (doc0/n0d), pip (0pt-motor).
1. Verifiera att ingen verklig trafik/data ligger bakom (2026-08-10:
`/api/health` visar `db/queue: not_configured` — marknads-/dev-läge).
2. Peka DNS om till Coolify. 3. Behåll Vercel-projektet tills certifierad
paritet (katalog, orderflöde, betalningar). 4. Avveckla först därefter.
## Manual deploy (proven)
Trigger the same, signed path — do not POST to Coolify directly unless the
pipeline itself is broken:
```bash
curl -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
-d '{"ref":"main"}' \
https://git.siax.io/api/v1/repos/admin/0pt/actions/workflows/cd.yml/dispatches
```
Emergency only (bypasses the attestation gate; still builds the branch head):
```bash
curl -X POST -H "Authorization: Bearer $COOLIFY_API_TOKEN" -H "Content-Type: application/json" \
-d '{"uuid":"<COOLIFY_APP_ID>"}' https://cloud.siax.io/api/v1/deploy
```
## Verify a deploy
```bash
curl -s https://0pt.app/api/ready # {"ready":true,"db":"ok","stripe":"ok",...}
curl -s https://0pt.app/api/health # liveness envelope
curl -s https://0pt.app/api/version # {"commit":"<sha>"}
```
`commit` must equal the current `main` SHA. `prod-smoke.yml` enforces exactly
that every 30 minutes; a green deploy is never confused with a current service.
## Rollback
Coolify builds from the branch head, so a rollback is a normal revert:
```bash
git revert --no-edit <bad-sha> && git push origin main
```
That runs the same chain and deploys the reverted tree. Coolify's own
"redeploy previous deployment" is the break-glass alternative.
## Configuration (names only — values live in Coolify/Infisical)
- Coolify app: `dockerfile` build pack, repo `git.siax.io/sax3l/0pt`, branch
`main`, health check `/api/health`, domains `0pt.app` + `api.0pt.app`.
- Repository variable: `COOLIFY_APP_ID`.
- Repository secrets: `COOLIFY_TOKEN`, `REGISTRY_USER`, `REGISTRY_TOKEN`,
`COSIGN_PRIVATE_KEY`, `COSIGN_PASSWORD`, `SIAX_CI_TOKEN`.
- App env: `DATABASE_URL`, `STRIPE_SECRET_KEY`, `AUTH_SECRET`… see `.env.example`
for the full, documented list (DOC0, INF0/decision Ollama, OpenFGA/project
grants, model policy, credit price book, OIDC).
## Known limitation
Coolify rebuilds from source; `/api/v1/deploy` has no digest parameter, so the
verified digest is recorded as provenance for the deploy rather than pinned as
the running bytes. True digest pinning would require a Coolify `dockerimage`
resource — an infrastructure decision, not a CI-YAML change.
+53
View File
@@ -45,6 +45,7 @@
import {
createRemoteJWKSet,
decodeJwt,
jwtVerify,
type JWTPayload,
type JWTVerifyGetKey,
@@ -53,6 +54,33 @@ import {
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
/** Central Keycloak (SIAX Portal) trust; additive, unset keeps legacy behaviour. */
export function keycloakIssuer(): string | undefined {
const raw = process.env.KEYCLOAK_ISSUER?.trim().replace(/\/+$/, '');
return raw && raw.length > 0 ? raw : undefined;
}
export function keycloakAudiences(): string[] {
const raw = process.env.KEYCLOAK_EXPECTED_AUDIENCE?.trim();
if (!raw) return ['account'];
const list = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
return list.length > 0 ? list : ['account'];
}
export function keycloakAuthorizedParty(): string | undefined {
const raw = process.env.KEYCLOAK_AUTHORIZED_PARTY?.trim();
return raw && raw.length > 0 ? raw : undefined;
}
function tokenIssuerUnsafe(token: string): string | undefined {
try {
const iss = decodeJwt(token).iss;
return typeof iss === 'string' ? iss : undefined;
} catch {
return undefined;
}
}
/** Standard + custom claim paths tenant derivation reads, in precedence order. */
export const ZITADEL_ORG_ID_CLAIM = 'urn:zitadel:iam:org:id';
export const ZITADEL_RESOURCEOWNER_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
@@ -190,6 +218,31 @@ export async function verifyBearer(
token: string,
opts: VerifyBearerOptions = {},
): Promise<VerifiedPrincipal> {
// Central Keycloak (SIAX Portal) user tokens, routed by issuer.
const kcIssuer = keycloakIssuer();
if (kcIssuer !== undefined && tokenIssuerUnsafe(token) === kcIssuer) {
const resolver = opts.keyResolver ?? keyResolverOverride ?? (await getRemoteJwks(kcIssuer));
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token, resolver, {
issuer: kcIssuer,
audience: keycloakAudiences(),
}));
} catch (err) {
throw new BearerVerificationError('keycloak_token_verification_failed', err);
}
const azp = typeof payload.azp === 'string' ? payload.azp : '';
const expectedAzp = keycloakAuthorizedParty();
if (expectedAzp !== undefined && azp !== expectedAzp) {
throw new BearerVerificationError('keycloak_azp_mismatch');
}
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
if (!sub) throw new BearerVerificationError('token_missing_sub');
const tenantId = deriveTenantId(payload, opts.tenantClaim ?? tenantClaimOverride());
if (!tenantId) throw new BearerVerificationError('token_missing_tenant_claim');
return { sub, tenantId, claims: payload };
}
const issuer = opts.issuer ?? zitadelIssuer();
const resolver = opts.keyResolver ?? keyResolverOverride ?? (await getRemoteJwks(issuer));