Compare commits

..
Author SHA1 Message Date
siax-bot 70444a369e fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-ef84832cdb
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m2s
2026-11-03 17:30:19 +01:00
34 changed files with 342 additions and 2458 deletions
+2 -2
View File
@@ -294,9 +294,9 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube analysis (self-hosted sonar.siax.dev)
- name: SonarQube analysis (self-hosted sonar.siax.io)
env:
SONAR_HOST_URL: https://sonar.siax.dev
SONAR_HOST_URL: https://sonar.siax.io
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
set -euo pipefail
-1
View File
@@ -1 +0,0 @@
{"allow":[{"pathPrefix":"components/states.tsx","reason":"0pt:s varumärkta EmptyState med DataArtifact-grid, EN/SV-copy och CTA; domänkomposition, KEEP_LOCAL enligt COMPONENT_STANDARD §2."},{"pathPrefix":"components/theme-provider.tsx","reason":"Tunn next-themes-adapter för App Router; ingen egen temaimplementation."},{"pathPrefix":"components/ui/toaster.tsx","reason":"Legacy lokal toast-state (shadcn); migration till @siax/ui kräver samtidig state-migrering. KEEP_LOCAL med backlog."}]}
-36
View File
@@ -1,36 +0,0 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.acquisition-consumer.schema.json",
"schemaVersion": "siax.acquisition-consumer/v1",
"application": "0pt",
"owner": "0pt",
"tenantScoped": true,
"capabilities": [
"siax.acquisition.plan",
"siax.acquisition.run",
"siax.acquisition.status",
"siax.acquisition.result",
"siax.acquisition.query_existing",
"siax.acquisition.discovery_search",
"siax.acquisition.extract",
"siax.acquisition.evidence"
],
"allowedPurposes": [
"data.acquisition",
"source.registration"
],
"dataCategories": [
"public",
"company",
"product",
"pricing",
"vehicle",
"property",
"document",
"event",
"other"
],
"maxCostSEK": 25,
"reuseExistingFacts": true,
"mustNotOwnEngines": true,
"engines": []
}
-52
View File
@@ -1,52 +0,0 @@
/**
* 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);
}
}
+28 -10
View File
@@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FileDropzone } from '@siax/ui/components/file-dropzone';
import { useT } from '@/components/locale-provider';
import { PortalShell } from '@/components/portal-shell';
@@ -73,6 +72,7 @@ export default function DocumentsPage() {
const [uploadNote, setUploadNote] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// One idempotency key per selected file: a retry (double click, network
// error, reload-and-pick-the-same-file) returns the SAME document instead of
// creating a duplicate. Picking a different file resets it.
@@ -277,16 +277,34 @@ export default function DocumentsPage() {
/>
</label>
<FileDropzone
className="mt-4"
label={t({ en: 'choose a file', sv: 'välj en fil' })}
hint={file ? file.name : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
disabled={uploading}
onFiles={(files) => {
uploadKey.current = null;
setFile(files[0] ?? null);
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
inputRef.current?.click();
}
}}
/>
className="mt-4 flex cursor-pointer flex-col items-center justify-center border border-dashed border-border px-6 py-10 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={label}>{t({ en: 'choose a file', sv: 'välj en fil' })}</span>
<span className="mt-3 text-[15px]">
{file
? file.name
: t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
</span>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(event) => {
uploadKey.current = null;
setFile(event.target.files?.[0] ?? null);
}}
/>
</div>
<button
type="button"
+1 -4
View File
@@ -4,7 +4,7 @@
* Customer calls use verified Zitadel bearer identity and receive immutable
* x-0pt-verified-* headers. Explicit machine routes may instead use the
* shared X-Internal-Token convention; mutation handlers verify it again for
* defence in depth. The canonical-fact write is one such explicit machine route.
* defence in depth.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
@@ -26,9 +26,6 @@ const EXEMPT_PATTERNS: RegExp[] = [
/** Routes that may be reached with a service token instead of end-user bearer auth. */
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
/^\/api\/0pt\/quality\/bridge-run$/,
// Acquisition engine -> 0pt canonical observation write (machine-to-machine,
// same X-Internal-Token class as the quality bridge; not a blanket bypass).
/^\/api\/0pt\/canonical\/facts$/,
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
/^\/api\/0pt\/internal\/er\/candidates$/,
/^\/api\/0pt\/search\/definitions$/,
+4 -5
View File
@@ -52,15 +52,13 @@
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@siax/0pt-decision": "workspace:*",
"@siax/access": "workspace:*",
"@siax/auth-zitadel": "0.1.1",
"@siax/charts": "^0.5.0",
"@siax/collaboration": "0.3.0",
"@siax/config": "^0.2.1",
"@siax/contracts": "^0.12.1",
"@siax/contracts": "^0.11.1",
"@siax/import-export": "^0.2.2",
"@siax/tokens": "^0.1.2",
"@siax/ui": "^0.16.13",
"@siax/ui": "^0.15.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -83,7 +81,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "3.25.76"
"zod": "3.25.76",
"@siax/access": "workspace:*"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
-121
View File
@@ -1,121 +0,0 @@
/**
* @siax/access idempotency primitives — the universal SIAX contract for any
* operation that must either happen exactly once or replay its receipt.
*
* This package is derived from the pattern proven in 0pt's durable upload
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
* generalized so DOC0, N0D and future products can adopt the identical shape
* instead of re-implementing it.
*
* Rules encoded here:
* 1. An operation identity binds EVERY result-affecting input (tenant,
* project, actor, payload version). A changed payload under the same key
* is a CONFLICT, never a replay of someone else's receipt.
* 2. One claimant per key: returning `false` from `start` means another
* caller owns the dispatch, and only the stored receipt (or bounded
* observation) may be answered.
* 3. A stored receipt is replayed verbatim; the caller never invents one.
* 4. Commit boundaries: NEVER write a network command before the claim is
* durably persisted, and never lose the claim after the command may have
* committed.
*/
import { createHash } from "node:crypto";
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
return createHash("sha256")
.update(JSON.stringify([version, ...parts]))
.digest("hex");
}
export interface OperationIdentity {
tenantId: string;
projectId: string;
actorId: string;
/** Client-generated, transport-level key (<=256 visible ASCII). */
idempotencyKey: string;
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
payload: readonly unknown[];
}
export function operationIdentity(operation: OperationIdentity): {
tenantId: string;
projectId: string;
actorId: string;
idempotencyKey: string;
fingerprint: string;
} {
if (
!operation ||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
) ||
!Array.isArray(operation.payload)
) {
throw new Error("Invalid idempotent operation identity");
}
void operation.payload;
return {
tenantId: operation.tenantId,
projectId: operation.projectId,
actorId: operation.actorId,
idempotencyKey: operation.idempotencyKey,
fingerprint: operationFingerprint([
operation.tenantId,
operation.projectId,
operation.actorId,
...operation.payload,
]),
};
}
export type OperationOutcome<R> =
| { kind: "claimed"; execute: () => Promise<R> }
| { kind: "replayed"; receipt: R }
| { kind: "conflict"; expected?: string };
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
export interface IdempotencyStore<Identity, Receipt> {
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
start(identity: Identity, fingerprint: string): Promise<boolean>;
/** Persist after a verified downstream commit. Must never be called before. */
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
/** Load a stored receipt for replay (exact fingerprint match). */
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
}
export type IdempotentStoreItemIdentity<I> = {
identity: I;
fingerprint: string;
};
export async function runIdempotentOperation<I, R>(input: {
store: IdempotencyStore<I, R>;
identity: I;
fingerprint: string;
/** Must be safe to call exactly once; a second call is never attempted here. */
dispatch: () => Promise<R>;
}): Promise<OperationOutcome<R>> {
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
try {
const won = await input.store.start(input.identity, input.fingerprint);
if (!won) {
const stored = await input.store.receipt(input.identity, input.fingerprint);
return stored !== undefined
? { kind: "replayed", receipt: stored }
: { kind: "replayed", receipt: undefined as unknown as R };
}
} catch (error) {
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
}
const receipt = await input.dispatch();
try {
await input.store.save(input.identity, input.fingerprint, receipt);
} catch {
/* The downstream commit is real; a journal-write failure must not turn a
real receipt into an error. The next retry replays via the stored receipt
if the save ever succeeds before a downstream resend. */
}
return { kind: "claimed", execute: async () => receipt };
}
+235 -2
View File
@@ -1,2 +1,235 @@
export * from './relationships';
export * from './idempotency';
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
-235
View File
@@ -1,235 +0,0 @@
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
+21 -55
View File
@@ -108,9 +108,6 @@ importers:
'@siax/access':
specifier: workspace:*
version: link:packages/siax-access
'@siax/auth-zitadel':
specifier: 0.1.1
version: 0.1.1
'@siax/charts':
specifier: ^0.5.0
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -121,8 +118,8 @@ importers:
specifier: ^0.2.1
version: 0.2.1(tailwindcss@4.3.3)
'@siax/contracts':
specifier: ^0.12.1
version: 0.12.1
specifier: ^0.11.1
version: 0.11.1
'@siax/import-export':
specifier: ^0.2.2
version: 0.2.2
@@ -130,8 +127,8 @@ importers:
specifier: ^0.1.2
version: 0.1.2
'@siax/ui':
specifier: ^0.16.13
version: 0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
specifier: ^0.15.1
version: 0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
autoprefixer:
specifier: ^10.4.20
version: 10.5.5(postcss@8.5.28)
@@ -1549,23 +1546,14 @@ packages:
cpu: [x64]
os: [win32]
'@siax/analytical-context@0.5.1':
resolution: {integrity: sha512-bqc2NGPyzLVcu4VUgz1XYsyghSM4c3ck5gBVFSzeRYMhe2cj0nf5Fm5n/sFaOuhMaKdDnX/TIR2FO6f21lgufg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fanalytical-context/-/0.5.1/analytical-context-0.5.1.tgz}
'@siax/auth-core@0.1.0':
resolution: {integrity: sha512-oTfEO4S6pv3rQ6bSh44+fRWPiwGr9nYCf6uWSBF8g5PFUspW6nr4wKQZVAkLNwfC0Pu6o+CH8ltsuu+fY8gOQA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-core/-/0.1.0/auth-core-0.1.0.tgz}
'@siax/auth-zitadel@0.1.1':
resolution: {integrity: sha512-SmoBswG5P7szZIaETqmo1NN5RhdXLq2RDR/1wObfxHM2yUEXNSZQJznZoAb8z3lm/PvBgQhmnHiOHCfidN1ZQg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-zitadel/-/0.1.1/auth-zitadel-0.1.1.tgz}
'@siax/charts@0.11.0':
resolution: {integrity: sha512-IQnby+h6jVMaRAM7DcfUMuMbSUjd8B3+AGk9N4pXk2GDudMRMjt0bTtu75637Jc1Mf4iNonvgp8ytKlS7KdW4A==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.11.0/charts-0.11.0.tgz}
'@siax/charts@0.5.0':
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
'@siax/charts@0.5.0':
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
'@siax/charts@0.5.1':
resolution: {integrity: sha512-SFo6771jplE3fpUu2IsK11NipM5iAlM2h1N7jdVgsqqs3do+TnOi2knhIPSpUBhKfh5bZwMvO5v2TsrZmtsO+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.1/charts-0.5.1.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
@@ -1581,20 +1569,14 @@ packages:
'@siax/contracts@0.11.1':
resolution: {integrity: sha512-sn0ZlprJE8LcbKF4PF8wBOhdOfygOG/JbU7nB+NDFBBP/AhtvMXcxFGl1oX0GadifD7VqZrVSoCliQqY/V/HYg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.11.1/contracts-0.11.1.tgz}
'@siax/contracts@0.12.1':
resolution: {integrity: sha512-gSvMYRSxfbPPAqUGRjUMO1s38E4LdmIzDEpiNNIiGVminDHHwCOCybnhf2YNYaIfm6VBYoq4rP4yCERrpJregA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.12.1/contracts-0.12.1.tgz}
'@siax/import-export@0.2.2':
resolution: {integrity: sha512-mb2fM6lVjWh6cVdoBFOLTdyjYRtjMjlSxNtpNHZHdW51tDrNZRNZc0Mz+lVIFsJHQ79NbL2J8GsWpHmL/rVBzg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.2/import-export-0.2.2.tgz}
'@siax/import-export@0.2.4':
resolution: {integrity: sha512-xYRLF/5B7EyK3jbZCsPvDcEhZRGC/72QQpkEIsFJQqKwUxsejiQ1RabjzLoF0wNjIKlubEYnkfxDBaJMeIgKrg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.4/import-export-0.2.4.tgz}
'@siax/tokens@0.1.2':
resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz}
'@siax/ui@0.16.13':
resolution: {integrity: sha512-qWmGexMa7kqol5O3MBDkraF/s5APAahNyGHQusDZ34YDZvGnlr3t7raWIVJMFHgz6D0Fsgyuak6210Ktn3SpHA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.16.13/ui-0.16.13.tgz}
'@siax/ui@0.15.1':
resolution: {integrity: sha512-GeU+Iwj7s3cWJUHfvt0r07dWGCGvh9jG9cS9D4EkdKb+EQGhOns2IGbDNPYRkLj/t55Qz2fjSrYCMwXO19zPDw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.15.1/ui-0.15.1.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
@@ -4243,17 +4225,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@siax/analytical-context@0.5.1': {}
'@siax/auth-core@0.1.0': {}
'@siax/auth-zitadel@0.1.1':
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@siax/auth-core': 0.1.0
clsx: 2.1.1
echarts: 6.1.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tailwind-merge: 2.6.1
'@siax/charts@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
'@siax/charts@0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
dependencies:
'@siax/analytical-context': 0.5.1
'@siax/config': 0.2.1(tailwindcss@4.3.3)
clsx: 2.1.1
echarts: 6.1.0
@@ -4263,14 +4244,6 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
clsx: 2.1.1
echarts: 6.1.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tailwind-merge: 2.6.1
'@siax/collaboration@0.3.0': {}
'@siax/config@0.2.1(tailwindcss@4.3.3)':
@@ -4279,19 +4252,13 @@ snapshots:
'@siax/contracts@0.11.1': {}
'@siax/contracts@0.12.1': {}
'@siax/import-export@0.2.2':
dependencies:
'@siax/contracts': 0.11.1
'@siax/import-export@0.2.4':
dependencies:
'@siax/contracts': 0.12.1
'@siax/tokens@0.1.2': {}
'@siax/ui@0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
'@siax/ui@0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
dependencies:
'@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -4314,11 +4281,10 @@ snapshots:
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@siax/analytical-context': 0.5.1
'@siax/charts': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
'@siax/charts': 0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
'@siax/config': 0.2.1(tailwindcss@4.3.3)
'@siax/contracts': 0.12.1
'@siax/import-export': 0.2.4
'@siax/contracts': 0.11.1
'@siax/import-export': 0.2.2
'@siax/tokens': 0.1.2
'@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
-821
View File
@@ -1,821 +0,0 @@
{
"schemaVersion": 1,
"repo": "0pt",
"role": "consumer",
"canonicalOwner": "sax3l/DES1GN",
"sourceRoots": [
"app",
"components"
],
"packagePolicy": {
"namespace": "@siax",
"registry": "https://git.siax.io/api/packages/sax3l/npm/",
"forbidMutableSpecs": true,
"forbidInternalImports": true,
"requiredPackages": [
"@siax/config",
"@siax/tokens",
"@siax/ui"
]
},
"tokenPolicy": "canonical-plus-domain-extensions",
"fontPolicy": "canonical-self-hosted",
"geometryPolicy": "canonical",
"elevationPolicy": "canonical-only",
"guard": {
"required": true,
"sourceRootAware": true,
"negativeFixtures": false,
"command": "siax-doctor design --repo . --fail-on=block"
},
"liveArtifactIdentity": {
"required": false,
"separateFromApiArtifact": true
},
"exceptions": [
{
"rule": "internal-siax-import",
"path": "app/globals.css",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/aspect-ratio.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/avatar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/breadcrumb.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/carousel.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/collapsible.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/drawer.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/form.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/label.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/pagination.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/progress.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/resizable.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/scroll-area.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/separator.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sonner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/spinner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/table.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toaster.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/empty.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
}
]
}
@@ -1,12 +0,0 @@
{
"id": "pe-artifact-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "artifact-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker image inspect RepoDigests (real registry digest)",
"proofRef": "docker://server6 image shxd6lepxuxfsjsp0ath3z1g:13e156ec7e6be534de9c28d28ec2daff0355ca7f image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"tool": "docker"
}
@@ -1,10 +0,0 @@
{
"id": "pe-ci-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "deploy-log",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "Coolify deployment record (application_deployment_queues, status=finished)",
"proofRef": "coolify://server6/shxd6lepxuxfsjsp0ath3z1g finished 2026-11-04 05:42:18",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f"
}
@@ -1,11 +0,0 @@
{
"id": "pe-running-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "running-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker inspect (running container image digest)",
"proofRef": "docker://server6 container/shxd6lepxuxfsjsp0ath3z1g-20261104T093337 image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
@@ -1,12 +0,0 @@
{
"id": "pe-sbom-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "sbom",
"result": "pass",
"verifiedAt": "2026-11-04T09:29:06Z",
"verifiedBy": "syft (estate-kampanj issue #76, skannad vid deployad commit)",
"proofRef": "siax/sbom/sbom-0pt-13e156ec7e6b.json",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"tool": "syft",
"sbomFormat": "cyclonedx-json"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
# 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.
+2 -2
View File
@@ -1,4 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPRG9iG1pjIW548KoIduQPD/B3Yb
tg5hPmy7o4feVQsdDnW38VtemVBQ4I+goytV9X4XkrFiweKmfqEMhGyPgg==
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6Cdqn5WinLh2POoCPzroqb2Tz0Jw
W+vJqjfc1+UybkeU9NZkamkDgCWunNHSn4gGmAyrZpZ8wQqxK35cBhwNeA==
-----END PUBLIC KEY-----
@@ -1,10 +0,0 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
"id": "sb-0pt-13e156ec7e6b",
"repo": "0pt",
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"branch": "main",
"buildId": "coolify:0pt",
"producedAt": "2026-11-04T01:26:48Z",
"artifactId": "shxd6lepxuxfsjsp0ath3z1g@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
-99
View File
@@ -1,99 +0,0 @@
-- 020_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
--
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
-- 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;
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;
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;
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));
+8 -25
View File
@@ -50,7 +50,6 @@ import {
type JWTPayload,
type JWTVerifyGetKey,
} from 'jose';
import { normalizeZitadelClaims, ZitadelClaimError } from '@siax/auth-zitadel';
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
@@ -255,29 +254,13 @@ export async function verifyBearer(
throw new BearerVerificationError('token_verification_failed', err);
}
try {
const identity = normalizeZitadelClaims(payload, {
source: 'jwt',
tenantClaim: opts.tenantClaim ?? tenantClaimOverride(),
});
return {
sub: identity.principal.subject,
tenantId: identity.tenant.tenantId,
claims: payload,
};
} catch (err) {
if (err instanceof ZitadelClaimError) {
if (/sub/i.test(err.reason)) {
throw new BearerVerificationError('token_missing_sub');
}
if (/tenant/i.test(err.reason)) {
throw new BearerVerificationError('token_missing_tenant_claim');
}
if (/aud/i.test(err.reason)) {
throw new BearerVerificationError('token_invalid_audience');
}
throw new BearerVerificationError('token_verification_failed', err);
}
throw err;
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
if (!sub || sub.length === 0) {
throw new BearerVerificationError('token_missing_sub');
}
const tenantId = deriveTenantId(payload, opts.tenantClaim ?? tenantClaimOverride());
if (!tenantId) {
throw new BearerVerificationError('token_missing_tenant_claim');
}
return { sub, tenantId, claims: payload };
}
+39 -159
View File
@@ -24,9 +24,7 @@ export interface Doc0UploadReceipt {
/** The POST may have committed even if its response could not be verified. */
export class Doc0UploadOutcomeUnknownError extends Error {
constructor() {
super(
'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key',
);
super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key');
this.name = 'Doc0UploadOutcomeUnknownError';
}
}
@@ -62,44 +60,6 @@ export type Doc0Inspection =
* Internal contract only: callers must retain this identity before dispatch,
* authorize its scope, and keep the same input bytes/metadata until dispatch.
*/
/**
* One upload identity per operation — not per file.
*
* "Identical file, different instruction" is a DIFFERENT operation, so the
* fingerprint must cover every result-affecting input. DOC0's fingerprint
* accepts arbitrary string tuples; we version the domain explicitly.
*
* Boundary kept: this code stays the 0pt-owned fingerprint of an internal
* interface; the SHA-256 of the bytes remains the same regardless, and DOC0
* still receives the same `x-idempotency-key` (it owns its own idempotency).
*/
export function bindOperationToUploadIdentity(
input: Doc0UploadInput,
operation: {
instruction: string;
fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
modelId: string;
dataClass: string;
format: 'json' | 'csv';
},
): Doc0UploadInput {
const payloadVersion = createHash('sha256')
.update(
JSON.stringify([
'0pt-structure-operation-v1',
operation.instruction.length,
operation.instruction,
operation.fields.map((f) => [f.name, f.type, f.required === true]),
operation.modelId,
operation.dataClass,
operation.format,
]),
)
.digest('hex')
.slice(0, 16);
return { ...input, filename: `${input.filename}#${payloadVersion}` };
}
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
sourceSha256: string;
sourceSizeBytes: number;
@@ -108,25 +68,13 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
wireMimeType: string;
} {
if (
![
input.tenantId,
input.projectId,
input.actorId,
input.requestId,
input.idempotencyKey,
input.filename,
input.mimeType,
].every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) =>
/^[\x21-\x7e]{1,256}$/.test(value),
) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(
input.classification,
) ||
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength === 0
)
throw new Error('Invalid DOC0 upload input');
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType]
.every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
) throw new Error('Invalid DOC0 upload input');
const wireMimeType = input.mimeType.toLowerCase();
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
throw new Error('Invalid DOC0 upload MIME type');
@@ -135,25 +83,10 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
const wireFilename = `source.${extension}`;
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
const sourceSizeBytes = input.bytes.byteLength;
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
'doc0-upload-v1',
input.tenantId,
input.projectId,
input.actorId,
sourceSha256,
sourceSizeBytes,
wireFilename,
wireMimeType,
input.filename,
'unknown',
input.classification,
[],
null,
]),
)
.digest('hex');
const requestFingerprint = createHash('sha256').update(JSON.stringify([
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
])).digest('hex');
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
}
@@ -176,9 +109,7 @@ async function readBoundedJson(response: Response): Promise<unknown> {
parts.push(chunk.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
} finally {
reader.releaseLock();
}
} finally { reader.releaseLock(); }
}
/**
@@ -200,28 +131,16 @@ export class Doc0UploadClient {
},
) {
let url: URL;
try {
url = new URL(options.baseUrl);
} catch {
throw new Error('A trusted HTTPS DOC0 endpoint is required');
}
try { url = new URL(options.baseUrl); }
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!options.serviceToken.trim() ||
!Number.isSafeInteger(options.maxBytes) ||
options.maxBytes <= 0 ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) ||
options.timeoutMs < 1_000 ||
options.timeoutMs > 300_000))
(options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000))
) {
throw new Error(
'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required',
);
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
}
// Keep the validated values even if the caller later changes its options object.
this.options = { ...options };
@@ -248,11 +167,7 @@ export class Doc0UploadClient {
classification: input.classification,
}),
);
body.set(
'file',
new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }),
identity.wireFilename,
);
body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename);
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
@@ -268,16 +183,11 @@ export class Doc0UploadClient {
body,
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
});
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
} catch { throw new Doc0UploadOutcomeUnknownError(); }
// Even a non-2xx response does not prove the write did not commit.
let decoded: unknown;
try {
decoded = await readBoundedJson(response);
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
try { decoded = await readBoundedJson(response); }
catch { throw new Doc0UploadOutcomeUnknownError(); }
const data = decoded as {
document?: {
documentId?: unknown;
@@ -329,63 +239,33 @@ export class Doc0UploadClient {
* is complete. No automatic retry, settlement or customer route is wired.
*/
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
if (
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every(
(value) => typeof value === 'string' && value.trim(),
) ||
input.projectId.length > 256 ||
input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) ||
input.sourceSizeBytes <= 0
) {
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => typeof value === 'string' && value.trim()) ||
input.projectId.length > 256 || input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
throw new Error('Invalid DOC0 inspection input');
}
let result: unknown;
try {
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
method: 'POST',
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: {
authorization: `Bearer ${this.options.serviceToken}`,
'content-type': 'application/json',
'x-tenant-id': input.tenantId,
'x-actor-id': input.actorId,
'x-request-id': input.requestId,
'x-idempotency-key': input.idempotencyKey,
},
body: JSON.stringify({
projectId: input.projectId,
sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes,
requestFingerprint: input.requestFingerprint,
}),
method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
});
result = await readBoundedJson(response);
} catch {
throw new Doc0InspectionUnavailableError();
}
if (!result || typeof result !== 'object' || Array.isArray(result))
throw new Doc0InspectionUnavailableError();
} catch { throw new Doc0InspectionUnavailableError(); }
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
const record = result as Record<string, unknown>;
if (
typeof record.status === 'string' &&
['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1
) {
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1) {
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
}
if (
record.status === 'completed_recorded' &&
Object.keys(record).length === 3 &&
typeof record.documentId === 'string' &&
record.documentId.trim() &&
typeof record.versionId === 'string' &&
record.versionId.trim()
) {
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
typeof record.documentId === 'string' && record.documentId.trim() &&
typeof record.versionId === 'string' && record.versionId.trim()) {
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
}
throw new Doc0InspectionUnavailableError();
@@ -1,227 +0,0 @@
/**
* 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[]>;
}
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: String(r.observed_at),
valid_from: r.valid_from ? String(r.valid_from) : undefined,
valid_to: r.valid_to ? String(r.valid_to) : undefined,
confidence: r.confidence === null ? undefined : Number(r.confidence),
quality_status: r.quality_status, status: r.status, created_at: String(r.created_at),
})) as CanonicalFact[];
});
}
}
-4
View File
@@ -1,4 +0,0 @@
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
import { CanonicalFactService } from "./canonical-fact-service";
export const canonicalFactService = new CanonicalFactService();
-165
View File
@@ -1,165 +0,0 @@
/**
* 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) });
}
}
-102
View File
@@ -1,102 +0,0 @@
/**
* 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;
}
-60
View File
@@ -263,63 +263,3 @@ export const activateSourceSchema = z.object({
tenant_id: z.string().min(1),
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(),
});
@@ -45,13 +45,6 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
expect(res.status).toBe(200);
});
it('a valid X-Internal-Token lets the acquisition engine write canonical facts', async () => {
const res = await middleware(
req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }),
);
expect(res.status).toBe(200);
});
it('an invalid X-Internal-Token still falls through to the ordinary Bearer requirement (401, not a bypass)', async () => {
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
expect(res.status).toBe(401);
-1
View File
@@ -40,7 +40,6 @@ async function sign(claims: Record<string, unknown>, opts: SignOpts = {}): Promi
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt()
.setIssuer(opts.issuer ?? ISSUER)
.setAudience('siax-test')
.setExpirationTime(opts.expirationTime ?? '2h')
.sign(privateKey);
}
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
bindOperationToUploadIdentity,
prepareDoc0UploadIdentity,
type Doc0UploadInput,
} from '../../src/document-workbench/doc0-upload-client';
const input: Doc0UploadInput = {
tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'k1',
filename: 'rapport.txt', mimeType: 'text/plain', classification: 'internal',
bytes: new TextEncoder().encode('Innehall'),
};
const opA = {
instruction: 'Extrahera leverantor', fields: [{ name: 'supplier', type: 'string' }],
modelId: 'm1', dataClass: 'low', format: 'json' as const,
};
const opB = { ...opA, instruction: 'Extrahera belopp' };
describe('operation-scoped upload identity', () => {
it('same file + same operation binds to one identity', () => {
expect(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)))
.toEqual(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)));
});
it('same file + DIFFERENT instruction = DIFFERENT identity (not the same operation)', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.requestFingerprint).not.toBe(b.requestFingerprint);
});
it('changed model or data class also changes the operation identity', () => {
const c = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, modelId: 'm2' }));
const d = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, dataClass: 'high' }));
const e = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, format: 'csv' }));
expect(c.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(d.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(e.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
});
it('raw bytes checksum stays stable across operations', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.sourceSha256).toBe(b.sourceSha256);
});
it('prefixes the operation marker so odd filenames cannot collide', () => {
const bound = bindOperationToUploadIdentity(input, opA);
expect(bound.filename.startsWith('rapport.txt#')).toBe(true);
});
});
-86
View File
@@ -1,86 +0,0 @@
/**
* 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);
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
describe("@siax/access universal idempotency", () => {
it("binds every result-affecting input into the fingerprint", () => {
const a = operationFingerprint(["t", "p", "u", "instruction-extrahera"]);
const b = operationFingerprint(["t", "p", "u", "instruction-extrahera-belopp"]);
expect(a).not.toBe(b);
});
it("rejects malformed identity (same shape 0pt/DOC0 both enforce)", () => {
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "../bad", payload: [] })).toThrow();
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "ok", payload: "not-array" as never })).toThrow();
});
it("conflicts when a reused key carries a changed payload", () => {
const fp1 = operationFingerprint(["t", "p", "u", "A"]);
const fp2 = operationFingerprint(["t", "p", "u", "B"]);
expect(fp1).not.toBe(fp2);
});
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
const stored = { x: 1 };
const store: any = {
start: async () => false,
save: async () => {},
receipt: async () => stored,
};
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { throw new Error("must not rerun"); },
});
expect(outcome.kind).toBe("replayed");
if (outcome.kind === "replayed") expect(outcome.receipt).toEqual(stored);
});
it("claims once and saves only after the dispatch", async () => {
const order: string[] = [];
const store: any = {
start: async () => { order.push("start"); return true; },
save: async () => { order.push("save"); },
receipt: async () => { order.push("receipt"); return undefined; },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { order.push("dispatch"); return { x: ++dispatched }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
expect(order).toEqual(["start", "dispatch", "save"]);
});
it("reports a store error as conflict, never as a fresh start", async () => {
const store: any = {
start: async () => { throw new Error("db down"); },
save: async () => {},
receipt: async () => { throw new Error("db down"); },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("conflict");
expect(dispatched).toBe(0);
});
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
const store: any = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
});
});