Foundational hardening (Waves 1–4): CI/Docker, flag-gated Zitadel auth, validation, tests #2

Merged
sax3l merged 3 commits from foundation/hardening into main 2026-07-25 16:10:26 +00:00
36 changed files with 3895 additions and 49 deletions
+35
View File
@@ -0,0 +1,35 @@
# Keep the build context small + reproducible. Do NOT exclude app source dirs
# (app/, components/, hooks/, lib/, packages/, src/, styles/, public/) -- next
# build needs them.
node_modules
.next
out
build
.git
.gitea
.github
# local env / secrets -- never ship into an image
.env
.env.*
!.env.example
.vercel
*.tsbuildinfo
next-env.d.ts
# python services + their large sample dumps are not part of the Next build
**/__pycache__
*.pyc
services/scrapers/output
# editor / OS noise
.DS_Store
*.log
npm-debug.log*
.pnpm-debug.log*
# the Docker files themselves
Dockerfile
.dockerignore
+78
View File
@@ -0,0 +1,78 @@
# =============================================================================
# 0pt web console -- example environment
# =============================================================================
# Copy to `.env` (gitignored) and fill in real values. Every key below is
# actually referenced by the code (derived from `process.env.*` usage); nothing
# here is invented. All secrets are placeholders -- do NOT commit real values.
#
# The app is designed to build and boot with these UNSET: lib/db.ts and
# lib/stripe.ts are lazy + env-guarded, and the storage/payment/doc routes
# return HTTP 501 ("not configured") until the relevant keys are provided.
# ---- Core -------------------------------------------------------------------
# Public base URL of this app (used for absolute links / callbacks).
NEXT_PUBLIC_APP_URL=http://localhost:3000
# ---- Database (Postgres) ----------------------------------------------------
# First one set wins: DATABASE_URL -> POSTGRES_URL -> POSTGRES_PRISMA_URL.
DATABASE_URL=postgres://user:password@localhost:5432/opt
POSTGRES_URL=
POSTGRES_PRISMA_URL=
# ---- Queue / cache (Redis) --------------------------------------------------
REDIS_URL=redis://localhost:6379
# ---- Auth -------------------------------------------------------------------
# Session-signing secret (generate a long random string).
AUTH_SECRET=change-me-long-random-string
# Optional dev-only login shortcut key (leave unset in production).
DEV_LOGIN_KEY=
# Encryption key for stored DB-connection grants (/api/connections/db-grant).
GRANT_ENC_KEY=change-me-32-byte-key
# Shared token for internal/service-to-service calls.
INTERNAL_TOKEN=change-me-internal-token
# ---- Zitadel / OIDC Bearer verification (Wave 2 hardening) ------------------
# 0pt verifies inbound Bearer tokens against the fleet Zitadel IdP
# (src/auth/zitadel.ts, wired via middleware.ts). JWKS are discovered from
# <issuer>/.well-known/openid-configuration, falling back to <issuer>/oauth/v2/keys.
#
# Fleet Zitadel issuer (OIDC). Default when unset: https://id-customers.siax.io
ZITADEL_ISSUER=https://id-customers.siax.io
# Flag-gated enforcement. When != "true" (DEFAULT) auth is non-breaking:
# a present Bearer token is best-effort verified and attached, but never
# rejected. Set to "true" to REQUIRE a valid token on /api/0pt/* (401 on
# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant).
AUTH_ENFORCE=false
# Optional override for the claim key that carries the SIAX tenant id. When
# unset, tenant derivation uses the documented precedence:
# urn:zitadel:iam:org:id -> urn:zitadel:iam:user:resourceowner:id -> tenant_id -> tenant.
ZITADEL_TENANT_CLAIM=
# ---- Payments (Stripe) ------------------------------------------------------
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# ---- Object storage (Cloudflare R2 / S3-compatible) -------------------------
# All three required to enable /api/uploads; otherwise it returns 501.
R2_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
R2_BUCKET=opt-uploads
R2_ACCESS_KEY_ID=change-me
# Optional local blob dir fallback used when R2 is not configured.
OPT_LOCAL_BLOB_DIR=./.opt-blobs
# ---- Document intelligence (Azure) ------------------------------------------
# Powers document extraction routes; unset => extraction "not configured".
DOC_INTELLIGENCE_ENDPOINT=https://<resource>.cognitiveservices.azure.com
DOC_INTELLIGENCE_KEY=change-me
DOC_INTELLIGENCE_API_VERSION=2024-11-30
# ---- Engine / integrations --------------------------------------------------
# URL of the 0pt data engine (services/api orchestrator).
ENGINE_URL=http://localhost:8000
# Odoo base URL for the bills/write-back adapter.
ODOO_BASE_URL=https://<your-odoo-host>
# ---- Test-only (tests/p0 real-infra suite; not needed to run the app) --------
# ST0RE_URL=
# ST0RE_TEST_TENANT_ID=
+62 -5
View File
@@ -1,10 +1,67 @@
name: Scraper CI
on: [push]
name: CI (SIAX Cloud)
# Self-hosted CI on SIAX Cloud (Gitea Actions, runners siax-runner-1/2),
# following the fleet-wide pattern (quality gate -> advisory security scan).
#
# WHAT THIS REPO SHIPS: a Next.js 16 web console PLUS the 0pt capability layer
# (src/, tests/). Wave 2/3 hardening added a real test runner (vitest), a
# scoped strict type-check (tsconfig.check.json), and a flat ESLint config, so
# the quality gate now runs them:
# * typecheck (BLOCKING) -> `tsc -p tsconfig.check.json` over src/ + tests/.
# * test (BLOCKING) -> `vitest run` (the src/ capability + auth + validation suites).
# * lint (ADVISORY, continue-on-error) -> `eslint src tests`. The v0-generated
# legacy adapter/test tree carries pre-existing lint noise
# (explicit-any / require-style imports), so lint is advisory to
# avoid blocking merges on inherited debt; the Wave 2 auth/validation
# additions themselves are lint-clean. Flip to blocking once the
# legacy noise is cleared.
# * build (BLOCKING) -> `next build` compiles the whole app.
#
# The security job is intentionally advisory (separate job, `--exit-code 0`) so
# it never blocks merges, matching the fleet convention.
on:
push:
branches: ["**"]
pull_request:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # daily safety re-run so CI runs even without a push
jobs:
lint:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Python files
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0)
run: |
echo "Scraper code verified"
corepack enable
corepack prepare pnpm@9 --activate
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Typecheck (tsc over src/ + tests/ -- blocking)
run: pnpm run typecheck
- name: Test (vitest -- blocking)
run: pnpm run test
- name: Lint (eslint -- advisory; legacy tree has pre-existing noise)
continue-on-error: true
run: pnpm run lint
- name: Build (next build -- blocking quality gate)
run: pnpm run build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Install Trivy natively and scan the checked-out workspace directly.
# (The `docker run -v "$PWD:/repo" aquasec/trivy` pattern is a silent no-op
# on this Docker-outside-of-Docker runner -- $PWD inside the job container
# is not a real path on the Docker daemon host, so it scans nothing.)
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Trivy scan (vuln + secrets, advisory)
run: |
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0 .
+6 -1
View File
@@ -24,4 +24,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
# pnpm local store (created when store-dir defaults into the project)
.pnpm-store/
# interim LocalBlobStore output (src/adapters/local-blob-store.ts)
.data/
+2
View File
@@ -1 +1,3 @@
verify-deps-before-run=false
include-workspace-root=true
confirmModulesPurge=false
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 110
}
+71
View File
@@ -0,0 +1,71 @@
# =============================================================================
# 0pt web console -- Dockerfile for Coolify (SIAX Cloud)
# =============================================================================
# This repo's deployable service is the Next.js 16 app at the repo root
# (package.json `start` == `next start`, liveness at GET /api/health). Unlike
# the tsx backend services elsewhere in the fleet (aud0/n0d/api0), a Next app
# needs a real build step, so this is a multi-stage build: install+build, then
# a slim runtime that runs the built output as the non-root `node` user.
#
# node:24-alpine matches the tag already proven in ../aud0 and ../api0. `wget`
# is added to the runtime stage so Coolify's container healthcheck has a binary
# to hit /api/health (bare node:*-alpine ships neither curl nor wget).
#
# next.config.mjs does NOT set `output: "standalone"` (that would be a source
# change, out of Wave-1 scope), so the runtime stage carries node_modules + the
# .next build output and runs `next start` directly.
# =============================================================================
# ---- build stage ------------------------------------------------------------
FROM node:24-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Enable corepack (pnpm@9 pinned below to match pnpm-lock.yaml lockfileVersion 9.0).
RUN corepack enable
# Install deps against the frozen lockfile first for layer caching.
# --ignore-workspace: the repo's pnpm-workspace.yaml is a v0-export artifact that
# carries only an `allowBuilds:` block and NO `packages:` field, which makes pnpm
# abort with "packages field missing or empty". There are no real workspace
# members (only the root package.json), so ignoring the workspace file is
# semantically a no-op and lets the frozen install resolve the root deps cleanly.
COPY package.json pnpm-lock.yaml .npmrc ./
RUN corepack prepare pnpm@9 --activate && pnpm install --frozen-lockfile --ignore-workspace
# Build the app. The lib/* clients (db, stripe, ...) are lazy + env-guarded, so
# `next build` succeeds without any runtime secrets present.
# --ignore-workspace again here: `COPY . .` reintroduces the v0 pnpm-workspace.yaml,
# and `pnpm run` re-parses it (same "packages field missing" abort) unless ignored.
COPY . .
RUN pnpm --ignore-workspace run build
# ---- runtime stage ----------------------------------------------------------
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN apk add --no-cache wget
# Built artifacts + resolved dependencies from the build stage. .next is chowned
# so `next start` can write its runtime cache as the non-root user.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1
# Invoke the next binary directly (pnpm/corepack are not set up in this stage).
CMD ["node_modules/.bin/next", "start"]
@@ -1,10 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, documentExtractionSchema } from "@/src/validation/0pt-schemas";
import { documentExtractionService } from "@/src/services/document-extraction-service";
export async function POST(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
try {
const { documentId } = await params;
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(documentExtractionSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const result = await documentExtractionService.extractDocument(documentId, {
tenant_id: body.tenant_id,
project_id: body.project_id,
+7 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, documentCaptureSchema } from "@/src/validation/0pt-schemas";
import { documentCaptureService } from "@/src/services/document-capture-service";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(documentCaptureSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const result = await documentCaptureService.captureFromMailbox(
{
mailbox: {
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, requestIcebergWriteSchema } from "@/src/validation/0pt-schemas";
import { IcebergWriteService } from "@/src/services/iceberg-write-service";
const writeService = new IcebergWriteService();
@@ -8,7 +9,12 @@ const writeService = new IcebergWriteService();
export async function POST(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
try {
const { mappingId } = await params;
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(requestIcebergWriteSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const result = await writeService.requestIcebergWrite({
dataset_iceberg_mapping_id: mappingId,
actor_id: body.actor_id,
+7 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, mapDatasetToIcebergTableSchema } from "@/src/validation/0pt-schemas";
import { IcebergWriteService } from "@/src/services/iceberg-write-service";
import { DatasetIcebergMappingService } from "@/src/services/dataset-iceberg-mapping-service";
@@ -16,7 +17,12 @@ const writeService = new IcebergWriteService({ mappingService });
// entrypoint, see docs/ICEBERG_WRITE_BOUNDARY.md.
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(mapDatasetToIcebergTableSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const result = await writeService.mapDatasetToIcebergTable({
tenant_id: body.tenant_id,
project_id: body.project_id,
@@ -15,6 +15,7 @@
* Details: docs/P0_QUERY_SOURCE_FLOW.md.
*/
import { NextRequest, NextResponse } from "next/server";
import { parseBody, sourceQuerySchema } from "@/src/validation/0pt-schemas";
import { SourceService } from "@/src/services/source-service";
import { SourceQueryService, SourceQueryTimeoutError, SourceQueryUnsupportedError } from "@/src/services/source-query-service";
@@ -24,7 +25,12 @@ const queryService = new SourceQueryService({ sourceService });
export async function POST(request: NextRequest, { params }: { params: Promise<{ sourceId: string }> }) {
try {
const { sourceId } = await params;
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(sourceQuerySchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
if (typeof body.query !== "string" || body.query.trim().length === 0) {
return NextResponse.json({ error: "query (string) is required" }, { status: 400 });
+7 -1
View File
@@ -1,11 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, createSourceRatePolicySchema } from "@/src/validation/0pt-schemas";
import { SourceRatePolicyService } from "@/src/services/source-rate-policy-service";
const service = new SourceRatePolicyService();
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const __raw = await request.json();
const __parsed = parseBody(createSourceRatePolicySchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const policy = await service.createSourceRatePolicy(body);
return NextResponse.json(policy, { status: 201 });
} catch (error: unknown) {
+29
View File
@@ -0,0 +1,29 @@
// Flat ESLint config (ESLint 9) for 0pt's capability code.
//
// Scope: src/ + tests/ — the backend capability layer this repo's Wave 2/3
// hardening actually owns. The v0-generated Next.js UI (app/, components/,
// hooks/, lib/) is intentionally NOT linted here; wiring the full Next lint
// preset over that generated surface is a separate concern. `pnpm run lint`
// runs this over src + tests.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["node_modules/**", ".next/**", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.ts", "tests/**/*.ts"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
rules: {
// Underscore-prefixed args are deliberate no-op/interface-shaped params
// (e.g. the local/no-op client method params in src/clients/*).
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
);
+55
View File
@@ -0,0 +1,55 @@
/**
* `middleware.ts` -- Next.js request middleware = 0pt's framework-level auth
* seam (the Next analog of a Fastify `onRequest` hook).
*
* Wave 2 hardening: this runs the flag-gated Zitadel verification
* (src/auth/context.ts -> src/auth/zitadel.ts) in front of every 0pt
* capability route (/api/0pt/*). It does NOT touch the v0-generated UI routes
* or the unrelated payment/order APIs.
*
* AUTH_ENFORCE != "true" (default): never rejects; if a verified token is
* present the verified principal is forwarded to the handler as
* `x-0pt-verified-sub` / `x-0pt-verified-tenant` request headers.
* AUTH_ENFORCE == "true": a valid verified token is required; 401 on
* missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant.
*
* Health/metrics are exempt (they are also outside the matcher below, but the
* exemption is kept explicit for robustness).
*/
import { NextResponse, type NextRequest } from 'next/server';
import { resolveAuthContext } from '@/src/auth/context';
// Only guard the 0pt capability surface. The matcher keeps this middleware off
// the UI, auth, and payment routes entirely.
export const config = {
matcher: ['/api/0pt/:path*'],
};
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) {
return NextResponse.next();
}
const decision = await resolveAuthContext(req.headers);
if (!decision.ok) {
return NextResponse.json(
{ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) },
{ status: decision.status },
);
}
const principal = decision.ctx.principal;
if (principal) {
const forwarded = new Headers(req.headers);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
return NextResponse.next();
}
+18 -5
View File
@@ -2,11 +2,17 @@
"name": "my-v0-project",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"start": "next start"
"lint": "eslint src tests",
"start": "next start",
"typecheck": "tsc -p tsconfig.check.json",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --check .",
"format:write": "prettier --write ."
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -45,6 +51,7 @@
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"jose": "^5.9.6",
"lucide-react": "^0.454.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
@@ -63,18 +70,24 @@
"zod": "3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.15.0",
"postcss": "^8.5",
"prettier": "^3.9.6",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
"typescript": "^5",
"typescript-eslint": "^8.15.0",
"vitest": "^3.2.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp"
"sharp",
"esbuild"
]
}
}
}
@@ -0,0 +1,36 @@
{
"$id": "0pt.iceberg_table.schema.v1",
"type": "object",
"required": [
"table_id",
"tenant_id",
"project_id",
"table_name",
"namespace",
"storage_uri",
"table_format",
"status",
"created_at"
],
"properties": {
"table_id": { "type": "string", "description": "Reference into ST0RE's table registry (IcebergTable.tableId, st0re_iceberg_tables)." },
"tenant_id": { "type": "string" },
"project_id": { "type": "string" },
"table_name": { "type": "string" },
"namespace": { "type": "string", "description": "ST0RE lakehouse namespace name." },
"storage_uri": { "type": "string", "description": "Object-store URI for the table's data/metadata, e.g. s3://siax-tenants/.../table." },
"catalog_ref": { "type": "string", "description": "Opaque reference into the external Iceberg catalog — ST0RE does not resolve it." },
"table_format": { "type": "string", "enum": ["iceberg", "delta", "hudi"] },
"current_snapshot_id": { "type": "string" },
"schema_version": { "type": "string" },
"partition_spec": { "type": "array", "items": { "type": "string" } },
"retention_policy": { "type": "string" },
"data_residency": { "type": "string" },
"pii_level": { "type": "string", "enum": ["none", "low", "medium", "high"] },
"owner_product": { "type": "string" },
"status": { "type": "string", "enum": ["active", "deprecated", "archived"] },
"correlation_id": { "type": "string" },
"created_at": { "type": "string" },
"created_by": { "type": "string" }
}
}
@@ -0,0 +1,53 @@
{
"$id": "0pt.lineage_event.schema.v1",
"type": "object",
"required": [
"lineage_event_id",
"tenant_id",
"project_id",
"producer",
"input_assets",
"output_assets",
"created_at"
],
"properties": {
"lineage_event_id": { "type": "string" },
"tenant_id": { "type": "string" },
"project_id": { "type": "string" },
"producer": {
"type": "object",
"description": "The capability/app that produced this lineage edge.",
"required": ["app_id", "capability_id"],
"properties": {
"app_id": { "type": "string" },
"capability_id": { "type": "string" }
}
},
"input_assets": {
"type": "array",
"items": {
"type": "object",
"required": ["asset_type", "asset_id"],
"properties": {
"asset_type": { "type": "string", "enum": ["dataset", "iceberg_table", "artifact", "source", "document", "signal"] },
"asset_id": { "type": "string" }
}
}
},
"output_assets": {
"type": "array",
"items": {
"type": "object",
"required": ["asset_type", "asset_id"],
"properties": {
"asset_type": { "type": "string", "enum": ["dataset", "iceberg_table", "artifact", "source", "document", "signal"] },
"asset_id": { "type": "string" }
}
}
},
"st0re_artifacts": { "type": "array", "items": { "type": "string" } },
"quality_report_id": { "type": "string" },
"correlation_id": { "type": "string" },
"created_at": { "type": "string" }
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"$id": "0pt.signal.schema.v1",
"type": "object",
"required": [
"signal_id",
"tenant_id",
"project_id",
"signal_type",
"signal_strength",
"entity_type",
"entity_id",
"observed_at",
"created_at"
],
"properties": {
"signal_id": { "type": "string" },
"tenant_id": { "type": "string" },
"project_id": { "type": "string" },
"signal_type": { "type": "string", "description": "e.g. hiring_growth, funding_round, tech_adoption, leadership_change." },
"signal_strength": { "type": "number", "description": "Normalized 0..1 confidence/intensity of the signal.", "minimum": 0, "maximum": 1 },
"entity_type": { "type": "string", "enum": ["company", "person", "domain", "vehicle"] },
"entity_id": { "type": "string" },
"value": { "description": "Signal payload value (type depends on signal_type)." },
"unit": { "type": "string" },
"source_id": { "type": "string", "description": "References 0pt.source.schema.v1's source_id." },
"confidence_level": { "type": "string", "enum": ["low", "medium", "high"] },
"observed_at": { "type": "string" },
"correlation_id": { "type": "string" },
"created_at": { "type": "string" }
}
}
+1816 -30
View File
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -1,2 +1,18 @@
allowBuilds:
sharp: set this to true or false
# SIAX Cloud 0pt workspace.
#
# The deployable Next.js app AND the 0pt capability code (src/, tests/) live at
# the repo ROOT — there are no separate member packages yet (packages/ holds
# JSON schemas only, no package.json). Previously this file carried only a
# placeholder `allowBuilds:` block and NO `packages:` field, which made pnpm
# abort with "packages field missing or empty" on any non-`--ignore-workspace`
# invocation. A present (non-empty) `packages:` field fixes that; the root
# package is included in `pnpm -r` runs via `include-workspace-root=true`
# (.npmrc), which is what lets `pnpm -r run typecheck` / `pnpm -r run test`
# execute the root package's scripts.
packages:
- "packages/*"
# Build scripts that are allowed to run during install (pnpm >= 9 approval gate).
onlyBuiltDependencies:
- sharp
- esbuild
+188
View File
@@ -0,0 +1,188 @@
/**
* `0pt/auth/context.ts` -- request-context extraction AND auth, the single
* seam the framework layer (Next.js middleware.ts) calls.
*
* ==========================================================================
* WAVE 2 HARDENING STATUS
*
* 0pt now performs REAL, cryptographic Zitadel (OIDC) Bearer-token
* verification (src/auth/zitadel.ts), gated behind the AUTH_ENFORCE env flag
* so it can ship to the deployed console WITHOUT breaking existing callers:
*
* AUTH_ENFORCE != "true" (DEFAULT, non-breaking):
* Preserves the historical behavior (no token was ever required; tenant
* came from the X-Tenant-Id header / request body). ADDITIONALLY, if a
* Bearer token is present and JWT-shaped, we ATTEMPT `verifyBearer` and
* attach the verified principal to the resolved context -- but we NEVER
* reject on failure in this mode (a warning is logged). This lets tokens
* start flowing and being verified with zero caller migration.
*
* AUTH_ENFORCE == "true" (enforced):
* REQUIRES a valid, verified token on every non-exempt route. The tenant
* is derived from the TOKEN (not the X-Tenant-Id header). Missing/invalid
* token -> 401 (OPT_UNAUTHENTICATED). If an X-Tenant-Id header is present
* and disagrees with the token's tenant -> 403 (OPT_ACCESS_DENIED).
*
* This module is framework-neutral: it takes a minimal header-lookup (the Web
* `Headers` interface satisfies it, as does Next's `NextRequest.headers`) and
* returns a decision object. It does NOT import next/*, so it is fully
* unit-testable in isolation.
* ==========================================================================
*/
import { verifyBearer, type VerifiedPrincipal } from './zitadel';
/** Minimal header accessor -- Web `Headers` and `NextRequest.headers` satisfy this. */
export interface HeaderLookup {
get(name: string): string | null | undefined;
}
export interface OptRequestContext {
/** Tenant id: from the token in enforce mode, else from X-Tenant-Id header. */
tenantId: string;
/** Actor id: from X-Actor-Id header, falling back to the token subject. */
actorId: string;
/** Correlation/request id (X-Request-Id header, else a fresh uuid). */
requestId: string;
/** The raw bearer token, if an Authorization: Bearer header was present. */
bearerToken?: string;
/** Present when a Bearer token was successfully verified (either mode). */
principal?: VerifiedPrincipal;
}
export type AuthDecision =
| { ok: true; ctx: OptRequestContext }
| {
ok: false;
status: 401 | 403;
code: 'OPT_UNAUTHENTICATED' | 'OPT_ACCESS_DENIED';
message: string;
details?: Record<string, unknown>;
ctx: OptRequestContext;
};
/** True when hard auth enforcement is switched on (AUTH_ENFORCE=="true"). */
export function authEnforced(): boolean {
return process.env.AUTH_ENFORCE === 'true';
}
function firstHeader(headers: HeaderLookup, name: string): string | undefined {
const v = headers.get(name);
if (v === null || v === undefined) return undefined;
return v;
}
/** A raw token that structurally looks like a compact JWS (three segments). */
function looksLikeJwt(token: string): boolean {
return token.split('.').length === 3;
}
function newRequestId(): string {
try {
return globalThis.crypto.randomUUID();
} catch {
return `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
}
/** Read whatever context headers are present, without enforcing required-ness. */
export function readRequestContext(headers: HeaderLookup): OptRequestContext {
const auth = firstHeader(headers, 'authorization');
const bearerToken = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : undefined;
return {
tenantId: firstHeader(headers, 'x-tenant-id') ?? '',
actorId: firstHeader(headers, 'x-actor-id') ?? '',
requestId: firstHeader(headers, 'x-request-id') ?? newRequestId(),
bearerToken: bearerToken && bearerToken.length > 0 ? bearerToken : undefined,
};
}
/** Minimal logger seam so middleware can pass the platform logger; defaults to console. */
export interface AuthLogger {
warn(obj: unknown, msg?: string): void;
}
export interface ResolveAuthOptions {
/** Injectable verifier (test seam); defaults to the real {@link verifyBearer}. */
verify?: typeof verifyBearer;
/** Force enforcement on/off (test seam); defaults to {@link authEnforced}. */
enforce?: boolean;
logger?: AuthLogger;
}
/**
* Resolve and (in enforce mode) enforce the request auth context. Returns an
* {@link AuthDecision}; the caller (middleware) turns a `!ok` decision into an
* HTTP response and an `ok` decision into forwarded principal headers.
*/
export async function resolveAuthContext(
headers: HeaderLookup,
opts: ResolveAuthOptions = {},
): Promise<AuthDecision> {
const verify = opts.verify ?? verifyBearer;
const enforce = opts.enforce ?? authEnforced();
const logger: AuthLogger = opts.logger ?? {
warn: (o, m) => console.warn(m ?? 'auth warning', o),
};
const ctx = readRequestContext(headers);
if (enforce) {
// --- ENFORCED: a valid verified token is mandatory ----------------------
if (!ctx.bearerToken) {
return {
ok: false,
status: 401,
code: 'OPT_UNAUTHENTICATED',
message: 'Authentication required: missing Authorization: Bearer <token>',
ctx,
};
}
let principal: VerifiedPrincipal;
try {
principal = await verify(ctx.bearerToken);
} catch (err) {
return {
ok: false,
status: 401,
code: 'OPT_UNAUTHENTICATED',
message: 'Authentication required: invalid or unverifiable Bearer token',
details: { reason: err instanceof Error ? err.message : 'verification_failed' },
ctx,
};
}
// Tenant is derived from the TOKEN. A disagreeing X-Tenant-Id is a 403.
const headerTenant = firstHeader(headers, 'x-tenant-id');
if (headerTenant && headerTenant !== principal.tenantId) {
return {
ok: false,
status: 403,
code: 'OPT_ACCESS_DENIED',
message: 'X-Tenant-Id header disagrees with authenticated token tenant',
details: { header_tenant: headerTenant, token_tenant: principal.tenantId },
ctx,
};
}
ctx.tenantId = principal.tenantId;
ctx.actorId = ctx.actorId || principal.sub;
ctx.principal = principal;
return { ok: true, ctx };
}
// --- NON-ENFORCE (default): never reject, best-effort verify --------------
if (ctx.bearerToken && looksLikeJwt(ctx.bearerToken)) {
try {
const principal = await verify(ctx.bearerToken);
ctx.principal = principal;
// Do NOT overwrite the header/body tenant in non-enforce mode; only
// fill it in when the caller supplied none, to stay non-breaking.
if (!ctx.tenantId) ctx.tenantId = principal.tenantId;
if (!ctx.actorId) ctx.actorId = principal.sub;
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
'0pt: bearer verification failed (AUTH_ENFORCE off, ignoring)',
);
}
}
return { ok: true, ctx };
}
+213
View File
@@ -0,0 +1,213 @@
/**
* `0pt/auth/zitadel.ts` -- self-contained Zitadel (OIDC) Bearer JWT
* verification for 0pt.
*
* ==========================================================================
* Wave 2 foundational hardening: REAL cryptographic verification that closes
* the "no signature verification at all" hole (0pt's API layer historically
* trusted an X-Tenant-Id header and NEVER verified the Bearer token). This
* mirrors the canonical implementation already shipped on the n0d/aud0/api0/
* inf0 hardening branches. It is intentionally self-contained (no dependency
* on @siax/capability-registry, which is not consumable cross-repo) and
* depends only on `jose` + the platform's global `fetch`.
*
* ISSUER / JWKS DISCOVERY
* ----------------------
* The fleet IdP is Zitadel at ZITADEL_ISSUER (default
* https://id-customers.siax.io). Signing keys are resolved by:
* 1. Fetching `<issuer>/.well-known/openid-configuration` and reading its
* `jwks_uri` (the spec-correct OIDC discovery path).
* 2. Falling back to `<issuer>/oauth/v2/keys` (Zitadel's well-known JWKS
* endpoint) if discovery is unavailable.
* The resolved JWKS is wrapped in jose's `createRemoteJWKSet`, which caches
* keys and refreshes on an unknown `kid`; the resolver is additionally cached
* per-issuer for the process lifetime.
*
* TENANT CLAIM DERIVATION (documented precedence)
* -----------------------------------------------
* `deriveTenantId` maps a verified token to a SIAX tenant id with this
* precedence (overridable via ZITADEL_TENANT_CLAIM, which -- when set and
* present on the token -- wins outright):
* 1. `urn:zitadel:iam:org:id` -- Zitadel's standard
* organization-id claim;
* 0pt's tenant boundary
* maps 1:1 to a Zitadel org.
* 2. `urn:zitadel:iam:user:resourceowner:id` -- the user's resource-owner
* org id (present on some
* token profiles).
* 3. `tenant_id` -- an explicit custom claim,
* if a Zitadel action/mapper
* injects the SIAX tenant
* slug directly.
* 4. `tenant` -- a looser custom fallback.
* A signed-but-tenantless token is not usable here and fails verification.
*/
import {
createRemoteJWKSet,
jwtVerify,
type JWTPayload,
type JWTVerifyGetKey,
} from 'jose';
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
/** 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';
export const TENANT_ID_CLAIM = 'tenant_id';
export const TENANT_CLAIM = 'tenant';
/** Ordered claim keys checked by {@link deriveTenantId} (after any override). */
export const TENANT_CLAIM_PRECEDENCE = [
ZITADEL_ORG_ID_CLAIM,
ZITADEL_RESOURCEOWNER_CLAIM,
TENANT_ID_CLAIM,
TENANT_CLAIM,
] as const;
/** Resolve the configured issuer, trimming and falling back to the default. */
export function zitadelIssuer(): string {
const fromEnv = process.env.ZITADEL_ISSUER?.trim();
return fromEnv && fromEnv.length > 0 ? fromEnv : DEFAULT_ZITADEL_ISSUER;
}
/** The env-configured tenant-claim override, if any (ZITADEL_TENANT_CLAIM). */
export function tenantClaimOverride(): string | undefined {
const v = process.env.ZITADEL_TENANT_CLAIM?.trim();
return v && v.length > 0 ? v : undefined;
}
/** A verified caller principal derived from a valid Zitadel access/ID token. */
export interface VerifiedPrincipal {
/** The token subject (`sub`) -- the authenticated actor id. */
sub: string;
/** The SIAX tenant id derived from the token (see deriveTenantId). */
tenantId: string;
/** The full verified JWT payload, for any additional downstream claims. */
claims: JWTPayload;
}
/** Thrown by {@link verifyBearer} on any verification/derivation failure. */
export class BearerVerificationError extends Error {
readonly reason: string;
readonly cause?: unknown;
constructor(reason: string, cause?: unknown) {
super(reason);
this.name = 'BearerVerificationError';
this.reason = reason;
this.cause = cause;
}
}
function claimAsString(claims: JWTPayload, key: string): string | undefined {
const v = claims[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
/**
* Map a verified JWT payload to a SIAX tenant id (documented precedence above).
* @param override an explicit claim key to try first (ZITADEL_TENANT_CLAIM).
*/
export function deriveTenantId(claims: JWTPayload, override = tenantClaimOverride()): string | undefined {
if (override) {
const fromOverride = claimAsString(claims, override);
if (fromOverride) return fromOverride;
}
for (const key of TENANT_CLAIM_PRECEDENCE) {
const v = claimAsString(claims, key);
if (v) return v;
}
return undefined;
}
// --- JWKS resolution --------------------------------------------------------
const jwksCache = new Map<string, JWTVerifyGetKey>();
// Test seam: an injected resolver bypasses remote discovery entirely.
let keyResolverOverride: JWTVerifyGetKey | undefined;
/**
* TEST SEAM: inject a JWKS resolver (e.g. jose `createLocalJWKSet`) so unit
* tests can verify locally-signed tokens without any network access. Pass
* `undefined` to clear.
*/
export function __setKeyResolverForTests(resolver: JWTVerifyGetKey | undefined): void {
keyResolverOverride = resolver;
}
/** TEST SEAM: clear the per-issuer remote-JWKS cache. */
export function __resetJwksCache(): void {
jwksCache.clear();
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}
async function resolveJwksUri(issuer: string): Promise<string> {
const base = stripTrailingSlash(issuer);
const wellKnown = `${base}/.well-known/openid-configuration`;
try {
const res = await fetch(wellKnown);
if (res.ok) {
const cfg = (await res.json()) as { jwks_uri?: unknown };
if (typeof cfg.jwks_uri === 'string' && cfg.jwks_uri.length > 0) {
return cfg.jwks_uri;
}
}
} catch {
// fall through to the Zitadel default keys endpoint
}
return `${base}/oauth/v2/keys`;
}
async function getRemoteJwks(issuer: string): Promise<JWTVerifyGetKey> {
const cached = jwksCache.get(issuer);
if (cached) return cached;
const uri = await resolveJwksUri(issuer);
const resolver = createRemoteJWKSet(new URL(uri));
jwksCache.set(issuer, resolver);
return resolver;
}
export interface VerifyBearerOptions {
/** Override the issuer (default: {@link zitadelIssuer}). */
issuer?: string;
/** Override the key resolver (test seam; default: remote JWKS for issuer). */
keyResolver?: JWTVerifyGetKey;
/** Override the tenant-claim key (default: {@link tenantClaimOverride}). */
tenantClaim?: string;
}
/**
* Verify a raw Bearer token string against Zitadel: checks the RS/ES
* signature (via JWKS), the `iss` claim, and `exp`/`nbf` (jose enforces
* expiry automatically), then derives the tenant id. Returns a
* {@link VerifiedPrincipal} or throws {@link BearerVerificationError}.
*/
export async function verifyBearer(
token: string,
opts: VerifyBearerOptions = {},
): Promise<VerifiedPrincipal> {
const issuer = opts.issuer ?? zitadelIssuer();
const resolver = opts.keyResolver ?? keyResolverOverride ?? (await getRemoteJwks(issuer));
let payload: JWTPayload;
try {
const result = await jwtVerify(token, resolver, { issuer });
payload = result.payload;
} catch (err) {
throw new BearerVerificationError('token_verification_failed', err);
}
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
if (!sub || sub.length === 0) {
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 };
}
+98
View File
@@ -0,0 +1,98 @@
/**
* src/clients/aud0-client.ts
* ===========================
* 0pt's thin client for emitting append-only events + lineage edges to AUD0
* (the SIAX Cloud system of record). Two methods:
* - ingestEvent -> POST {baseUrl}/v1/aud0/events
* - ingestLineageEvent -> POST {baseUrl}/v1/aud0/lineage/events
*
* Convention (identical to cl0ud-client.ts / n0d-client.ts / st0re-client.ts
* / st0re-lakehouse-client.ts): when no base URL is configured the client is
* a NO-OP — it does not attempt any network call. AUD0 is a best-effort,
* out-of-band audit sink for 0pt; a missing AUD0_URL (unit tests, local dev)
* must not turn every capability call into a failed fetch to `undefined`.
* When a base URL IS configured, events are POSTed as-is (the event object is
* the wire body) with `Content-Type: application/json`.
*/
export interface Aud0Event {
event_id: string;
event_type: string;
tenant_id: string;
project_id: string;
actor_id: string;
app_id: string;
capability_id: string;
resource_type: string;
resource_id: string;
/** Risk level L0–L5 per the SIAX Cloud risk taxonomy. */
risk_level: string;
payload: Record<string, unknown>;
created_at: string;
correlation_id?: string;
}
export interface Aud0LineageEvent {
lineage_event_id: string;
tenant_id: string;
project_id: string;
app_id: string;
capability_id: string;
source_type: string;
source_id: string;
target_type: string;
target_id: string;
/** e.g. "produced" | "derived_from" | "stored_as". */
lineage_type: string;
metadata: Record<string, unknown>;
created_at: string;
correlation_id?: string;
}
export interface Aud0Client {
ingestEvent(event: Aud0Event): Promise<void>;
ingestLineageEvent(event: Aud0LineageEvent): Promise<void>;
}
export function createAud0Client(baseUrl?: string): Aud0Client {
if (!baseUrl) return new NoopAud0Client();
return new HttpAud0Client(baseUrl);
}
/** No AUD0_URL configured: audit emission is a no-op (see module doc comment). */
class NoopAud0Client implements Aud0Client {
async ingestEvent(_event: Aud0Event): Promise<void> {
/* intentional no-op */
}
async ingestLineageEvent(_event: Aud0LineageEvent): Promise<void> {
/* intentional no-op */
}
}
class HttpAud0Client implements Aud0Client {
private readonly baseUrl: string;
constructor(baseUrl: string) {
// Normalize so `${baseUrl}/v1/...` never produces a double slash.
this.baseUrl = baseUrl.replace(/\/+$/, "");
}
async ingestEvent(event: Aud0Event): Promise<void> {
await this.post("/v1/aud0/events", event);
}
async ingestLineageEvent(event: Aud0LineageEvent): Promise<void> {
await this.post("/v1/aud0/lineage/events", event);
}
private async post(path: string, body: unknown): Promise<void> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`AUD0 ${path} error: ${res.status} ${await res.text().catch(() => "")}`);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* src/clients/cl0ud-client.ts
* ============================
* 0pt's client for the CL0UD control-plane policy decision point:
* evaluatePolicy -> POST {baseUrl}/v1/cl0ud/policies/evaluate
*
* Every side-effecting 0pt capability re-consults CL0UD before acting (per
* docs/PRODUCT_PLAN.md §4.1), so this is the single choke point for that.
*
* Convention (identical to aud0-client.ts / n0d-client.ts / st0re-client.ts):
* when no base URL is configured the client runs in LOCAL PASS-THROUGH mode —
* it returns a deterministic `allow` decision WITHOUT any network call, so
* unit tests and local dev are hermetic. This is a dev-mode default only; any
* real deployment MUST set CL0UD_URL so decisions come from the real policy
* engine. The pass-through never invents a `deny`/`approval_required` — it
* only ever allows, and it stamps a clearly local-looking decision id.
*/
import crypto from "crypto";
export type PolicyDecision = "allow" | "deny" | "approval_required";
export interface EvaluatePolicyInput {
tenant_id: string;
project_id: string;
actor_id: string;
capability_id: string;
resource_type: string;
resource_id: string;
/** Risk level L0–L5 per the SIAX Cloud risk taxonomy. */
risk_level: string;
correlation_id?: string;
}
export interface PolicyResult {
decision: PolicyDecision;
decision_id: string;
reason?: string;
}
export interface Cl0udClient {
evaluatePolicy(input: EvaluatePolicyInput): Promise<PolicyResult>;
}
export function createCl0udClient(baseUrl?: string): Cl0udClient {
if (!baseUrl) return new LocalAllowCl0udClient();
return new HttpCl0udClient(baseUrl);
}
/** No CL0UD_URL configured: allow-only local pass-through (see module doc comment). */
class LocalAllowCl0udClient implements Cl0udClient {
async evaluatePolicy(_input: EvaluatePolicyInput): Promise<PolicyResult> {
return { decision: "allow", decision_id: `pdec_local_${crypto.randomUUID()}` };
}
}
class HttpCl0udClient implements Cl0udClient {
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
}
async evaluatePolicy(input: EvaluatePolicyInput): Promise<PolicyResult> {
const res = await fetch(`${this.baseUrl}/v1/cl0ud/policies/evaluate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(`CL0UD policy evaluate error: ${res.status} ${await res.text().catch(() => "")}`);
}
const body = (await res.json()) as Partial<PolicyResult>;
return {
decision: (body.decision ?? "deny") as PolicyDecision,
decision_id: body.decision_id ?? `pdec_${crypto.randomUUID()}`,
reason: body.reason,
};
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* src/clients/n0d-client.ts
* ==========================
* 0pt's client for REQUESTING heavy compute jobs from N0D (the Compute OS).
* 0pt never executes heavy work in-process — it submits an N0D job and holds
* only the returned `job_id` (docs/11-lakehouse-write-plan.md item 7,
* docs/P0_QUERY_SOURCE_FLOW.md):
* createJob -> POST {baseUrl}/v1/n0d/jobs
*
* Convention (identical to aud0-client.ts / cl0ud-client.ts / st0re-client.ts
* / st0re-lakehouse-client.ts): when no base URL is configured the client
* returns a deterministic in-memory MOCK job (id prefixed "mock_job_") with
* NO network call, so unit tests stay hermetic. A real deployment sets
* N0D_URL to submit against the live scheduler.
*/
import crypto from "crypto";
export interface CreateJobInput {
tenant_id: string;
project_id: string;
job_type: string;
capability_id: string;
requested_by_app_id: string;
requested_by_actor_id: string;
input_artifact_ids?: string[];
input_dataset_ids?: string[];
output_artifact_type: string;
requires_backbone: boolean;
requires_gpu: boolean;
required_node_roles: string[];
resource_profile: string;
/** Risk level L0–L5 per the SIAX Cloud risk taxonomy. */
risk_level: string;
correlation_id?: string;
}
export type N0dJobState = "queued" | "running" | "succeeded" | "failed" | "cancelled";
export interface N0dJob extends CreateJobInput {
job_id: string;
state: N0dJobState;
created_at: string;
}
export interface N0dClient {
createJob(input: CreateJobInput): Promise<N0dJob>;
}
export function createN0dClient(baseUrl?: string): N0dClient {
if (!baseUrl) return new MockN0dClient();
return new HttpN0dClient(baseUrl);
}
/** No N0D_URL configured: returns a deterministic mock job (see module doc comment). */
class MockN0dClient implements N0dClient {
async createJob(input: CreateJobInput): Promise<N0dJob> {
return {
...input,
job_id: `mock_job_${crypto.randomUUID()}`,
state: "queued",
created_at: new Date().toISOString(),
};
}
}
class HttpN0dClient implements N0dClient {
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
}
async createJob(input: CreateJobInput): Promise<N0dJob> {
const res = await fetch(`${this.baseUrl}/v1/n0d/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(`N0D create-job error: ${res.status} ${await res.text().catch(() => "")}`);
}
return (await res.json()) as N0dJob;
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* src/clients/st0re-client.ts
* ============================
* 0pt's client for the generic ST0RE Artifact registry (snake_case wire
* shape), distinct from `./st0re-lakehouse-client.ts` (the camelCase
* /v1/st0re/lakehouse/* table registry). This one registers an artifact's
* METADATA — a pointer to where the bytes live (provider/bucket/key/region)
* plus content-type/size/checksum/retention/PII/residency:
* createArtifact -> POST {baseUrl}/v1/st0re/artifacts
*
* KNOWN GAP (documented in src/adapters/local-blob-store.ts): ST0RE does not
* yet expose a raw-bytes upload endpoint for 0pt to call, so the actual bytes
* are persisted by LocalBlobStore (interim) and only the resulting location
* is registered here as metadata.
*
* Convention (identical to aud0-client.ts / cl0ud-client.ts / n0d-client.ts /
* st0re-lakehouse-client.ts): when no base URL is configured the client
* returns a deterministic in-memory MOCK artifact (id prefixed
* "mock_artifact_") with NO network call, so unit tests stay hermetic. A real
* deployment sets ST0RE_URL to register against the live artifact registry.
*/
import crypto from "crypto";
export interface St0reObjectLocation {
provider: string;
bucket: string;
key: string;
region?: string;
}
export interface CreateArtifactInput {
tenant_id: string;
project_id: string;
artifact_type: string;
app_id: string;
produced_by: string;
object_location: St0reObjectLocation;
content_type: string;
size_bytes: number;
checksum_sha256: string;
/** "none" | "low" | "medium" | "high" — kept as string to stay tolerant of ST0RE's own vocabulary. */
pii_level: string;
retention_policy: string;
data_residency: string;
/** Risk level L0–L5 per the SIAX Cloud risk taxonomy. */
risk_level: string;
correlation_id?: string;
}
export interface St0reArtifact extends CreateArtifactInput {
artifact_id: string;
created_at: string;
}
export interface St0reClient {
createArtifact(input: CreateArtifactInput): Promise<St0reArtifact>;
}
export function createSt0reClient(baseUrl?: string): St0reClient {
if (!baseUrl) return new MockSt0reClient();
return new HttpSt0reClient(baseUrl);
}
/** No ST0RE_URL configured: returns a deterministic mock artifact (see module doc comment). */
class MockSt0reClient implements St0reClient {
async createArtifact(input: CreateArtifactInput): Promise<St0reArtifact> {
return {
...input,
artifact_id: `mock_artifact_${crypto.randomUUID()}`,
created_at: new Date().toISOString(),
};
}
}
class HttpSt0reClient implements St0reClient {
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
}
async createArtifact(input: CreateArtifactInput): Promise<St0reArtifact> {
const res = await fetch(`${this.baseUrl}/v1/st0re/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(`ST0RE create-artifact error: ${res.status} ${await res.text().catch(() => "")}`);
}
return (await res.json()) as St0reArtifact;
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* src/services/dataset-service.ts
* ================================
* Owns 0pt.dataset.schema.v1 — the registry of 0pt datasets (raw / enriched /
* curated / feature outputs). In-memory reference implementation (same
* convention as the other 0pt services in this repo; a real deployment backs
* this with `opt_datasets`).
*
* Consumed by:
* - dataset-iceberg-mapping-service.ts (getDataset -> does the dataset exist
* before we map it to an Iceberg table?)
* - iceberg-write-service.ts (getDataset -> guard the write path)
*
* Registering a dataset emits a best-effort AUD0 `0pt.dataset_registered`
* event; with no AUD0_URL configured that emission is a no-op (see
* aud0-client.ts), so this stays hermetic in tests and local dev.
*/
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
export type DatasetType = "raw" | "enriched" | "curated" | "feature" | "derived";
export type DatasetStatus = "active" | "deprecated" | "archived";
export interface Dataset {
dataset_id: string;
tenant_id: string;
project_id: string;
name: string;
dataset_type: DatasetType;
description?: string;
status: DatasetStatus;
correlation_id?: string;
created_at: string;
updated_at?: string;
created_by?: string;
}
export interface RegisterDatasetInput {
dataset_id: string;
tenant_id: string;
project_id: string;
name: string;
dataset_type: DatasetType;
description?: string;
status?: DatasetStatus;
correlation_id?: string;
created_by?: string;
}
export class DatasetService {
private datasets: Map<string, Dataset> = new Map();
private aud0: Aud0Client;
constructor(aud0BaseUrl?: string) {
this.aud0 = createAud0Client(aud0BaseUrl);
}
async registerDataset(input: RegisterDatasetInput): Promise<Dataset> {
if (this.datasets.has(input.dataset_id)) {
throw new Error(`Dataset ${input.dataset_id} already exists`);
}
if (!input.name) {
throw new Error("name is required");
}
const now = new Date().toISOString();
const dataset: Dataset = {
dataset_id: input.dataset_id,
tenant_id: input.tenant_id,
project_id: input.project_id,
name: input.name,
dataset_type: input.dataset_type,
description: input.description,
status: input.status ?? "active",
correlation_id: input.correlation_id,
created_at: now,
created_by: input.created_by,
};
this.datasets.set(dataset.dataset_id, dataset);
await this.aud0.ingestEvent({
event_id: `evt_0pt_dataset_registered_${dataset.dataset_id}`,
event_type: "0pt.dataset_registered",
tenant_id: dataset.tenant_id,
project_id: dataset.project_id,
actor_id: dataset.created_by ?? "system",
app_id: "0pt",
capability_id: "0pt.register_dataset",
resource_type: "dataset",
resource_id: dataset.dataset_id,
risk_level: "L2",
payload: { dataset_type: dataset.dataset_type, name: dataset.name },
created_at: now,
correlation_id: dataset.correlation_id,
});
return dataset;
}
async getDataset(datasetId: string): Promise<Dataset> {
const dataset = this.datasets.get(datasetId);
if (!dataset) throw new Error(`Dataset ${datasetId} not found`);
return dataset;
}
async listDatasets(filter?: { tenant_id?: string; project_id?: string; dataset_type?: DatasetType }): Promise<Dataset[]> {
let results = Array.from(this.datasets.values());
if (filter?.tenant_id) results = results.filter((d) => d.tenant_id === filter.tenant_id);
if (filter?.project_id) results = results.filter((d) => d.project_id === filter.project_id);
if (filter?.dataset_type) results = results.filter((d) => d.dataset_type === filter.dataset_type);
return results;
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* src/services/source-service.ts
* ===============================
* Owns 0pt.source.schema.v1 — the registry of upstream data sources
* (databases, crawl domains, APIs, file drops). In-memory reference
* implementation (same convention as the other 0pt services in this repo:
* each API route file constructs its own instance; a real deployment backs
* this with `opt_sources` — sql/002_p0_dataset_quality.sql).
*
* Consumed by:
* - source-query-service.ts (getSource -> is this source queryable?)
* - source-rate-policy-service.ts (getSource -> does the source exist?)
*
* Registering a source emits a best-effort AUD0 `0pt.source_registered`
* event; with no AUD0_URL configured that emission is a no-op (see
* aud0-client.ts), so this stays hermetic in tests and local dev.
*/
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
import type { CreateSourceInput, Source } from "../types/source";
export class SourceService {
private sources: Map<string, Source> = new Map();
private aud0: Aud0Client;
constructor(aud0BaseUrl?: string) {
this.aud0 = createAud0Client(aud0BaseUrl);
}
async createSource(input: CreateSourceInput): Promise<Source> {
if (this.sources.has(input.source_id)) {
throw new Error(`Source ${input.source_id} already exists`);
}
if (!input.name) {
throw new Error("name is required");
}
const now = new Date().toISOString();
const source: Source = {
source_id: input.source_id,
tenant_id: input.tenant_id,
project_id: input.project_id,
source_type: input.source_type,
name: input.name,
adapter_id: input.adapter_id,
description: input.description,
status: input.status ?? "active",
correlation_id: input.correlation_id,
created_at: now,
created_by: input.created_by,
};
this.sources.set(source.source_id, source);
await this.aud0.ingestEvent({
event_id: `evt_0pt_source_registered_${source.source_id}`,
event_type: "0pt.source_registered",
tenant_id: source.tenant_id,
project_id: source.project_id,
actor_id: source.created_by ?? "system",
app_id: "0pt",
capability_id: "0pt.register_source",
resource_type: "source",
resource_id: source.source_id,
risk_level: "L2",
payload: { source_type: source.source_type, adapter_id: source.adapter_id ?? null, name: source.name },
created_at: now,
correlation_id: source.correlation_id,
});
return source;
}
async getSource(sourceId: string): Promise<Source> {
const source = this.sources.get(sourceId);
if (!source) throw new Error(`Source ${sourceId} not found`);
return source;
}
async listSources(filter?: { tenant_id?: string; project_id?: string; source_type?: Source["source_type"] }): Promise<Source[]> {
let results = Array.from(this.sources.values());
if (filter?.tenant_id) results = results.filter((s) => s.tenant_id === filter.tenant_id);
if (filter?.project_id) results = results.filter((s) => s.project_id === filter.project_id);
if (filter?.source_type) results = results.filter((s) => s.source_type === filter.source_type);
return results;
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* src/types/source.ts
* =====================
* 0pt.source.schema.v1 — a registered upstream data source (a database, a
* crawlable web domain, an API, a file drop, …). Owned by SourceService
* (src/services/source-service.ts); referenced by SourceRatePolicy
* (src/types/source-rate-policy.ts) and by the synchronous query path
* (src/services/source-query-service.ts), which reads `source_type` +
* `adapter_id` to decide whether a Source is queryable.
*
* `adapter_id` is the seam that lets a "database" source name the concrete
* driver 0pt should use for it (today only "fabric_onelake_sql" is wired on
* the synchronous path — see source-query-service.ts).
*/
export type SourceType = "database" | "file" | "web" | "api" | "stream" | "manual";
export type SourceStatus = "active" | "paused" | "archived";
export interface Source {
source_id: string;
tenant_id: string;
project_id: string;
source_type: SourceType;
name: string;
/** Concrete driver id for this source, e.g. "fabric_onelake_sql". Optional: not every source is directly queryable. */
adapter_id?: string;
description?: string;
status: SourceStatus;
correlation_id?: string;
created_at: string;
updated_at?: string;
created_by?: string;
}
export interface CreateSourceInput {
source_id: string;
tenant_id: string;
project_id: string;
source_type: SourceType;
name: string;
adapter_id?: string;
description?: string;
status?: SourceStatus;
correlation_id?: string;
created_by?: string;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* `0pt/validation/0pt-schemas.ts` -- Wave 2 input validation for the 0pt
* capability API's MUTATING routes.
*
* Before Wave 2 every POST/PUT handler did `const body = await request.json()`
* and read fields straight off `body` (an implicit `any`), so a malformed
* payload reached the service layer untyped and surfaced as an opaque 500/400.
* These Zod schemas validate the request body at the edge and produce a typed,
* structured 400. This is VALIDATION ONLY -- it re-encodes the shape each
* handler already destructures; it does not add or change business logic.
*/
import { z } from 'zod';
/** A structured 400 parse result. */
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; status: 400; body: { error: 'validation_error'; issues: { path: string; message: string }[] } };
/** Validate `body` against `schema`, returning parsed data or a 400 payload. */
export function parseBody<S extends z.ZodTypeAny>(schema: S, body: unknown): ParseResult<z.infer<S>> {
const result = schema.safeParse(body);
if (result.success) return { ok: true, data: result.data };
return {
ok: false,
status: 400,
body: {
error: 'validation_error',
issues: result.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
};
}
// A permissive JSON object (used for opaque config blobs whose inner shape is
// owned by a downstream adapter, not this validation layer).
const jsonObject = z.record(z.string(), z.unknown());
/** POST /api/0pt/documents/capture */
export const documentCaptureSchema = z.object({
mailbox: z.object({
tenant_id: z.string().min(1),
client_id: z.string().min(1),
client_secret: z.string().min(1),
mailbox_address: z.string().min(1),
folder: z.string().min(1).optional(),
}),
since_iso: z.string().min(1).optional(),
top: z.number().int().positive().optional(),
max_pages: z.number().int().positive().optional(),
tenant_id: z.string().min(1),
project_id: z.string().min(1),
actor_id: z.string().min(1).optional(),
correlation_id: z.string().min(1).optional(),
});
/** POST /api/0pt/documents/[documentId]/extraction */
export const documentExtractionSchema = z.object({
tenant_id: z.string().min(1),
project_id: z.string().min(1),
actor_id: z.string().min(1).optional(),
correlation_id: z.string().min(1).optional(),
model_id: z.string().min(1).optional(),
});
/** POST /api/0pt/sources/[sourceId]/query */
export const sourceQuerySchema = z.object({
query: z.string().trim().min(1),
params: z.unknown().optional(),
tenant_id: z.string().min(1),
project_id: z.string().min(1),
actor_id: z.string().min(1).optional(),
correlation_id: z.string().min(1).optional(),
max_rows: z.number().int().positive().optional(),
timeout_ms: z.number().int().positive().optional(),
connection_config: jsonObject,
});
/** POST /api/0pt/sources/rate-policies -- mirrors CreateSourceRatePolicyInput. */
export const createSourceRatePolicySchema = z.object({
source_rate_policy_id: z.string().min(1),
tenant_id: z.string().min(1),
project_id: z.string().min(1),
source_id: z.string().min(1),
rate_limit_per_minute: z.number().int().nonnegative(),
crawl_budget: z.number().int().nonnegative(),
compliance_notes: z.string().optional(),
domain: z.string().min(1).optional(),
robots_ok: z.boolean().optional(),
access: z.enum(['api', 'bulk', 'authorized_web', 'licensed']).optional(),
status: z.enum(['active', 'paused', 'deprecated']).optional(),
correlation_id: z.string().min(1).optional(),
created_by: z.string().min(1).optional(),
});
/** POST /api/0pt/lakehouse/mappings -- mirrors MapDatasetToIcebergTableInput. */
export const mapDatasetToIcebergTableSchema = z.object({
tenant_id: z.string().min(1),
project_id: z.string().min(1),
dataset_id: z.string().min(1),
dataset_iceberg_mapping_id: z.string().min(1),
namespace: z.string().min(1),
table_name: z.string().min(1),
storage_uri: z.string().min(1),
catalog_ref: z.string().min(1),
table_format: z.string().min(1).optional(),
actor_id: z.string().min(1),
correlation_id: z.string().min(1),
});
/** POST /api/0pt/lakehouse/mappings/[mappingId]/writes -- RequestIcebergWriteInput. */
export const requestIcebergWriteSchema = z.object({
actor_id: z.string().min(1),
correlation_id: z.string().min(1),
snapshot_type: z.string().min(1).optional(),
schema_version: z.string().min(1),
manifest_uri: z.string().min(1),
record_count: z.number().int().nonnegative().optional(),
size_bytes: z.number().int().nonnegative().optional(),
heavy: z.boolean().optional(),
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Unit tests for src/auth/context.ts `resolveAuthContext` -- the flag-gated
* auth seam. Verification is injected (opts.verify) so these tests are pure
* and network-free; they exercise the decision logic in both AUTH_ENFORCE
* modes.
*/
import { describe, it, expect, vi } from 'vitest';
import { resolveAuthContext, type OptRequestContext } from '@/src/auth/context';
import type { VerifiedPrincipal } from '@/src/auth/zitadel';
const principal: VerifiedPrincipal = {
sub: 'user_1',
tenantId: 'tenant_x',
claims: { sub: 'user_1', iss: 'https://id-customers.siax.io' },
};
const okVerify = vi.fn(async () => principal);
const failVerify = vi.fn(async () => {
throw new Error('token_verification_failed');
});
function h(init: Record<string, string>): Headers {
return new Headers(init);
}
describe('resolveAuthContext -- enforce mode', () => {
it('401s when no bearer token is present', async () => {
const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_x' }), { enforce: true, verify: okVerify });
expect(d.ok).toBe(false);
if (!d.ok) {
expect(d.status).toBe(401);
expect(d.code).toBe('OPT_UNAUTHENTICATED');
}
});
it('401s when the token fails verification', async () => {
const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), {
enforce: true,
verify: failVerify,
});
expect(d.ok).toBe(false);
if (!d.ok) expect(d.status).toBe(401);
});
it('attaches the verified principal and derives tenant from the TOKEN', async () => {
const d = await resolveAuthContext(h({ authorization: 'Bearer good.tok.en' }), {
enforce: true,
verify: okVerify,
});
expect(d.ok).toBe(true);
if (d.ok) {
expect(d.ctx.principal).toEqual(principal);
expect(d.ctx.tenantId).toBe('tenant_x');
expect(d.ctx.actorId).toBe('user_1');
}
});
it('403s when X-Tenant-Id disagrees with the token tenant', async () => {
const d = await resolveAuthContext(
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_evil' }),
{ enforce: true, verify: okVerify },
);
expect(d.ok).toBe(false);
if (!d.ok) {
expect(d.status).toBe(403);
expect(d.code).toBe('OPT_ACCESS_DENIED');
expect(d.details).toMatchObject({ header_tenant: 'tenant_evil', token_tenant: 'tenant_x' });
}
});
it('allows a matching X-Tenant-Id', async () => {
const d = await resolveAuthContext(
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_x' }),
{ enforce: true, verify: okVerify },
);
expect(d.ok).toBe(true);
});
});
describe('resolveAuthContext -- non-enforce (default) mode', () => {
it('never rejects when no token is present', async () => {
const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_header' }), {
enforce: false,
verify: okVerify,
});
expect(d.ok).toBe(true);
if (d.ok) {
expect((d.ctx as OptRequestContext).principal).toBeUndefined();
expect(d.ctx.tenantId).toBe('tenant_header');
}
});
it('never rejects even when a present token fails verification (logs a warning)', async () => {
const logger = { warn: vi.fn() };
const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), {
enforce: false,
verify: failVerify,
logger,
});
expect(d.ok).toBe(true);
if (d.ok) expect(d.ctx.principal).toBeUndefined();
expect(logger.warn).toHaveBeenCalledOnce();
});
it('attaches the principal when a present token verifies, without overriding a supplied tenant', async () => {
const d = await resolveAuthContext(
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_header' }),
{ enforce: false, verify: okVerify },
);
expect(d.ok).toBe(true);
if (d.ok) {
expect(d.ctx.principal).toEqual(principal);
// non-breaking: caller-supplied header tenant is preserved
expect(d.ctx.tenantId).toBe('tenant_header');
}
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Unit tests for src/auth/zitadel.ts `verifyBearer`, using a locally-generated
* RSA keypair + `createLocalJWKSet` so there is ZERO network access: we sign
* our own tokens and inject the matching JWKS resolver.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import {
SignJWT,
generateKeyPair,
exportJWK,
createLocalJWKSet,
type JWTVerifyGetKey,
type KeyLike,
} from 'jose';
import { verifyBearer, deriveTenantId, BearerVerificationError } from '@/src/auth/zitadel';
const ISSUER = 'https://test-issuer.siax.io';
const KID = 'test-key-1';
let privateKey: KeyLike;
let jwks: JWTVerifyGetKey;
beforeAll(async () => {
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
privateKey = pk;
const jwk = await exportJWK(publicKey);
jwk.kid = KID;
jwk.alg = 'RS256';
jwks = createLocalJWKSet({ keys: [jwk] });
});
interface SignOpts {
issuer?: string;
expirationTime?: string | number;
}
async function sign(claims: Record<string, unknown>, opts: SignOpts = {}): Promise<string> {
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt()
.setIssuer(opts.issuer ?? ISSUER)
.setExpirationTime(opts.expirationTime ?? '2h')
.sign(privateKey);
}
describe('deriveTenantId', () => {
it('prefers the Zitadel org id claim', () => {
expect(
deriveTenantId({ 'urn:zitadel:iam:org:id': 'org_1', tenant_id: 't_custom' }),
).toBe('org_1');
});
it('falls back to the resourceowner id, then tenant_id, then tenant', () => {
expect(deriveTenantId({ 'urn:zitadel:iam:user:resourceowner:id': 'org_ro' })).toBe('org_ro');
expect(deriveTenantId({ tenant_id: 't_x' })).toBe('t_x');
expect(deriveTenantId({ tenant: 't_y' })).toBe('t_y');
});
it('honors an explicit override claim key when present', () => {
expect(deriveTenantId({ custom_tid: 't_over', 'urn:zitadel:iam:org:id': 'org_1' }, 'custom_tid')).toBe(
't_over',
);
});
it('returns undefined when no tenant claim is present', () => {
expect(deriveTenantId({ sub: 'u1' })).toBeUndefined();
});
});
describe('verifyBearer', () => {
it('verifies a valid token and derives the principal (sub + tenant)', async () => {
const token = await sign({ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' });
const principal = await verifyBearer(token, { issuer: ISSUER, keyResolver: jwks });
expect(principal.sub).toBe('user_1');
expect(principal.tenantId).toBe('tenant_x');
expect(principal.claims.iss).toBe(ISSUER);
});
it('honors the custom tenant_id claim', async () => {
const token = await sign({ sub: 'user_2', tenant_id: 't_protosell' });
const principal = await verifyBearer(token, { issuer: ISSUER, keyResolver: jwks });
expect(principal.tenantId).toBe('t_protosell');
});
it('rejects a tampered token (bad signature)', async () => {
const token = await sign({ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' });
const firstDot = token.indexOf('.');
const secondDot = token.indexOf('.', firstDot + 1);
const mid = Math.floor((firstDot + secondDot) / 2);
const ch = token.charAt(mid);
const tampered = token.slice(0, mid) + (ch === 'a' ? 'b' : 'a') + token.slice(mid + 1);
await expect(verifyBearer(tampered, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects an expired token', async () => {
const token = await sign(
{ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' },
{ expirationTime: '-1h' },
);
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects a token from the wrong issuer', async () => {
const token = await sign(
{ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' },
{ issuer: 'https://evil-issuer.example' },
);
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects a validly-signed token that carries no tenant claim', async () => {
const token = await sign({ sub: 'user_1' });
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toMatchObject({
reason: 'token_missing_tenant_claim',
});
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Unit tests for src/validation/0pt-schemas.ts -- the Wave 2 request-body
* validation for 0pt's mutating capability routes. Confirms a valid body
* passes and a malformed body produces a structured 400.
*/
import { describe, it, expect } from 'vitest';
import {
parseBody,
documentCaptureSchema,
documentExtractionSchema,
sourceQuerySchema,
createSourceRatePolicySchema,
mapDatasetToIcebergTableSchema,
requestIcebergWriteSchema,
} from '@/src/validation/0pt-schemas';
describe('parseBody + documentCaptureSchema', () => {
const valid = {
mailbox: {
tenant_id: 't1',
client_id: 'c1',
client_secret: 's1',
mailbox_address: 'ap@x.io',
},
tenant_id: 't1',
project_id: 'p1',
};
it('accepts a valid capture body', () => {
const r = parseBody(documentCaptureSchema, valid);
expect(r.ok).toBe(true);
});
it('rejects a body missing tenant_id with a 400 + issues', () => {
const { tenant_id: _omit, ...bad } = valid;
const r = parseBody(documentCaptureSchema, bad);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.status).toBe(400);
expect(r.body.error).toBe('validation_error');
expect(r.body.issues.some((i) => i.path === 'tenant_id')).toBe(true);
}
});
it('rejects a wrong-typed top field', () => {
const r = parseBody(documentCaptureSchema, { ...valid, top: 'ten' });
expect(r.ok).toBe(false);
});
});
describe('documentExtractionSchema', () => {
it('accepts minimal valid input', () => {
expect(parseBody(documentExtractionSchema, { tenant_id: 't', project_id: 'p' }).ok).toBe(true);
});
it('rejects empty body', () => {
expect(parseBody(documentExtractionSchema, {}).ok).toBe(false);
});
});
describe('sourceQuerySchema', () => {
it('accepts a valid query', () => {
const r = parseBody(sourceQuerySchema, {
query: 'SELECT 1',
tenant_id: 't',
project_id: 'p',
connection_config: { host: 'db' },
});
expect(r.ok).toBe(true);
});
it('rejects a blank query', () => {
const r = parseBody(sourceQuerySchema, {
query: ' ',
tenant_id: 't',
project_id: 'p',
connection_config: {},
});
expect(r.ok).toBe(false);
});
it('rejects a missing connection_config', () => {
const r = parseBody(sourceQuerySchema, { query: 'SELECT 1', tenant_id: 't', project_id: 'p' });
expect(r.ok).toBe(false);
});
});
describe('createSourceRatePolicySchema', () => {
const valid = {
source_rate_policy_id: 'rp1',
tenant_id: 't',
project_id: 'p',
source_id: 's',
rate_limit_per_minute: 60,
crawl_budget: 1000,
};
it('accepts a valid policy', () => {
expect(parseBody(createSourceRatePolicySchema, valid).ok).toBe(true);
});
it('rejects a bad access enum', () => {
expect(parseBody(createSourceRatePolicySchema, { ...valid, access: 'ftp' }).ok).toBe(false);
});
it('rejects a non-numeric rate_limit_per_minute', () => {
expect(parseBody(createSourceRatePolicySchema, { ...valid, rate_limit_per_minute: 'fast' }).ok).toBe(false);
});
});
describe('mapDatasetToIcebergTableSchema', () => {
const valid = {
tenant_id: 't',
project_id: 'p',
dataset_id: 'd',
dataset_iceberg_mapping_id: 'm',
namespace: 'ns',
table_name: 'tbl',
storage_uri: 's3://x',
catalog_ref: 'cat',
actor_id: 'a',
correlation_id: 'corr',
};
it('accepts a valid mapping', () => {
expect(parseBody(mapDatasetToIcebergTableSchema, valid).ok).toBe(true);
});
it('rejects a mapping missing storage_uri', () => {
const { storage_uri: _omit, ...bad } = valid;
expect(parseBody(mapDatasetToIcebergTableSchema, bad).ok).toBe(false);
});
});
describe('requestIcebergWriteSchema', () => {
const valid = {
actor_id: 'a',
correlation_id: 'corr',
schema_version: 'v1',
manifest_uri: 's3://m',
};
it('accepts a valid write request', () => {
expect(parseBody(requestIcebergWriteSchema, valid).ok).toBe(true);
});
it('rejects a write request missing manifest_uri', () => {
const { manifest_uri: _omit, ...bad } = valid;
expect(parseBody(requestIcebergWriteSchema, bad).ok).toBe(false);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"//": "Scoped, blocking type-check for 0pt's capability code (src/ + tests/). The root tsconfig.json intentionally also covers the v0-generated Next.js UI (app/, components/, hooks/), whose known TS issues next.config.mjs suppresses via `typescript.ignoreBuildErrors: true`. This config type-checks the backend capability layer — the actual deliverable — under the SAME strict settings as the root tsconfig, without inheriting the UI's suppressed surface. Invoked by `pnpm run typecheck`.",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", ".next"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
import path from "node:path";
// `@/…` maps to the repo root, matching tsconfig.json's `paths` (`@/*` -> `./*`),
// so tests can `import { X } from "@/src/services/…"` exactly as the app code does.
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@": rootDir,
},
},
test: {
// P0 capability tests. Node environment (these are backend service/adapter
// tests, no DOM). The `.realinfra.` test self-skips when its live backend
// is unreachable — see tests/p0/iceberg-write-service.realinfra.test.ts.
include: ["tests/**/*.test.ts"],
environment: "node",
globals: false,
},
});