Foundational hardening (Waves 1–4): CI/Docker, flag-gated Zitadel auth, validation, tests #2
@@ -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
|
||||
@@ -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
@@ -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
@@ -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/
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
verify-deps-before-run=false
|
||||
include-workspace-root=true
|
||||
confirmModulesPurge=false
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 110
|
||||
}
|
||||
+71
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: "^_" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
Generated
+1816
-30
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(() => "")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user