Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa1f76657 | ||
|
|
ea40a67940 | ||
|
|
c4a7b83f61 | ||
|
|
56d3a8341b |
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user