Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e701efc8 | ||
|
|
48fbe6b184 | ||
|
|
b90cded2d6 | ||
|
|
aba909c29a | ||
|
|
e00c7b0fc5 | ||
|
|
de76f3795b | ||
|
|
3421748f07 | ||
|
|
9ac681ee27 | ||
|
|
cdbb10b8e3 | ||
|
|
6bbbcf75d4 | ||
|
|
58dca92d9f | ||
|
|
7ca7b98fb2 | ||
|
|
b269a05538 | ||
|
|
1b7c6c2de9 | ||
|
|
b7627a3a31 | ||
|
|
afd0b4ec46 | ||
|
|
4dbabd14ac | ||
|
|
b2499ace79 | ||
|
|
0213459f33 | ||
|
|
c91d2aaca6 | ||
|
|
1a581fde29 | ||
|
|
bfdc5d1326 | ||
|
|
831781f19d | ||
|
|
b97ad0b2f4 | ||
|
|
019cc23fa2 | ||
|
|
849e7ae802 | ||
|
|
04aeaa754b | ||
|
|
98039d3d68 | ||
|
|
8c91b90ae0 | ||
|
|
4f92cdd102 | ||
|
|
9ca16b7f6c | ||
|
|
a424cf1581 | ||
|
|
f77a4fec96 | ||
|
|
58f81a10b7 | ||
|
|
6ca6e8e0a6 | ||
|
|
cb0b951c42 | ||
|
|
415396f25d | ||
|
|
c46ebbf810 | ||
|
|
03cdb7815e | ||
|
|
116304849b | ||
|
|
c7966eb586 | ||
|
|
d5aea59482 | ||
|
|
ce6be94e50 | ||
|
|
4c20e5457f | ||
|
|
b29e4848b6 | ||
|
|
f53bcf8f50 | ||
|
|
76e3c0dc2d | ||
|
|
70444a369e |
@@ -58,6 +58,16 @@ DEV_LOGIN_KEY=
|
|||||||
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
|
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
|
||||||
# on every engineFetch() call (lib/engine.ts).
|
# on every engineFetch() call (lib/engine.ts).
|
||||||
INTERNAL_TOKEN=change-me-internal-token
|
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
|
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
|
||||||
|
|||||||
+21
-2
@@ -294,9 +294,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: SonarQube analysis (self-hosted sonar.siax.io)
|
- name: SonarQube analysis (self-hosted sonar.siax.dev)
|
||||||
env:
|
env:
|
||||||
SONAR_HOST_URL: https://sonar.siax.io
|
SONAR_HOST_URL: https://sonar.siax.dev
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -309,3 +309,22 @@ jobs:
|
|||||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
docker cp . "$cid":/usr/src
|
docker cp . "$cid":/usr/src
|
||||||
docker start -a "$cid"
|
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']))})"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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."}]}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/canonical/facts/route.ts
|
||||||
|
* ====================================
|
||||||
|
* POST — canonical structured-observation write (entities/facts/relations).
|
||||||
|
* GET — tenant-scoped read for consumers (products, agents, L0RE projection).
|
||||||
|
*
|
||||||
|
* 0pt owns observed facts; L0RE owns epistemic propositions/projections and
|
||||||
|
* consumes this API. Tenant is always cross-checked against the verified
|
||||||
|
* principal (assertTenantMatchesVerified), never trusted from the body alone.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { canonicalWriteSchema, parseBody } from "@/src/validation/0pt-schemas";
|
||||||
|
import { canonicalFactService } from "@/src/services/canonical-fact-registry";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const parsed = parseBody(canonicalWriteSchema, await request.json());
|
||||||
|
if (!parsed.ok) return NextResponse.json(parsed.body, { status: parsed.status });
|
||||||
|
const body = parsed.data;
|
||||||
|
const guard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (guard) return guard;
|
||||||
|
const result = await canonicalFactService.write(body);
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
const limitRaw = searchParams.get("limit");
|
||||||
|
const facts = await canonicalFactService.query({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
entity_id: searchParams.get("entity_id") ?? undefined,
|
||||||
|
entity_type: searchParams.get("entity_type") ?? undefined,
|
||||||
|
identity_namespace: searchParams.get("identity_namespace") ?? undefined,
|
||||||
|
identity_value: searchParams.get("identity_value") ?? undefined,
|
||||||
|
predicate: searchParams.get("predicate") ?? undefined,
|
||||||
|
limit: limitRaw ? Number(limitRaw) : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ facts });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas"
|
|||||||
import { qualityService } from "@/src/services/quality";
|
import { qualityService } from "@/src/services/quality";
|
||||||
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
|
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
|
||||||
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +36,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
}
|
}
|
||||||
const body = __parsed.data;
|
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, {
|
await ensureBaselineRules(qualityService, {
|
||||||
tenant_id: body.tenant_id,
|
tenant_id: body.tenant_id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { FileDropzone } from '@siax/ui/components/file-dropzone';
|
||||||
import { useT } from '@/components/locale-provider';
|
import { useT } from '@/components/locale-provider';
|
||||||
import { PortalShell } from '@/components/portal-shell';
|
import { PortalShell } from '@/components/portal-shell';
|
||||||
|
|
||||||
@@ -72,7 +73,6 @@ export default function DocumentsPage() {
|
|||||||
const [uploadNote, setUploadNote] = useState<string | null>(null);
|
const [uploadNote, setUploadNote] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
|
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
// One idempotency key per selected file: a retry (double click, network
|
// One idempotency key per selected file: a retry (double click, network
|
||||||
// error, reload-and-pick-the-same-file) returns the SAME document instead of
|
// error, reload-and-pick-the-same-file) returns the SAME document instead of
|
||||||
// creating a duplicate. Picking a different file resets it.
|
// creating a duplicate. Picking a different file resets it.
|
||||||
@@ -277,34 +277,16 @@ export default function DocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<FileDropzone
|
||||||
role="button"
|
className="mt-4"
|
||||||
tabIndex={0}
|
label={t({ en: 'choose a file', sv: 'välj en fil' })}
|
||||||
onClick={() => inputRef.current?.click()}
|
hint={file ? file.name : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
|
||||||
onKeyDown={(event) => {
|
disabled={uploading}
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
onFiles={(files) => {
|
||||||
event.preventDefault();
|
uploadKey.current = null;
|
||||||
inputRef.current?.click();
|
setFile(files[0] ?? null);
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Legacy instance audit — 0pt (server5) and L0RE (server5) — 2026-11-07
|
||||||
|
|
||||||
|
Canonical runtimes: **0pt on server6** (Coolify app `shxd6lepxuxfsjsp0ath3z1g`,
|
||||||
|
`0pt.app`) and **L0RE on server9** (`l0re.siax.io`). This document records the
|
||||||
|
older instances on server5 and their disposition. It is not production
|
||||||
|
documentation for either product.
|
||||||
|
|
||||||
|
## 1. 0pt legacy (server5)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Containers | `0pt-api` (`0pt-api:c46ebbf`, created 2026-11-05), `0pt-web` (`:18080`) |
|
||||||
|
| Database | `0pt-pg`, user/db `opt`, 11 MB |
|
||||||
|
| Exposure | `0pt-web` only on `100.124.7.55:18080`; no DNS record, no reverse proxy |
|
||||||
|
| Consumers | none found (`/etc/siax`, `/opt/siax`, site0 and L0RE deploy configs) |
|
||||||
|
| Unique data | 32 canonical facts (30 GLEIF web observations + `body_type` YDL405 + `passmark.find_prime_numbers.v10`) |
|
||||||
|
|
||||||
|
**Data reconciliation:** all 32 facts were exported and re-imported through the
|
||||||
|
canonical API (`POST /api/0pt/canonical/facts`) into the server6 deployment.
|
||||||
|
Replay returned `duplicates: 32`, so the import is idempotent. Production now
|
||||||
|
holds 34 facts (32 migrated + 2 verification observations) and 4 entities.
|
||||||
|
|
||||||
|
**Backup:** `server5:/var/backups/0pt-legacy/opt_legacy_20261107.dump.zst`
|
||||||
|
(custom format, 32 KB) + offsite `server11:/home/backup/0pt/`.
|
||||||
|
|
||||||
|
**Action taken:** `docker update --restart=no` + `docker stop` for `0pt-api` and
|
||||||
|
`0pt-web`. `0pt-pg` left running for forensics/rollback. `0pt.app` verified 200
|
||||||
|
after the stop (served by server6).
|
||||||
|
|
||||||
|
**Full removal (requires owner decision):** snapshot/destroy `0pt-pg` volume
|
||||||
|
after the retention window, remove the legacy compose project, revoke the old
|
||||||
|
`INTERNAL_TOKEN` if it was ever stored on server5.
|
||||||
|
|
||||||
|
## 2. L0RE orphan (server5)
|
||||||
|
|
||||||
|
Documented separately in `admin/l0re` PR #485
|
||||||
|
(`docs/runbook/l0re-orphan-decommission-20261107.md`): API stopped with
|
||||||
|
`restart=no`, dump + restore proof (8 propositions/62 assertions), no unique
|
||||||
|
legitimate data (pilot facts already re-projected to production). `l0re-pg` and
|
||||||
|
`l0re-opensearch` remain for forensics.
|
||||||
|
|
||||||
|
## 3. Single-runtime rule
|
||||||
|
|
||||||
|
One primary runtime per logical service. A container image of a product running
|
||||||
|
outside its registered Coolify app is a finding, not a fallback. Both server5
|
||||||
|
instances were reachable only from the host, which is why the split survived
|
||||||
|
until this audit.
|
||||||
+69
-5
@@ -4,12 +4,16 @@
|
|||||||
* Customer calls use verified Zitadel bearer identity and receive immutable
|
* Customer calls use verified Zitadel bearer identity and receive immutable
|
||||||
* x-0pt-verified-* headers. Explicit machine routes may instead use the
|
* x-0pt-verified-* headers. Explicit machine routes may instead use the
|
||||||
* shared X-Internal-Token convention; mutation handlers verify it again for
|
* shared X-Internal-Token convention; mutation handlers verify it again for
|
||||||
* defence in depth.
|
* defence in depth. The canonical-fact write is one such explicit machine route.
|
||||||
*/
|
*/
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
||||||
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
||||||
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
||||||
|
import {
|
||||||
|
SERVICE_TENANT_SCOPE_HEADER,
|
||||||
|
resolveInternalServiceTenants,
|
||||||
|
} from '@/src/auth/internal-service-tenant';
|
||||||
|
|
||||||
assertAuthConfigSafe();
|
assertAuthConfigSafe();
|
||||||
|
|
||||||
@@ -26,19 +30,77 @@ const EXEMPT_PATTERNS: RegExp[] = [
|
|||||||
/** Routes that may be reached with a service token instead of end-user bearer auth. */
|
/** Routes that may be reached with a service token instead of end-user bearer auth. */
|
||||||
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
||||||
/^\/api\/0pt\/quality\/bridge-run$/,
|
/^\/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\/evidence-outbox\/dispatch$/,
|
||||||
/^\/api\/0pt\/internal\/er\/candidates$/,
|
/^\/api\/0pt\/internal\/er\/candidates$/,
|
||||||
/^\/api\/0pt\/search\/definitions$/,
|
/^\/api\/0pt\/search\/definitions$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
let _authDiagCount = 0;
|
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> {
|
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
|
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
|
||||||
|
|
||||||
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
|
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
|
||||||
return NextResponse.next();
|
// 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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_authDiagCount < 5) {
|
if (_authDiagCount < 5) {
|
||||||
@@ -62,7 +124,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|||||||
typeof session.exp === 'number' &&
|
typeof session.exp === 'number' &&
|
||||||
session.exp > Date.now()
|
session.exp > Date.now()
|
||||||
) {
|
) {
|
||||||
const forwarded = new Headers(req.headers);
|
const forwarded = stripVerifiedHeaders(req);
|
||||||
forwarded.set('x-0pt-verified-sub', session.sub);
|
forwarded.set('x-0pt-verified-sub', session.sub);
|
||||||
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
||||||
return NextResponse.next({ request: { headers: forwarded } });
|
return NextResponse.next({ request: { headers: forwarded } });
|
||||||
@@ -83,11 +145,13 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|||||||
|
|
||||||
const principal = decision.ctx.principal;
|
const principal = decision.ctx.principal;
|
||||||
if (principal) {
|
if (principal) {
|
||||||
const forwarded = new Headers(req.headers);
|
const forwarded = stripVerifiedHeaders(req);
|
||||||
forwarded.set('x-0pt-verified-sub', principal.sub);
|
forwarded.set('x-0pt-verified-sub', principal.sub);
|
||||||
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
||||||
return NextResponse.next({ request: { headers: forwarded } });
|
return NextResponse.next({ request: { headers: forwarded } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
// 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) } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* @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 };
|
||||||
|
}
|
||||||
@@ -1,235 +1,2 @@
|
|||||||
/**
|
export * from './relationships';
|
||||||
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
|
export * from './idempotency';
|
||||||
*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -89,7 +89,16 @@
|
|||||||
"0pt.source_registry_list",
|
"0pt.source_registry_list",
|
||||||
"0pt.order_create",
|
"0pt.order_create",
|
||||||
"0pt.order_payment",
|
"0pt.order_payment",
|
||||||
"0pt.write_iceberg_table"
|
"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"
|
||||||
],
|
],
|
||||||
"surfaces": [
|
"surfaces": [
|
||||||
"api",
|
"api",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
# 0pt — cosign-signeringskedjan (2026-09-29)
|
# 0pt — cosign-signeringskedjan (2026-09-29)
|
||||||
|
|
||||||
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-8b5e23aa9b (sha256:889753b7e5a9db3e1c870eeb38f450cb0f20498e1025edb28153233e0b06f46d) är cosign-signerad
|
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-ef84832cdb (sha256:a8e2ebc09bafe1f0d198f72bda700f9b826ac80c7a72f0d21879d851c30dbb1d) är cosign-signerad
|
||||||
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
|
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPRG9iG1pjIW548KoIduQPD/B3Yb
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6Cdqn5WinLh2POoCPzroqb2Tz0Jw
|
||||||
tg5hPmy7o4feVQsdDnW38VtemVBQ4I+goytV9X4XkrFiweKmfqEMhGyPgg==
|
W+vJqjfc1+UybkeU9NZkamkDgCWunNHSn4gGmAyrZpZ8wQqxK35cBhwNeA==
|
||||||
-----END PUBLIC KEY-----
|
-----END PUBLIC KEY-----
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
|
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
|
||||||
"id": "sb-0pt-13e156ec7e6b",
|
"id": "sb-0pt-13e156ec7e6b",
|
||||||
"repo": "0PT",
|
"repo": "0pt",
|
||||||
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
|
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"buildId": "coolify:0pt",
|
"buildId": "coolify:0pt",
|
||||||
"producedAt": "2026-11-04T01:26:48Z"
|
"producedAt": "2026-11-04T01:26:48Z",
|
||||||
|
"artifactId": "shxd6lepxuxfsjsp0ath3z1g@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- 022_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
|
||||||
|
-- must consume these rows through this API/contract, never by writing here.
|
||||||
|
--
|
||||||
|
-- Conflict preservation is structural: the unique key includes source_ref and
|
||||||
|
-- observed_at, so two sources disagreeing about the same predicate are two
|
||||||
|
-- rows, never an overwrite. `status` records observed/supported/disputed/
|
||||||
|
-- retracted; reconciliation is a separate, explicit decision.
|
||||||
|
--
|
||||||
|
-- Tenant isolation: RLS is FORCEd and keyed on a transaction-local GUC that
|
||||||
|
-- only trusted server code sets (same pattern as 019_document_credit_rls.sql).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS opt_canonical_entities (
|
||||||
|
entity_id UUID PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL, -- company|person|web_page|product|...
|
||||||
|
identity_namespace TEXT NOT NULL, -- url|orgnr|lei|gtin|...
|
||||||
|
identity_value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, entity_type, identity_namespace, identity_value)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_canonical_entities_tenant_type
|
||||||
|
ON opt_canonical_entities (tenant_id, entity_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS opt_canonical_facts (
|
||||||
|
fact_id UUID PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
entity_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||||
|
predicate TEXT NOT NULL, -- e.g. web.title, product.price
|
||||||
|
value_json JSONB NOT NULL,
|
||||||
|
value_type TEXT NOT NULL, -- string|number|boolean|date|json
|
||||||
|
value_unit TEXT,
|
||||||
|
source_ref TEXT NOT NULL,
|
||||||
|
source_record_id TEXT,
|
||||||
|
st0re_artifact_id TEXT,
|
||||||
|
aud0_evidence_ref TEXT,
|
||||||
|
run_ref TEXT,
|
||||||
|
extractor_version TEXT NOT NULL,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_from TIMESTAMPTZ,
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||||
|
quality_status TEXT NOT NULL DEFAULT 'unvalidated', -- passed|warned|failed|unvalidated
|
||||||
|
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_fact/v1',
|
||||||
|
status TEXT NOT NULL DEFAULT 'observed'
|
||||||
|
CHECK (status IN ('observed','supported','disputed','retracted')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, entity_id, predicate, source_ref, observed_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_canonical_facts_entity
|
||||||
|
ON opt_canonical_facts (tenant_id, entity_id, predicate);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_canonical_facts_predicate
|
||||||
|
ON opt_canonical_facts (tenant_id, predicate, observed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS opt_canonical_relations (
|
||||||
|
relation_id UUID PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
subject_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||||
|
predicate TEXT NOT NULL, -- LINKS_TO|SOLD_BY|USES_SOFTWARE|...
|
||||||
|
object_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||||
|
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
source_ref TEXT NOT NULL,
|
||||||
|
st0re_artifact_id TEXT,
|
||||||
|
aud0_evidence_ref TEXT,
|
||||||
|
run_ref TEXT,
|
||||||
|
extractor_version TEXT NOT NULL,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_from TIMESTAMPTZ,
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||||
|
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_relation/v1',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, subject_id, predicate, object_id, source_ref, observed_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_canonical_relations_subject
|
||||||
|
ON opt_canonical_relations (tenant_id, subject_id, predicate);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
|
||||||
|
ON opt_canonical_relations (tenant_id, object_id, predicate);
|
||||||
|
|
||||||
|
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));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 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
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* `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));
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ export interface Doc0UploadReceipt {
|
|||||||
/** The POST may have committed even if its response could not be verified. */
|
/** The POST may have committed even if its response could not be verified. */
|
||||||
export class Doc0UploadOutcomeUnknownError extends Error {
|
export class Doc0UploadOutcomeUnknownError extends Error {
|
||||||
constructor() {
|
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';
|
this.name = 'Doc0UploadOutcomeUnknownError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,44 @@ export type Doc0Inspection =
|
|||||||
* Internal contract only: callers must retain this identity before dispatch,
|
* Internal contract only: callers must retain this identity before dispatch,
|
||||||
* authorize its scope, and keep the same input bytes/metadata until 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): {
|
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
||||||
sourceSha256: string;
|
sourceSha256: string;
|
||||||
sourceSizeBytes: number;
|
sourceSizeBytes: number;
|
||||||
@@ -68,13 +108,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
|||||||
wireMimeType: string;
|
wireMimeType: string;
|
||||||
} {
|
} {
|
||||||
if (
|
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.tenantId, input.actorId, input.requestId, input.idempotencyKey]
|
input.projectId,
|
||||||
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
|
input.actorId,
|
||||||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
|
input.requestId,
|
||||||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
|
input.idempotencyKey,
|
||||||
) throw new Error('Invalid DOC0 upload input');
|
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();
|
const wireMimeType = input.mimeType.toLowerCase();
|
||||||
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
|
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
|
||||||
throw new Error('Invalid DOC0 upload MIME type');
|
throw new Error('Invalid DOC0 upload MIME type');
|
||||||
@@ -83,10 +135,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
|||||||
const wireFilename = `source.${extension}`;
|
const wireFilename = `source.${extension}`;
|
||||||
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
|
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
|
||||||
const sourceSizeBytes = input.bytes.byteLength;
|
const sourceSizeBytes = input.bytes.byteLength;
|
||||||
const requestFingerprint = createHash('sha256').update(JSON.stringify([
|
const requestFingerprint = createHash('sha256')
|
||||||
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
|
.update(
|
||||||
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
|
JSON.stringify([
|
||||||
])).digest('hex');
|
'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 };
|
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +176,9 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
|||||||
parts.push(chunk.value);
|
parts.push(chunk.value);
|
||||||
}
|
}
|
||||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
|
||||||
} finally { reader.releaseLock(); }
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,16 +200,28 @@ export class Doc0UploadClient {
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try { url = new URL(options.baseUrl); }
|
try {
|
||||||
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
|
url = new URL(options.baseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('A trusted HTTPS DOC0 endpoint is required');
|
||||||
|
}
|
||||||
if (
|
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() ||
|
!options.serviceToken.trim() ||
|
||||||
!Number.isSafeInteger(options.maxBytes) ||
|
!Number.isSafeInteger(options.maxBytes) ||
|
||||||
options.maxBytes <= 0 ||
|
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.
|
// Keep the validated values even if the caller later changes its options object.
|
||||||
this.options = { ...options };
|
this.options = { ...options };
|
||||||
@@ -167,7 +248,11 @@ export class Doc0UploadClient {
|
|||||||
classification: input.classification,
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
|
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
|
||||||
@@ -183,11 +268,16 @@ export class Doc0UploadClient {
|
|||||||
body,
|
body,
|
||||||
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
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.
|
// Even a non-2xx response does not prove the write did not commit.
|
||||||
let decoded: unknown;
|
let decoded: unknown;
|
||||||
try { decoded = await readBoundedJson(response); }
|
try {
|
||||||
catch { throw new Doc0UploadOutcomeUnknownError(); }
|
decoded = await readBoundedJson(response);
|
||||||
|
} catch {
|
||||||
|
throw new Doc0UploadOutcomeUnknownError();
|
||||||
|
}
|
||||||
const data = decoded as {
|
const data = decoded as {
|
||||||
document?: {
|
document?: {
|
||||||
documentId?: unknown;
|
documentId?: unknown;
|
||||||
@@ -239,33 +329,63 @@ export class Doc0UploadClient {
|
|||||||
* is complete. No automatic retry, settlement or customer route is wired.
|
* is complete. No automatic retry, settlement or customer route is wired.
|
||||||
*/
|
*/
|
||||||
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
|
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
|
||||||
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
|
if (
|
||||||
.every((value) => typeof value === 'string' && value.trim()) ||
|
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every(
|
||||||
input.projectId.length > 256 || input.idempotencyKey.length > 256 ||
|
(value) => typeof value === 'string' && value.trim(),
|
||||||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
|
) ||
|
||||||
!Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
|
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');
|
throw new Error('Invalid DOC0 inspection input');
|
||||||
}
|
}
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
|
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),
|
method: 'POST',
|
||||||
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
|
redirect: 'error',
|
||||||
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
|
cache: 'no-store',
|
||||||
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
|
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
||||||
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
|
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);
|
result = await readBoundedJson(response);
|
||||||
} catch { throw new Doc0InspectionUnavailableError(); }
|
} catch {
|
||||||
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
|
throw new Doc0InspectionUnavailableError();
|
||||||
|
}
|
||||||
|
if (!result || typeof result !== 'object' || Array.isArray(result))
|
||||||
|
throw new Doc0InspectionUnavailableError();
|
||||||
const record = result as Record<string, unknown>;
|
const record = result as Record<string, unknown>;
|
||||||
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
|
if (
|
||||||
Object.keys(record).length === 1) {
|
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' };
|
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
|
||||||
}
|
}
|
||||||
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
|
if (
|
||||||
typeof record.documentId === 'string' && record.documentId.trim() &&
|
record.status === 'completed_recorded' &&
|
||||||
typeof record.versionId === 'string' && record.versionId.trim()) {
|
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 };
|
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
|
||||||
}
|
}
|
||||||
throw new Doc0InspectionUnavailableError();
|
throw new Doc0InspectionUnavailableError();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
* authz hole without changing every route's request contract).
|
* authz hole without changing every route's request contract).
|
||||||
*/
|
*/
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
|
||||||
|
|
||||||
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
|
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
|
||||||
|
|
||||||
@@ -52,6 +53,26 @@ export function assertTenantMatchesVerified(
|
|||||||
tenantId: string | null | undefined,
|
tenantId: string | null | undefined,
|
||||||
): NextResponse | null {
|
): NextResponse | null {
|
||||||
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
|
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 (!verified) return null;
|
||||||
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
||||||
if (tenantId !== verified) {
|
if (tenantId !== verified) {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* src/repositories/canonical-fact-repository.ts
|
||||||
|
* =============================================
|
||||||
|
* Storage seam for 0pt canonical entities/facts/relations (sql/022).
|
||||||
|
* Same split as source-repository.ts: one interface, an in-memory
|
||||||
|
* implementation (dev/test) and a Postgres implementation (production),
|
||||||
|
* chosen once via resolveStoreMode.
|
||||||
|
*
|
||||||
|
* Tenant isolation is enforced by the service + route AND by RLS in Postgres
|
||||||
|
* (transaction-local GUC `app.opt_canonical_tenant_id`, same pattern as
|
||||||
|
* 019_document_credit_rls.sql). The in-memory backend scopes by tenant_id in
|
||||||
|
* every read/write.
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { resolveStoreMode } from "../platform/dependency-mode";
|
||||||
|
import type {
|
||||||
|
CanonicalEntity,
|
||||||
|
CanonicalEntityInput,
|
||||||
|
CanonicalFact,
|
||||||
|
CanonicalFactInput,
|
||||||
|
CanonicalQuery,
|
||||||
|
CanonicalRelationInput,
|
||||||
|
} from "../types/canonical-facts";
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl, type RepositorySql } from "./db";
|
||||||
|
|
||||||
|
export interface CanonicalFactRepository {
|
||||||
|
upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]>;
|
||||||
|
insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
|
||||||
|
insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
|
||||||
|
queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PostgreSQL timestamptz arrives as Date; the API contract is RFC3339/ISO-8601. */
|
||||||
|
function iso(value: unknown): string {
|
||||||
|
return value instanceof Date ? value.toISOString() : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entityKey(e: CanonicalEntityInput): string {
|
||||||
|
return `${e.entity_type}\u0000${e.identity_namespace}\u0000${e.identity_value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCanonicalFactRepository(databaseUrl?: string): CanonicalFactRepository {
|
||||||
|
const mode = resolveStoreMode("0pt.canonical_facts", databaseUrl ?? repositoryDatabaseUrl());
|
||||||
|
if (mode.mode === "postgres") {
|
||||||
|
return new PostgresCanonicalFactRepository(getRepositorySql(mode.url));
|
||||||
|
}
|
||||||
|
return new InMemoryCanonicalFactRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── in-memory (dev/test) ────────────────────────────────────────────────────
|
||||||
|
class InMemoryCanonicalFactRepository implements CanonicalFactRepository {
|
||||||
|
private entities = new Map<string, CanonicalEntity>();
|
||||||
|
private facts = new Map<string, CanonicalFact>();
|
||||||
|
|
||||||
|
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return inputs.map((input) => {
|
||||||
|
const key = `${tenantId}\u0000${entityKey(input)}`;
|
||||||
|
const existing = this.entities.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const entity: CanonicalEntity = {
|
||||||
|
...input,
|
||||||
|
entity_id: randomUUID(),
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
this.entities.set(key, entity);
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||||
|
let inserted = 0;
|
||||||
|
let duplicates = 0;
|
||||||
|
for (const input of inputs) {
|
||||||
|
const entityId = entityIds.get(entityKey(input.entity));
|
||||||
|
if (!entityId) continue;
|
||||||
|
const key = `${tenantId}\u0000${entityId}\u0000${input.predicate}\u0000${input.source_ref}\u0000${input.observed_at}`;
|
||||||
|
if (this.facts.has(key)) {
|
||||||
|
duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.facts.set(key, {
|
||||||
|
...input,
|
||||||
|
fact_id: randomUUID(),
|
||||||
|
tenant_id: tenantId,
|
||||||
|
entity_id: entityId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
return { inserted, duplicates };
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertRelations(): Promise<{ inserted: number; duplicates: number }> {
|
||||||
|
// Relations are not persisted in the in-memory backend beyond validation;
|
||||||
|
// tests assert the write path through facts + counts.
|
||||||
|
return { inserted: 0, duplicates: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||||
|
const limit = query.limit ?? 100;
|
||||||
|
return [...this.facts.values()]
|
||||||
|
.filter((f) => f.tenant_id === query.tenant_id)
|
||||||
|
.filter((f) => (query.entity_id ? f.entity_id === query.entity_id : true))
|
||||||
|
.filter((f) => (query.predicate ? f.predicate === query.predicate : true))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── postgres (production) ───────────────────────────────────────────────────
|
||||||
|
class PostgresCanonicalFactRepository implements CanonicalFactRepository {
|
||||||
|
constructor(private readonly sql: RepositorySql) {}
|
||||||
|
|
||||||
|
private async tx<T>(tenantId: string, fn: (tx: RepositorySql) => Promise<T>): Promise<T> {
|
||||||
|
return this.sql.begin(async (tx) => {
|
||||||
|
await tx`SELECT set_config('app.opt_canonical_tenant_id', ${tenantId}, true)`;
|
||||||
|
return fn(tx as unknown as RepositorySql);
|
||||||
|
}) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
|
||||||
|
if (inputs.length === 0) return [];
|
||||||
|
return this.tx(tenantId, async (tx) => {
|
||||||
|
const out: CanonicalEntity[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const rows = await tx`
|
||||||
|
INSERT INTO opt_canonical_entities (entity_id, tenant_id, entity_type, identity_namespace, identity_value)
|
||||||
|
VALUES (${randomUUID()}, ${tenantId}, ${input.entity_type}, ${input.identity_namespace}, ${input.identity_value})
|
||||||
|
ON CONFLICT (tenant_id, entity_type, identity_namespace, identity_value)
|
||||||
|
DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING entity_id, tenant_id, entity_type, identity_namespace, identity_value, created_at, updated_at`;
|
||||||
|
const r = rows[0];
|
||||||
|
out.push({
|
||||||
|
entity_id: r.entity_id, tenant_id: r.tenant_id, entity_type: r.entity_type,
|
||||||
|
identity_namespace: r.identity_namespace, identity_value: r.identity_value,
|
||||||
|
created_at: String(r.created_at), updated_at: String(r.updated_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||||
|
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
|
||||||
|
return this.tx(tenantId, async (tx) => {
|
||||||
|
let inserted = 0;
|
||||||
|
for (const input of inputs) {
|
||||||
|
const entityId = entityIds.get(entityKey(input.entity));
|
||||||
|
if (!entityId) continue;
|
||||||
|
const rows = await tx`
|
||||||
|
INSERT INTO opt_canonical_facts (
|
||||||
|
fact_id, tenant_id, entity_id, predicate, value_json, value_type, value_unit,
|
||||||
|
source_ref, source_record_id, st0re_artifact_id, aud0_evidence_ref, run_ref,
|
||||||
|
extractor_version, observed_at, valid_from, valid_to, confidence, quality_status, status
|
||||||
|
) VALUES (
|
||||||
|
${randomUUID()}, ${tenantId}, ${entityId}, ${input.predicate}, ${JSON.stringify(input.value)}::jsonb,
|
||||||
|
${input.value_type ?? "json"}, ${input.value_unit ?? null}, ${input.source_ref},
|
||||||
|
${input.source_record_id ?? null}, ${input.st0re_artifact_id ?? null},
|
||||||
|
${input.aud0_evidence_ref ?? null}, ${input.run_ref ?? null}, ${input.extractor_version},
|
||||||
|
${input.observed_at}, ${input.valid_from ?? null}, ${input.valid_to ?? null},
|
||||||
|
${input.confidence ?? null}, ${input.quality_status ?? "unvalidated"}, ${input.status ?? "observed"}
|
||||||
|
)
|
||||||
|
ON CONFLICT (tenant_id, entity_id, predicate, source_ref, observed_at) DO NOTHING
|
||||||
|
RETURNING fact_id`;
|
||||||
|
if (rows.length > 0) inserted += 1;
|
||||||
|
}
|
||||||
|
return { inserted, duplicates: inputs.length - inserted };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||||
|
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
|
||||||
|
return this.tx(tenantId, async (tx) => {
|
||||||
|
let inserted = 0;
|
||||||
|
for (const input of inputs) {
|
||||||
|
const subjectId = entityIds.get(entityKey(input.subject));
|
||||||
|
const objectId = entityIds.get(entityKey(input.object));
|
||||||
|
if (!subjectId || !objectId) continue;
|
||||||
|
const rows = await tx`
|
||||||
|
INSERT INTO opt_canonical_relations (
|
||||||
|
relation_id, tenant_id, subject_id, predicate, object_id, attributes_json,
|
||||||
|
source_ref, st0re_artifact_id, aud0_evidence_ref, run_ref, extractor_version,
|
||||||
|
observed_at, valid_from, valid_to, confidence
|
||||||
|
) VALUES (
|
||||||
|
${randomUUID()}, ${tenantId}, ${subjectId}, ${input.predicate}, ${objectId},
|
||||||
|
${JSON.stringify(input.attributes ?? {})}::jsonb, ${input.source_ref},
|
||||||
|
${input.st0re_artifact_id ?? null}, ${input.aud0_evidence_ref ?? null},
|
||||||
|
${input.run_ref ?? null}, ${input.extractor_version}, ${input.observed_at},
|
||||||
|
${input.valid_from ?? null}, ${input.valid_to ?? null}, ${input.confidence ?? null}
|
||||||
|
)
|
||||||
|
ON CONFLICT (tenant_id, subject_id, predicate, object_id, source_ref, observed_at) DO NOTHING
|
||||||
|
RETURNING relation_id`;
|
||||||
|
if (rows.length > 0) inserted += 1;
|
||||||
|
}
|
||||||
|
return { inserted, duplicates: inputs.length - inserted };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||||
|
const limit = query.limit ?? 100;
|
||||||
|
return this.tx(query.tenant_id, async (tx) => {
|
||||||
|
const rows = await tx`
|
||||||
|
SELECT f.*, e.entity_type, e.identity_namespace, e.identity_value
|
||||||
|
FROM opt_canonical_facts f
|
||||||
|
JOIN opt_canonical_entities e ON e.entity_id = f.entity_id
|
||||||
|
WHERE f.tenant_id = ${query.tenant_id}
|
||||||
|
${query.entity_id ? tx`AND f.entity_id = ${query.entity_id}` : tx``}
|
||||||
|
${query.predicate ? tx`AND f.predicate = ${query.predicate}` : tx``}
|
||||||
|
${query.entity_type ? tx`AND e.entity_type = ${query.entity_type}` : tx``}
|
||||||
|
${query.identity_namespace ? tx`AND e.identity_namespace = ${query.identity_namespace}` : tx``}
|
||||||
|
${query.identity_value ? tx`AND e.identity_value = ${query.identity_value}` : tx``}
|
||||||
|
ORDER BY f.observed_at DESC
|
||||||
|
LIMIT ${limit}`;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
fact_id: r.fact_id, tenant_id: r.tenant_id, entity_id: r.entity_id,
|
||||||
|
entity: { entity_type: r.entity_type, identity_namespace: r.identity_namespace, identity_value: r.identity_value },
|
||||||
|
predicate: r.predicate, value: r.value_json, value_type: r.value_type,
|
||||||
|
value_unit: r.value_unit ?? undefined, source_ref: r.source_ref,
|
||||||
|
source_record_id: r.source_record_id ?? undefined,
|
||||||
|
st0re_artifact_id: r.st0re_artifact_id ?? undefined,
|
||||||
|
aud0_evidence_ref: r.aud0_evidence_ref ?? undefined, run_ref: r.run_ref ?? undefined,
|
||||||
|
extractor_version: r.extractor_version, observed_at: iso(r.observed_at),
|
||||||
|
valid_from: r.valid_from ? iso(r.valid_from) : undefined,
|
||||||
|
valid_to: r.valid_to ? iso(r.valid_to) : undefined,
|
||||||
|
confidence: r.confidence === null ? undefined : Number(r.confidence),
|
||||||
|
quality_status: r.quality_status, status: r.status, created_at: iso(r.created_at),
|
||||||
|
})) as CanonicalFact[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
|
||||||
|
import { CanonicalFactService } from "./canonical-fact-service";
|
||||||
|
|
||||||
|
export const canonicalFactService = new CanonicalFactService();
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* src/services/canonical-fact-service.ts
|
||||||
|
* ======================================
|
||||||
|
* 0pt's canonical structured-observation write/read surface (sql/022).
|
||||||
|
*
|
||||||
|
* Ownership boundary: 0pt stores normalized OBSERVED facts (what a source
|
||||||
|
* said, when, from which artifact). L0RE owns epistemic propositions and
|
||||||
|
* projections and must consume these rows through this API. Nothing here
|
||||||
|
* overwrites an earlier observation: the unique key includes source_ref +
|
||||||
|
* observed_at, so conflicting sources are preserved as separate rows.
|
||||||
|
*
|
||||||
|
* Idempotency: identical re-writes hit ON CONFLICT DO NOTHING and are
|
||||||
|
* reported as `duplicates`, never duplicated.
|
||||||
|
*/
|
||||||
|
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
|
||||||
|
import { SiaxError } from "../platform/errors";
|
||||||
|
import {
|
||||||
|
createCanonicalFactRepository,
|
||||||
|
entityKey,
|
||||||
|
type CanonicalFactRepository,
|
||||||
|
} from "../repositories/canonical-fact-repository";
|
||||||
|
import type {
|
||||||
|
CanonicalEntity,
|
||||||
|
CanonicalEntityInput,
|
||||||
|
CanonicalFact,
|
||||||
|
CanonicalFactInput,
|
||||||
|
CanonicalQuery,
|
||||||
|
CanonicalRelationInput,
|
||||||
|
CanonicalWriteRequest,
|
||||||
|
CanonicalWriteResult,
|
||||||
|
FactValueType,
|
||||||
|
} from "../types/canonical-facts";
|
||||||
|
|
||||||
|
const FACT_VALUE_TYPES: FactValueType[] = ["string", "number", "boolean", "date", "json"];
|
||||||
|
|
||||||
|
function inferValueType(value: unknown): FactValueType {
|
||||||
|
if (typeof value === "string") return "string";
|
||||||
|
if (typeof value === "number") return "number";
|
||||||
|
if (typeof value === "boolean") return "boolean";
|
||||||
|
return "json";
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEntity(entity: CanonicalEntityInput, label: string): void {
|
||||||
|
if (!entity || !entity.entity_type || !entity.identity_namespace || !entity.identity_value) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `${label} requires entity_type, identity_namespace and identity_value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFact(fact: CanonicalFactInput, index: number): void {
|
||||||
|
assertEntity(fact.entity, `facts[${index}].entity`);
|
||||||
|
if (!fact.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].predicate is required`);
|
||||||
|
if (!fact.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].source_ref is required`);
|
||||||
|
if (!fact.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].extractor_version is required`);
|
||||||
|
if (!fact.observed_at || Number.isNaN(Date.parse(fact.observed_at))) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].observed_at must be an ISO timestamp`);
|
||||||
|
}
|
||||||
|
if (fact.value === undefined) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value is required`);
|
||||||
|
if (fact.value_type && !FACT_VALUE_TYPES.includes(fact.value_type)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value_type is invalid`);
|
||||||
|
}
|
||||||
|
if (fact.confidence !== undefined && (fact.confidence < 0 || fact.confidence > 1)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].confidence must be between 0 and 1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelation(relation: CanonicalRelationInput, index: number): void {
|
||||||
|
assertEntity(relation.subject, `relations[${index}].subject`);
|
||||||
|
assertEntity(relation.object, `relations[${index}].object`);
|
||||||
|
if (!relation.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].predicate is required`);
|
||||||
|
if (!relation.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].source_ref is required`);
|
||||||
|
if (!relation.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].extractor_version is required`);
|
||||||
|
if (!relation.observed_at || Number.isNaN(Date.parse(relation.observed_at))) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].observed_at must be an ISO timestamp`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalFactServiceOptions {
|
||||||
|
repository?: CanonicalFactRepository;
|
||||||
|
aud0BaseUrl?: string;
|
||||||
|
databaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanonicalFactService {
|
||||||
|
private readonly repo: CanonicalFactRepository;
|
||||||
|
private readonly aud0: Aud0Client;
|
||||||
|
|
||||||
|
constructor(opts?: CanonicalFactServiceOptions) {
|
||||||
|
this.repo = opts?.repository ?? createCanonicalFactRepository(opts?.databaseUrl);
|
||||||
|
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(input: CanonicalWriteRequest): Promise<CanonicalWriteResult> {
|
||||||
|
if (!input.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
|
||||||
|
const facts = input.facts ?? [];
|
||||||
|
const relations = input.relations ?? [];
|
||||||
|
const explicit = input.entities ?? [];
|
||||||
|
if (facts.length === 0 && relations.length === 0 && explicit.length === 0) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "at least one entity, fact or relation is required");
|
||||||
|
}
|
||||||
|
facts.forEach(assertFact);
|
||||||
|
relations.forEach(assertRelation);
|
||||||
|
|
||||||
|
// Unique entity inputs from explicit + facts + relations.
|
||||||
|
const unique = new Map<string, CanonicalEntityInput>();
|
||||||
|
for (const e of [...explicit, ...facts.map((f) => f.entity), ...relations.flatMap((r) => [r.subject, r.object])]) {
|
||||||
|
assertEntity(e, "entity");
|
||||||
|
unique.set(entityKey(e), e);
|
||||||
|
}
|
||||||
|
const entities: CanonicalEntity[] = await this.repo.upsertEntities(input.tenant_id, [...unique.values()]);
|
||||||
|
const entityIds = new Map(entities.map((e) => [entityKey(e), e.entity_id]));
|
||||||
|
|
||||||
|
const normalizedFacts = facts.map((f) => ({ ...f, value_type: f.value_type ?? inferValueType(f.value) }));
|
||||||
|
const factResult = await this.repo.insertFacts(input.tenant_id, normalizedFacts, entityIds);
|
||||||
|
const relationResult = await this.repo.insertRelations(input.tenant_id, relations, entityIds);
|
||||||
|
|
||||||
|
// AUD0: counts and provenance refs only, never fact values (PII discipline).
|
||||||
|
try {
|
||||||
|
await this.aud0.ingestEvent({
|
||||||
|
event_id: `evt_0pt_canonical_facts_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
event_type: "0pt.canonical_facts_written",
|
||||||
|
tenant_id: input.tenant_id,
|
||||||
|
project_id: input.project_id ?? input.tenant_id,
|
||||||
|
actor_id: "0pt",
|
||||||
|
app_id: "0pt",
|
||||||
|
capability_id: "0pt.canonical_fact_write",
|
||||||
|
resource_type: "canonical_facts",
|
||||||
|
resource_id: input.tenant_id,
|
||||||
|
risk_level: "L2",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
entities: entities.length,
|
||||||
|
facts_inserted: factResult.inserted,
|
||||||
|
facts_duplicates: factResult.duplicates,
|
||||||
|
relations_inserted: relationResult.inserted,
|
||||||
|
relations_duplicates: relationResult.duplicates,
|
||||||
|
source_refs: [...new Set([...facts.map((f) => f.source_ref), ...relations.map((r) => r.source_ref)])],
|
||||||
|
run_refs: [...new Set([...facts.map((f) => f.run_ref), ...relations.map((r) => r.run_ref)].filter(Boolean))],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// AUD0 outage must not lose the canonical write; the caller sees counts.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities,
|
||||||
|
facts: normalizedFacts.map((f) => ({
|
||||||
|
fact_id: "",
|
||||||
|
entity_id: entityIds.get(entityKey(f.entity)) ?? "",
|
||||||
|
predicate: f.predicate,
|
||||||
|
})),
|
||||||
|
relations: relations.map((r) => ({
|
||||||
|
relation_id: "",
|
||||||
|
subject_id: entityIds.get(entityKey(r.subject)) ?? "",
|
||||||
|
predicate: r.predicate,
|
||||||
|
object_id: entityIds.get(entityKey(r.object)) ?? "",
|
||||||
|
})),
|
||||||
|
duplicates: factResult.duplicates + relationResult.duplicates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||||
|
if (!query.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
|
||||||
|
return this.repo.queryFacts({ ...query, limit: Math.min(query.limit ?? 100, 500) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* src/types/canonical-facts.ts — 0pt canonical structured observations.
|
||||||
|
*
|
||||||
|
* 0pt owns OBSERVED facts (source said X at time T, from artifact A). L0RE
|
||||||
|
* owns epistemic propositions/assertions and projections derived from these.
|
||||||
|
* This contract is the single write path into the canonical observation store;
|
||||||
|
* downstream products consume it through the same API instead of their own DB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CanonicalEntityInput {
|
||||||
|
entity_type: string;
|
||||||
|
identity_namespace: string;
|
||||||
|
identity_value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalEntity extends CanonicalEntityInput {
|
||||||
|
entity_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FactValueType = "string" | "number" | "boolean" | "date" | "json";
|
||||||
|
export type QualityStatus = "passed" | "warned" | "failed" | "unvalidated";
|
||||||
|
export type FactStatus = "observed" | "supported" | "disputed" | "retracted";
|
||||||
|
|
||||||
|
export interface CanonicalFactInput {
|
||||||
|
entity: CanonicalEntityInput;
|
||||||
|
predicate: string;
|
||||||
|
value: unknown;
|
||||||
|
value_type?: FactValueType;
|
||||||
|
value_unit?: string;
|
||||||
|
source_ref: string;
|
||||||
|
source_record_id?: string;
|
||||||
|
st0re_artifact_id?: string;
|
||||||
|
aud0_evidence_ref?: string;
|
||||||
|
run_ref?: string;
|
||||||
|
extractor_version: string;
|
||||||
|
observed_at: string;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_to?: string;
|
||||||
|
confidence?: number;
|
||||||
|
quality_status?: QualityStatus;
|
||||||
|
status?: FactStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRelationInput {
|
||||||
|
subject: CanonicalEntityInput;
|
||||||
|
predicate: string;
|
||||||
|
object: CanonicalEntityInput;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
source_ref: string;
|
||||||
|
st0re_artifact_id?: string;
|
||||||
|
aud0_evidence_ref?: string;
|
||||||
|
run_ref?: string;
|
||||||
|
extractor_version: string;
|
||||||
|
observed_at: string;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_to?: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalFact extends CanonicalFactInput {
|
||||||
|
fact_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRelation extends CanonicalRelationInput {
|
||||||
|
relation_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
subject_id: string;
|
||||||
|
object_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalWriteRequest {
|
||||||
|
tenant_id: string;
|
||||||
|
project_id?: string;
|
||||||
|
entities?: CanonicalEntityInput[];
|
||||||
|
facts?: CanonicalFactInput[];
|
||||||
|
relations?: CanonicalRelationInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalWriteResult {
|
||||||
|
entities: CanonicalEntity[];
|
||||||
|
facts: { fact_id: string; entity_id: string; predicate: string }[];
|
||||||
|
relations: { relation_id: string; subject_id: string; predicate: string; object_id: string }[];
|
||||||
|
/** Identical re-inserts skipped by the unique key (idempotent replay). */
|
||||||
|
duplicates: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalQuery {
|
||||||
|
tenant_id: string;
|
||||||
|
entity_id?: string;
|
||||||
|
entity_type?: string;
|
||||||
|
identity_namespace?: string;
|
||||||
|
identity_value?: string;
|
||||||
|
predicate?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -263,3 +263,63 @@ export const activateSourceSchema = z.object({
|
|||||||
tenant_id: z.string().min(1),
|
tenant_id: z.string().min(1),
|
||||||
actor_id: z.string().min(1).optional(),
|
actor_id: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** POST /api/0pt/canonical/facts — canonical structured observation write (sql/022). */
|
||||||
|
const canonicalEntityInputSchema = z.object({
|
||||||
|
entity_type: z.string().min(1),
|
||||||
|
identity_namespace: z.string().min(1),
|
||||||
|
identity_value: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canonicalValueSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.number(),
|
||||||
|
z.boolean(),
|
||||||
|
z.null(),
|
||||||
|
z.array(z.unknown()),
|
||||||
|
jsonObject,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const canonicalFactInputSchema = z.object({
|
||||||
|
entity: canonicalEntityInputSchema,
|
||||||
|
predicate: z.string().min(1),
|
||||||
|
value: canonicalValueSchema,
|
||||||
|
value_type: z.enum(['string', 'number', 'boolean', 'date', 'json']).optional(),
|
||||||
|
value_unit: z.string().min(1).optional(),
|
||||||
|
source_ref: z.string().min(1),
|
||||||
|
source_record_id: z.string().min(1).optional(),
|
||||||
|
st0re_artifact_id: z.string().min(1).optional(),
|
||||||
|
aud0_evidence_ref: z.string().min(1).optional(),
|
||||||
|
run_ref: z.string().min(1).optional(),
|
||||||
|
extractor_version: z.string().min(1),
|
||||||
|
observed_at: z.string().min(1),
|
||||||
|
valid_from: z.string().min(1).optional(),
|
||||||
|
valid_to: z.string().min(1).optional(),
|
||||||
|
confidence: z.number().min(0).max(1).optional(),
|
||||||
|
quality_status: z.enum(['passed', 'warned', 'failed', 'unvalidated']).optional(),
|
||||||
|
status: z.enum(['observed', 'supported', 'disputed', 'retracted']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const canonicalRelationInputSchema = z.object({
|
||||||
|
subject: canonicalEntityInputSchema,
|
||||||
|
predicate: z.string().min(1),
|
||||||
|
object: canonicalEntityInputSchema,
|
||||||
|
attributes: jsonObject.optional(),
|
||||||
|
source_ref: z.string().min(1),
|
||||||
|
st0re_artifact_id: z.string().min(1).optional(),
|
||||||
|
aud0_evidence_ref: z.string().min(1).optional(),
|
||||||
|
run_ref: z.string().min(1).optional(),
|
||||||
|
extractor_version: z.string().min(1),
|
||||||
|
observed_at: z.string().min(1),
|
||||||
|
valid_from: z.string().min(1).optional(),
|
||||||
|
valid_to: z.string().min(1).optional(),
|
||||||
|
confidence: z.number().min(0).max(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const canonicalWriteSchema = z.object({
|
||||||
|
tenant_id: z.string().min(1),
|
||||||
|
project_id: z.string().min(1).optional(),
|
||||||
|
entities: z.array(canonicalEntityInputSchema).optional(),
|
||||||
|
facts: z.array(canonicalFactInputSchema).optional(),
|
||||||
|
relations: z.array(canonicalRelationInputSchema).optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +45,13 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
|
|||||||
expect(res.status).toBe(200);
|
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 () => {
|
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' }));
|
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Canonical structured observations (sql/022): idempotent write, conflict
|
||||||
|
* preservation, tenant isolation and relation identity resolution.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CanonicalFactService } from "@/src/services/canonical-fact-service";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
|
||||||
|
const T = "t_canonical_test";
|
||||||
|
const ENTITY = { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org" };
|
||||||
|
|
||||||
|
function fact(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
entity: ENTITY,
|
||||||
|
predicate: "web.title",
|
||||||
|
value: "Example Domain",
|
||||||
|
source_ref: "source:example",
|
||||||
|
extractor_version: "0pt.motor.extraction/v1",
|
||||||
|
observed_at: "2026-11-04T12:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CanonicalFactService — write", () => {
|
||||||
|
it("creates the entity and the fact, and query returns it", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
const result = await svc.write({ tenant_id: T, facts: [fact()] });
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0].identity_value).toBe("https://example.org");
|
||||||
|
expect(result.facts).toHaveLength(1);
|
||||||
|
expect(result.duplicates).toBe(0);
|
||||||
|
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].predicate).toBe("web.title");
|
||||||
|
expect(rows[0].value_type).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: an identical replay is reported as a duplicate, not stored twice", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
await svc.write({ tenant_id: T, facts: [fact()] });
|
||||||
|
const replay = await svc.write({ tenant_id: T, facts: [fact()] });
|
||||||
|
expect(replay.duplicates).toBe(1);
|
||||||
|
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves conflicting sources as separate observations", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:a", value: "A" })] });
|
||||||
|
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:b", value: "B" })] });
|
||||||
|
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.map((r) => r.value).sort()).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates tenants", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
await svc.write({ tenant_id: "t_a", facts: [fact()] });
|
||||||
|
expect(await svc.query({ tenant_id: "t_b" })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves relation subject/object identities to entity ids", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
const result = await svc.write({
|
||||||
|
tenant_id: T,
|
||||||
|
relations: [{
|
||||||
|
subject: ENTITY,
|
||||||
|
predicate: "LINKS_TO",
|
||||||
|
object: { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org/next" },
|
||||||
|
source_ref: "source:example",
|
||||||
|
extractor_version: "0pt.motor.extraction/v1",
|
||||||
|
observed_at: "2026-11-04T12:00:00Z",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
expect(result.entities).toHaveLength(2);
|
||||||
|
expect(result.relations[0].subject_id).toBeTruthy();
|
||||||
|
expect(result.relations[0].object_id).toBeTruthy();
|
||||||
|
expect(result.relations[0].subject_id).not.toBe(result.relations[0].object_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fact without observed_at or with an invalid confidence", async () => {
|
||||||
|
const svc = new CanonicalFactService();
|
||||||
|
await expect(svc.write({ tenant_id: T, facts: [fact({ observed_at: undefined })] })).rejects.toBeInstanceOf(SiaxError);
|
||||||
|
await expect(svc.write({ tenant_id: T, facts: [fact({ confidence: 2 })] })).rejects.toBeInstanceOf(SiaxError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
operationFingerprint,
|
||||||
|
operationIdentity,
|
||||||
|
runIdempotentOperation,
|
||||||
|
type IdempotencyStore,
|
||||||
|
} 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: IdempotencyStore<unknown, { x: number }> = {
|
||||||
|
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: IdempotencyStore<unknown, { x: number }> = {
|
||||||
|
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: IdempotencyStore<unknown, { x: number }> = {
|
||||||
|
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: IdempotencyStore<unknown, { x: number }> = { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user