Compare commits
5 Commits
fix/gatewa
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586129faf4 | ||
|
|
92c6431ccf | ||
| 9f1a08185e | |||
| d2e408656b | |||
| 54c278b871 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
infra/step-ca/dev-password
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
postgres-federated:
|
postgres-federated:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
environment:
|
environment:
|
||||||
@@ -45,6 +46,7 @@ services:
|
|||||||
valkey-federated:
|
valkey-federated:
|
||||||
image: valkey/valkey:8-alpine
|
image: valkey/valkey:8-alpine
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -55,6 +57,64 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step-CA — Mosaic Federation internal certificate authority
|
||||||
|
#
|
||||||
|
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
||||||
|
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
||||||
|
# reproducible deployments and digest-first promotion in CI).
|
||||||
|
#
|
||||||
|
# Profile: `federated` — this service must not start in non-federated dev.
|
||||||
|
#
|
||||||
|
# Password:
|
||||||
|
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
||||||
|
# ./infra/step-ca/dev-password.example and customise locally).
|
||||||
|
# Prod: replace the bind-mount with a Docker secret:
|
||||||
|
# secrets:
|
||||||
|
# ca_password:
|
||||||
|
# external: true
|
||||||
|
# and reference it as `/run/secrets/ca_password` (same path the
|
||||||
|
# init script already uses).
|
||||||
|
#
|
||||||
|
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:0.27.4
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
||||||
|
volumes:
|
||||||
|
- step_ca_data:/home/step
|
||||||
|
# init script — executed as the container entrypoint
|
||||||
|
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||||
|
# X.509 template skeleton (wired in M2-04)
|
||||||
|
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||||
|
# Dev password file — GITIGNORED; copy from dev-password.example
|
||||||
|
# In production, replace this with a Docker secret (see comment above).
|
||||||
|
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||||
|
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||||
|
healthcheck:
|
||||||
|
# The healthcheck requires the root cert to exist, which is only true
|
||||||
|
# after init.sh has completed on first boot. start_period gives init
|
||||||
|
# time to finish before Docker starts counting retries.
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'step',
|
||||||
|
'ca',
|
||||||
|
'health',
|
||||||
|
'--ca-url',
|
||||||
|
'https://localhost:9000',
|
||||||
|
'--root',
|
||||||
|
'/home/step/certs/root_ca.crt',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_federated_data:
|
pg_federated_data:
|
||||||
valkey_federated_data:
|
valkey_federated_data:
|
||||||
|
step_ca_data:
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ RUN corepack enable
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Copy workspace manifests first for layer-cached install
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
COPY plugins/ ./plugins/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/gateway build
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
|
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||||
|
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||||
|
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||||
|
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
COPY --from=builder /app/apps/gateway/package.json ./package.json
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ Goal: Two federated-tier gateways stood up on Portainer at `mos-test-1.woltje.co
|
|||||||
> **Tracking issue:** #482.
|
> **Tracking issue:** #482.
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------------------------------------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------------------------------------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M2-DEPLOY-01 | not-started | Verify `gateway:fed-v0.1.0-m1` image was published by `.woodpecker/publish.yml` on tag push; if not, investigate and remediate. Document image URI in deployment artifact. | #482 | sonnet | feat/federation-deploy-image-verify | — | 2K | publish.yml registers `gateway:$CI_COMMIT_TAG` destination; should already exist at `git.mosaicstack.dev/mosaicstack/stack/gateway:fed-v0.1.0-m1`. |
|
| FED-M2-DEPLOY-01 | done | Verify `gateway:fed-v0.1.0-m1` image was published by `.woodpecker/publish.yml` on tag push; if not, investigate and remediate. Document image URI in deployment artifact. | #482 | sonnet | (verified inline, no PR) | — | 2K | Tag exists; digest `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec` captured for digest-pinned deploys. |
|
||||||
| FED-M2-DEPLOY-02 | not-started | Author Portainer git-stack compose file `deploy/portainer/federated-test.stack.yml` (gateway + PG-pgvector + Valkey, env-driven). Use immutable tag, not `latest`. | #482 | sonnet | feat/federation-deploy-stack-template | DEPLOY-01 | 5K | Stack must be parameterizable via env (`STACK_DOMAIN`, `BETTERAUTH_SECRET`, etc.) so one template serves both hosts. |
|
| FED-M2-DEPLOY-02 | done | Author Portainer git-stack compose file `deploy/portainer/federated-test.stack.yml` (gateway + PG-pgvector + Valkey, env-driven). Use immutable tag, not `latest`. | #482 | sonnet | feat/federation-deploy-stack-template | DEPLOY-01 | 5K | Shipped in PR #485. Digest-pinned. Env: STACK_NAME, HOST_FQDN, POSTGRES_PASSWORD, BETTER_AUTH_SECRET, BETTER_AUTH_URL. |
|
||||||
| FED-M2-DEPLOY-03 | not-started | Deploy stack to mos-test-1.woltje.com via `~/.config/mosaic/tools/portainer/`. Verify M1 acceptance: federated-tier boot succeeds; `mosaic gateway doctor --json` returns green; pgvector `vector(3)` round-trip works. | #482 | sonnet | feat/federation-deploy-test-1 | DEPLOY-02 | 3K | Requires `PORTAINER_URL` + `PORTAINER_API_KEY` env (vault-loaded). DNS for mos-test-1 must resolve before deploy. |
|
| FED-M2-DEPLOY-IMG-FIX | in-progress | Gateway image runtime broken (ERR_MODULE_NOT_FOUND for `dotenv`); Dockerfile copies `.pnpm/` store but not `apps/gateway/node_modules` symlinks. Switch to `pnpm deploy` for self-contained runtime. | #482 | sonnet | (subagent in flight) | DEPLOY-02 | 4K | Subagent `a78a9ab0ddae91fbc` in flight. Triggers Kaniko rebuild on merge; capture new digest; bump stack template in follow-up PR before redeploy. |
|
||||||
| FED-M2-DEPLOY-04 | not-started | Deploy stack to mos-test-2.woltje.com via Portainer wrapper. Same M1 acceptance probes as DEPLOY-03. | #482 | sonnet | feat/federation-deploy-test-2 | DEPLOY-02 | 3K | Independent of DEPLOY-03 (parallelizable). Same secret material with distinct domain + secrets per host. |
|
| FED-M2-DEPLOY-03 | blocked | Deploy stack to mos-test-1.woltje.com via `~/.config/mosaic/tools/portainer/`. Verify M1 acceptance: federated-tier boot succeeds; `mosaic gateway doctor --json` returns green; pgvector `vector(3)` round-trip works. | #482 | sonnet | feat/federation-deploy-test-1 | IMG-FIX | 3K | Stack created on Portainer endpoint 3 (Swarm `local`), but blocked on image fix. Container fails on boot until IMG-FIX merges + redeploy. |
|
||||||
|
| FED-M2-DEPLOY-04 | blocked | Deploy stack to mos-test-2.woltje.com via Portainer wrapper. Same M1 acceptance probes as DEPLOY-03. | #482 | sonnet | feat/federation-deploy-test-2 | IMG-FIX | 3K | Same status as DEPLOY-03. Stack created; blocked on image fix. |
|
||||||
| FED-M2-DEPLOY-05 | not-started | Document deployment in `docs/federation/TEST-INFRA.md`: hosts, image tags, secrets sourcing, redeploy procedure, teardown. Update MISSION-MANIFEST with deployment status. | #482 | haiku | feat/federation-deploy-docs | DEPLOY-03,04 | 3K | Operator-facing doc; mentions but does not duplicate `tools/portainer/README.md`. |
|
| FED-M2-DEPLOY-05 | not-started | Document deployment in `docs/federation/TEST-INFRA.md`: hosts, image tags, secrets sourcing, redeploy procedure, teardown. Update MISSION-MANIFEST with deployment status. | #482 | haiku | feat/federation-deploy-docs | DEPLOY-03,04 | 3K | Operator-facing doc; mentions but does not duplicate `tools/portainer/README.md`. |
|
||||||
|
|
||||||
**Deploy workstream estimate:** ~16K tokens
|
**Deploy workstream estimate:** ~16K tokens
|
||||||
@@ -63,11 +64,11 @@ Goal: Two federated-tier gateways stood up on Portainer at `mos-test-1.woltje.co
|
|||||||
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M2-01 | not-started | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | `federation_audit_log` is created but not yet written to (audit logic is M4). Reserve `query_hash`, `outcome`, `bytes_out` columns. |
|
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
||||||
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
||||||
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
||||||
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. |
|
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. **Acceptance**: must (a) wire `federation.tpl` template into `mosaic-fed` provisioner config and (b) include a unit/integration test asserting issued certs contain BOTH OIDs — fails-loud guard against silent OID stripping (carry-forward from M2-02 review). |
|
||||||
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
||||||
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
||||||
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
||||||
|
|||||||
@@ -523,3 +523,92 @@ Independent security review surfaced three high-impact and four medium findings;
|
|||||||
- #8: confirm `packages/config/dist` not git-tracked
|
- #8: confirm `packages/config/dist` not git-tracked
|
||||||
|
|
||||||
**Next mission step:** FED-M2 (Step-CA + grant schema + admin CLI). Per TASKS.md scope rule, M2 will be decomposed when it enters active planning. Issue #461 tracks scope.
|
**Next mission step:** FED-M2 (Step-CA + grant schema + admin CLI). Per TASKS.md scope rule, M2 will be decomposed when it enters active planning. Issue #461 tracks scope.
|
||||||
|
|
||||||
|
## Session 20 — 2026-04-21 — FED-M2 kickoff
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
- **Workstream split**: parallel CODE (M2-01..M2-13, ~72K) + DEPLOY (DEPLOY-01..DEPLOY-05, ~16K) tracks; re-converge at M2-10 E2E.
|
||||||
|
- **Test hosts**: `mos-test-1.woltje.com` (querying side / Server A), `mos-test-2.woltje.com` (serving side / Server B). Wildcard `*.woltje.com` A→174.137.97.162 already exists; Traefik wildcard cert covers both subdomains. No DNS or cert work needed pre-deploy.
|
||||||
|
- **Portainer access**: requires `PORTAINER_INSECURE=1` flag added to mosaic wrappers (self-signed cert at `https://10.1.1.43:9443`). PR pending on `feat/mosaic-portainer-tls-flag`.
|
||||||
|
- **Image policy**: deploy by digest (immutable) per Mosaic policy. `gateway:fed-v0.1.0-m1` digest = `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`.
|
||||||
|
|
||||||
|
### DEPLOY-01 — image manifest verified
|
||||||
|
|
||||||
|
- Tag `fed-v0.1.0-m1` exists at `git.mosaicstack.dev/mosaicstack/stack/gateway`
|
||||||
|
- Digest: `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`
|
||||||
|
- 9 layers, ~530MB total
|
||||||
|
- Use this digest in DEPLOY-02 stack template (do NOT reference `:fed-v0.1.0-m1` tag in stack — pin to digest)
|
||||||
|
|
||||||
|
### Registry auth note
|
||||||
|
|
||||||
|
- Gitea container registry uses Bearer token flow (`/v2/token?service=container_registry&scope=repository:<repo>:pull`)
|
||||||
|
- Username: `jarvis` (NOT `mosaicstack`); password: `gitea.mosaicstack.token` from credentials.json
|
||||||
|
- Direct `Authorization: Bearer <pat>` does NOT work — must exchange PAT for registry token first
|
||||||
|
|
||||||
|
### Active PRs
|
||||||
|
|
||||||
|
- #483 — docs: M2 mission planning (TASKS decomposition + manifest update) — CI running
|
||||||
|
- (pending) `feat/mosaic-portainer-tls-flag` — wrapper PORTAINER_INSECURE flag (sonnet subagent in progress)
|
||||||
|
- (pending) `feat/federation-m2-schema` — FED-M2-01 DB schema migration (sonnet subagent in progress)
|
||||||
|
|
||||||
|
### MISSION-MANIFEST layout fix
|
||||||
|
|
||||||
|
- Initial M2 commit had Test Infrastructure block inserted by lint-staged prettier between "Last Updated" and "Parent Mission" — split mission frontmatter
|
||||||
|
- Fixed in 3d001fdb: moved Parent Mission back to frontmatter, kept Test Infrastructure as standalone H2 between Mission and Context
|
||||||
|
|
||||||
|
## Session 21 — 2026-04-21/22 — DEPLOY-02 merged, gateway image bug discovered, M2-01 in remediation
|
||||||
|
|
||||||
|
### PRs merged
|
||||||
|
|
||||||
|
- **#483** — docs(federation): M2 mission planning (TASKS decomposition + manifest update)
|
||||||
|
- **#484** — feat(mosaic-portainer): PORTAINER_INSECURE flag for self-signed TLS (wrapper sync to `~/.config/mosaic/tools/portainer/` done manually due to broken `mosaic upgrade` `set -o pipefail` on dash)
|
||||||
|
- **#485** — feat(deploy): portainer stack template `deploy/portainer/federated-test.stack.yml` for federation test instances [DEPLOY-02]
|
||||||
|
|
||||||
|
### Stack deployed (mos-test-1, mos-test-2)
|
||||||
|
|
||||||
|
- Both stacks created on Portainer endpoint 3 (`local` Swarm @ 10.1.1.43, the only endpoint with traefik-public + woltje.com wildcard cert)
|
||||||
|
- Swarm ID `l7z67tfpd4bvj4979ufpkyi50`
|
||||||
|
- Image pinned to digest `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`
|
||||||
|
- Traefik labels target `${HOST_FQDN}` per env
|
||||||
|
|
||||||
|
### CRITICAL FINDING — gateway image runtime-broken
|
||||||
|
|
||||||
|
- `docker run` against `gateway:fed-v0.1.0-m1` fails immediately:
|
||||||
|
`Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'dotenv' imported from /app/dist/main.js`
|
||||||
|
- Root cause: `docker/gateway.Dockerfile` copies `/app/node_modules` from builder — but pnpm puts deps in the content-addressed `.pnpm/` store with symlinks at `apps/gateway/node_modules/*`. The runner stage misses the symlinks → Node can't resolve workspace deps.
|
||||||
|
- M1 release was never runtime-tested as a stripped container; CI passed because tests run in dev tree where pnpm symlinks are intact.
|
||||||
|
- **Fix in flight** (subagent `a78a9ab0ddae91fbc`): switch builder to `pnpm --filter @mosaic/gateway --prod deploy /deploy`, then runner copies `/deploy/node_modules` + `/deploy/dist` + `/deploy/package.json`.
|
||||||
|
|
||||||
|
### M2-01 schema review verdict — NEEDS CHANGES
|
||||||
|
|
||||||
|
- PR #486 (`feat/federation-m2-schema`) — independent reviewer (sonnet) found 2 real issues:
|
||||||
|
1. `federation_audit_log` time-range indexes missing `.desc()` on `created_at` (3 places)
|
||||||
|
2. Reserved columns missing per TASKS.md M2-01 spec: `query_hash`, `outcome`, `bytes_out` (M4 will write; spec said reserve now)
|
||||||
|
- Also notes (advisory): subject_user_id correctly `text` (matches BetterAuth users.id; spec defect, not code defect); peer→grant cascade test not present (would be trivial to add)
|
||||||
|
- **Remediation in flight** (subagent `a673dd9355dc26f82` in worktree `agent-a4404ac1`): apply DESC + reserved cols, regenerate migration in place (preferred) or stack 0009 (fallback), force-push, post PR comment.
|
||||||
|
|
||||||
|
### Process notes
|
||||||
|
|
||||||
|
- Branch race incident: schema subagent + wrapper subagent both ran in main checkout → schema files appeared on wrapper branch. Recovered by TaskStop, `git checkout --` to clean, respawned schema subagent with `isolation: "worktree"`. **Rule going forward:** any subagent doing code edits gets `isolation: "worktree"` unless work is single-file and the orchestrator confirms no other branch will touch overlapping files.
|
||||||
|
- `pr-create.sh` shell-quotes backticks badly → use `tea pr create --repo mosaicstack/stack` directly (matches CLI-skill behavior). Will leave a followup to harden pr-create.sh.
|
||||||
|
- Gitea registry auth: bearer-token exchange flow (`/v2/token?service=container_registry&scope=repository:<repo>:pull`) — direct `Authorization: Bearer <pat>` returns 401.
|
||||||
|
- Portainer Swarm stack create endpoint: `POST /api/stacks/create/swarm/string?endpointId=<id>` (NOT `/api/stacks?type=1` — deprecated and rejected with 400).
|
||||||
|
|
||||||
|
### In-flight at compaction boundary
|
||||||
|
|
||||||
|
- Subagent `a78a9ab0ddae91fbc` — Dockerfile pnpm-deploy fix → PR (not yet opened at handoff)
|
||||||
|
- Subagent `a673dd9355dc26f82` — M2-01 schema remediation (DESC + reserved cols) → force-push to PR #486
|
||||||
|
- Both will trigger CI; orchestrator must independently re-review fixes (especially the security-adjacent schema work) per "always verify subagent claims" rule.
|
||||||
|
|
||||||
|
### Next after subagents return
|
||||||
|
|
||||||
|
1. Independent re-review of schema remediation (different subagent, fresh context)
|
||||||
|
2. Merge #486 if green
|
||||||
|
3. Merge Dockerfile fix PR if green → triggers Kaniko CI rebuild → capture new digest
|
||||||
|
4. Update `deploy/portainer/federated-test.stack.yml` to new digest in a small PR
|
||||||
|
5. Redeploy mos-test-1 + mos-test-2 (Portainer stack update via API)
|
||||||
|
6. Verify HTTPS reachability + `/health` endpoint at both hosts
|
||||||
|
7. DEPLOY-03/04 acceptance probes (`mosaic gateway doctor --json`, pgvector `vector(3)` round-trip)
|
||||||
|
8. DEPLOY-05: author `docs/federation/TEST-INFRA.md`
|
||||||
|
9. M2-02 (Step-CA sidecar) kicks off after image health is green
|
||||||
|
|||||||
1
infra/step-ca/dev-password.example
Normal file
1
infra/step-ca/dev-password.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dev-only-step-ca-password-do-not-use-in-production
|
||||||
60
infra/step-ca/init.sh
Executable file
60
infra/step-ca/init.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# infra/step-ca/init.sh
|
||||||
|
#
|
||||||
|
# Idempotent first-boot initialiser for the Mosaic Federation CA.
|
||||||
|
#
|
||||||
|
# On the first run (no /home/step/config/ca.json present) this script:
|
||||||
|
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
||||||
|
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
||||||
|
#
|
||||||
|
# On subsequent runs (config already exists) this script skips init and
|
||||||
|
# starts the CA directly.
|
||||||
|
#
|
||||||
|
# The provisioner name "mosaic-fed" is consumed by:
|
||||||
|
# apps/gateway/src/federation/ca.service.ts (added in M2-04)
|
||||||
|
#
|
||||||
|
# Password source:
|
||||||
|
# Dev: mounted from ./infra/step-ca/dev-password via bind mount.
|
||||||
|
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
||||||
|
#
|
||||||
|
# OID template:
|
||||||
|
# infra/step-ca/templates/federation.tpl is copied into the CA config
|
||||||
|
# directory so the JWK provisioner can reference it. The template
|
||||||
|
# skeleton is wired in M2-04 when the CA service lands the SAN-bearing
|
||||||
|
# CSR work.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CA_CONFIG="/home/step/config/ca.json"
|
||||||
|
PASSWORD_FILE="/run/secrets/ca_password"
|
||||||
|
|
||||||
|
if [ ! -f "${CA_CONFIG}" ]; then
|
||||||
|
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
||||||
|
|
||||||
|
step ca init \
|
||||||
|
--name "Mosaic Federation CA" \
|
||||||
|
--dns "localhost" \
|
||||||
|
--dns "step-ca" \
|
||||||
|
--address ":9000" \
|
||||||
|
--provisioner "mosaic-fed" \
|
||||||
|
--password-file "${PASSWORD_FILE}" \
|
||||||
|
--provisioner-password-file "${PASSWORD_FILE}" \
|
||||||
|
--no-db
|
||||||
|
|
||||||
|
echo "[step-ca init] CA initialised."
|
||||||
|
|
||||||
|
# Copy the X.509 template into the Step-CA config directory so the
|
||||||
|
# provisioner can reference it in M2-04.
|
||||||
|
if [ -f "/etc/step-ca-templates/federation.tpl" ]; then
|
||||||
|
mkdir -p /home/step/templates
|
||||||
|
cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl
|
||||||
|
echo "[step-ca init] Federation X.509 template copied to /home/step/templates/."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Startup complete."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] Config already exists — skipping init."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Starting Step-CA on :9000..."
|
||||||
|
exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}"
|
||||||
48
infra/step-ca/templates/federation.tpl
Normal file
48
infra/step-ca/templates/federation.tpl
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"sans": {{ toJson .SANs }},
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Mosaic Federation X.509 Certificate Template
|
||||||
|
============================================
|
||||||
|
This template is used by the "mosaic-fed" JWK provisioner to sign
|
||||||
|
federation client certificates.
|
||||||
|
|
||||||
|
Custom OID extensions (per PRD §6):
|
||||||
|
1.3.6.1.4.1.99999.1 — mosaic.federation.grantId (UUID string)
|
||||||
|
1.3.6.1.4.1.99999.2 — mosaic.federation.subjectUserId (UUID string)
|
||||||
|
|
||||||
|
TODO (M2-04): Wire actual OID extensions below once the CA service
|
||||||
|
(apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR
|
||||||
|
work and the template can be exercised end-to-end.
|
||||||
|
|
||||||
|
Step-CA template reference:
|
||||||
|
https://smallstep.com/docs/step-ca/templates
|
||||||
|
|
||||||
|
Expected final shape of the extensions block (placeholder — not yet
|
||||||
|
activated):
|
||||||
|
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
|
"critical": false,
|
||||||
|
"value": {{ toJson (first .Token.mosaic_grant_id) }}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
|
"critical": false,
|
||||||
|
"value": {{ toJson (first .Token.mosaic_subject_user_id) }}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
The provisioner must pass these values in the ACME/JWK token payload
|
||||||
|
(token claims `mosaic_grant_id` and `mosaic_subject_user_id`) when
|
||||||
|
submitting the CSR. M2-04 owns that work.
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
"keyUsage": ["digitalSignature"],
|
||||||
|
"extKeyUsage": ["clientAuth"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": false
|
||||||
|
}
|
||||||
|
}
|
||||||
75
packages/db/drizzle/0008_smart_lyja.sql
Normal file
75
packages/db/drizzle/0008_smart_lyja.sql
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
CREATE TYPE "public"."grant_status" AS ENUM('active', 'revoked', 'expired');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."peer_state" AS ENUM('pending', 'active', 'suspended', 'revoked');--> statement-breakpoint
|
||||||
|
CREATE TABLE "admin_tokens" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"scope" text DEFAULT 'admin' NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"request_id" text NOT NULL,
|
||||||
|
"peer_id" uuid,
|
||||||
|
"subject_user_id" text,
|
||||||
|
"grant_id" uuid,
|
||||||
|
"verb" text NOT NULL,
|
||||||
|
"resource" text NOT NULL,
|
||||||
|
"status_code" integer NOT NULL,
|
||||||
|
"result_count" integer,
|
||||||
|
"denied_reason" text,
|
||||||
|
"latency_ms" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"query_hash" text,
|
||||||
|
"outcome" text,
|
||||||
|
"bytes_out" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_grants" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"subject_user_id" text NOT NULL,
|
||||||
|
"peer_id" uuid NOT NULL,
|
||||||
|
"scope" jsonb NOT NULL,
|
||||||
|
"status" "grant_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"revoked_reason" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_peers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"common_name" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"cert_pem" text NOT NULL,
|
||||||
|
"cert_serial" text NOT NULL,
|
||||||
|
"cert_not_after" timestamp with time zone NOT NULL,
|
||||||
|
"client_key_pem" text,
|
||||||
|
"state" "peer_state" DEFAULT 'pending' NOT NULL,
|
||||||
|
"endpoint_url" text,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "federation_peers_common_name_unique" UNIQUE("common_name"),
|
||||||
|
CONSTRAINT "federation_peers_cert_serial_unique" UNIQUE("cert_serial")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "admin_tokens" ADD CONSTRAINT "admin_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_peers_state_idx" ON "federation_peers" USING btree ("state");
|
||||||
3375
packages/db/drizzle/meta/0008_snapshot.json
Normal file
3375
packages/db/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1774227064500,
|
"when": 1774227064500,
|
||||||
"tag": "0006_swift_shen",
|
"tag": "0006_swift_shen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776822435828,
|
||||||
|
"tag": "0008_smart_lyja",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
424
packages/db/src/federation.integration.test.ts
Normal file
424
packages/db/src/federation.integration.test.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* FED-M2-01 — Integration test: federation DB schema (peers / grants / audit_log).
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* (or any postgres with the mosaic schema already applied)
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/db test src/federation.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Applies the federation migration SQL directly (idempotent: CREATE TYPE/TABLE
|
||||||
|
* with IF NOT EXISTS guards applied via inline SQL before the migration DDL).
|
||||||
|
* - Assumes the base schema (users table etc.) already exists in the target DB.
|
||||||
|
* - All test rows use the `fed-m2-01-` prefix; cleanup in afterAll.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Federation tables + enums apply cleanly against the existing schema.
|
||||||
|
* 2. Insert a sample user + peer + grant + audit row; verify round-trip.
|
||||||
|
* 3. FK cascade: deleting the user cascades to federation_grants.
|
||||||
|
* 4. FK set-null: deleting the peer sets federation_audit_log.peer_id to NULL.
|
||||||
|
* 5. Enum constraint: inserting an invalid status/state value throws a DB error.
|
||||||
|
* 6. Unique constraint: duplicate cert_serial throws a DB error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
/** Recognisable test-row prefix for safe cleanup without full-table truncation. */
|
||||||
|
const T = 'fed-m2-01';
|
||||||
|
|
||||||
|
// Deterministic IDs (UUID format required for uuid PK columns: 8-4-4-4-12 hex digits).
|
||||||
|
const PEER1_ID = `f2000001-0000-4000-8000-000000000001`;
|
||||||
|
const PEER2_ID = `f2000002-0000-4000-8000-000000000002`;
|
||||||
|
const USER1_ID = `${T}-user-1`;
|
||||||
|
|
||||||
|
let sql: ReturnType<typeof postgres> | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!run) return;
|
||||||
|
sql = postgres(PG_URL, { max: 1, connect_timeout: 10, idle_timeout: 10 });
|
||||||
|
|
||||||
|
// Apply the federation enums and tables idempotently.
|
||||||
|
// This mirrors the migration file but uses IF NOT EXISTS guards so it can run
|
||||||
|
// against a DB that may not have had drizzle migrations tracked.
|
||||||
|
await sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE peer_state AS ENUM ('pending', 'active', 'suspended', 'revoked');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE grant_status AS ENUM ('active', 'revoked', 'expired');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_peers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
common_name text NOT NULL,
|
||||||
|
display_name text NOT NULL,
|
||||||
|
cert_pem text NOT NULL,
|
||||||
|
cert_serial text NOT NULL,
|
||||||
|
cert_not_after timestamp with time zone NOT NULL,
|
||||||
|
client_key_pem text,
|
||||||
|
state peer_state NOT NULL DEFAULT 'pending',
|
||||||
|
endpoint_url text,
|
||||||
|
last_seen_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
CONSTRAINT federation_peers_common_name_unique UNIQUE (common_name),
|
||||||
|
CONSTRAINT federation_peers_cert_serial_unique UNIQUE (cert_serial)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_peers_cert_serial_idx ON federation_peers (cert_serial)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_peers_state_idx ON federation_peers (state)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_grants (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
subject_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
peer_id uuid NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
||||||
|
scope jsonb NOT NULL,
|
||||||
|
status grant_status NOT NULL DEFAULT 'active',
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
revoked_reason text
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_grants_subject_status_idx ON federation_grants (subject_user_id, status)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_grants_peer_status_idx ON federation_grants (peer_id, status)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_audit_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id text NOT NULL,
|
||||||
|
peer_id uuid REFERENCES federation_peers(id) ON DELETE SET NULL,
|
||||||
|
subject_user_id text REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
grant_id uuid REFERENCES federation_grants(id) ON DELETE SET NULL,
|
||||||
|
verb text NOT NULL,
|
||||||
|
resource text NOT NULL,
|
||||||
|
status_code integer NOT NULL,
|
||||||
|
result_count integer,
|
||||||
|
denied_reason text,
|
||||||
|
latency_ms integer,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
query_hash text,
|
||||||
|
outcome text,
|
||||||
|
bytes_out integer
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx
|
||||||
|
ON federation_audit_log (peer_id, created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx
|
||||||
|
ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx
|
||||||
|
ON federation_audit_log (created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!sql) return;
|
||||||
|
|
||||||
|
// Cleanup in FK-safe order (children before parents).
|
||||||
|
await sql`DELETE FROM federation_audit_log WHERE request_id LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql`
|
||||||
|
DELETE FROM federation_grants
|
||||||
|
WHERE subject_user_id LIKE ${T + '%'}
|
||||||
|
OR revoked_reason LIKE ${T + '%'}
|
||||||
|
`.catch(() => {});
|
||||||
|
await sql`DELETE FROM federation_peers WHERE common_name LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql`DELETE FROM users WHERE id LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql.end({ timeout: 3 }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation schema — integration', () => {
|
||||||
|
// ── 1. Insert sample rows ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('inserts a user, peer, grant, and audit row without constraint violation', async () => {
|
||||||
|
const certPem = '-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----';
|
||||||
|
|
||||||
|
// User — BetterAuth users.id is text (any string, not uuid).
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${USER1_ID}, ${'M2-01 Test User'}, ${USER1_ID + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Peer
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER1_ID},
|
||||||
|
${T + '-gateway-example-com'},
|
||||||
|
${'Test Peer'},
|
||||||
|
${certPem},
|
||||||
|
${T + '-serial-001'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Grant — scope is jsonb; pass as JSON string and cast server-side.
|
||||||
|
const scopeJson = JSON.stringify({
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
operations: ['list', 'get'],
|
||||||
|
});
|
||||||
|
const grants = await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${USER1_ID},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
expect(grants).toHaveLength(1);
|
||||||
|
const grantId = grants[0]!['id'] as string;
|
||||||
|
|
||||||
|
// Audit log row
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_audit_log
|
||||||
|
(request_id, peer_id, subject_user_id, grant_id, verb, resource, status_code, created_at)
|
||||||
|
VALUES (
|
||||||
|
${T + '-req-001'},
|
||||||
|
${PEER1_ID},
|
||||||
|
${USER1_ID},
|
||||||
|
${grantId},
|
||||||
|
${'list'},
|
||||||
|
${'tasks'},
|
||||||
|
${200},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Verify the audit row is present and has correct data.
|
||||||
|
const auditRows = await sql!`
|
||||||
|
SELECT * FROM federation_audit_log WHERE request_id = ${T + '-req-001'}
|
||||||
|
`;
|
||||||
|
expect(auditRows).toHaveLength(1);
|
||||||
|
expect(auditRows[0]!['status_code']).toBe(200);
|
||||||
|
expect(auditRows[0]!['verb']).toBe('list');
|
||||||
|
expect(auditRows[0]!['resource']).toBe('tasks');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// ── 2. FK cascade: user delete cascades grants ─────────────────────────────
|
||||||
|
|
||||||
|
it('cascade-deletes federation_grants when the subject user is deleted', async () => {
|
||||||
|
const cascadeUserId = `${T}-cascade-user`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${cascadeUserId}, ${'Cascade User'}, ${cascadeUserId + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, revoked_reason, created_at)
|
||||||
|
VALUES (
|
||||||
|
${cascadeUserId},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
${T + '-cascade-test'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const before = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||||
|
`;
|
||||||
|
expect(before[0]!['cnt']).toBe(1);
|
||||||
|
|
||||||
|
// Delete user → grants should cascade-delete.
|
||||||
|
await sql!`DELETE FROM users WHERE id = ${cascadeUserId}`;
|
||||||
|
|
||||||
|
const after = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||||
|
`;
|
||||||
|
expect(after[0]!['cnt']).toBe(0);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// ── 3. FK set-null: peer delete sets audit_log.peer_id to NULL ────────────
|
||||||
|
|
||||||
|
it('sets federation_audit_log.peer_id to NULL when the peer is deleted', async () => {
|
||||||
|
// Insert a throwaway peer for this specific cascade test.
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER2_ID},
|
||||||
|
${T + '-gateway-throwaway-com'},
|
||||||
|
${'Throwaway Peer'},
|
||||||
|
${'cert-pem-placeholder'},
|
||||||
|
${T + '-serial-002'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
const reqId = `${T}-req-setnull`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_audit_log
|
||||||
|
(request_id, peer_id, subject_user_id, verb, resource, status_code, created_at)
|
||||||
|
VALUES (
|
||||||
|
${reqId},
|
||||||
|
${PEER2_ID},
|
||||||
|
${USER1_ID},
|
||||||
|
${'get'},
|
||||||
|
${'tasks'},
|
||||||
|
${200},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql!`DELETE FROM federation_peers WHERE id = ${PEER2_ID}`;
|
||||||
|
|
||||||
|
const rows = await sql!`
|
||||||
|
SELECT peer_id FROM federation_audit_log WHERE request_id = ${reqId}
|
||||||
|
`;
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!['peer_id']).toBeNull();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// ── 4. Enum constraint: invalid grant_status rejected ─────────────────────
|
||||||
|
|
||||||
|
it('rejects an invalid grant_status value with a DB error', async () => {
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${USER1_ID},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'invalid_status'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 5. Enum constraint: invalid peer_state rejected ───────────────────────
|
||||||
|
|
||||||
|
it('rejects an invalid peer_state value with a DB error', async () => {
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${'bad-state-peer'},
|
||||||
|
${'Bad State'},
|
||||||
|
${'pem'},
|
||||||
|
${'bad-serial-999'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'invalid_state'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 6. Unique constraint: duplicate cert_serial rejected ──────────────────
|
||||||
|
|
||||||
|
it('rejects a duplicate cert_serial with a unique constraint violation', async () => {
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${T + '-dup-cn'},
|
||||||
|
${'Dup Peer'},
|
||||||
|
${'pem'},
|
||||||
|
${T + '-serial-001'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'pending'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 7. FK cascade: peer delete cascades to federation_grants ─────────────
|
||||||
|
|
||||||
|
it('cascade-deletes federation_grants when the owning peer is deleted', async () => {
|
||||||
|
const PEER3_ID = `f2000003-0000-4000-8000-000000000003`;
|
||||||
|
const cascadeGrantUserId = `${T}-cascade-grant-user`;
|
||||||
|
|
||||||
|
// Insert a dedicated user and peer for this test.
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER3_ID},
|
||||||
|
${T + '-gateway-cascade-peer'},
|
||||||
|
${'Cascade Peer'},
|
||||||
|
${'cert-pem-cascade'},
|
||||||
|
${T + '-serial-003'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${cascadeGrantUserId},
|
||||||
|
${PEER3_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const before = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||||
|
`;
|
||||||
|
expect(before[0]!['cnt']).toBe(1);
|
||||||
|
|
||||||
|
// Delete peer → grants should cascade-delete.
|
||||||
|
await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`;
|
||||||
|
|
||||||
|
const after = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||||
|
`;
|
||||||
|
expect(after[0]!['cnt']).toBe(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {});
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
20
packages/db/src/federation.ts
Normal file
20
packages/db/src/federation.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Federation schema re-exports.
|
||||||
|
*
|
||||||
|
* The actual table and enum definitions live in schema.ts (alongside all other
|
||||||
|
* Drizzle tables) to avoid CJS/ESM cross-import issues when drizzle-kit loads
|
||||||
|
* schema files via esbuild-register. Application code that wants named imports
|
||||||
|
* for federation symbols should import from this file.
|
||||||
|
*
|
||||||
|
* M2-01: DB tables and enums only. No business logic.
|
||||||
|
* M2-03 will add JSON schema validation for the `scope` column.
|
||||||
|
* M4 will write rows to federation_audit_log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
peerStateEnum,
|
||||||
|
grantStatusEnum,
|
||||||
|
federationPeers,
|
||||||
|
federationGrants,
|
||||||
|
federationAuditLog,
|
||||||
|
} from './schema.js';
|
||||||
@@ -2,6 +2,7 @@ export { createDb, type Db, type DbHandle } from './client.js';
|
|||||||
export { createPgliteDb } from './client-pglite.js';
|
export { createPgliteDb } from './client-pglite.js';
|
||||||
export { runMigrations } from './migrate.js';
|
export { runMigrations } from './migrate.js';
|
||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
|
export * from './federation.js';
|
||||||
export {
|
export {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -585,3 +586,194 @@ export const summarizationJobs = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Federation ──────────────────────────────────────────────────────────────
|
||||||
|
// Enums declared before tables that reference them.
|
||||||
|
// All federation definitions live in this file (avoids CJS/ESM cross-import
|
||||||
|
// issues when drizzle-kit loads schema files via esbuild-register).
|
||||||
|
// Application code imports from `federation.ts` which re-exports from here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a federation peer.
|
||||||
|
* - pending: registered but not yet approved / TLS handshake not confirmed
|
||||||
|
* - active: fully operational; mTLS verified
|
||||||
|
* - suspended: temporarily blocked; cert still valid
|
||||||
|
* - revoked: cert revoked; no traffic allowed
|
||||||
|
*/
|
||||||
|
export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspended', 'revoked']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a federation grant.
|
||||||
|
* - active: grant is in effect
|
||||||
|
* - revoked: manually revoked before expiry
|
||||||
|
* - expired: natural expiry (expires_at passed)
|
||||||
|
*/
|
||||||
|
export const grantStatusEnum = pgEnum('grant_status', ['active', 'revoked', 'expired']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered peer gateway identified by its Step-CA certificate CN.
|
||||||
|
* Represents both inbound peers (other gateways querying us) and outbound
|
||||||
|
* peers (gateways we query — identified by client_key_pem being set).
|
||||||
|
*/
|
||||||
|
export const federationPeers = pgTable(
|
||||||
|
'federation_peers',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/** Certificate CN, e.g. "gateway-uscllc-com". Unique — one row per peer identity. */
|
||||||
|
commonName: text('common_name').notNull().unique(),
|
||||||
|
|
||||||
|
/** Human-friendly label shown in admin UI. */
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
|
||||||
|
/** Pinned PEM certificate used for mTLS verification. */
|
||||||
|
certPem: text('cert_pem').notNull(),
|
||||||
|
|
||||||
|
/** Certificate serial number — used for CRL / revocation lookup. */
|
||||||
|
certSerial: text('cert_serial').notNull().unique(),
|
||||||
|
|
||||||
|
/** Certificate expiry — used by the renewal scheduler (FED-M6). */
|
||||||
|
certNotAfter: timestamp('cert_not_after', { withTimezone: true }).notNull(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed (encrypted) private key for outbound connections TO this peer.
|
||||||
|
* NULL for inbound-only peer rows (we serve them; we don't call them).
|
||||||
|
*/
|
||||||
|
clientKeyPem: text('client_key_pem'),
|
||||||
|
|
||||||
|
/** Current peer lifecycle state. */
|
||||||
|
state: peerStateEnum('state').notNull().default('pending'),
|
||||||
|
|
||||||
|
/** Base URL for outbound queries, e.g. "https://woltje.com:443". NULL for inbound-only peers. */
|
||||||
|
endpointUrl: text('endpoint_url'),
|
||||||
|
|
||||||
|
/** Timestamp of the most recent successful inbound or outbound request. */
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
/** Populated when the cert is revoked; NULL while the peer is active. */
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// CRL / revocation lookups by serial.
|
||||||
|
index('federation_peers_cert_serial_idx').on(t.certSerial),
|
||||||
|
// Filter peers by state (e.g. find all active peers for outbound routing).
|
||||||
|
index('federation_peers_state_idx').on(t.state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grant lets a specific peer cert query a specific local user's data within
|
||||||
|
* a defined scope. Scopes are validated by JSON Schema in M2-03; this table
|
||||||
|
* stores them as raw jsonb.
|
||||||
|
*/
|
||||||
|
export const federationGrants = pgTable(
|
||||||
|
'federation_grants',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local user whose data this grant exposes.
|
||||||
|
* Cascade delete: if the user account is deleted, revoke all their grants.
|
||||||
|
*/
|
||||||
|
subjectUserId: text('subject_user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peer gateway holding the grant.
|
||||||
|
* Cascade delete: if the peer record is removed, the grant is moot.
|
||||||
|
*/
|
||||||
|
peerId: uuid('peer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope object — validated by JSON Schema (M2-03).
|
||||||
|
* Example: { "resources": ["tasks", "notes"], "operations": ["list", "get"] }
|
||||||
|
*/
|
||||||
|
scope: jsonb('scope').notNull(),
|
||||||
|
|
||||||
|
/** Current grant lifecycle state. */
|
||||||
|
status: grantStatusEnum('status').notNull().default('active'),
|
||||||
|
|
||||||
|
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
/** Populated when the grant is explicitly revoked. */
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
/** Human-readable reason for revocation (audit trail). */
|
||||||
|
revokedReason: text('revoked_reason'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Hot path: look up active grants for a subject user (auth middleware).
|
||||||
|
index('federation_grants_subject_status_idx').on(t.subjectUserId, t.status),
|
||||||
|
// Hot path: look up active grants held by a peer (inbound request check).
|
||||||
|
index('federation_grants_peer_status_idx').on(t.peerId, t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log of all federation requests.
|
||||||
|
* M4 writes rows here. M2 only creates the table.
|
||||||
|
*
|
||||||
|
* All FKs use SET NULL so audit rows survive peer/user/grant deletion.
|
||||||
|
*/
|
||||||
|
export const federationAuditLog = pgTable(
|
||||||
|
'federation_audit_log',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/** UUIDv7 from the X-Request-ID header — correlates with OTEL traces. */
|
||||||
|
requestId: text('request_id').notNull(),
|
||||||
|
|
||||||
|
/** Peer that made the request. SET NULL if the peer is later deleted. */
|
||||||
|
peerId: uuid('peer_id').references(() => federationPeers.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Subject user whose data was queried. SET NULL if the user is deleted. */
|
||||||
|
subjectUserId: text('subject_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Grant under which the request was authorised. SET NULL if the grant is deleted. */
|
||||||
|
grantId: uuid('grant_id').references(() => federationGrants.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Request verb: "list" | "get" | "search". */
|
||||||
|
verb: text('verb').notNull(),
|
||||||
|
|
||||||
|
/** Resource type: "tasks" | "notes" | "memory" | etc. */
|
||||||
|
resource: text('resource').notNull(),
|
||||||
|
|
||||||
|
/** HTTP status code returned to the peer. */
|
||||||
|
statusCode: integer('status_code').notNull(),
|
||||||
|
|
||||||
|
/** Number of items returned (NULL for non-list requests or errors). */
|
||||||
|
resultCount: integer('result_count'),
|
||||||
|
|
||||||
|
/** Why the request was denied (NULL when allowed). */
|
||||||
|
deniedReason: text('denied_reason'),
|
||||||
|
|
||||||
|
/** End-to-end latency in milliseconds. */
|
||||||
|
latencyMs: integer('latency_ms'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
// Reserved for M4 — see PRD 7.3
|
||||||
|
/** SHA-256 of the normalised GraphQL/REST query string; written by M4 search. */
|
||||||
|
queryHash: text('query_hash'),
|
||||||
|
/** Request outcome: "allowed" | "denied" | "partial"; written by M4. */
|
||||||
|
outcome: text('outcome'),
|
||||||
|
/** Response payload size in bytes; written by M4. */
|
||||||
|
bytesOut: integer('bytes_out'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Per-peer request history in reverse chronological order.
|
||||||
|
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
|
||||||
|
// Per-user access log in reverse chronological order.
|
||||||
|
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
|
||||||
|
// Global time-range scans (dashboards, rate-limit windows).
|
||||||
|
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user