Compare commits

..
Author SHA1 Message Date
OpenCode Agent c30b30327a fix(0pt): renumber canonical facts migration to 022 (020 was taken)
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m20s
2026-11-05 19:40:04 +01:00
OpenCode Agent 932563c2a6 feat(0pt): canonical structured observations — entities, facts, relations (write+query)
CI (SIAX Cloud) / quality (pull_request) Successful in 10m42s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 28s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m0s
2026-11-05 18:52:36 +01:00
36 changed files with 311 additions and 2240 deletions
-10
View File
@@ -58,16 +58,6 @@ DEV_LOGIN_KEY=
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
# on every engineFetch() call (lib/engine.ts).
INTERNAL_TOKEN=change-me-internal-token
# Tenant authority for the internal-token principal. Comma-separated allowlist
# of tenants this deployment's engine is entitled to write for, e.g.
# "379976258701823235". This is the ONLY source of tenant authority for
# X-Internal-Token: a request header may never name a tenant. Empty/unset means
# the service principal has no tenant authority and tenant-scoped writes
# (canonical facts, quality bridge runs) are denied with 403. Exactly ONE
# tenant is supported: the shared token identifies one service process, not a
# set of principals. A multi-tenant list is refused (401) until a verified
# workload-identity binding exists.
INTERNAL_SERVICE_TENANTS=
# ---------------------------------------------------------------------------
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
+2 -21
View File
@@ -294,9 +294,9 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube analysis (self-hosted sonar.siax.dev)
- name: SonarQube analysis (self-hosted sonar.siax.io)
env:
SONAR_HOST_URL: https://sonar.siax.dev
SONAR_HOST_URL: https://sonar.siax.io
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
set -euo pipefail
@@ -309,22 +309,3 @@ jobs:
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp . "$cid":/usr/src
docker start -a "$cid"
design-conformance:
name: Component conformance (blocking)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Checkout canonical siax-standard (SSOT)
uses: actions/checkout@v4
with:
repository: admin/siax-standard
token: ${{ secrets.SIAX_CI_TOKEN }}
path: siax-standard-ssot
- name: Component conformance (blocking)
run: |
node --input-type=module -e "import('./siax-standard-ssot/packages/doctor/src/commands/component-conformance.mjs').then(m=>{process.exit(m.run(['--repo', process.env.GITHUB_WORKSPACE, '--allow-path-prefix', 'siax-standard-ssot/', '--fail-on=block']))})"
-1
View File
@@ -1 +0,0 @@
{"allow":[{"pathPrefix":"components/states.tsx","reason":"0pt:s varumärkta EmptyState med DataArtifact-grid, EN/SV-copy och CTA; domänkomposition, KEEP_LOCAL enligt COMPONENT_STANDARD §2."},{"pathPrefix":"components/theme-provider.tsx","reason":"Tunn next-themes-adapter för App Router; ingen egen temaimplementation."},{"pathPrefix":"components/ui/toaster.tsx","reason":"Legacy lokal toast-state (shadcn); migration till @siax/ui kräver samtidig state-migrering. KEEP_LOCAL med backlog."}]}
-6
View File
@@ -26,7 +26,6 @@ import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas"
import { qualityService } from "@/src/services/quality";
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
@@ -36,11 +35,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
// Tenant-scoped write (auto-provisions baseline rules + stores a run):
// the internal service principal must stay inside its server-configured
// tenant allowlist, never a tenant named by the caller.
const guard = assertTenantMatchesVerified(request, body.tenant_id);
if (guard) return guard;
await ensureBaselineRules(qualityService, {
tenant_id: body.tenant_id,
+28 -10
View File
@@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FileDropzone } from '@siax/ui/components/file-dropzone';
import { useT } from '@/components/locale-provider';
import { PortalShell } from '@/components/portal-shell';
@@ -73,6 +72,7 @@ export default function DocumentsPage() {
const [uploadNote, setUploadNote] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// One idempotency key per selected file: a retry (double click, network
// error, reload-and-pick-the-same-file) returns the SAME document instead of
// creating a duplicate. Picking a different file resets it.
@@ -277,16 +277,34 @@ export default function DocumentsPage() {
/>
</label>
<FileDropzone
className="mt-4"
label={t({ en: 'choose a file', sv: 'välj en fil' })}
hint={file ? file.name : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
disabled={uploading}
onFiles={(files) => {
uploadKey.current = null;
setFile(files[0] ?? null);
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
inputRef.current?.click();
}
}}
/>
className="mt-4 flex cursor-pointer flex-col items-center justify-center border border-dashed border-border px-6 py-10 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={label}>{t({ en: 'choose a file', sv: 'välj en fil' })}</span>
<span className="mt-3 text-[15px]">
{file
? file.name
: t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
</span>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(event) => {
uploadKey.current = null;
setFile(event.target.files?.[0] ?? null);
}}
/>
</div>
<button
type="button"
-78
View File
@@ -1,78 +0,0 @@
# 0pt workload identity — målbild, nuläge och övergång
Status: **design klar, provisionering blockerad** (2026-11-07).
Ägare: CL0UD (identitet) / 0pt (konsument).
## 1. Nuläge
0pt autentiserar maskin-till-maskin med en **delad hemlighet**:
- `X-Internal-Token` mot `INTERNAL_TOKEN` (samma värde i engine och edge).
- Tenant-auktoritet kommer från serverkonfigurationen `INTERNAL_SERVICE_TENANTS`
(se `src/auth/internal-service-tenant.ts`), aldrig från en klientheader.
- Tom allowlist ⇒ ingen tenant-auktoritet ⇒ tenant-scopade skrivningar 403.
Det här är säkert men grovkornigt: en enda hemlighet representerar hela
engine-processen, och varje ny konsument kräver en manuell allowlist-post.
## 2. Målbild (CL0UD workload identity)
CL0UD äger redan maskineriet:
- `POST /account/service-accounts` skapar en service account (identitet).
- `POST /account/service-accounts/{id}/workload-token` mints en **kortlivad**
(10 min default, 1 h max) workload-token vars `capabilities` är en delmängd av
service accountens `platform.cl0ud_service_account_scopes.allowedCapabilities`.
- `@siax/platform-core` `auth/workload.ts` är verifieringsadaptern; publika
nycklar via JWKS.
- `packages/capability-registry/src/workload-identities.ts` deklarerar
arbetsbelastningsnamn och `aud:*`-audiences. Modulen är uttryckligen **inte**
provisionerad och **inte** inkopplad i utfärdande eller verifiering än.
För 0pt innebär det:
| Egenskap | Idag | Mål |
|---|---|---|
| Credential | delad `INTERNAL_TOKEN` | CL0UD-workload-token, `aud:0pt`, 10 min |
| Tenant | serverkonfigurerad allowlist | `tenant_id`-claim i verifierad token |
| Capabilities | route-lista i middleware | `capabilities`-claim (t.ex. `0pt.canonical.write`) |
| Rotation | manuell | automatisk (kortlivad token) |
| Spårbarhet | delad hemlighet | per-arbetsbelastning (`0pt-engine`, `site0-pipeline`) |
## 3. Integrationsdesign (när provisionering är möjlig)
1. **Verifiering före route-logik.** Middleware accepterar
`Authorization: Bearer <workload-token>` för `INTERNAL_SERVICE_ROUTES` om
signaturen verifieras mot CL0UD JWKS, `aud` innehåller `0pt` och `exp` är
giltig. Ingen klientheader får bidra till tenant.
2. **Tenant från claim, inte från allowlist.** `tenant_id` i den verifierade
tokenen sätts som `x-0pt-verified-tenant`; `INTERNAL_SERVICE_TENANTS`
används endast för legacy `X-Internal-Token`.
3. **Capabilities per route.** Route-metadata (`0pt.canonical.write`,
`0pt.quality.run`) jämförs med tokenens `capabilities`. Saknad capability ⇒
403, inte 401 (identiteten är giltig, behörigheten räcker inte).
4. **Dual-accept under övergång.** Båda credentialtyperna accepteras tills
engine och site0-pipeline mintar workload-tokens; därefter tas
`X-Internal-Token` bort i en egen release med negativt test.
5. **Negativa tester i samma svit som idag**
(`tests/auth/internal-token-tenant-scope.test.ts`): fel `aud` ⇒ 401, utgången
token ⇒ 401, tenant utanför claim ⇒ 403, saknad capability ⇒ 403.
## 4. Blockerare
- `/account/service-accounts` svarar **307 → /login** utan operator-session;
ingen giltig `SIAX_TOKEN` finns i Infisical-miljön.
- Zitadel admin-PAT (`/opt/siax-access/zitadel-admin-pat`) är invalid
(AUTH-7fs1e), så nya machine users kan inte mintas via det flödet.
- `workload-identities.ts` är deklaration-only: ingen identitet är
`provisioned: true`.
Åtgärd kräver en operatörsinloggning i CL0UD-konsolen (eller en ny giltig
admin-PAT) och är därför **EXTERNALLY_BLOCKED** från agentsidan.
## 5. Vad som inte ska göras
- Ingen egen JWT-signering i 0pt.
- Ingen parallell identitetsplattform.
- Ingen klientheader som bär tenant.
- Ingen långlivad delad hemlighet som "tillfällig" lösning utan slutdatum.
+5 -69
View File
@@ -4,16 +4,12 @@
* Customer calls use verified Zitadel bearer identity and receive immutable
* x-0pt-verified-* headers. Explicit machine routes may instead use the
* shared X-Internal-Token convention; mutation handlers verify it again for
* defence in depth. The canonical-fact write is one such explicit machine route.
* defence in depth.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
import {
SERVICE_TENANT_SCOPE_HEADER,
resolveInternalServiceTenants,
} from '@/src/auth/internal-service-tenant';
assertAuthConfigSafe();
@@ -30,77 +26,19 @@ const EXEMPT_PATTERNS: RegExp[] = [
/** Routes that may be reached with a service token instead of end-user bearer auth. */
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
/^\/api\/0pt\/quality\/bridge-run$/,
// Acquisition engine -> 0pt canonical observation write (machine-to-machine,
// same X-Internal-Token class as the quality bridge; not a blanket bypass).
/^\/api\/0pt\/canonical\/facts$/,
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
/^\/api\/0pt\/internal\/er\/candidates$/,
/^\/api\/0pt\/search\/definitions$/,
];
let _authDiagCount = 0;
let internalScopeWarned = false;
/**
* Headers that only middleware may set. Anything a client sends under these
* names is a forgery attempt and is removed before forwarding — including on
* the internal-token path, which previously passed the original header map
* through unchanged.
*/
const VERIFIED_HEADERS = [
'x-0pt-verified-tenant',
'x-0pt-verified-sub',
SERVICE_TENANT_SCOPE_HEADER,
] as const;
function stripVerifiedHeaders(req: NextRequest): Headers {
const forwarded = new Headers(req.headers);
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
return forwarded;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
// P0 tenant isolation (2026-11-07): X-Internal-Token proves the service —
// not a tenant. Tenant authority is SERVER CONFIG (INTERNAL_SERVICE_TENANTS),
// never a client header: an inbound `X-0pt-Internal-Tenant` is ignored and
// stripped. With no configured tenants the principal has no tenant
// authority and tenant-scoped writes fail closed with 403.
const forwarded = stripVerifiedHeaders(req);
const tenants = resolveInternalServiceTenants();
if (tenants.length > 1) {
// The shared X-Internal-Token identifies ONE service process, not a set
// of independently authenticated principals. A multi-tenant allowlist
// would let any holder of that shared token choose between all listed
// tenants, which is exactly the client-controlled authority this guard
// exists to remove. Multi-tenant service access requires a verified
// workload-identity binding (CL0UD delegation contract); until that
// exists, an ambiguous configuration fails closed.
console.error(
'[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists',
);
return NextResponse.json(
{
error: 'OPT_UNAUTHENTICATED',
message:
'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists',
},
{ status: 401 },
);
}
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
if (tenants.length === 1) {
forwarded.set('x-0pt-verified-tenant', tenants[0]);
} else if (tenants.length === 0 && !internalScopeWarned) {
internalScopeWarned = true;
console.warn(
'[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied',
);
}
return NextResponse.next({ request: { headers: forwarded } });
return NextResponse.next();
}
if (_authDiagCount < 5) {
@@ -124,7 +62,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
typeof session.exp === 'number' &&
session.exp > Date.now()
) {
const forwarded = stripVerifiedHeaders(req);
const forwarded = new Headers(req.headers);
forwarded.set('x-0pt-verified-sub', session.sub);
forwarded.set('x-0pt-verified-tenant', session.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
@@ -145,13 +83,11 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
const principal = decision.ctx.principal;
if (principal) {
const forwarded = stripVerifiedHeaders(req);
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 } });
}
// No verified principal (explicit AUTH_ENFORCE=false development mode).
// Never forward a client-named verified header, even in dev.
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
return NextResponse.next();
}
-121
View File
@@ -1,121 +0,0 @@
/**
* @siax/access idempotency primitives — the universal SIAX contract for any
* operation that must either happen exactly once or replay its receipt.
*
* This package is derived from the pattern proven in 0pt's durable upload
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
* generalized so DOC0, N0D and future products can adopt the identical shape
* instead of re-implementing it.
*
* Rules encoded here:
* 1. An operation identity binds EVERY result-affecting input (tenant,
* project, actor, payload version). A changed payload under the same key
* is a CONFLICT, never a replay of someone else's receipt.
* 2. One claimant per key: returning `false` from `start` means another
* caller owns the dispatch, and only the stored receipt (or bounded
* observation) may be answered.
* 3. A stored receipt is replayed verbatim; the caller never invents one.
* 4. Commit boundaries: NEVER write a network command before the claim is
* durably persisted, and never lose the claim after the command may have
* committed.
*/
import { createHash } from "node:crypto";
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
return createHash("sha256")
.update(JSON.stringify([version, ...parts]))
.digest("hex");
}
export interface OperationIdentity {
tenantId: string;
projectId: string;
actorId: string;
/** Client-generated, transport-level key (<=256 visible ASCII). */
idempotencyKey: string;
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
payload: readonly unknown[];
}
export function operationIdentity(operation: OperationIdentity): {
tenantId: string;
projectId: string;
actorId: string;
idempotencyKey: string;
fingerprint: string;
} {
if (
!operation ||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
) ||
!Array.isArray(operation.payload)
) {
throw new Error("Invalid idempotent operation identity");
}
void operation.payload;
return {
tenantId: operation.tenantId,
projectId: operation.projectId,
actorId: operation.actorId,
idempotencyKey: operation.idempotencyKey,
fingerprint: operationFingerprint([
operation.tenantId,
operation.projectId,
operation.actorId,
...operation.payload,
]),
};
}
export type OperationOutcome<R> =
| { kind: "claimed"; execute: () => Promise<R> }
| { kind: "replayed"; receipt: R }
| { kind: "conflict"; expected?: string };
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
export interface IdempotencyStore<Identity, Receipt> {
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
start(identity: Identity, fingerprint: string): Promise<boolean>;
/** Persist after a verified downstream commit. Must never be called before. */
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
/** Load a stored receipt for replay (exact fingerprint match). */
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
}
export type IdempotentStoreItemIdentity<I> = {
identity: I;
fingerprint: string;
};
export async function runIdempotentOperation<I, R>(input: {
store: IdempotencyStore<I, R>;
identity: I;
fingerprint: string;
/** Must be safe to call exactly once; a second call is never attempted here. */
dispatch: () => Promise<R>;
}): Promise<OperationOutcome<R>> {
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
try {
const won = await input.store.start(input.identity, input.fingerprint);
if (!won) {
const stored = await input.store.receipt(input.identity, input.fingerprint);
return stored !== undefined
? { kind: "replayed", receipt: stored }
: { kind: "replayed", receipt: undefined as unknown as R };
}
} catch (error) {
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
}
const receipt = await input.dispatch();
try {
await input.store.save(input.identity, input.fingerprint, receipt);
} catch {
/* The downstream commit is real; a journal-write failure must not turn a
real receipt into an error. The next retry replays via the stored receipt
if the save ever succeeds before a downstream resend. */
}
return { kind: "claimed", execute: async () => receipt };
}
+235 -2
View File
@@ -1,2 +1,235 @@
export * from './relationships';
export * from './idempotency';
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
-235
View File
@@ -1,235 +0,0 @@
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
+1 -10
View File
@@ -89,16 +89,7 @@
"0pt.source_registry_list",
"0pt.order_create",
"0pt.order_payment",
"0pt.write_iceberg_table",
"0pt.structure.resolve_entities",
"0pt.structure.get_lineage",
"0pt.structure.quality_run",
"0pt.structure.quality_list_runs",
"0pt.structure.quality_checks",
"0pt.structure.quality_reports",
"0pt.structure.extract_document",
"0pt.structure.route_parsing",
"0pt.structure.normalize_value"
"0pt.write_iceberg_table"
],
"surfaces": [
"api",
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.extract_document",
"name": "0pt.structure.extract_document",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L2",
"piiLevel": "high",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.extract_document",
"description": "Canonical capability descriptor for 0pt.structure.extract_document (owner 0pt). Verified against registry + openapi operationId 0pt.extract_document."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.get_lineage",
"name": "0pt.structure.get_lineage",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.get_provenance",
"description": "Canonical capability descriptor for 0pt.structure.get_lineage (owner 0pt). Verified against registry + openapi operationId 0pt.get_provenance."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.normalize_value",
"name": "0pt.structure.normalize_value",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.normalize_value",
"description": "Canonical capability descriptor for 0pt.structure.normalize_value (owner 0pt). Verified against registry + openapi operationId 0pt.normalize_value."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.quality_checks",
"name": "0pt.structure.quality_checks",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.checks",
"description": "Canonical capability descriptor for 0pt.structure.quality_checks (owner 0pt). Verified against registry + openapi operationId 0pt.quality.checks."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.quality_list_runs",
"name": "0pt.structure.quality_list_runs",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.list_runs",
"description": "Canonical capability descriptor for 0pt.structure.quality_list_runs (owner 0pt). Verified against registry + openapi operationId 0pt.quality.list_runs."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.quality_reports",
"name": "0pt.structure.quality_reports",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.reports",
"description": "Canonical capability descriptor for 0pt.structure.quality_reports (owner 0pt). Verified against registry + openapi operationId 0pt.quality.reports."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.quality_run",
"name": "0pt.structure.quality_run",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.run",
"description": "Canonical capability descriptor for 0pt.structure.quality_run (owner 0pt). Verified against registry + openapi operationId 0pt.quality.run."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.resolve_entities",
"name": "0pt.structure.resolve_entities",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L2",
"piiLevel": "high",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.resolve_entities",
"description": "Canonical capability descriptor for 0pt.structure.resolve_entities (owner 0pt). Verified against registry + openapi operationId 0pt.resolve_entities."
}
@@ -1,15 +0,0 @@
{
"id": "0pt.structure.route_parsing",
"name": "0pt.structure.route_parsing",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.route_parsing",
"description": "Canonical capability descriptor for 0pt.structure.route_parsing (owner 0pt). Verified against registry + openapi operationId 0pt.route_parsing."
}
-821
View File
@@ -1,821 +0,0 @@
{
"schemaVersion": 1,
"repo": "0pt",
"role": "consumer",
"canonicalOwner": "sax3l/DES1GN",
"sourceRoots": [
"app",
"components"
],
"packagePolicy": {
"namespace": "@siax",
"registry": "https://git.siax.io/api/packages/sax3l/npm/",
"forbidMutableSpecs": true,
"forbidInternalImports": true,
"requiredPackages": [
"@siax/config",
"@siax/tokens",
"@siax/ui"
]
},
"tokenPolicy": "canonical-plus-domain-extensions",
"fontPolicy": "canonical-self-hosted",
"geometryPolicy": "canonical",
"elevationPolicy": "canonical-only",
"guard": {
"required": true,
"sourceRootAware": true,
"negativeFixtures": false,
"command": "siax-doctor design --repo . --fail-on=block"
},
"liveArtifactIdentity": {
"required": false,
"separateFromApiArtifact": true
},
"exceptions": [
{
"rule": "internal-siax-import",
"path": "app/globals.css",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/aspect-ratio.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/avatar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/breadcrumb.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/carousel.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/collapsible.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/drawer.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/form.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/label.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/pagination.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/progress.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/resizable.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/scroll-area.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/separator.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sonner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/spinner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/table.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toaster.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/empty.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
}
]
}
@@ -1,12 +0,0 @@
{
"id": "pe-artifact-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "artifact-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker image inspect RepoDigests (real registry digest)",
"proofRef": "docker://server6 image shxd6lepxuxfsjsp0ath3z1g:13e156ec7e6be534de9c28d28ec2daff0355ca7f image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"tool": "docker"
}
@@ -1,10 +0,0 @@
{
"id": "pe-ci-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "deploy-log",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "Coolify deployment record (application_deployment_queues, status=finished)",
"proofRef": "coolify://server6/shxd6lepxuxfsjsp0ath3z1g finished 2026-11-04 05:42:18",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f"
}
@@ -1,11 +0,0 @@
{
"id": "pe-running-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "running-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker inspect (running container image digest)",
"proofRef": "docker://server6 container/shxd6lepxuxfsjsp0ath3z1g-20261104T093337 image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
@@ -1,12 +0,0 @@
{
"id": "pe-sbom-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "sbom",
"result": "pass",
"verifiedAt": "2026-11-04T09:29:06Z",
"verifiedBy": "syft (estate-kampanj issue #76, skannad vid deployad commit)",
"proofRef": "siax/sbom/sbom-0pt-13e156ec7e6b.json",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"tool": "syft",
"sbomFormat": "cyclonedx-json"
}
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
"id": "sb-0pt-13e156ec7e6b",
"repo": "0pt",
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"branch": "main",
"buildId": "coolify:0pt",
"producedAt": "2026-11-04T01:26:48Z",
"artifactId": "shxd6lepxuxfsjsp0ath3z1g@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
+1 -4
View File
@@ -1,4 +1,4 @@
-- 022_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
-- 020_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
--
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
@@ -82,21 +82,18 @@ CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_entities_tenant ON opt_canonical_entities;
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_facts_tenant ON opt_canonical_facts;
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_relations_tenant ON opt_canonical_relations;
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
-19
View File
@@ -1,19 +0,0 @@
-- 023_canonical_grants.sql — app-role privileges for 022 canonical tables.
--
-- Tables created by a manual psql migration are owned by the migrating role
-- (0pt_edge); the running application connects as 0pt_app. Existing 0pt tables
-- carry explicit grants rather than default ACLs, so the canonical tables need
-- the same treatment. RLS still applies to 0pt_app (FORCE is on); this only
-- grants the statement privileges, not row visibility.
--
-- Idempotent and role-guarded: no-op in environments without the app role.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '0pt_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE
ON opt_canonical_entities, opt_canonical_facts, opt_canonical_relations
TO 0pt_app;
END IF;
END
$$;
-74
View File
@@ -1,74 +0,0 @@
/**
* `src/auth/internal-service-tenant.ts` — server-side tenant scope for the
* machine-to-machine principal authenticated by `X-Internal-Token`.
*
* WHY THIS EXISTS (P0, 2026-11-07)
* --------------------------------
* `X-Internal-Token` proves that the caller is the SIAX engine process. It
* does NOT prove which tenant that process may act for. The first attempt at
* fixing the "internal token bypasses the verified-tenant guard" hole copied a
* CLIENT HEADER (`X-0pt-Internal-Tenant`) or fell back to a single
* `INTERNAL_TENANT_ID` env var:
*
* const internalTenant =
* req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID;
*
* That is not an authorization source. Any holder of the shared internal token
* could still name any tenant in a request header, which is exactly the
* cross-tenant write the fix was supposed to stop (tenant_id
* 999999999999999999 returned 201 before).
*
* The correct model mirrors a verified Bearer token: the principal's tenant
* authority is a SERVER-SIDE fact, never a request field.
*
* - `INTERNAL_SERVICE_TENANTS` is a comma-separated allowlist configured on
* the 0pt deployment (env/secret), i.e. "the tenants this engine instance
* is entitled to write for".
* - When the allowlist is empty, the internal principal has NO tenant
* authority: tenant-scoped writes fail closed (403). Routes that do not
* carry a tenant (evidence outbox dispatch, ER candidates, search
* definitions) are unaffected.
* - A single allowlisted tenant is ALSO placed in `x-0pt-verified-tenant`
* by middleware so existing single-tenant checks keep working; two or more
* are carried in `x-0pt-verified-service-tenants` and the tenant guard
* checks membership.
* - Any inbound value for either header is stripped by middleware before
* forwarding, so a client can never forge tenant authority.
*
* No client header is read here. `X-0pt-Internal-Tenant` is deliberately not a
* contract.
*/
/** Env var: comma-separated tenants this internal service principal may assert. */
export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS';
/**
* Request header installed by middleware.ts ONLY (never accepted from a
* client, and stripped from every inbound request). Carries the full
* server-configured service tenant scope; may be the empty string, which means
* "service principal without tenant authority".
*/
export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants';
/** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */
export function parseServiceTenants(value: string | undefined | null): string[] {
if (!value) return [];
const tenants: string[] = [];
for (const part of value.split(',')) {
const tenant = part.trim();
if (tenant && !tenants.includes(tenant)) tenants.push(tenant);
}
return tenants;
}
/** Read the configured service tenant allowlist from the environment. */
export function resolveInternalServiceTenants(
env: { INTERNAL_SERVICE_TENANTS?: string } = process.env,
): string[] {
return parseServiceTenants(env.INTERNAL_SERVICE_TENANTS);
}
/** Read the scope middleware installed on the request (already stripped+set). */
export function readServiceTenantScope(headers: { get(name: string): string | null | undefined }): string[] {
return parseServiceTenants(headers.get(SERVICE_TENANT_SCOPE_HEADER));
}
+39 -159
View File
@@ -24,9 +24,7 @@ export interface Doc0UploadReceipt {
/** The POST may have committed even if its response could not be verified. */
export class Doc0UploadOutcomeUnknownError extends Error {
constructor() {
super(
'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key',
);
super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key');
this.name = 'Doc0UploadOutcomeUnknownError';
}
}
@@ -62,44 +60,6 @@ export type Doc0Inspection =
* Internal contract only: callers must retain this identity before dispatch,
* authorize its scope, and keep the same input bytes/metadata until dispatch.
*/
/**
* One upload identity per operation — not per file.
*
* "Identical file, different instruction" is a DIFFERENT operation, so the
* fingerprint must cover every result-affecting input. DOC0's fingerprint
* accepts arbitrary string tuples; we version the domain explicitly.
*
* Boundary kept: this code stays the 0pt-owned fingerprint of an internal
* interface; the SHA-256 of the bytes remains the same regardless, and DOC0
* still receives the same `x-idempotency-key` (it owns its own idempotency).
*/
export function bindOperationToUploadIdentity(
input: Doc0UploadInput,
operation: {
instruction: string;
fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
modelId: string;
dataClass: string;
format: 'json' | 'csv';
},
): Doc0UploadInput {
const payloadVersion = createHash('sha256')
.update(
JSON.stringify([
'0pt-structure-operation-v1',
operation.instruction.length,
operation.instruction,
operation.fields.map((f) => [f.name, f.type, f.required === true]),
operation.modelId,
operation.dataClass,
operation.format,
]),
)
.digest('hex')
.slice(0, 16);
return { ...input, filename: `${input.filename}#${payloadVersion}` };
}
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
sourceSha256: string;
sourceSizeBytes: number;
@@ -108,25 +68,13 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
wireMimeType: string;
} {
if (
![
input.tenantId,
input.projectId,
input.actorId,
input.requestId,
input.idempotencyKey,
input.filename,
input.mimeType,
].every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) =>
/^[\x21-\x7e]{1,256}$/.test(value),
) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(
input.classification,
) ||
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength === 0
)
throw new Error('Invalid DOC0 upload input');
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType]
.every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
) throw new Error('Invalid DOC0 upload input');
const wireMimeType = input.mimeType.toLowerCase();
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
throw new Error('Invalid DOC0 upload MIME type');
@@ -135,25 +83,10 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
const wireFilename = `source.${extension}`;
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
const sourceSizeBytes = input.bytes.byteLength;
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
'doc0-upload-v1',
input.tenantId,
input.projectId,
input.actorId,
sourceSha256,
sourceSizeBytes,
wireFilename,
wireMimeType,
input.filename,
'unknown',
input.classification,
[],
null,
]),
)
.digest('hex');
const requestFingerprint = createHash('sha256').update(JSON.stringify([
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
])).digest('hex');
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
}
@@ -176,9 +109,7 @@ async function readBoundedJson(response: Response): Promise<unknown> {
parts.push(chunk.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
} finally {
reader.releaseLock();
}
} finally { reader.releaseLock(); }
}
/**
@@ -200,28 +131,16 @@ export class Doc0UploadClient {
},
) {
let url: URL;
try {
url = new URL(options.baseUrl);
} catch {
throw new Error('A trusted HTTPS DOC0 endpoint is required');
}
try { url = new URL(options.baseUrl); }
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!options.serviceToken.trim() ||
!Number.isSafeInteger(options.maxBytes) ||
options.maxBytes <= 0 ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) ||
options.timeoutMs < 1_000 ||
options.timeoutMs > 300_000))
(options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000))
) {
throw new Error(
'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required',
);
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
}
// Keep the validated values even if the caller later changes its options object.
this.options = { ...options };
@@ -248,11 +167,7 @@ export class Doc0UploadClient {
classification: input.classification,
}),
);
body.set(
'file',
new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }),
identity.wireFilename,
);
body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename);
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
@@ -268,16 +183,11 @@ export class Doc0UploadClient {
body,
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
});
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
} catch { throw new Doc0UploadOutcomeUnknownError(); }
// Even a non-2xx response does not prove the write did not commit.
let decoded: unknown;
try {
decoded = await readBoundedJson(response);
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
try { decoded = await readBoundedJson(response); }
catch { throw new Doc0UploadOutcomeUnknownError(); }
const data = decoded as {
document?: {
documentId?: unknown;
@@ -329,63 +239,33 @@ export class Doc0UploadClient {
* is complete. No automatic retry, settlement or customer route is wired.
*/
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
if (
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every(
(value) => typeof value === 'string' && value.trim(),
) ||
input.projectId.length > 256 ||
input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) ||
input.sourceSizeBytes <= 0
) {
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => typeof value === 'string' && value.trim()) ||
input.projectId.length > 256 || input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
throw new Error('Invalid DOC0 inspection input');
}
let result: unknown;
try {
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
method: 'POST',
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: {
authorization: `Bearer ${this.options.serviceToken}`,
'content-type': 'application/json',
'x-tenant-id': input.tenantId,
'x-actor-id': input.actorId,
'x-request-id': input.requestId,
'x-idempotency-key': input.idempotencyKey,
},
body: JSON.stringify({
projectId: input.projectId,
sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes,
requestFingerprint: input.requestFingerprint,
}),
method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
});
result = await readBoundedJson(response);
} catch {
throw new Doc0InspectionUnavailableError();
}
if (!result || typeof result !== 'object' || Array.isArray(result))
throw new Doc0InspectionUnavailableError();
} catch { throw new Doc0InspectionUnavailableError(); }
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
const record = result as Record<string, unknown>;
if (
typeof record.status === 'string' &&
['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1
) {
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1) {
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
}
if (
record.status === 'completed_recorded' &&
Object.keys(record).length === 3 &&
typeof record.documentId === 'string' &&
record.documentId.trim() &&
typeof record.versionId === 'string' &&
record.versionId.trim()
) {
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
typeof record.documentId === 'string' && record.documentId.trim() &&
typeof record.versionId === 'string' && record.versionId.trim()) {
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
}
throw new Doc0InspectionUnavailableError();
-21
View File
@@ -29,7 +29,6 @@
* authz hole without changing every route's request contract).
*/
import { NextResponse, type NextRequest } from "next/server";
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
@@ -53,26 +52,6 @@ export function assertTenantMatchesVerified(
tenantId: string | null | undefined,
): NextResponse | null {
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
// Internal service principal: tenant authority is the SERVER-CONFIGURED
// allowlist middleware installed (never a client header). The scope header is
// present even when the allowlist is empty, which means "no tenant
// authority" -> fail closed. Routes that do not take a tenant never call
// this guard, so evidence dispatch / ER / search definitions keep working.
if (request.headers.get("x-0pt-verified-service-tenants") !== null) {
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
const scope = readServiceTenantScope(request.headers);
if (!scope.includes(tenantId)) {
return NextResponse.json(
{
error: "OPT_ACCESS_DENIED",
message: "tenant_id is outside the internal service principal's configured tenant scope",
details: { requested_tenant: tenantId, service_tenant_scope: scope },
},
{ status: 403 },
);
}
return null;
}
if (!verified) return null;
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
if (tenantId !== verified) {
@@ -1,262 +0,0 @@
/**
* P0 regression (2026-11-07): tenant authority for the machine-to-machine
* (`X-Internal-Token`) principal is SERVER CONFIGURATION, never a client
* header.
*
* The first attempt at this fix read `X-0pt-Internal-Tenant` (or a single
* `INTERNAL_TENANT_ID`) and copied it into `x-0pt-verified-tenant`. Any holder
* of the shared internal token could then still name any tenant, so the
* original cross-tenant write (tenant_id 999999999999999999 -> 201) remained
* possible through a different header. These tests pin the corrected model:
*
* - `INTERNAL_SERVICE_TENANTS` (server env) is the only source of tenant
* authority for the service principal.
* - No configured scope => tenant-scoped writes are denied (fail closed).
* - Client-supplied `x-0pt-internal-tenant`, `x-0pt-verified-tenant` and
* `x-0pt-verified-service-tenants` are ignored and stripped.
* - Bearer/session principals are unchanged.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { middleware } from '../../middleware';
import {
parseServiceTenants,
resolveInternalServiceTenants,
SERVICE_TENANT_SCOPE_HEADER,
} from '@/src/auth/internal-service-tenant';
import { assertTenantMatchesVerified } from '@/src/http/tenant-guard';
import { POST as canonicalPOST } from '@/app/api/0pt/canonical/facts/route';
import { POST as bridgePOST } from '@/app/api/0pt/quality/bridge-run/route';
import { POST as evidenceDispatchPOST } from '@/app/api/0pt/internal/evidence-outbox/dispatch/route';
const INTERNAL_TOKEN = 'engine-shared-secret';
const SCOPED_ROUTE = '/api/0pt/canonical/facts';
function req(path: string, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(new URL(path, 'http://localhost'), { headers });
}
/** Headers middleware forwarded to the route handler (Next stores them here). */
function forwarded(res: { headers: { get(name: string): string | null } }, name: string): string | null {
return res.headers.get(`x-middleware-request-${name.toLowerCase()}`);
}
function guardRequest(headers: Record<string, string>): NextRequest {
return new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
});
}
describe('parseServiceTenants / resolveInternalServiceTenants', () => {
it('splits, trims and de-duplicates the allowlist', () => {
expect(parseServiceTenants(' t_a , t_b ,, t_a ')).toEqual(['t_a', 't_b']);
});
it('an unset/empty allowlist means no tenant authority', () => {
expect(parseServiceTenants(undefined)).toEqual([]);
expect(parseServiceTenants('')).toEqual([]);
expect(resolveInternalServiceTenants({})).toEqual([]);
});
});
describe('middleware: internal-token tenant authority comes from server config', () => {
const saved = {
auth: process.env.AUTH_ENFORCE,
token: process.env.INTERNAL_TOKEN,
tenants: process.env.INTERNAL_SERVICE_TENANTS,
node: process.env.NODE_ENV,
};
beforeEach(() => {
delete process.env.AUTH_ENFORCE; // default posture: enforced
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
delete process.env.INTERNAL_SERVICE_TENANTS;
});
afterEach(() => {
const values: Record<string, string | undefined> = {
AUTH_ENFORCE: saved.auth,
INTERNAL_TOKEN: saved.token,
INTERNAL_SERVICE_TENANTS: saved.tenants,
NODE_ENV: saved.node,
};
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('single allowlisted tenant is installed as the verified tenant', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('REFUSES an ambiguous multi-tenant allowlist (no workload-identity binding yet)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain('ambiguous');
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBeNull();
});
it('a single allowlist entry is still forwarded as the service scope', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('');
});
it('IGNORES a client-supplied X-0pt-Internal-Tenant (not an authority source)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-internal-tenant': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
});
it('STRIPS a forged x-0pt-verified-tenant and forged scope header', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-verified-tenant': 't_victim',
'x-0pt-verified-sub': 'user_victim',
'x-0pt-verified-service-tenants': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, 'x-0pt-verified-sub')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('strips forged verified headers on the unauthenticated development path too', async () => {
process.env.AUTH_ENFORCE = 'false';
const res = await middleware(req('/api/0pt/documents/capture', { 'x-0pt-verified-tenant': 't_victim' }));
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
});
it('an invalid internal token still falls through to 401', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': 'wrong' }));
expect(res.status).toBe(401);
});
});
describe('assertTenantMatchesVerified: service scope is enforced fail-closed', () => {
it('no service scope header behaves exactly as before (Bearer verified tenant)', () => {
const same = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_a',
);
expect(same).toBeNull();
const cross = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_victim',
);
expect(cross?.status).toBe(403);
});
it('allows a tenant inside the configured service scope', () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a,t_b' }),
't_b',
);
expect(res).toBeNull();
});
it('DENIES a tenant outside the configured service scope (403)', async () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }),
't_victim',
);
expect(res?.status).toBe(403);
const body = await res!.json();
expect(body.error).toBe('OPT_ACCESS_DENIED');
expect(body.details.service_tenant_scope).toEqual(['t_a']);
});
it('DENIES every tenant when the configured scope is empty (fail closed, even if a verified tenant was also set)', () => {
const res = assertTenantMatchesVerified(
guardRequest({
[SERVICE_TENANT_SCOPE_HEADER]: '',
'x-0pt-verified-tenant': '999999999999999999',
}),
'999999999999999999',
);
expect(res?.status).toBe(403);
});
});
describe('route handlers: internal service cannot write a foreign tenant', () => {
it('canonical facts: cross-tenant write is 403 before the service runs', async () => {
const res = await canonicalPOST(
new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
entities: [],
facts: [],
relations: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('quality bridge-run: cross-tenant run is 403 before rules are provisioned', async () => {
const res = await bridgePOST(
new NextRequest(new URL('/api/0pt/quality/bridge-run', 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
project_id: 'p1',
dataset_id: 'ds_1',
rows: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('evidence-outbox dispatch needs no tenant and is unchanged by the scope', async () => {
// No DB in this test env: the route authenticates first, then reports a
// dependency error -- anything except 401/403 proves the scope did not
// turn a tenant-less machine route into a denial.
const previous = process.env.INTERNAL_TOKEN;
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
try {
const res = await evidenceDispatchPOST(
new NextRequest(new URL('/api/0pt/internal/evidence-outbox/dispatch', 'http://localhost'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-internal-token': INTERNAL_TOKEN,
[SERVICE_TENANT_SCOPE_HEADER]: '',
},
body: JSON.stringify({ limit: 1 }),
}),
);
expect([401, 403]).not.toContain(res.status);
} finally {
if (previous === undefined) delete process.env.INTERNAL_TOKEN;
else process.env.INTERNAL_TOKEN = previous;
}
});
});
@@ -45,13 +45,6 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
expect(res.status).toBe(200);
});
it('a valid X-Internal-Token lets the acquisition engine write canonical facts', async () => {
const res = await middleware(
req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }),
);
expect(res.status).toBe(200);
});
it('an invalid X-Internal-Token still falls through to the ordinary Bearer requirement (401, not a bypass)', async () => {
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
expect(res.status).toBe(401);
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
bindOperationToUploadIdentity,
prepareDoc0UploadIdentity,
type Doc0UploadInput,
} from '../../src/document-workbench/doc0-upload-client';
const input: Doc0UploadInput = {
tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'k1',
filename: 'rapport.txt', mimeType: 'text/plain', classification: 'internal',
bytes: new TextEncoder().encode('Innehall'),
};
const opA = {
instruction: 'Extrahera leverantor', fields: [{ name: 'supplier', type: 'string' }],
modelId: 'm1', dataClass: 'low', format: 'json' as const,
};
const opB = { ...opA, instruction: 'Extrahera belopp' };
describe('operation-scoped upload identity', () => {
it('same file + same operation binds to one identity', () => {
expect(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)))
.toEqual(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)));
});
it('same file + DIFFERENT instruction = DIFFERENT identity (not the same operation)', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.requestFingerprint).not.toBe(b.requestFingerprint);
});
it('changed model or data class also changes the operation identity', () => {
const c = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, modelId: 'm2' }));
const d = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, dataClass: 'high' }));
const e = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, format: 'csv' }));
expect(c.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(d.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(e.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
});
it('raw bytes checksum stays stable across operations', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.sourceSha256).toBe(b.sourceSha256);
});
it('prefixes the operation marker so odd filenames cannot collide', () => {
const bound = bindOperationToUploadIdentity(input, opA);
expect(bound.filename.startsWith('rapport.txt#')).toBe(true);
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
describe("@siax/access universal idempotency", () => {
it("binds every result-affecting input into the fingerprint", () => {
const a = operationFingerprint(["t", "p", "u", "instruction-extrahera"]);
const b = operationFingerprint(["t", "p", "u", "instruction-extrahera-belopp"]);
expect(a).not.toBe(b);
});
it("rejects malformed identity (same shape 0pt/DOC0 both enforce)", () => {
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "../bad", payload: [] })).toThrow();
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "ok", payload: "not-array" as never })).toThrow();
});
it("conflicts when a reused key carries a changed payload", () => {
const fp1 = operationFingerprint(["t", "p", "u", "A"]);
const fp2 = operationFingerprint(["t", "p", "u", "B"]);
expect(fp1).not.toBe(fp2);
});
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
const stored = { x: 1 };
const store: any = {
start: async () => false,
save: async () => {},
receipt: async () => stored,
};
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { throw new Error("must not rerun"); },
});
expect(outcome.kind).toBe("replayed");
if (outcome.kind === "replayed") expect(outcome.receipt).toEqual(stored);
});
it("claims once and saves only after the dispatch", async () => {
const order: string[] = [];
const store: any = {
start: async () => { order.push("start"); return true; },
save: async () => { order.push("save"); },
receipt: async () => { order.push("receipt"); return undefined; },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { order.push("dispatch"); return { x: ++dispatched }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
expect(order).toEqual(["start", "dispatch", "save"]);
});
it("reports a store error as conflict, never as a fresh start", async () => {
const store: any = {
start: async () => { throw new Error("db down"); },
save: async () => {},
receipt: async () => { throw new Error("db down"); },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("conflict");
expect(dispatched).toBe(0);
});
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
const store: any = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
});
});