feat(document-workbench): wire customer upload, structuring, credits and readiness
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 13s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m26s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m33s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 13s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m26s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m33s
- POST /api/0pt/documents/upload: real multipart bytes into DOC0 through the durable attempt journal and the existing coordinator; verified tenant/actor only, project authorization fail-closed. - POST /api/0pt/documents/structure: DOC0 segments -> INF0 -> evidence-bound validation -> optional JSON/CSV export, with credit reserve/settle. - Credits: balance, server-owned price book, Stripe Checkout and idempotent webhook reconciliation via a Stripe re-read of session + payment intent. - /portal/dokument and /portal/krediter: SIAX-styled workspace. - @siax/access: universal, framework-neutral OpenFGA project relationship primitive reusable by DOC0/N0D/other products. - Readiness: real Redis PING probe instead of presence-only; queue is now a live dependency when configured. - Dockerfile: copy every workspace manifest so --frozen-lockfile works. - Format/build hygiene: .prettierignore, .worktrees ignored, valid manifest YAML.
This commit is contained in:
@@ -202,3 +202,51 @@ NOCODB_API_TOKEN=
|
|||||||
# Test-only (tests/p0 real-infra suite; not needed to run the app)
|
# Test-only (tests/p0 real-infra suite; not needed to run the app)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ST0RE_TEST_TENANT_ID=
|
# ST0RE_TEST_TENANT_ID=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Document workbench (upload -> DOC0 -> INF0 -> validated export -> credits)
|
||||||
|
# Wired in code today: app/api/0pt/documents/*, app/api/0pt/credits/*.
|
||||||
|
# Every value is optional to boot and mandatory to USE: a missing integration
|
||||||
|
# makes its route answer 503 "not configured" instead of fabricating success.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DOC0 document service. URL must be absolute HTTPS; token is a service PAT.
|
||||||
|
DOC0_URL=https://doc0.siax.io
|
||||||
|
DOC0_SERVICE_TOKEN=
|
||||||
|
# Maximum accepted upload size (bytes). Default 25 MiB.
|
||||||
|
OPT_MAX_UPLOAD_BYTES=26214400
|
||||||
|
|
||||||
|
# INF0 AI gateway (model calls). No provider key is ever placed in this repo.
|
||||||
|
INF0_URL=https://api.inf0.siax.io
|
||||||
|
INF0_API_TOKEN=
|
||||||
|
|
||||||
|
# PAY0 (optional; Stripe is used directly for credit checkout today).
|
||||||
|
PAY0_URL=https://pay0.siax.io
|
||||||
|
PAY0_API_TOKEN=
|
||||||
|
|
||||||
|
# N0D / ST0RE (used by the broader pipeline; not required by the routes above).
|
||||||
|
N0D_URL=https://api.n0d.siax.io
|
||||||
|
N0D_API_TOKEN=
|
||||||
|
ST0RE_URL=https://api.st0re.siax.io
|
||||||
|
ST0RE_API_TOKEN=
|
||||||
|
|
||||||
|
# OpenFGA project authorization. Without all four values, document routes fail
|
||||||
|
# closed with PROJECT_AUTHORIZATION_NOT_CONFIGURED.
|
||||||
|
OPENFGA_API_URL=https://fga.siax.io
|
||||||
|
OPENFGA_STORE_ID=
|
||||||
|
OPENFGA_MODEL_ID=
|
||||||
|
OPENFGA_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Model policy for the document workbench. Without this, structuring answers
|
||||||
|
# MODEL_POLICY_NOT_CONFIGURED.
|
||||||
|
OPT_ALLOWED_MODELS=
|
||||||
|
OPT_ALLOWED_DATA_CLASSES=none,low
|
||||||
|
OPT_MODEL_DESTINATION=external
|
||||||
|
OPT_EXTERNAL_TRANSFER_ALLOWED=false
|
||||||
|
OPT_MODEL_MAX_INPUT_TOKENS=60000
|
||||||
|
OPT_MODEL_MAX_OUTPUT_TOKENS=4000
|
||||||
|
|
||||||
|
# Server-owned, versioned credit price book (JSON, one line). No price is ever
|
||||||
|
# read from a browser. Without it, credit packs/checkout/structuring answer
|
||||||
|
# PRICE_BOOK_NOT_CONFIGURED.
|
||||||
|
# {"version":"2026-11-01","currency":"SEK","packs":[{"id":"starter","credits":100,"amountMinor":9900}],"metering":{"pagesPerCredit":5,"inputTokensPerCredit":20000,"outputTokensPerCredit":4000}}
|
||||||
|
OPT_CREDIT_PRICE_BOOK=
|
||||||
|
|||||||
@@ -52,3 +52,6 @@ services/scrapers/output/
|
|||||||
# local-only environment overrides (all real values live in Infisical)
|
# local-only environment overrides (all real values live in Infisical)
|
||||||
.env.local
|
.env.local
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
|
# parallel git worktrees (policy: hidden inside the repo, never committed)
|
||||||
|
/.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Build output and local worktrees are not source.
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.worktrees/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -4,9 +4,9 @@ schemaVersion: siax.des1gn-consumer/v1
|
|||||||
repo: 0pt
|
repo: 0pt
|
||||||
generatedAt: 2026-09-23T21:12:27.075Z
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
consumes:
|
consumes:
|
||||||
@siax/0pt-decision: 'härlett ur package.json (deklarerad workspace:*)'
|
'@siax/0pt-decision': 'härlett ur package.json (deklarerad workspace:*)'
|
||||||
@siax/config: 'härlett ur package.json (deklarerad ^0.2.1)'
|
'@siax/config': 'härlett ur package.json (deklarerad ^0.2.1)'
|
||||||
@siax/tokens: 'härlett ur package.json (deklarerad ^0.1.2)'
|
'@siax/tokens': 'härlett ur package.json (deklarerad ^0.1.2)'
|
||||||
@siax/ui: 'härlett ur package.json (deklarerad ^0.4.1)'
|
'@siax/ui': 'härlett ur package.json (deklarerad ^0.4.1)'
|
||||||
localPrimitives: []
|
localPrimitives: []
|
||||||
notes: GENERATED stub - tomt ar den arliga baseline-formen; konsumerade kanoniska DES1GN-paket (pinned) listas under consumes, kvarvarande lokala primitives motiveras i notes.
|
notes: GENERATED stub - tomt ar den arliga baseline-formen; konsumerade kanoniska DES1GN-paket (pinned) listas under consumes, kvarvarande lokala primitives motiveras i notes.
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --ac
|
|||||||
# (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens
|
# (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens
|
||||||
# manifest saknas på disk.
|
# manifest saknas på disk.
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
|
# Every workspace importer in pnpm-lock.yaml must have its manifest on disk,
|
||||||
|
# otherwise --frozen-lockfile refuses to install. Keep this list in sync with
|
||||||
|
# `packages/*/package.json`.
|
||||||
COPY packages/schemas/package.json ./packages/schemas/package.json
|
COPY packages/schemas/package.json ./packages/schemas/package.json
|
||||||
|
COPY packages/decision/package.json ./packages/decision/package.json
|
||||||
|
COPY packages/siax-access/package.json ./packages/siax-access/package.json
|
||||||
|
|
||||||
# --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript
|
# --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript
|
||||||
# så fort byggmiljön råkar ha NODE_ENV=production satt.
|
# så fort byggmiljön råkar ha NODE_ENV=production satt.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** GET /api/0pt/credits/balance — the authenticated tenant's credit balance. */
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const url = repositoryDatabaseUrl();
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Credits are not available until the database is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ledger = new PostgresCreditLedger(getRepositorySql(url));
|
||||||
|
const balance = await ledger.balance(verified.scope.tenantId);
|
||||||
|
return NextResponse.json({ tenant_id: verified.scope.tenantId, ...balance });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit balance unavailable';
|
||||||
|
const notConfigured = /RLS-enforced|isolation migration/i.test(message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: notConfigured ? 'CREDITS_NOT_CONFIGURED' : 'CREDITS_UNAVAILABLE', message },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { getStripe } from '@/lib/stripe';
|
||||||
|
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
|
||||||
|
import { findPack, readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function appBaseUrl(request: NextRequest): string {
|
||||||
|
const configured = process.env.NEXT_PUBLIC_APP_URL;
|
||||||
|
if (configured && /^https:\/\//.test(configured)) return configured.replace(/\/+$/, '');
|
||||||
|
return request.nextUrl.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/credits/checkout { pack_id }
|
||||||
|
*
|
||||||
|
* Creates a Stripe Checkout Session and stores the server-owned price snapshot
|
||||||
|
* BEFORE any credit exists. Credits are granted only when the Stripe webhook
|
||||||
|
* re-reads the session and its payment intent and the ledger accepts the
|
||||||
|
* idempotent verified-payment command.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId } = verified.scope;
|
||||||
|
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PRICE_BOOK_NOT_CONFIGURED', message: 'Credit purchases are not available yet' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stripe = getStripe();
|
||||||
|
if (!stripe) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'STRIPE_NOT_CONFIGURED', message: 'Payments are not available yet' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const databaseUrl = repositoryDatabaseUrl();
|
||||||
|
if (!databaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Credits are not available until the database is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const packId = (body as { pack_id?: unknown } | null)?.pack_id;
|
||||||
|
if (typeof packId !== 'string' || !isValidScopeId(packId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_PACK', message: 'A valid pack_id is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pack = findPack(book, packId);
|
||||||
|
if (!pack) {
|
||||||
|
return NextResponse.json({ error: 'UNKNOWN_PACK', message: 'Unknown credit pack' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseId = crypto.randomUUID();
|
||||||
|
const base = appBaseUrl(request);
|
||||||
|
const metadata = {
|
||||||
|
kind: '0pt_credit_purchase',
|
||||||
|
tenant_id: tenantId,
|
||||||
|
purchase_id: purchaseId,
|
||||||
|
price_book_version: book.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await stripe.checkout.sessions.create({
|
||||||
|
mode: 'payment',
|
||||||
|
client_reference_id: tenantId,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price_data: {
|
||||||
|
currency: book.currency.toLowerCase(),
|
||||||
|
unit_amount: pack.amountMinor,
|
||||||
|
product_data: { name: `${pack.credits} 0pt credits` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata,
|
||||||
|
payment_intent_data: { metadata },
|
||||||
|
success_url: `${base}/portal/krediter?status=success&purchase=${encodeURIComponent(purchaseId)}`,
|
||||||
|
cancel_url: `${base}/portal/krediter?status=cancelled`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider could not start a checkout' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new PostgresCreditPurchaseStore(getRepositorySql(databaseUrl)).create({
|
||||||
|
tenantId,
|
||||||
|
purchaseId,
|
||||||
|
checkoutSessionId: session.id,
|
||||||
|
amountMinor: pack.amountMinor,
|
||||||
|
currency: book.currency,
|
||||||
|
credits: pack.credits,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Never leave a payable session with no server-side snapshot behind it.
|
||||||
|
try {
|
||||||
|
await stripe.checkout.sessions.expire(session.id);
|
||||||
|
} catch {
|
||||||
|
/* best effort; the webhook will refuse the unmatched session */
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit purchase could not be stored';
|
||||||
|
return NextResponse.json({ error: 'CREDITS_NOT_CONFIGURED', message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider returned no checkout URL' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
purchase_id: purchaseId,
|
||||||
|
checkout_url: session.url,
|
||||||
|
credits: pack.credits,
|
||||||
|
amount_minor: pack.amountMinor,
|
||||||
|
currency: book.currency,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/0pt/credits/packs — sellable credit packs from the server-owned
|
||||||
|
* price book. No price is ever accepted from the client.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'PRICE_BOOK_NOT_CONFIGURED',
|
||||||
|
message: 'Credit packs are not available until the server price book is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
version: book.version,
|
||||||
|
currency: book.currency,
|
||||||
|
packs: book.packs.map((pack) => ({
|
||||||
|
id: pack.id,
|
||||||
|
credits: pack.credits,
|
||||||
|
amount_minor: pack.amountMinor,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function csv(value: string | undefined): string[] {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/0pt/documents/models — the server-approved model routes and limits
|
||||||
|
* the customer may choose from. Never a free-form model list.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const models = csv(process.env.OPT_ALLOWED_MODELS);
|
||||||
|
const dataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
|
||||||
|
const destination = process.env.OPT_MODEL_DESTINATION;
|
||||||
|
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
|
||||||
|
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
|
||||||
|
if (
|
||||||
|
models.length === 0 ||
|
||||||
|
dataClasses.length === 0 ||
|
||||||
|
(destination !== 'internal' && destination !== 'external') ||
|
||||||
|
!Number.isSafeInteger(maxInputTokens) ||
|
||||||
|
maxInputTokens <= 0 ||
|
||||||
|
!Number.isSafeInteger(maxOutputTokens) ||
|
||||||
|
maxOutputTokens <= 0
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'MODEL_POLICY_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until the model policy is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
return NextResponse.json({
|
||||||
|
models,
|
||||||
|
data_classes: dataClasses,
|
||||||
|
destination,
|
||||||
|
external_transfer_allowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
|
||||||
|
max_input_tokens: maxInputTokens,
|
||||||
|
max_output_tokens: maxOutputTokens,
|
||||||
|
price_book_version: book?.version ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-workbench/doc0-source-client';
|
||||||
|
import { Inf0GatewayClient, Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client';
|
||||||
|
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||||
|
import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { structureDocument } from '@/src/document-workbench/structure-document';
|
||||||
|
import { exportValidatedOutput } from '@/src/document-workbench/export-validated-output';
|
||||||
|
import type {
|
||||||
|
OutputFieldContract,
|
||||||
|
OutputFieldType,
|
||||||
|
} from '@/src/document-workbench/structured-output-validation';
|
||||||
|
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
|
||||||
|
import { createProjectAuthorizer } from '@/src/document-workbench/project-access';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const FIELD_TYPES: readonly OutputFieldType[] = ['string', 'integer', 'boolean', 'decimal', 'date'];
|
||||||
|
const DATA_CLASSES = ['none', 'low', 'medium', 'high', 'restricted'] as const;
|
||||||
|
|
||||||
|
function csv(value: string | undefined): string[] {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelPolicy() {
|
||||||
|
const allowedModelIds = csv(process.env.OPT_ALLOWED_MODELS);
|
||||||
|
const allowedDataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
|
||||||
|
const destination = process.env.OPT_MODEL_DESTINATION;
|
||||||
|
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
|
||||||
|
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
|
||||||
|
if (
|
||||||
|
allowedModelIds.length === 0 ||
|
||||||
|
allowedDataClasses.length === 0 ||
|
||||||
|
(destination !== 'internal' && destination !== 'external') ||
|
||||||
|
!Number.isSafeInteger(maxInputTokens) ||
|
||||||
|
maxInputTokens <= 0 ||
|
||||||
|
!Number.isSafeInteger(maxOutputTokens) ||
|
||||||
|
maxOutputTokens <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowedModelIds,
|
||||||
|
allowedDataClasses,
|
||||||
|
destination: destination as 'internal' | 'external',
|
||||||
|
externalTransferAllowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
|
||||||
|
maxInputTokens,
|
||||||
|
maxOutputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFields(raw: unknown): OutputFieldContract[] | undefined {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) return undefined;
|
||||||
|
const fields: OutputFieldContract[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
|
||||||
|
const field = item as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof field.name !== 'string' ||
|
||||||
|
!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(field.name) ||
|
||||||
|
seen.has(field.name) ||
|
||||||
|
typeof field.type !== 'string' ||
|
||||||
|
!FIELD_TYPES.includes(field.type as OutputFieldType)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
seen.add(field.name);
|
||||||
|
fields.push({ name: field.name, type: field.type as OutputFieldType, required: true });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StructureBody {
|
||||||
|
project_id?: unknown;
|
||||||
|
document_id?: unknown;
|
||||||
|
version_id?: unknown;
|
||||||
|
checksum_sha256?: unknown;
|
||||||
|
pipeline_stage?: unknown;
|
||||||
|
instruction?: unknown;
|
||||||
|
fields?: unknown;
|
||||||
|
model_id?: unknown;
|
||||||
|
data_class?: unknown;
|
||||||
|
format?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/documents/structure
|
||||||
|
*
|
||||||
|
* Upload → DOC0 segments → INF0 model → mechanically validated, evidence-bound
|
||||||
|
* output (optionally exported). Credits are reserved before the model runs and
|
||||||
|
* settled to the measured usage afterwards.
|
||||||
|
*
|
||||||
|
* Nothing is trusted from the client except the requested target fields and
|
||||||
|
* instruction. Source text is re-read from DOC0 and every returned value must
|
||||||
|
* point at an exact span in that text.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId, actorId } = verified.scope;
|
||||||
|
|
||||||
|
const runtimeConfig = readDocumentWorkbenchRuntime();
|
||||||
|
if (!runtimeConfig.doc0 || !runtimeConfig.inf0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'WORKBENCH_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until DOC0 and INF0 are configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const policy = modelPolicy();
|
||||||
|
if (!policy) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'MODEL_POLICY_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until the model policy is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const priceBook = readCreditPriceBook();
|
||||||
|
const databaseUrl = repositoryDatabaseUrl();
|
||||||
|
if (!priceBook || !databaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until credits are configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: StructureBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as StructureBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = body.project_id;
|
||||||
|
const documentId = body.document_id;
|
||||||
|
const versionId = body.version_id;
|
||||||
|
const checksum = body.checksum_sha256;
|
||||||
|
const pipelineStage = body.pipeline_stage;
|
||||||
|
const instruction = body.instruction;
|
||||||
|
const modelId = body.model_id;
|
||||||
|
const dataClass = body.data_class ?? 'low';
|
||||||
|
const format = body.format ?? 'json';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isValidScopeId(projectId) ||
|
||||||
|
!isValidScopeId(documentId) ||
|
||||||
|
!isValidScopeId(versionId) ||
|
||||||
|
typeof checksum !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(checksum) ||
|
||||||
|
pipelineStage !== 'parsed' ||
|
||||||
|
typeof instruction !== 'string' ||
|
||||||
|
!instruction.trim() ||
|
||||||
|
instruction.length > 4000 ||
|
||||||
|
typeof modelId !== 'string' ||
|
||||||
|
!policy.allowedModelIds.includes(modelId) ||
|
||||||
|
typeof dataClass !== 'string' ||
|
||||||
|
!(DATA_CLASSES as readonly string[]).includes(dataClass) ||
|
||||||
|
(format !== 'json' && format !== 'csv')
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_REQUEST', message: 'Invalid document structure request' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fields = parseFields(body.fields);
|
||||||
|
if (!fields) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_FIELDS', message: 'A valid field contract is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = createProjectAuthorizer();
|
||||||
|
if (!(await authorize({ tenantId, projectId, actorId }))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const correlationId = request.headers.get('x-request-id') ?? crypto.randomUUID();
|
||||||
|
const context = {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
capability: '0pt.document.structure.v1',
|
||||||
|
correlationId,
|
||||||
|
idempotencyKey: correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sources;
|
||||||
|
try {
|
||||||
|
sources = await new Doc0SourceClient({
|
||||||
|
baseUrl: runtimeConfig.doc0.baseUrl,
|
||||||
|
serviceToken: runtimeConfig.doc0.serviceToken,
|
||||||
|
}).read({
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
requestId: correlationId,
|
||||||
|
receipt: {
|
||||||
|
documentId,
|
||||||
|
versionId,
|
||||||
|
checksumSha256: checksum,
|
||||||
|
pipelineStage: 'parsed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Doc0SourceUnavailableError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'DOC0_SOURCE_UNAVAILABLE',
|
||||||
|
message:
|
||||||
|
'The extracted source could not be verified (version, checksum, page cap or tenant). Review or reconciliation is required.',
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DOC0_SOURCE_UNAVAILABLE', message: 'The extracted source is unavailable' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceChars = sources.sources.reduce((total, source) => total + source.text.length, 0);
|
||||||
|
const estimatedInputTokens = Math.ceil(JSON.stringify(sources.sources).length / 4) + 1024;
|
||||||
|
if (estimatedInputTokens > policy.maxInputTokens) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'SOURCE_TOO_LARGE',
|
||||||
|
message: 'The document is larger than the configured model input limit',
|
||||||
|
},
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumCredits = creditsForUsage(priceBook, {
|
||||||
|
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
|
||||||
|
inputTokens: estimatedInputTokens,
|
||||||
|
outputTokens: policy.maxOutputTokens,
|
||||||
|
});
|
||||||
|
const reservationId = crypto.randomUUID();
|
||||||
|
const ledger = new PostgresCreditLedger(getRepositorySql(databaseUrl));
|
||||||
|
try {
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `reserve:${reservationId}`,
|
||||||
|
kind: 'reserve',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: maximumCredits,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit reservation failed';
|
||||||
|
if (/Insufficient available credits/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'INSUFFICIENT_CREDITS',
|
||||||
|
message: 'Not enough credits for this job',
|
||||||
|
required_credits: maximumCredits,
|
||||||
|
},
|
||||||
|
{ status: 402 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await structureDocument({
|
||||||
|
gateway: new Inf0GatewayClient({
|
||||||
|
baseUrl: runtimeConfig.inf0.baseUrl,
|
||||||
|
serviceToken: runtimeConfig.inf0.serviceToken,
|
||||||
|
}),
|
||||||
|
request: {
|
||||||
|
context,
|
||||||
|
admission: {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
jobId: `doc:${documentId}:${correlationId}`,
|
||||||
|
modelId,
|
||||||
|
dataClass,
|
||||||
|
maxInputTokens: policy.maxInputTokens,
|
||||||
|
maxOutputTokens: policy.maxOutputTokens,
|
||||||
|
maximumCredits,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
allowedModelIds: policy.allowedModelIds,
|
||||||
|
allowedDataClasses: policy.allowedDataClasses,
|
||||||
|
destination: policy.destination,
|
||||||
|
externalTransferAllowed: policy.externalTransferAllowed,
|
||||||
|
maxInputTokens: policy.maxInputTokens,
|
||||||
|
maxOutputTokens: policy.maxOutputTokens,
|
||||||
|
},
|
||||||
|
reservation: {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
jobId: `doc:${documentId}:${correlationId}`,
|
||||||
|
status: 'reserved',
|
||||||
|
credits: maximumCredits,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
countTokens: async (messages) => Math.ceil(JSON.stringify(messages).length / 4),
|
||||||
|
instructions: instruction,
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
const charged = Math.min(
|
||||||
|
maximumCredits,
|
||||||
|
creditsForUsage(priceBook, {
|
||||||
|
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
|
||||||
|
inputTokens: result.model.promptTokens,
|
||||||
|
outputTokens: result.model.completionTokens,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let balance;
|
||||||
|
try {
|
||||||
|
balance = (
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `settle:${reservationId}`,
|
||||||
|
kind: 'settle',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: charged,
|
||||||
|
})
|
||||||
|
).balance;
|
||||||
|
} catch {
|
||||||
|
// The model result is real; keep the reservation for reconciliation
|
||||||
|
// rather than reporting a balance we could not write.
|
||||||
|
balance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'mechanically_validated') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'needs_review',
|
||||||
|
issues: result.issues,
|
||||||
|
model: result.model,
|
||||||
|
credits: { reserved: maximumCredits, charged, balance },
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exported =
|
||||||
|
format === 'json'
|
||||||
|
? exportValidatedOutput({
|
||||||
|
format: 'json',
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
})
|
||||||
|
: exportValidatedOutput({
|
||||||
|
format: 'csv',
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'mechanically_validated',
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
model: result.model,
|
||||||
|
credits: { reserved: maximumCredits, charged, balance },
|
||||||
|
export:
|
||||||
|
exported.status === 'ready'
|
||||||
|
? { media_type: exported.mediaType, content: exported.content }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Inf0OutcomeUnknownError) {
|
||||||
|
// Never release a reservation whose model call may have completed.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'outcome_unknown',
|
||||||
|
message:
|
||||||
|
'The model call may have completed. The credit reservation is kept until its usage is reconciled.',
|
||||||
|
reservation_id: reservationId,
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `release:${reservationId}`,
|
||||||
|
kind: 'release',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* the reservation stays for reconciliation */
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Structuring failed';
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'STRUCTURE_FAILED', message, reservation_id: reservationId },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { Doc0UploadClient } from '@/src/document-workbench/doc0-upload-client';
|
||||||
|
import {
|
||||||
|
DocumentUploadCoordinator,
|
||||||
|
type CoordinatedUpload,
|
||||||
|
} from '@/src/document-workbench/upload-coordinator';
|
||||||
|
import { PostgresUploadAttemptStore } from '@/src/document-workbench/upload-attempt-store';
|
||||||
|
import {
|
||||||
|
createProjectAuthorizer,
|
||||||
|
projectAuthorizationConfigured,
|
||||||
|
} from '@/src/document-workbench/project-access';
|
||||||
|
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CLASSIFICATIONS = [
|
||||||
|
'public',
|
||||||
|
'internal',
|
||||||
|
'confidential',
|
||||||
|
'restricted',
|
||||||
|
'personal-data',
|
||||||
|
'financial',
|
||||||
|
'legal',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isClassification(value: string): value is (typeof CLASSIFICATIONS)[number] {
|
||||||
|
return (CLASSIFICATIONS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyKey(request: NextRequest, form: FormData): string | undefined {
|
||||||
|
const fromHeader = request.headers.get('idempotency-key');
|
||||||
|
const fromBody = form.get('idempotency_key');
|
||||||
|
const value = fromHeader ?? (typeof fromBody === 'string' ? fromBody : undefined);
|
||||||
|
return value && /^[\x21-\x7e]{1,256}$/.test(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/documents/upload — customer document upload.
|
||||||
|
*
|
||||||
|
* multipart/form-data:
|
||||||
|
* file (required) the original bytes; never trusted metadata
|
||||||
|
* project_id (required) verified project the file belongs to
|
||||||
|
* idempotency_key (required) or `Idempotency-Key` header
|
||||||
|
* classification optional, defaults to `restricted` (conservative)
|
||||||
|
*
|
||||||
|
* The tenant and actor come ONLY from middleware's verified headers. The route
|
||||||
|
* fails closed when project authorization or DOC0 is not configured.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId, actorId } = verified.scope;
|
||||||
|
|
||||||
|
const runtimeConfig = readDocumentWorkbenchRuntime();
|
||||||
|
if (!runtimeConfig.doc0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'DOC0_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until DOC0 is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!projectAuthorizationConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'PROJECT_AUTHORIZATION_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until project authorization is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const databaseUrl = repositoryDatabaseUrl();
|
||||||
|
if (!databaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'UPLOAD_JOURNAL_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until its durable journal is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > runtimeConfig.doc0.maxUploadBytes + 128 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'A multipart form with a file is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get('file');
|
||||||
|
const projectId = form.get('project_id');
|
||||||
|
const key = idempotencyKey(request, form);
|
||||||
|
const classificationRaw = form.get('classification');
|
||||||
|
const classification =
|
||||||
|
typeof classificationRaw === 'string' && classificationRaw.trim()
|
||||||
|
? classificationRaw.trim()
|
||||||
|
: 'restricted';
|
||||||
|
|
||||||
|
if (!(file instanceof File) || file.size <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'A non-empty file is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isValidScopeId(projectId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_PROJECT', message: 'A valid project_id is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
message: 'An idempotency key is required for document uploads',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isClassification(classification)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_CLASSIFICATION', message: 'Unknown classification' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.size > runtimeConfig.doc0.maxUploadBytes) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'The uploaded file could not be read' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bytes.byteLength === 0 || bytes.byteLength !== file.size) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'The uploaded file is incomplete' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = createProjectAuthorizer();
|
||||||
|
const sql = getRepositorySql(databaseUrl);
|
||||||
|
const coordinator = new DocumentUploadCoordinator({
|
||||||
|
attempts: new PostgresUploadAttemptStore(sql),
|
||||||
|
doc0: new Doc0UploadClient({
|
||||||
|
baseUrl: runtimeConfig.doc0.baseUrl,
|
||||||
|
serviceToken: runtimeConfig.doc0.serviceToken,
|
||||||
|
maxBytes: runtimeConfig.doc0.maxUploadBytes,
|
||||||
|
}),
|
||||||
|
authorize: async (scope) => authorize(scope),
|
||||||
|
maxBytes: runtimeConfig.doc0.maxUploadBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
let outcome: CoordinatedUpload;
|
||||||
|
try {
|
||||||
|
outcome = await coordinator.execute({
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
|
||||||
|
idempotencyKey: key,
|
||||||
|
filename: file.name || 'source',
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
classification,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
if (/not authorized/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/cannot be reused/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'IDEMPOTENCY_CONFLICT',
|
||||||
|
message: 'The idempotency key was already used for different content',
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'UPLOAD_FAILED', message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.status === 'receipt_verified') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'receipt_verified',
|
||||||
|
document_id: outcome.receipt.documentId,
|
||||||
|
version_id: outcome.receipt.versionId,
|
||||||
|
checksum_sha256: outcome.receipt.checksumSha256,
|
||||||
|
pipeline_stage: outcome.receipt.pipelineStage,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'outcome_unknown',
|
||||||
|
message:
|
||||||
|
'The upload may have been accepted. Its journal record is reported below; it does not prove storage or extraction.',
|
||||||
|
observation: outcome.observation ?? { status: 'reconciliation_required' },
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,97 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import type Stripe from "stripe";
|
import type Stripe from 'stripe';
|
||||||
import { getStripe } from "@/lib/stripe";
|
import { getStripe } from '@/lib/stripe';
|
||||||
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
|
import { getOrder, setStatusIfCurrent } from '@/lib/orders-store';
|
||||||
import { isSiaxError } from "../../siax-error-mapping";
|
import { isSiaxError } from '../../siax-error-mapping';
|
||||||
|
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||||
|
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
|
||||||
|
import {
|
||||||
|
StripeCreditVerificationError,
|
||||||
|
verifyStripeCreditPurchase,
|
||||||
|
} from '@/src/document-workbench/stripe-credit-verification';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit purchases are granted ONLY from Stripe's own re-read of the session
|
||||||
|
* and payment intent. The webhook body is a trigger, never proof of price or
|
||||||
|
* payment. The ledger command is idempotent, so duplicate deliveries are safe.
|
||||||
|
*/
|
||||||
|
async function handleCreditPurchase(
|
||||||
|
stripe: NonNullable<ReturnType<typeof getStripe>>,
|
||||||
|
session: Stripe.Checkout.Session,
|
||||||
|
) {
|
||||||
|
const tenantId = session.metadata?.tenant_id;
|
||||||
|
const purchaseId = session.metadata?.purchase_id;
|
||||||
|
if (!tenantId || !purchaseId) {
|
||||||
|
return NextResponse.json({ error: 'unmatched_session' }, { status: 422 });
|
||||||
|
}
|
||||||
|
const url = repositoryDatabaseUrl();
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ received: false, reason: 'credits_not_configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const store = new PostgresCreditPurchaseStore(getRepositorySql(url));
|
||||||
|
const snapshot = await store.get(tenantId, purchaseId);
|
||||||
|
if (!snapshot) {
|
||||||
|
// Do not acknowledge a paid purchase we cannot bind to a server snapshot.
|
||||||
|
return NextResponse.json({ received: false, reason: 'purchase_not_found' }, { status: 503 });
|
||||||
|
}
|
||||||
|
if (snapshot.checkoutSessionId !== session.id) {
|
||||||
|
return NextResponse.json({ error: 'session_mismatch' }, { status: 422 });
|
||||||
|
}
|
||||||
|
const command = await verifyStripeCreditPurchase(stripe, snapshot, { tenantId });
|
||||||
|
const result = await new PostgresCreditLedger(getRepositorySql(url)).apply(command);
|
||||||
|
return NextResponse.json({ received: true, credited: !result.replay, balance: result.balance });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit reconciliation failed';
|
||||||
|
if (error instanceof StripeCreditVerificationError) {
|
||||||
|
const terminal = ['mismatch', 'not_paid', 'refunded', 'unsupported'].includes(error.reason);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ received: false, reason: error.reason, message },
|
||||||
|
{ status: terminal ? 422 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ received: false, reason: 'ledger_unavailable', message }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/webhooks/stripe (docs/04) — verify signature, advance order status.
|
// POST /api/webhooks/stripe (docs/04) — verify signature, advance order status.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const stripe = getStripe();
|
const stripe = getStripe();
|
||||||
const whSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
const whSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
const sig = req.headers.get("stripe-signature");
|
const sig = req.headers.get('stripe-signature');
|
||||||
if (!stripe || !whSecret) {
|
if (!stripe || !whSecret) {
|
||||||
// Never acknowledge an event that was not signature-verified and processed.
|
// Never acknowledge an event that was not signature-verified and processed.
|
||||||
// A non-2xx response keeps Stripe's delivery retry/reconciliation path open.
|
// A non-2xx response keeps Stripe's delivery retry/reconciliation path open.
|
||||||
return NextResponse.json({ received: false, reason: "not_configured" }, { status: 503 });
|
return NextResponse.json({ received: false, reason: 'not_configured' }, { status: 503 });
|
||||||
}
|
}
|
||||||
if (!sig) {
|
if (!sig) {
|
||||||
return NextResponse.json({ error: "missing_signature" }, { status: 400 });
|
return NextResponse.json({ error: 'missing_signature' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const body = await req.text();
|
const body = await req.text();
|
||||||
let event;
|
let event;
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(body, sig, whSecret);
|
event = stripe.webhooks.constructEvent(body, sig, whSecret);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "bad_signature" }, { status: 400 });
|
return NextResponse.json({ error: 'bad_signature' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (event.type === "checkout.session.completed") {
|
if (
|
||||||
|
event.type === 'checkout.session.completed' ||
|
||||||
|
event.type === 'checkout.session.async_payment_succeeded'
|
||||||
|
) {
|
||||||
const s = event.data.object as Stripe.Checkout.Session;
|
const s = event.data.object as Stripe.Checkout.Session;
|
||||||
const oid = s.metadata?.order_id;
|
const oid = s.metadata?.order_id;
|
||||||
const kind = s.metadata?.kind;
|
const kind = s.metadata?.kind;
|
||||||
if (!oid || (kind !== "deposit" && kind !== "balance")) {
|
if (kind === '0pt_credit_purchase') {
|
||||||
return NextResponse.json({ error: "unmatched_session" }, { status: 422 });
|
return handleCreditPurchase(stripe, s);
|
||||||
}
|
}
|
||||||
if (s.payment_status !== "paid") {
|
if (!oid || (kind !== 'deposit' && kind !== 'balance')) {
|
||||||
return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 });
|
return NextResponse.json({ error: 'unmatched_session' }, { status: 422 });
|
||||||
|
}
|
||||||
|
if (s.payment_status !== 'paid') {
|
||||||
|
return NextResponse.json({ error: 'payment_not_confirmed' }, { status: 503 });
|
||||||
}
|
}
|
||||||
// 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500,
|
// 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500,
|
||||||
// and never a silent received:true for a paid event that was NOT processed —
|
// and never a silent received:true for a paid event that was NOT processed —
|
||||||
@@ -41,47 +100,52 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
order = await getOrder(oid);
|
order = await getOrder(oid);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
if (isSiaxError(err))
|
||||||
|
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 });
|
if (!order) return NextResponse.json({ error: 'order_not_found' }, { status: 503 });
|
||||||
if (!order.tenant_id || !order.buyer_subject) {
|
if (!order.tenant_id || !order.buyer_subject) {
|
||||||
// A legacy anonymous draft is not a payable order. Do not silently
|
// A legacy anonymous draft is not a payable order. Do not silently
|
||||||
// acknowledge a paid event until an audited owner-remediation path exists.
|
// acknowledge a paid event until an audited owner-remediation path exists.
|
||||||
return NextResponse.json({ error: "order_owner_missing" }, { status: 503 });
|
return NextResponse.json({ error: 'order_owner_missing' }, { status: 503 });
|
||||||
}
|
}
|
||||||
const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore;
|
const expectedAmount = kind === 'balance' ? order.balance_ore : order.deposit_ore;
|
||||||
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") {
|
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== 'sek') {
|
||||||
return NextResponse.json({ error: "payment_mismatch" }, { status: 422 });
|
return NextResponse.json({ error: 'payment_mismatch' }, { status: 422 });
|
||||||
}
|
}
|
||||||
if (kind === "balance") {
|
if (kind === 'balance') {
|
||||||
if (order.status !== "accepted" && order.status !== "delivered") {
|
if (order.status !== 'accepted' && order.status !== 'delivered') {
|
||||||
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 });
|
||||||
}
|
}
|
||||||
let delivered = false;
|
let delivered = false;
|
||||||
try {
|
try {
|
||||||
delivered = order.status === "accepted" && !!(await setStatusIfCurrent(oid, "accepted", "delivered"));
|
delivered = order.status === 'accepted' && !!(await setStatusIfCurrent(oid, 'accepted', 'delivered'));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
if (isSiaxError(err))
|
||||||
|
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (order.status === "accepted" && !delivered) {
|
if (order.status === 'accepted' && !delivered) {
|
||||||
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
return NextResponse.json({ error: 'order_update_failed' }, { status: 503 });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status);
|
const alreadyAdvanced = ['in_production', 'review', 'revision', 'accepted', 'delivered'].includes(
|
||||||
if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) {
|
order.status,
|
||||||
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
);
|
||||||
|
if (order.status !== 'draft' && order.status !== 'deposit_pending' && !alreadyAdvanced) {
|
||||||
|
return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 });
|
||||||
}
|
}
|
||||||
let advanced = true;
|
let advanced = true;
|
||||||
try {
|
try {
|
||||||
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, "in_production"));
|
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, 'in_production'));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
if (isSiaxError(err))
|
||||||
|
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!advanced) {
|
if (!advanced) {
|
||||||
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
return NextResponse.json({ error: 'order_update_failed' }, { status: 503 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useT } from '@/components/locale-provider';
|
||||||
|
import { PortalShell } from '@/components/portal-shell';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /portal/dokument — the customer's document workspace.
|
||||||
|
*
|
||||||
|
* Three honest steps:
|
||||||
|
* 1. Upload the original file (real bytes, multipart) into DOC0.
|
||||||
|
* 2. Describe the structure you want and the fields to extract.
|
||||||
|
* 3. Review the evidence-bound output and download JSON/CSV.
|
||||||
|
*
|
||||||
|
* Nothing on this page fabricates success: a 503 means the capability is not
|
||||||
|
* configured, and an "outcome unknown" upload is shown as exactly that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Receipt = {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
checksum_sha256: string;
|
||||||
|
pipeline_stage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldRow = { name: string; type: 'string' | 'integer' | 'boolean' | 'decimal' | 'date' };
|
||||||
|
|
||||||
|
type Evidence = {
|
||||||
|
field: string;
|
||||||
|
sourceId: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
transform: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StructureResponse = {
|
||||||
|
status: 'mechanically_validated' | 'needs_review' | 'outcome_unknown';
|
||||||
|
value?: Record<string, string | number | boolean>;
|
||||||
|
evidence?: Evidence[];
|
||||||
|
issues?: { field: string; code: string; row?: number }[];
|
||||||
|
model?: {
|
||||||
|
modelExecuted: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd?: number;
|
||||||
|
};
|
||||||
|
credits?: { reserved: number; charged: number; balance?: { available: number; reserved: number } };
|
||||||
|
export?: { media_type: string; content: string };
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
reservation_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelsResponse = {
|
||||||
|
models: string[];
|
||||||
|
data_classes: string[];
|
||||||
|
destination: string;
|
||||||
|
external_transfer_allowed: boolean;
|
||||||
|
max_input_tokens: number;
|
||||||
|
max_output_tokens: number;
|
||||||
|
price_book_version: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPES: FieldRow['type'][] = ['string', 'integer', 'decimal', 'date', 'boolean'];
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [projectId, setProjectId] = useState('');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [receipt, setReceipt] = useState<Receipt | null>(null);
|
||||||
|
const [uploadNote, setUploadNote] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [instruction, setInstruction] = useState('');
|
||||||
|
const [fields, setFields] = useState<FieldRow[]>([{ name: 'organization', type: 'string' }]);
|
||||||
|
const [models, setModels] = useState<ModelsResponse | null>(null);
|
||||||
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [format, setFormat] = useState<'json' | 'csv'>('json');
|
||||||
|
const [structuring, setStructuring] = useState(false);
|
||||||
|
const [result, setResult] = useState<StructureResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem('0pt.project_id');
|
||||||
|
if (stored) setProjectId(stored);
|
||||||
|
fetch('/api/0pt/documents/models', { cache: 'no-store' })
|
||||||
|
.then(async (r) => {
|
||||||
|
const body = (await r.json()) as ModelsResponse & { message?: string };
|
||||||
|
if (!r.ok) {
|
||||||
|
setModelsError(body.message ?? 'Modellpolicyn är inte konfigurerad');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModels(body);
|
||||||
|
setModelId((current) => current || body.models[0] || '');
|
||||||
|
})
|
||||||
|
.catch(() => setModelsError('Kunde inte läsa modellpolicyn'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const upload = useCallback(async () => {
|
||||||
|
if (!file || uploading) return;
|
||||||
|
if (!projectId.trim()) {
|
||||||
|
setError(t({ en: 'A project id is required', sv: 'Ett projekt-id krävs' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
setUploadNote(null);
|
||||||
|
setUnknownOutcome(null);
|
||||||
|
setReceipt(null);
|
||||||
|
window.localStorage.setItem('0pt.project_id', projectId.trim());
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('file', file);
|
||||||
|
form.set('project_id', projectId.trim());
|
||||||
|
form.set('idempotency_key', crypto.randomUUID());
|
||||||
|
form.set('classification', 'restricted');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/0pt/documents/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'x-request-id': crypto.randomUUID() },
|
||||||
|
});
|
||||||
|
const body = (await response.json().catch(() => ({}))) as Record<string, unknown> & {
|
||||||
|
status?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (response.status === 201) {
|
||||||
|
setReceipt({
|
||||||
|
document_id: String(body.document_id),
|
||||||
|
version_id: String(body.version_id),
|
||||||
|
checksum_sha256: String(body.checksum_sha256),
|
||||||
|
pipeline_stage: String(body.pipeline_stage),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status === 202) {
|
||||||
|
setUnknownOutcome(body.observation ?? body);
|
||||||
|
setUploadNote(
|
||||||
|
t({
|
||||||
|
en: 'The upload may have been accepted. It is not confirmed.',
|
||||||
|
sv: 'Uppladdningen kan ha tagits emot. Den är inte bekräftad.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadNote(
|
||||||
|
body.message ?? body.error ?? t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setUploadNote(t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' }));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [file, projectId, t, uploading]);
|
||||||
|
|
||||||
|
const structure = useCallback(async () => {
|
||||||
|
if (!receipt || structuring) return;
|
||||||
|
if (!instruction.trim()) {
|
||||||
|
setError(t({ en: 'Describe the structure you want', sv: 'Beskriv strukturen du vill ha' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!modelId) {
|
||||||
|
setError(t({ en: 'No approved model is configured', sv: 'Ingen godkänd modell är konfigurerad' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStructuring(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/0pt/documents/structure', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-request-id': crypto.randomUUID() },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: projectId.trim(),
|
||||||
|
document_id: receipt.document_id,
|
||||||
|
version_id: receipt.version_id,
|
||||||
|
checksum_sha256: receipt.checksum_sha256,
|
||||||
|
pipeline_stage: receipt.pipeline_stage,
|
||||||
|
instruction,
|
||||||
|
fields,
|
||||||
|
model_id: modelId,
|
||||||
|
data_class: 'low',
|
||||||
|
format,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = (await response.json().catch(() => ({}))) as StructureResponse;
|
||||||
|
if (response.ok || response.status === 202) {
|
||||||
|
setResult(body);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
body.message ?? body.error ?? t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' }));
|
||||||
|
} finally {
|
||||||
|
setStructuring(false);
|
||||||
|
}
|
||||||
|
}, [receipt, structuring, instruction, modelId, projectId, fields, format, t]);
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!result?.export) return;
|
||||||
|
const blob = new Blob([result.export.content], { type: result.export.media_type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `0pt-${receipt?.document_id ?? 'result'}.${format}`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
if (fields.length >= 100) return;
|
||||||
|
setFields((current) => [...current, { name: `field_${current.length + 1}`, type: 'string' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(index: number, patch: Partial<FieldRow>) {
|
||||||
|
setFields((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index: number) {
|
||||||
|
setFields((current) => current.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = 'border border-border bg-background p-6';
|
||||||
|
const label = 'font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground';
|
||||||
|
const input =
|
||||||
|
'mt-2 w-full border border-border bg-background px-3 py-2 font-mono text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="customer" title={t({ en: 'Documents', sv: 'Dokument' })}>
|
||||||
|
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||||
|
<p className="max-w-2xl text-pretty text-[15px] leading-relaxed text-muted-foreground">
|
||||||
|
{t({
|
||||||
|
en: 'Upload a file, describe how it should be structured, and get evidence-bound data you can download.',
|
||||||
|
sv: 'Ladda upp en fil, beskriv hur den ska struktureras och få evidensbunden data som du kan ladda ner.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 1 · upload */}
|
||||||
|
<section className={card}>
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||||
|
{t({ en: '1 · Source', sv: '1 · Underlag' })}
|
||||||
|
</h2>
|
||||||
|
{receipt ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] uppercase tracking-[0.18em]"
|
||||||
|
style={{ color: 'var(--accent-red)' }}
|
||||||
|
>
|
||||||
|
{t({ en: 'uploaded', sv: 'uppladdad' })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className={label}>{t({ en: 'project id', sv: 'projekt-id' })}</span>
|
||||||
|
<input
|
||||||
|
className={input}
|
||||||
|
value={projectId}
|
||||||
|
onChange={(event) => setProjectId(event.target.value)}
|
||||||
|
placeholder="prj_…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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) => setFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={!file || uploading}
|
||||||
|
className="mt-4 border border-foreground bg-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] text-background disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{uploading ? t({ en: 'uploading…', sv: 'laddar upp…' }) : t({ en: 'upload', sv: 'ladda upp' })}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{receipt ? (
|
||||||
|
<dl className="mt-4 grid gap-2 font-mono text-[11px] text-muted-foreground sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="uppercase tracking-[0.16em]">document</dt>
|
||||||
|
<dd className="break-all">{receipt.document_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="uppercase tracking-[0.16em]">version</dt>
|
||||||
|
<dd className="break-all">{receipt.version_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="uppercase tracking-[0.16em]">sha-256</dt>
|
||||||
|
<dd className="break-all">{receipt.checksum_sha256}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
{uploadNote ? <p className="mt-4 text-[14px] text-muted-foreground">{uploadNote}</p> : null}
|
||||||
|
{unknownOutcome ? (
|
||||||
|
<pre className="mt-3 overflow-auto border border-border bg-muted/30 p-3 font-mono text-[11px]">
|
||||||
|
{JSON.stringify(unknownOutcome, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2 · structure */}
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||||
|
{t({ en: '2 · Desired structure', sv: '2 · Önskad struktur' })}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className={label}>{t({ en: 'instruction', sv: 'instruktion' })}</span>
|
||||||
|
<textarea
|
||||||
|
className={`${input} min-h-[96px]`}
|
||||||
|
value={instruction}
|
||||||
|
maxLength={4000}
|
||||||
|
onChange={(event) => setInstruction(event.target.value)}
|
||||||
|
placeholder={t({
|
||||||
|
en: 'One row per agreement with parties, term, price and notice period.',
|
||||||
|
sv: 'En rad per avtal med parter, avtalstid, pris och uppsägningstid.',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<span className={label}>{t({ en: 'fields', sv: 'fält' })}</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="min-w-[160px] flex-1 border border-border bg-background px-3 py-2 font-mono text-[12px]"
|
||||||
|
value={field.name}
|
||||||
|
onChange={(event) => updateField(index, { name: event.target.value })}
|
||||||
|
aria-label={`field ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="border border-border bg-background px-3 py-2 font-mono text-[12px]"
|
||||||
|
value={field.type}
|
||||||
|
onChange={(event) => updateField(index, { type: event.target.value as FieldRow['type'] })}
|
||||||
|
>
|
||||||
|
{TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(index)}
|
||||||
|
className="px-2 font-mono text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addField}
|
||||||
|
className="mt-2 font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"
|
||||||
|
>
|
||||||
|
+ {t({ en: 'add field', sv: 'lägg till fält' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t({ en: 'model', sv: 'modell' })}</span>
|
||||||
|
<select
|
||||||
|
className={input}
|
||||||
|
value={modelId}
|
||||||
|
onChange={(event) => setModelId(event.target.value)}
|
||||||
|
disabled={!models}
|
||||||
|
>
|
||||||
|
{models?.models.map((model) => (
|
||||||
|
<option key={model} value={model}>
|
||||||
|
{model}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t({ en: 'format', sv: 'format' })}</span>
|
||||||
|
<select
|
||||||
|
className={input}
|
||||||
|
value={format}
|
||||||
|
onChange={(event) => setFormat(event.target.value as 'json' | 'csv')}
|
||||||
|
>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modelsError ? <p className="mt-3 text-[13px] text-muted-foreground">{modelsError}</p> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={structure}
|
||||||
|
disabled={!receipt || structuring}
|
||||||
|
className="mt-4 border border-foreground bg-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] text-background disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{structuring
|
||||||
|
? t({ en: 'structuring…', sv: 'strukturerar…' })
|
||||||
|
: t({ en: 'structure', sv: 'strukturera' })}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3 · result */}
|
||||||
|
{(result || error) && (
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||||
|
{t({ en: '3 · Result', sv: '3 · Resultat' })}
|
||||||
|
</h2>
|
||||||
|
{error ? <p className="mt-4 text-[14px]">{error}</p> : null}
|
||||||
|
|
||||||
|
{result?.status === 'mechanically_validated' ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 overflow-auto border border-border">
|
||||||
|
<table className="w-full border-collapse text-left font-mono text-[12px]">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(result.value ?? {}).map(([key, value]) => (
|
||||||
|
<tr key={key} className="border-b border-border last:border-b-0">
|
||||||
|
<th className="px-3 py-2 font-normal uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{key}
|
||||||
|
</th>
|
||||||
|
<td className="px-3 py-2">{String(value)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{result.evidence?.length ?? 0} {t({ en: 'evidence spans', sv: 'evidensspann' })}
|
||||||
|
{result.credits ? ` · ${result.credits.charged} credits` : ''}
|
||||||
|
</p>
|
||||||
|
{result.export ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={download}
|
||||||
|
className="mt-4 border border-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||||
|
>
|
||||||
|
{t({ en: 'download', sv: 'ladda ner' })} {format.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result?.status === 'needs_review' ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
{t({
|
||||||
|
en: 'The model output did not pass evidence validation. Nothing was exported.',
|
||||||
|
sv: 'Modellens svar klarade inte evidenskontrollen. Inget exporterades.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-1 font-mono text-[11px] text-muted-foreground">
|
||||||
|
{result.issues?.map((issue, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{issue.field || '(root)'} · {issue.code}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result?.status === 'outcome_unknown' ? (
|
||||||
|
<p className="mt-4 text-[14px]">{result.message}</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useT } from '@/components/locale-provider';
|
||||||
|
import { PortalShell } from '@/components/portal-shell';
|
||||||
|
import { formatSEK } from '@/lib/data';
|
||||||
|
|
||||||
|
type Pack = { id: string; credits: number; amount_minor: number };
|
||||||
|
|
||||||
|
export default function CreditsPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [balance, setBalance] = useState<{ available: number; reserved: number } | null>(null);
|
||||||
|
const [balanceNote, setBalanceNote] = useState<string | null>(null);
|
||||||
|
const [packs, setPacks] = useState<Pack[]>([]);
|
||||||
|
const [currency, setCurrency] = useState('SEK');
|
||||||
|
const [packsNote, setPacksNote] = useState<string | null>(null);
|
||||||
|
const [buying, setBuying] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0pt/credits/balance', { cache: 'no-store' });
|
||||||
|
const body = (await r.json()) as { available?: number; reserved?: number; message?: string };
|
||||||
|
if (r.ok) setBalance({ available: body.available ?? 0, reserved: body.reserved ?? 0 });
|
||||||
|
else
|
||||||
|
setBalanceNote(
|
||||||
|
body.message ?? t({ en: 'Credits are not available', sv: 'Krediter är inte tillgängliga' }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setBalanceNote(t({ en: 'Could not read the balance', sv: 'Kunde inte läsa saldot' }));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0pt/credits/packs', { cache: 'no-store' });
|
||||||
|
const body = (await r.json()) as { packs?: Pack[]; currency?: string; message?: string };
|
||||||
|
if (r.ok && body.packs) {
|
||||||
|
setPacks(body.packs);
|
||||||
|
setCurrency(body.currency ?? 'SEK');
|
||||||
|
} else {
|
||||||
|
setPacksNote(
|
||||||
|
body.message ??
|
||||||
|
t({ en: 'Credit packs are not configured', sv: 'Kreditpaket är inte konfigurerade' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPacksNote(t({ en: 'Could not read credit packs', sv: 'Kunde inte läsa kreditpaket' }));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const state = params.get('status');
|
||||||
|
if (state === 'success') {
|
||||||
|
setStatus(
|
||||||
|
t({
|
||||||
|
en: 'Payment received. Credits are added when Stripe confirms the payment.',
|
||||||
|
sv: 'Betalning mottagen. Krediter läggs till när Stripe bekräftar betalningen.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (state === 'cancelled') {
|
||||||
|
setStatus(t({ en: 'Checkout cancelled.', sv: 'Betalningen avbröts.' }));
|
||||||
|
}
|
||||||
|
}, [load, t]);
|
||||||
|
|
||||||
|
const buy = useCallback(
|
||||||
|
async (packId: string) => {
|
||||||
|
setBuying(packId);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/0pt/credits/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pack_id: packId }),
|
||||||
|
});
|
||||||
|
const body = (await response.json().catch(() => ({}))) as { checkout_url?: string; message?: string };
|
||||||
|
if (response.ok && body.checkout_url) {
|
||||||
|
window.location.href = body.checkout_url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
body.message ?? t({ en: 'Checkout is not available', sv: 'Betalning är inte tillgänglig' }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setStatus(t({ en: 'Checkout is not available', sv: 'Betalning är inte tillgänglig' }));
|
||||||
|
} finally {
|
||||||
|
setBuying(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = 'border border-border bg-background p-6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="customer" title={t({ en: 'Credits', sv: 'Krediter' })}>
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<section className={card}>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{t({ en: 'balance', sv: 'saldo' })}
|
||||||
|
</span>
|
||||||
|
{balance ? (
|
||||||
|
<p className="mt-3 font-mono text-3xl tracking-[-0.02em]">
|
||||||
|
{balance.available}
|
||||||
|
<span className="ml-2 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{t({ en: 'available', sv: 'tillgängliga' })} · {balance.reserved}{' '}
|
||||||
|
{t({ en: 'reserved', sv: 'reserverade' })}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-[14px] text-muted-foreground">{balanceNote ?? '…'}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||||
|
{t({ en: 'Buy credits', sv: 'Köp krediter' })}
|
||||||
|
</h2>
|
||||||
|
{packs.length === 0 ? (
|
||||||
|
<p className="mt-3 text-[14px] text-muted-foreground">{packsNote ?? '…'}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 divide-y divide-border border-y border-border">
|
||||||
|
{packs.map((pack) => (
|
||||||
|
<li key={pack.id} className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[13px]">{pack.credits} credits</p>
|
||||||
|
<p className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{formatSEK(pack.amount_minor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => buy(pack.id)}
|
||||||
|
disabled={buying !== null}
|
||||||
|
className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{buying === pack.id ? t({ en: 'opening…', sv: 'öppnar…' }) : t({ en: 'buy', sv: 'köp' })}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{status ? <p className="mt-4 text-[14px] text-muted-foreground">{status}</p> : null}
|
||||||
|
<p className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{currency}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ const STATUS_LABEL: Record<string, { en: string; sv: string }> = {
|
|||||||
const NAV: { label: { en: string; sv: string }; href: string }[] = [
|
const NAV: { label: { en: string; sv: string }; href: string }[] = [
|
||||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
||||||
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
||||||
|
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
|
||||||
|
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
|
||||||
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
||||||
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
||||||
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type NavItem = { label: { en: string; sv: string }; href: string };
|
|||||||
const CUSTOMER_NAV: NavItem[] = [
|
const CUSTOMER_NAV: NavItem[] = [
|
||||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
||||||
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
||||||
|
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
|
||||||
|
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
|
||||||
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
||||||
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
||||||
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Document workbench — upload → structure → validated export
|
||||||
|
|
||||||
|
The customer-facing slice that turns an uploaded file into evidence-bound data.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose | Fails closed with |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/0pt/documents/upload` | multipart upload of the original bytes into DOC0, recorded in a durable attempt journal before dispatch | `503 DOC0_NOT_CONFIGURED`, `503 PROJECT_AUTHORIZATION_NOT_CONFIGURED`, `503 UPLOAD_JOURNAL_NOT_CONFIGURED` |
|
||||||
|
| `POST /api/0pt/documents/structure` | re-reads the DOC0 segment index, calls INF0, validates every field against an exact source span, reserves/settles credits | `503 WORKBENCH_NOT_CONFIGURED`, `503 MODEL_POLICY_NOT_CONFIGURED`, `503 CREDITS_NOT_CONFIGURED`, `402 INSUFFICIENT_CREDITS` |
|
||||||
|
| `GET /api/0pt/documents/models` | the server-approved model routes and limits | `503 MODEL_POLICY_NOT_CONFIGURED` |
|
||||||
|
| `GET /api/0pt/credits/balance` | tenant credit balance | `503 CREDITS_NOT_CONFIGURED` |
|
||||||
|
| `GET /api/0pt/credits/packs` | sellable packs from the server-owned price book | `503 PRICE_BOOK_NOT_CONFIGURED` |
|
||||||
|
| `POST /api/0pt/credits/checkout` | Stripe Checkout session + server price snapshot | `503 PRICE_BOOK_NOT_CONFIGURED`, `503 STRIPE_NOT_CONFIGURED` |
|
||||||
|
| `POST /api/webhooks/stripe` | re-reads the session and payment intent, then grants credits idempotently | non-2xx until processed |
|
||||||
|
|
||||||
|
UI: `/portal/dokument` (upload, structure, review, download) and `/portal/krediter`
|
||||||
|
(balance, packs, checkout).
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
1. **Tenant and actor come only from middleware's verified headers.** A customer
|
||||||
|
route never trusts `tenant_id`/`actor_id` from the body.
|
||||||
|
2. **Project access is checked by OpenFGA** (`@siax/access`), the same primitive
|
||||||
|
DOC0 uses. Unconfigured or unavailable means *denied*, never allowed.
|
||||||
|
3. **The model never sees unverified text.** `Doc0SourceClient` verifies
|
||||||
|
version, tenant, checksum, segment hashes and that the index was not
|
||||||
|
truncated before anything is sent to INF0.
|
||||||
|
4. **Every returned value must point at an exact span** in the source; otherwise
|
||||||
|
the result is `needs_review` and nothing is exported.
|
||||||
|
5. **Credits are reserved before the model runs and settled to measured usage.**
|
||||||
|
An unknown model outcome keeps the reservation for reconciliation instead of
|
||||||
|
releasing it.
|
||||||
|
6. **No price is ever read from the client.** The price book is a versioned,
|
||||||
|
server-owned JSON document (`OPT_CREDIT_PRICE_BOOK`).
|
||||||
|
|
||||||
|
## Reusable packages
|
||||||
|
|
||||||
|
- `packages/siax-access` (`@siax/access`) — framework-neutral OpenFGA
|
||||||
|
relationship checks and a fail-closed `isProjectAccessAllowed`. Intended for
|
||||||
|
DOC0, N0D and every other SIAX product instead of per-repo copies of
|
||||||
|
`project-relationships.ts`.
|
||||||
|
- `src/document-workbench/*` — upload coordination, DOC0 source reading, INF0
|
||||||
|
admission, structured-output validation, export and credit ledger. These
|
||||||
|
modules have no Next.js imports and can be lifted into a published package
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## What is still deliberately not claimed
|
||||||
|
|
||||||
|
- The output is `mechanically_validated`, not proven complete or semantically
|
||||||
|
correct. OCR quality and coverage require review.
|
||||||
|
- Refunds and chargebacks do not automatically revoke credits; no such handler
|
||||||
|
is wired.
|
||||||
|
- Format breadth is limited by DOC0's parser. A file DOC0 cannot parse is
|
||||||
|
reported, never guessed.
|
||||||
+98
-10
@@ -18,6 +18,8 @@
|
|||||||
// fabricated check; reporting "not_configured" would
|
// fabricated check; reporting "not_configured" would
|
||||||
// hide that a value was provided. This is its own
|
// hide that a value was provided. This is its own
|
||||||
// honest state rather than either of those two.
|
// honest state rather than either of those two.
|
||||||
|
import net from "node:net";
|
||||||
|
import tls from "node:tls";
|
||||||
import { dbUrl, getSql } from "./db";
|
import { dbUrl, getSql } from "./db";
|
||||||
import { getStripe } from "./stripe";
|
import { getStripe } from "./stripe";
|
||||||
import { hasEngine } from "./engine";
|
import { hasEngine } from "./engine";
|
||||||
@@ -135,15 +137,96 @@ export async function checkStripe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis/queue: presence-only, honestly. No queue client library is a
|
* Redis/queue reachability.
|
||||||
* dependency of this package (grep-verified against package.json) and no
|
*
|
||||||
* code path in this repo reads REDIS_URL to open a connection -- so there is
|
* This used to report `not_integrated` because no code opened REDIS_URL. The
|
||||||
* no real check to run. Reporting "ok" would fabricate a probe that doesn't
|
* readiness probe now performs a real round trip (AUTH when the URL carries a
|
||||||
* exist; this reports "not_integrated" instead of silently upgrading to a
|
* password, then PING -> +PONG) using only `node:net`/`node:tls`, so a queue
|
||||||
* pretend pass.
|
* that is configured but dead fails readiness instead of hiding behind
|
||||||
|
* `not_integrated`. The probe is injectable for tests.
|
||||||
*/
|
*/
|
||||||
export function checkQueue(): DepResult {
|
export type QueueProbe = (redisUrl: string, timeoutMs: number) => Promise<void>;
|
||||||
return { status: process.env.REDIS_URL ? "not_integrated" : "not_configured" };
|
|
||||||
|
export function redisPing(redisUrl: string, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(redisUrl);
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid REDIS_URL"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
|
||||||
|
reject(new Error("Unsupported REDIS_URL scheme"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = url.port ? Number(url.port) : 6379;
|
||||||
|
if (!Number.isSafeInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
reject(new Error("Invalid REDIS_URL port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const socket =
|
||||||
|
url.protocol === "rediss:"
|
||||||
|
? tls.connect({ host: url.hostname, port, servername: url.hostname })
|
||||||
|
: net.connect({ host: url.hostname, port });
|
||||||
|
let settled = false;
|
||||||
|
const done = (error?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.destroy();
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => done(new Error("redis ping timed out")), timeoutMs);
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("error", (error: Error) => done(error));
|
||||||
|
socket.on("close", () => {
|
||||||
|
if (!settled) done(new Error("redis connection closed before PONG"));
|
||||||
|
});
|
||||||
|
socket.on("data", (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
if (buffer.includes("+PONG")) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buffer.startsWith("-")) {
|
||||||
|
done(new Error(`redis error: ${buffer.trim()}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// AUTH succeeded (or was unnecessary) but PING has not answered yet.
|
||||||
|
});
|
||||||
|
socket.on("connect", () => {
|
||||||
|
const password = url.password ? decodeURIComponent(url.password) : "";
|
||||||
|
if (url.username && url.username !== "default" && !password) {
|
||||||
|
done(new Error("redis username without password is not supported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// RESP arrays: correct for any password content, unlike inline commands.
|
||||||
|
const encode = (parts: string[]): string =>
|
||||||
|
`*${parts.length}\r\n${parts
|
||||||
|
.map((part) => `$${Buffer.byteLength(part, "utf8")}\r\n${part}\r\n`)
|
||||||
|
.join("")}`;
|
||||||
|
const payload =
|
||||||
|
(password ? encode(["AUTH", password]) : "") + encode(["PING"]);
|
||||||
|
socket.write(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkQueue(
|
||||||
|
probe: QueueProbe = redisPing,
|
||||||
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<DepResult> {
|
||||||
|
const redisUrl = process.env.REDIS_URL;
|
||||||
|
if (!redisUrl) return { status: "not_configured" };
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await withTimeout(probe(redisUrl, timeoutMs), timeoutMs + 250, "redis ping");
|
||||||
|
return { status: "ok", latencyMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "error", detail: errMessage(err), latencyMs: Date.now() - start };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DependencyResults {
|
export interface DependencyResults {
|
||||||
@@ -154,8 +237,13 @@ export interface DependencyResults {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAllDependencies(): Promise<DependencyResults> {
|
export async function checkAllDependencies(): Promise<DependencyResults> {
|
||||||
const [db, engine, stripe] = await Promise.all([checkDatabase(), checkEngine(), checkStripe()]);
|
const [db, engine, stripe, queue] = await Promise.all([
|
||||||
return { db, engine, stripe, queue: checkQueue() };
|
checkDatabase(),
|
||||||
|
checkEngine(),
|
||||||
|
checkStripe(),
|
||||||
|
checkQueue(),
|
||||||
|
]);
|
||||||
|
return { db, engine, stripe, queue };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when any dependency that IS configured/integrated is broken. */
|
/** True when any dependency that IS configured/integrated is broken. */
|
||||||
|
|||||||
+2
-1
@@ -81,7 +81,8 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76",
|
||||||
|
"@siax/access": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.15.0",
|
"@eslint/js": "^9.15.0",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@siax/access",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Universal, framework-neutral project/tenant relationship checks for SIAX Cloud (OpenFGA). Reused by 0pt, DOC0, N0D and other products instead of per-repo copies.",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"license": "UNLICENSED"
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
|
||||||
|
*
|
||||||
|
* This is the reusable extraction of the pattern first proven in DOC0's
|
||||||
|
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
|
||||||
|
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
|
||||||
|
* consume the exact same authorization primitive instead of copying it:
|
||||||
|
*
|
||||||
|
* 0pt — document upload + structuring scope
|
||||||
|
* DOC0 — document/version/signature scope
|
||||||
|
* N0D — job submission scope
|
||||||
|
* any app — tenant/project membership
|
||||||
|
*
|
||||||
|
* IMPORTANT: a relationship match is ONE input to authorization, never a
|
||||||
|
* complete decision. The caller must independently bind tenant and actor to a
|
||||||
|
* verified principal (never to request headers), enforce tenant/project
|
||||||
|
* status, capability, data-handling policy and an authoritative tuple
|
||||||
|
* lifecycle. `resolveRelationship` fails closed: only an explicit
|
||||||
|
* `relationships_match` is treated as allowed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
|
||||||
|
|
||||||
|
/** The outcome of the composite project check. */
|
||||||
|
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
|
||||||
|
|
||||||
|
export interface ProjectRelationshipScope {
|
||||||
|
tenantId: string;
|
||||||
|
projectId: string;
|
||||||
|
actorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
|
||||||
|
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||||
|
const maxResponseBytes = 16 * 1024;
|
||||||
|
|
||||||
|
export interface OpenFgaRelationshipsOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
storeId: string;
|
||||||
|
modelId: string;
|
||||||
|
serviceToken: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
|
||||||
|
testOnlyAllowLoopbackHttp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
|
||||||
|
* and never retries: an unavailable authorization source must stay visible to
|
||||||
|
* the caller so it can fail closed.
|
||||||
|
*/
|
||||||
|
export class OpenFgaRelationships {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly storeId: string;
|
||||||
|
private readonly modelId: string;
|
||||||
|
private readonly serviceToken: string;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
|
||||||
|
constructor(options: OpenFgaRelationshipsOptions) {
|
||||||
|
const url = new URL(options.baseUrl);
|
||||||
|
const loopbackTest =
|
||||||
|
process.env.NODE_ENV === 'test' &&
|
||||||
|
options.testOnlyAllowLoopbackHttp === true &&
|
||||||
|
url.protocol === 'http:' &&
|
||||||
|
url.hostname === '127.0.0.1';
|
||||||
|
if (
|
||||||
|
(!loopbackTest && url.protocol !== 'https:') ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
typeof options.storeId !== 'string' ||
|
||||||
|
typeof options.modelId !== 'string' ||
|
||||||
|
!ulid.test(options.storeId) ||
|
||||||
|
!ulid.test(options.modelId) ||
|
||||||
|
typeof options.serviceToken !== 'string' ||
|
||||||
|
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
|
||||||
|
(options.timeoutMs !== undefined &&
|
||||||
|
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid trusted OpenFGA configuration');
|
||||||
|
}
|
||||||
|
this.baseUrl = url.toString().replace(/\/+$/, '');
|
||||||
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
this.storeId = options.storeId;
|
||||||
|
this.modelId = options.modelId;
|
||||||
|
this.serviceToken = options.serviceToken;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? 5_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both checks are necessary: project membership alone may inherit from a
|
||||||
|
* different parent tenant if bad tuples associate a project with two
|
||||||
|
* tenants. Returns `relationships_match` only when both hold.
|
||||||
|
*/
|
||||||
|
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
|
||||||
|
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
const { tenantId, projectId, actorId } = scope;
|
||||||
|
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
|
||||||
|
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
|
||||||
|
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
|
||||||
|
return parent === 'allowed' ? 'relationships_match' : parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One raw relationship check. Public so products can map their own tuples. */
|
||||||
|
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
|
||||||
|
if (
|
||||||
|
![user, relation, object].every(
|
||||||
|
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.serviceToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
authorization_model_id: this.modelId,
|
||||||
|
consistency: 'HIGHER_CONSISTENCY',
|
||||||
|
tuple_key: { user, relation, object },
|
||||||
|
}),
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) return 'unavailable';
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const part = await reader.read();
|
||||||
|
if (part.done) break;
|
||||||
|
total += part.value.byteLength;
|
||||||
|
if (total > maxResponseBytes) {
|
||||||
|
void reader.cancel().catch(() => undefined);
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
chunks.push(part.value);
|
||||||
|
}
|
||||||
|
const result: unknown = JSON.parse(
|
||||||
|
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!result ||
|
||||||
|
typeof result !== 'object' ||
|
||||||
|
Array.isArray(result) ||
|
||||||
|
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
|
||||||
|
) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical environment variable names used across the fleet. Reads are
|
||||||
|
* explicit (no implicit defaults) so a missing value is visible.
|
||||||
|
*/
|
||||||
|
export const OPENFGA_ENV = {
|
||||||
|
baseUrl: 'OPENFGA_API_URL',
|
||||||
|
storeId: 'OPENFGA_STORE_ID',
|
||||||
|
modelId: 'OPENFGA_MODEL_ID',
|
||||||
|
serviceToken: 'OPENFGA_AUTH_TOKEN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface ProjectAccessOptions {
|
||||||
|
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
|
||||||
|
relationships?: OpenFgaRelationships;
|
||||||
|
/**
|
||||||
|
* Optional extra policy (project status, capability, data-handling). It runs
|
||||||
|
* only after the relationship check matched; a rejection is final.
|
||||||
|
*/
|
||||||
|
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fail closed: only an explicit match with no policy objection is allowed. */
|
||||||
|
export async function isProjectAccessAllowed(
|
||||||
|
scope: ProjectRelationshipScope,
|
||||||
|
options: ProjectAccessOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!options.relationships) return false;
|
||||||
|
const result = await options.relationships.checkProject(scope);
|
||||||
|
if (result !== 'relationships_match') return false;
|
||||||
|
if (options.additionalPolicy) {
|
||||||
|
try {
|
||||||
|
return (await options.additionalPolicy(scope)) === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
|
||||||
|
* Returns `undefined` (fail closed) when any value is missing or invalid.
|
||||||
|
*/
|
||||||
|
export function openFgaFromEnv(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
fetchImpl?: typeof fetch,
|
||||||
|
): OpenFgaRelationships | undefined {
|
||||||
|
const baseUrl = env[OPENFGA_ENV.baseUrl];
|
||||||
|
const storeId = env[OPENFGA_ENV.storeId];
|
||||||
|
const modelId = env[OPENFGA_ENV.modelId];
|
||||||
|
const serviceToken = env[OPENFGA_ENV.serviceToken];
|
||||||
|
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
|
||||||
|
try {
|
||||||
|
return new OpenFgaRelationships({
|
||||||
|
baseUrl,
|
||||||
|
storeId,
|
||||||
|
modelId,
|
||||||
|
serviceToken,
|
||||||
|
...(fetchImpl ? { fetchImpl } : {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5
@@ -105,6 +105,9 @@ importers:
|
|||||||
'@siax/0pt-decision':
|
'@siax/0pt-decision':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/decision
|
version: link:packages/decision
|
||||||
|
'@siax/access':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:packages/siax-access
|
||||||
'@siax/charts':
|
'@siax/charts':
|
||||||
specifier: ^0.5.0
|
specifier: ^0.5.0
|
||||||
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
@@ -246,6 +249,8 @@ importers:
|
|||||||
|
|
||||||
packages/schemas: {}
|
packages/schemas: {}
|
||||||
|
|
||||||
|
packages/siax-access: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** Run with DOC0_CHECKOUT=/absolute/path "$DOC0_CHECKOUT/node_modules/.bin/tsx" scripts/verify-doc0-source-wire.ts.
|
||||||
|
* In-memory test only: proves the current DOC0 response shape, not production auth or storage. */
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { isAbsolute, resolve } from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { Doc0SourceClient } from '../src/document-workbench/doc0-source-client';
|
||||||
|
import { structureDocumentBatch } from '../src/document-workbench/structure-document-batch';
|
||||||
|
import { exportValidatedBatch } from '../src/document-workbench/structured-batch';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const checkout = process.env.DOC0_CHECKOUT;
|
||||||
|
if (!checkout || !isAbsolute(checkout)) throw new Error('DOC0_CHECKOUT must name an absolute local checkout');
|
||||||
|
const load = (path: string) => import(pathToFileURL(resolve(checkout, path)).href);
|
||||||
|
const [{ buildServer }, { InMemoryDocumentStore }, { InMemoryDocumentVersionStore }, { InMemoryParseStore }, { segmentText }] = await Promise.all([
|
||||||
|
load('src/api/app.ts'), load('src/domains/document/store.ts'), load('src/domains/document-version/store.ts'),
|
||||||
|
load('src/domains/parse/store.ts'), load('src/parsing/segments.ts'),
|
||||||
|
]);
|
||||||
|
process.env.AUTH_ENFORCE = 'false';
|
||||||
|
Object.assign(process.env, { NODE_ENV: 'test' });
|
||||||
|
const documents = new InMemoryDocumentStore(), versions = new InMemoryDocumentVersionStore(), parses = new InMemoryParseStore();
|
||||||
|
const input = { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', requestId: 'req', receipt: {
|
||||||
|
documentId: 'doc', versionId: 'v1', checksumSha256: createHash('sha256').update('fixture bytes').digest('hex'), pipelineStage: 'parsed',
|
||||||
|
} };
|
||||||
|
await documents.create({ tenantId: 't-test', projectId: 'p-test', documentId: 'doc', appId: 'test', displayName: 'fixture', documentType: 'unknown', classification: 'internal', tags: [], createdBy: 'actor' });
|
||||||
|
await versions.append({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', versionNumber: 1, checksumSha256: input.receipt.checksumSha256, sizeBytes: 13, mimeType: 'text/plain', st0reArtifactRef: 'fixture', pipelineStage: 'parsed', extractedMetadata: {}, customMetadata: {}, createdBy: 'actor' });
|
||||||
|
const parts = segmentText('Acme AB\n\nInvoice 123');
|
||||||
|
await parses.appendDocumentText({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', charCount: parts.charCount, segmentCount: parts.segments.length, segmentsTruncated: parts.truncated, textSha256: parts.textSha256 });
|
||||||
|
await parses.appendTextSegments(parts.segments.map((s: object, i: number) => ({ ...s, tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', segmentId: `seg-${i}`, sourceVersionSha256: input.receipt.checksumSha256 })));
|
||||||
|
const app = buildServer({ documentStore: documents, versionStore: versions, parseStore: parses });
|
||||||
|
const fetchImpl: typeof fetch = async (url, init) => {
|
||||||
|
const target = new URL(String(url));
|
||||||
|
const out = await app.inject({ method: 'GET', url: target.pathname + target.search, headers: Object.fromEntries(new Headers(init?.headers).entries()) });
|
||||||
|
return new Response(out.body, { status: out.statusCode, headers: { 'content-type': 'application/json' } });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.example', serviceToken: 'fixture', fetchImpl });
|
||||||
|
const result = await client.read(input);
|
||||||
|
if (result.sources.length !== 2 || result.sources[0]?.text !== 'Acme AB' || result.sources[1]?.text !== 'Invoice 123') throw new Error('DOC0 wire mismatch');
|
||||||
|
const fields = [{ name: 'value', type: 'string' as const, required: true }];
|
||||||
|
const records = result.sources.map((source) => ({ value: { value: source.text }, evidence: [{ field: 'value', sourceId: source.id, start: 0, end: source.text.length, transform: 'exact' }] }));
|
||||||
|
const request = {
|
||||||
|
context: { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', capability: 'inf0.complete', correlationId: 'req' },
|
||||||
|
admission: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', modelId: 'local_only', dataClass: 'high', maxInputTokens: 200, maxOutputTokens: 100, maximumCredits: 5 },
|
||||||
|
policy: { tenantId: 't-test', projectId: 'p-test', allowedModelIds: ['local_only'], allowedDataClasses: ['high'], destination: 'internal' as const, externalTransferAllowed: false, maxInputTokens: 200, maxOutputTokens: 100 },
|
||||||
|
reservation: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', status: 'reserved' as const, credits: 5 },
|
||||||
|
};
|
||||||
|
const structured = await structureDocumentBatch({ request, fields, sources: result.sources, instructions: 'Extract values', countTokens: async () => 50,
|
||||||
|
gateway: { complete: async () => ({ status: 'model_stopped', content: JSON.stringify({ records }), modelExecuted: 'fixture', providerKind: 'local', promptTokens: 50, completionTokens: 20, totalTokens: 70 }) } });
|
||||||
|
if (structured.status !== 'mechanically_validated') throw new Error('model fixture evidence failed');
|
||||||
|
const exported = exportValidatedBatch({ format: 'json', fields, sources: result.sources, records: structured.records });
|
||||||
|
if (exported.status !== 'ready' || JSON.parse(exported.content).length !== 2) throw new Error('export fixture failed');
|
||||||
|
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: checkout, encoding: 'utf8' }).trim();
|
||||||
|
console.log(`DOC0 ${revision}: actual route through 0pt source and validated export; in-memory stores, test auth and model fixture`);
|
||||||
|
} finally { await app.close(); }
|
||||||
|
}
|
||||||
|
main().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -24,6 +24,21 @@ export interface CreditBalance {
|
|||||||
export class PostgresCreditLedger {
|
export class PostgresCreditLedger {
|
||||||
constructor(private readonly sql: RepositorySql) {}
|
constructor(private readonly sql: RepositorySql) {}
|
||||||
|
|
||||||
|
/** Read-only balance for the authenticated tenant. Never creates a row. */
|
||||||
|
async balance(tenantId: string): Promise<CreditBalance> {
|
||||||
|
if (typeof tenantId !== "string" || !tenantId.trim() || tenantId.length > 256) {
|
||||||
|
throw new Error("Invalid credit tenant");
|
||||||
|
}
|
||||||
|
return withCreditTenant(this.sql, tenantId, async (tx) => {
|
||||||
|
const rows = await tx<{ available: string; reserved: string }[]>`
|
||||||
|
SELECT available, reserved FROM opt_credit_accounts WHERE tenant_id = ${tenantId}`;
|
||||||
|
const row = rows[0];
|
||||||
|
return row
|
||||||
|
? { available: Number(row.available), reserved: Number(row.reserved) }
|
||||||
|
: { available: 0, reserved: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async apply(command: CreditCommand): Promise<{ replay: boolean; balance: CreditBalance }> {
|
async apply(command: CreditCommand): Promise<{ replay: boolean; balance: CreditBalance }> {
|
||||||
const { tenantId, idempotencyKey, kind, reference, amount } = command;
|
const { tenantId, idempotencyKey, kind, reference, amount } = command;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { Doc0UploadReceipt } from './doc0-upload-client';
|
||||||
|
import type { TextSource } from './structured-output-validation';
|
||||||
|
|
||||||
|
export class Doc0SourceUnavailableError extends Error {
|
||||||
|
constructor() { super('DOC0 extracted source could not be verified; review or reconciliation is required'); this.name = 'Doc0SourceUnavailableError'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
|
const MAX_SEGMENTS = 100;
|
||||||
|
const MAX_SOURCE_BYTES = 192 * 1024;
|
||||||
|
|
||||||
|
async function boundedJson(response: Response): Promise<unknown> {
|
||||||
|
if (!response.ok || !response.body) throw new Error('source unavailable');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
for (;;) {
|
||||||
|
const part = await reader.read();
|
||||||
|
if (part.done) break;
|
||||||
|
size += part.value.byteLength;
|
||||||
|
if (size > MAX_RESPONSE_BYTES) { void reader.cancel().catch(() => undefined); throw new Error('source too large'); }
|
||||||
|
chunks.push(part.value);
|
||||||
|
}
|
||||||
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) as unknown;
|
||||||
|
} finally { reader.releaseLock(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a bounded DOC0 index for a verified upload receipt. The caller must
|
||||||
|
* independently authenticate and authorize the tenant/project/actor, and
|
||||||
|
* DOC0 must enforce the same project policy. The segment index is not proof
|
||||||
|
* of OCR accuracy, semantic correctness, or complete source extraction.
|
||||||
|
*/
|
||||||
|
export class Doc0SourceClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
|
||||||
|
constructor(options: { baseUrl: string; serviceToken: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(options.baseUrl); } catch { throw new Error('Invalid DOC0 source configuration'); }
|
||||||
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !options.serviceToken.trim()
|
||||||
|
|| (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1000 || options.timeoutMs > 300000))) {
|
||||||
|
throw new Error('Invalid DOC0 source configuration');
|
||||||
|
}
|
||||||
|
this.baseUrl = url.toString().replace(/\/+$/, '');
|
||||||
|
this.token = options.serviceToken;
|
||||||
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? 120000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(input: {
|
||||||
|
tenantId: string; projectId: string; actorId: string; requestId: string; receipt: Doc0UploadReceipt;
|
||||||
|
}): Promise<{ sources: TextSource[]; reportedTextSha256: string }> {
|
||||||
|
const snapshot = structuredClone(input);
|
||||||
|
const { tenantId, projectId, actorId, requestId, receipt } = snapshot;
|
||||||
|
if (![tenantId, projectId, actorId, requestId, receipt?.documentId, receipt?.versionId]
|
||||||
|
.every((value) => typeof value === 'string' && /^[\x21-\x7e]{1,256}$/.test(value))
|
||||||
|
|| !/^[0-9a-f]{64}$/.test(receipt?.checksumSha256 ?? '') || receipt?.pipelineStage !== 'parsed') {
|
||||||
|
throw new Doc0SourceUnavailableError();
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents/${encodeURIComponent(receipt.documentId)}/versions/${encodeURIComponent(receipt.versionId)}/segments?limit=${MAX_SEGMENTS}&offset=0`, {
|
||||||
|
method: 'GET', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
headers: { authorization: `Bearer ${this.token}`, 'x-tenant-id': tenantId, 'x-actor-id': actorId, 'x-request-id': requestId },
|
||||||
|
});
|
||||||
|
value = await boundedJson(response);
|
||||||
|
} catch { throw new Doc0SourceUnavailableError(); }
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Doc0SourceUnavailableError();
|
||||||
|
const page = value as { texts?: unknown; segments?: unknown; offset?: unknown };
|
||||||
|
if (page.offset !== 0 || !Array.isArray(page.texts) || page.texts.length !== 1 || !Array.isArray(page.segments)) {
|
||||||
|
throw new Doc0SourceUnavailableError();
|
||||||
|
}
|
||||||
|
const meta = page.texts[0] as Record<string, unknown> | null;
|
||||||
|
if (!meta || meta.tenantId !== tenantId || meta.documentId !== receipt.documentId || meta.versionId !== receipt.versionId
|
||||||
|
|| typeof meta.documentTextId !== 'string' || !meta.documentTextId || typeof meta.parserRunId !== 'string' || !meta.parserRunId
|
||||||
|
|| meta.segmentsTruncated !== false || !Number.isSafeInteger(meta.segmentCount) || (meta.segmentCount as number) < 1
|
||||||
|
|| (meta.segmentCount as number) > MAX_SEGMENTS || meta.segmentCount !== page.segments.length
|
||||||
|
|| !Number.isSafeInteger(meta.charCount) || (meta.charCount as number) < 1
|
||||||
|
|| typeof meta.textSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(meta.textSha256)) {
|
||||||
|
throw new Doc0SourceUnavailableError();
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const sources: TextSource[] = [];
|
||||||
|
let priorEnd = 0;
|
||||||
|
for (const [index, value] of page.segments.entries()) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Doc0SourceUnavailableError();
|
||||||
|
const segment = value as Record<string, unknown>;
|
||||||
|
if (segment.tenantId !== tenantId || segment.documentId !== receipt.documentId || segment.versionId !== receipt.versionId
|
||||||
|
|| segment.documentTextId !== meta.documentTextId || segment.parserRunId !== meta.parserRunId
|
||||||
|
|| segment.sourceVersionSha256 !== receipt.checksumSha256 || segment.sequence !== index
|
||||||
|
|| typeof segment.segmentId !== 'string' || !segment.segmentId || ids.has(segment.segmentId)
|
||||||
|
|| typeof segment.text !== 'string' || !segment.text || !Number.isSafeInteger(segment.charStart)
|
||||||
|
|| !Number.isSafeInteger(segment.charEnd) || (segment.charStart as number) < priorEnd
|
||||||
|
|| (segment.charEnd as number) - (segment.charStart as number) !== segment.text.length
|
||||||
|
|| (segment.charEnd as number) > (meta.charCount as number)
|
||||||
|
|| segment.segmentSha256 !== createHash('sha256').update(segment.text, 'utf8').digest('hex')) {
|
||||||
|
throw new Doc0SourceUnavailableError();
|
||||||
|
}
|
||||||
|
ids.add(segment.segmentId);
|
||||||
|
priorEnd = segment.charEnd as number;
|
||||||
|
sources.push({ id: segment.segmentId, text: segment.text });
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(JSON.stringify(sources), 'utf8') > MAX_SOURCE_BYTES) throw new Doc0SourceUnavailableError();
|
||||||
|
return { sources, reportedTextSha256: meta.textSha256 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Server-owned, versioned credit price book.
|
||||||
|
*
|
||||||
|
* Prices are a business decision, not a code default: this module never
|
||||||
|
* invents a price. It parses an explicit `OPT_CREDIT_PRICE_BOOK` JSON document
|
||||||
|
* and fails closed when it is missing or invalid, so the checkout route can
|
||||||
|
* answer "price book not configured" instead of charging an arbitrary amount.
|
||||||
|
*
|
||||||
|
* Expected shape (all money in minor units, all quantities safe integers):
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "version": "2026-11-01",
|
||||||
|
* "currency": "SEK",
|
||||||
|
* "packs": [{ "id": "starter", "credits": 100, "amountMinor": 9900 }],
|
||||||
|
* "metering": {
|
||||||
|
* "pagesPerCredit": 5,
|
||||||
|
* "inputTokensPerCredit": 20000,
|
||||||
|
* "outputTokensPerCredit": 4000
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CreditPack {
|
||||||
|
id: string;
|
||||||
|
credits: number;
|
||||||
|
amountMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditMetering {
|
||||||
|
pagesPerCredit: number;
|
||||||
|
inputTokensPerCredit: number;
|
||||||
|
outputTokensPerCredit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditPriceBook {
|
||||||
|
version: string;
|
||||||
|
currency: string;
|
||||||
|
packs: readonly CreditPack[];
|
||||||
|
metering: CreditMetering;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
function safePositive(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= MAX_SAFE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePositiveOrZero(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_SAFE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePack(raw: unknown, currency: string): CreditPack {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new Error('Each credit pack must be an object');
|
||||||
|
}
|
||||||
|
const pack = raw as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof pack.id !== 'string' ||
|
||||||
|
!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(pack.id) ||
|
||||||
|
!safePositive(pack.credits) ||
|
||||||
|
!safePositive(pack.amountMinor) ||
|
||||||
|
(pack.currency !== undefined && pack.currency !== currency)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid credit pack');
|
||||||
|
}
|
||||||
|
return { id: pack.id, credits: pack.credits, amountMinor: pack.amountMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreditPriceBook(raw: unknown): CreditPriceBook {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new Error('Price book must be an object');
|
||||||
|
}
|
||||||
|
const doc = raw as Record<string, unknown>;
|
||||||
|
const version = doc.version;
|
||||||
|
const currency = doc.currency;
|
||||||
|
if (
|
||||||
|
typeof version !== 'string' ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}(?:\.\d+)?$/.test(version) ||
|
||||||
|
typeof currency !== 'string' ||
|
||||||
|
!/^[A-Z]{3}$/.test(currency) ||
|
||||||
|
!Array.isArray(doc.packs) ||
|
||||||
|
doc.packs.length === 0 ||
|
||||||
|
doc.packs.length > 100
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid price book header or packs');
|
||||||
|
}
|
||||||
|
const packs = doc.packs.map((pack) => parsePack(pack, currency));
|
||||||
|
const ids = new Set(packs.map((pack) => pack.id));
|
||||||
|
if (ids.size !== packs.length) throw new Error('Duplicate credit pack id');
|
||||||
|
if (!doc.metering || typeof doc.metering !== 'object' || Array.isArray(doc.metering)) {
|
||||||
|
throw new Error('Price book metering is required');
|
||||||
|
}
|
||||||
|
const metering = doc.metering as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
!safePositive(metering.pagesPerCredit) ||
|
||||||
|
!safePositive(metering.inputTokensPerCredit) ||
|
||||||
|
!safePositive(metering.outputTokensPerCredit)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid metering rates');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
currency,
|
||||||
|
packs,
|
||||||
|
metering: {
|
||||||
|
pagesPerCredit: metering.pagesPerCredit,
|
||||||
|
inputTokensPerCredit: metering.inputTokensPerCredit,
|
||||||
|
outputTokensPerCredit: metering.outputTokensPerCredit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns `undefined` (fail closed) when the price book is absent or invalid. */
|
||||||
|
export function readCreditPriceBook(env: NodeJS.ProcessEnv = process.env): CreditPriceBook | undefined {
|
||||||
|
const raw = env.OPT_CREDIT_PRICE_BOOK;
|
||||||
|
if (!raw || !raw.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
return parseCreditPriceBook(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPack(book: CreditPriceBook, packId: string): CreditPack | undefined {
|
||||||
|
return book.packs.find((pack) => pack.id === packId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round each metered unit up to its billing block, exactly like credit-quote. */
|
||||||
|
export function creditsForUsage(
|
||||||
|
book: CreditPriceBook,
|
||||||
|
units: { pages: number; inputTokens: number; outputTokens: number },
|
||||||
|
): number {
|
||||||
|
if (
|
||||||
|
!safePositiveOrZero(units.pages) ||
|
||||||
|
!safePositiveOrZero(units.inputTokens) ||
|
||||||
|
!safePositiveOrZero(units.outputTokens)
|
||||||
|
) {
|
||||||
|
throw new Error('Usage units must be nonnegative safe integers');
|
||||||
|
}
|
||||||
|
const blocks =
|
||||||
|
Math.ceil(units.pages / book.metering.pagesPerCredit) +
|
||||||
|
Math.ceil(units.inputTokens / book.metering.inputTokensPerCredit) +
|
||||||
|
Math.ceil(units.outputTokens / book.metering.outputTokensPerCredit);
|
||||||
|
if (!Number.isSafeInteger(blocks) || blocks < 0) {
|
||||||
|
throw new Error('Credit estimate outside supported range');
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 0pt project authorization for the document workbench.
|
||||||
|
*
|
||||||
|
* Reuses the universal `@siax/access` OpenFGA relationship primitive (the same
|
||||||
|
* one DOC0 proved) instead of a per-repo copy. FAIL CLOSED: when OpenFGA is not
|
||||||
|
* configured, or the relationship source is unavailable, the answer is "no".
|
||||||
|
*/
|
||||||
|
import { OpenFgaRelationships, isProjectAccessAllowed } from '@siax/access';
|
||||||
|
import { readDocumentWorkbenchRuntime } from './runtime-config';
|
||||||
|
|
||||||
|
export interface ProjectScope {
|
||||||
|
tenantId: string;
|
||||||
|
projectId: string;
|
||||||
|
actorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectAuthorizer = (scope: ProjectScope) => Promise<boolean>;
|
||||||
|
|
||||||
|
export function createProjectAuthorizer(env: NodeJS.ProcessEnv = process.env): ProjectAuthorizer {
|
||||||
|
const runtime = readDocumentWorkbenchRuntime(env);
|
||||||
|
let relationships: OpenFgaRelationships | undefined;
|
||||||
|
if (runtime.openfga) {
|
||||||
|
try {
|
||||||
|
relationships = new OpenFgaRelationships(runtime.openfga);
|
||||||
|
} catch {
|
||||||
|
relationships = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return async (scope) => isProjectAccessAllowed(scope, { relationships });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the server has enough configuration to authorize at all. */
|
||||||
|
export function projectAuthorizationConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
return Boolean(readDocumentWorkbenchRuntime(env).openfga);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Server-side runtime configuration for the document workbench.
|
||||||
|
*
|
||||||
|
* Every integration is optional to BOOT and mandatory to USE: a route that
|
||||||
|
* needs DOC0/INF0/PAY0 returns a typed "not configured" error instead of
|
||||||
|
* fabricating success. URLs must be trusted HTTPS endpoints; tokens are never
|
||||||
|
* logged or returned.
|
||||||
|
*
|
||||||
|
* This module is deliberately framework-neutral and free of product imports so
|
||||||
|
* it can be lifted into a shared package without change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HttpsEndpoint {
|
||||||
|
baseUrl: string;
|
||||||
|
serviceToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentWorkbenchRuntime {
|
||||||
|
doc0?: HttpsEndpoint & { maxUploadBytes: number };
|
||||||
|
inf0?: HttpsEndpoint;
|
||||||
|
pay0?: HttpsEndpoint;
|
||||||
|
n0d?: HttpsEndpoint;
|
||||||
|
st0re?: HttpsEndpoint;
|
||||||
|
openfga?: {
|
||||||
|
baseUrl: string;
|
||||||
|
storeId: string;
|
||||||
|
modelId: string;
|
||||||
|
serviceToken: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
function first(...values: Array<string | undefined>): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only absolute HTTPS endpoints with no credentials/query/hash are trusted. */
|
||||||
|
export function isTrustedHttpsUrl(value: string | undefined): value is string {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
urlNames: readonly string[],
|
||||||
|
tokenNames: readonly string[],
|
||||||
|
): HttpsEndpoint | undefined {
|
||||||
|
const baseUrl = first(...urlNames.map((name) => env[name]));
|
||||||
|
const serviceToken = first(...tokenNames.map((name) => env[name]));
|
||||||
|
if (!isTrustedHttpsUrl(baseUrl) || !serviceToken) return undefined;
|
||||||
|
return { baseUrl: baseUrl.replace(/\/+$/, ''), serviceToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInt(value: string | undefined, fallback: number): number {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDocumentWorkbenchRuntime(env: NodeJS.ProcessEnv = process.env): DocumentWorkbenchRuntime {
|
||||||
|
const doc0 = endpoint(env, ['DOC0_URL', 'DOC0_API_URL'], ['DOC0_SERVICE_TOKEN', 'DOC0_SVC_PAT']);
|
||||||
|
const inf0 = endpoint(env, ['INF0_URL', 'INF0_API_URL'], ['INF0_API_TOKEN', 'INF0_SERVICE_TOKEN']);
|
||||||
|
const pay0 = endpoint(env, ['PAY0_URL', 'PAY0_API_URL'], ['PAY0_API_TOKEN', 'PAY0_SERVICE_TOKEN']);
|
||||||
|
const n0d = endpoint(env, ['N0D_URL', 'N0D_API_URL'], ['N0D_API_TOKEN', 'N0D_SERVICE_TOKEN']);
|
||||||
|
const st0re = endpoint(env, ['ST0RE_URL', 'ST0RE_API_URL'], ['ST0RE_API_TOKEN', 'ST0RE_SERVICE_TOKEN']);
|
||||||
|
|
||||||
|
const openfgaBase = first(env.OPENFGA_API_URL);
|
||||||
|
const openfgaStore = first(env.OPENFGA_STORE_ID);
|
||||||
|
const openfgaModel = first(env.OPENFGA_MODEL_ID);
|
||||||
|
const openfgaToken = first(env.OPENFGA_AUTH_TOKEN, env.OPENFGA_AUTH_KEY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(doc0
|
||||||
|
? {
|
||||||
|
doc0: {
|
||||||
|
...doc0,
|
||||||
|
maxUploadBytes: positiveInt(env.OPT_MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(inf0 ? { inf0 } : {}),
|
||||||
|
...(pay0 ? { pay0 } : {}),
|
||||||
|
...(n0d ? { n0d } : {}),
|
||||||
|
...(st0re ? { st0re } : {}),
|
||||||
|
...(isTrustedHttpsUrl(openfgaBase) && openfgaStore && openfgaModel && openfgaToken
|
||||||
|
? {
|
||||||
|
openfga: {
|
||||||
|
baseUrl: openfgaBase.replace(/\/+$/, ''),
|
||||||
|
storeId: openfgaStore,
|
||||||
|
modelId: openfgaModel,
|
||||||
|
serviceToken: openfgaToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type Stripe from 'stripe';
|
||||||
|
import type { CreditCommand } from './credit-ledger';
|
||||||
|
import type { CreditPurchaseSnapshot } from './pay0-credit-verification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe-backed credit verification.
|
||||||
|
*
|
||||||
|
* The webhook is only a trigger: this class re-reads the checkout session and
|
||||||
|
* its payment intent from Stripe and derives the grant from that authoritative
|
||||||
|
* response. Nothing in the webhook body is trusted for price, credits or
|
||||||
|
* tenant. The resulting command is idempotent per (tenant, purchase).
|
||||||
|
*
|
||||||
|
* This does not implement refunds, chargebacks or partial captures beyond
|
||||||
|
* refusing to grant when Stripe reports anything other than a fully paid,
|
||||||
|
* unrefunded session.
|
||||||
|
*/
|
||||||
|
export interface StripeCheckoutReader {
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
retrieve(id: string, options: { expand: string[] }): Promise<Stripe.Checkout.Session>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StripeCreditVerificationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly reason: 'not_found' | 'mismatch' | 'not_paid' | 'refunded' | 'unsupported',
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'StripeCreditVerificationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyStripeCreditPurchase(
|
||||||
|
stripe: StripeCheckoutReader,
|
||||||
|
snapshot: CreditPurchaseSnapshot,
|
||||||
|
context: { tenantId: string },
|
||||||
|
): Promise<CreditCommand> {
|
||||||
|
if (
|
||||||
|
context.tenantId !== snapshot.tenantId ||
|
||||||
|
!snapshot.checkoutSessionId.trim() ||
|
||||||
|
!Number.isSafeInteger(snapshot.credits) ||
|
||||||
|
snapshot.credits <= 0
|
||||||
|
) {
|
||||||
|
throw new StripeCreditVerificationError('Invalid or mismatched credit purchase', 'mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(snapshot.checkoutSessionId, {
|
||||||
|
expand: ['payment_intent'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.id !== snapshot.checkoutSessionId) {
|
||||||
|
throw new StripeCreditVerificationError('Session id mismatch', 'mismatch');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
session.metadata?.kind !== '0pt_credit_purchase' ||
|
||||||
|
session.metadata?.tenant_id !== snapshot.tenantId ||
|
||||||
|
session.metadata?.purchase_id !== snapshot.purchaseId
|
||||||
|
) {
|
||||||
|
throw new StripeCreditVerificationError('Session metadata does not belong to this purchase', 'mismatch');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
session.amount_total !== snapshot.amountMinor ||
|
||||||
|
session.currency?.toUpperCase() !== snapshot.currency
|
||||||
|
) {
|
||||||
|
throw new StripeCreditVerificationError(
|
||||||
|
'Session amount or currency does not match the server price snapshot',
|
||||||
|
'mismatch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (session.payment_status !== 'paid') {
|
||||||
|
throw new StripeCreditVerificationError('Session is not paid', 'not_paid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = session.payment_intent;
|
||||||
|
if (!intent || typeof intent === 'string') {
|
||||||
|
throw new StripeCreditVerificationError('Payment intent was not expanded', 'unsupported');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
intent.status !== 'succeeded' ||
|
||||||
|
intent.currency?.toUpperCase() !== snapshot.currency ||
|
||||||
|
(typeof intent.amount_received === 'number' && intent.amount_received < snapshot.amountMinor)
|
||||||
|
) {
|
||||||
|
throw new StripeCreditVerificationError(
|
||||||
|
'Payment intent is not a completed capture of the snapshot amount',
|
||||||
|
'not_paid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: snapshot.tenantId,
|
||||||
|
idempotencyKey: `credit_purchase:${snapshot.tenantId}:${snapshot.purchaseId}`,
|
||||||
|
kind: 'verified_payment',
|
||||||
|
reference: intent.id,
|
||||||
|
amount: snapshot.credits,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Verified request scope for customer-facing 0pt routes.
|
||||||
|
*
|
||||||
|
* Middleware (`middleware.ts`) performs real Zitadel Bearer verification and
|
||||||
|
* forwards the verified subject/tenant only when a principal was verified.
|
||||||
|
* Customer routes must NOT fall back to client-supplied tenant/actor values:
|
||||||
|
* that fallback exists only for the explicit local-development mode, and even
|
||||||
|
* there this helper requires an explicit dev tenant header.
|
||||||
|
*/
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { VERIFIED_TENANT_HEADER } from './tenant-guard';
|
||||||
|
|
||||||
|
export const VERIFIED_SUBJECT_HEADER = 'x-0pt-verified-sub';
|
||||||
|
/** Dev-only header (AUTH_ENFORCE=false) so local flows still have a scope. */
|
||||||
|
export const DEV_TENANT_HEADER = 'x-0pt-dev-tenant';
|
||||||
|
export const DEV_SUBJECT_HEADER = 'x-0pt-dev-subject';
|
||||||
|
|
||||||
|
const idPattern = /^[\x21-\x7e]{1,256}$/;
|
||||||
|
|
||||||
|
export interface VerifiedScope {
|
||||||
|
tenantId: string;
|
||||||
|
actorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function devMode(): boolean {
|
||||||
|
return process.env.AUTH_ENFORCE === 'false' && process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVerifiedScope(
|
||||||
|
request: NextRequest,
|
||||||
|
): { scope: VerifiedScope } | { response: NextResponse } {
|
||||||
|
const tenantId = request.headers.get(VERIFIED_TENANT_HEADER);
|
||||||
|
const actorId = request.headers.get(VERIFIED_SUBJECT_HEADER);
|
||||||
|
if (tenantId && actorId && idPattern.test(tenantId) && idPattern.test(actorId)) {
|
||||||
|
return { scope: { tenantId, actorId } };
|
||||||
|
}
|
||||||
|
if (devMode()) {
|
||||||
|
const devTenant = request.headers.get(DEV_TENANT_HEADER);
|
||||||
|
const devActor = request.headers.get(DEV_SUBJECT_HEADER);
|
||||||
|
if (devTenant && devActor && idPattern.test(devTenant) && idPattern.test(devActor)) {
|
||||||
|
return { scope: { tenantId: devTenant, actorId: devActor } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
response: NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'OPT_UNAUTHENTICATED',
|
||||||
|
message: 'A verified tenant and subject are required',
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidScopeId(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && idPattern.test(value);
|
||||||
|
}
|
||||||
@@ -52,14 +52,13 @@ describe("GET /api/health", () => {
|
|||||||
expect(body.queue).toBe("not_configured");
|
expect(body.queue).toBe("not_configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports queue as not_integrated (never a fabricated ok) when REDIS_URL is set but unused", async () => {
|
it("reports a configured but unreachable queue as error (never a fabricated ok)", async () => {
|
||||||
process.env.REDIS_URL = "redis://localhost:6379";
|
process.env.REDIS_URL = "redis://127.0.0.1:1";
|
||||||
const res = await healthGET();
|
const res = await healthGET();
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.queue).toBe("not_integrated");
|
expect(body.queue).toBe("error");
|
||||||
// Not wiring a real check for an unused integration is not itself a
|
// Liveness stays 200; readiness is what fails closed on a broken dependency.
|
||||||
// degradation -- overall liveness stays ok.
|
expect(res.status).toBe(200);
|
||||||
expect(body.ok).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("response includes a ts timestamp", async () => {
|
it("response includes a ts timestamp", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Doc0SourceClient, Doc0SourceUnavailableError } from '../../src/document-workbench/doc0-source-client';
|
||||||
|
|
||||||
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||||
|
const receipt = { documentId: 'doc-a', versionId: 'version-a', checksumSha256: 'a'.repeat(64), pipelineStage: 'parsed' };
|
||||||
|
const input = { tenantId: 'tenant-a', projectId: 'project-a', actorId: 'user-a', requestId: 'request-a', receipt };
|
||||||
|
const text = 'Acme 😀';
|
||||||
|
const metadata = { tenantId: input.tenantId, documentId: receipt.documentId, versionId: receipt.versionId,
|
||||||
|
documentTextId: 'text-a', parserRunId: 'run-a', segmentsTruncated: false, segmentCount: 1, charCount: text.length, textSha256: hash(text) };
|
||||||
|
const segment = { tenantId: input.tenantId, documentId: receipt.documentId, versionId: receipt.versionId,
|
||||||
|
documentTextId: 'text-a', parserRunId: 'run-a', segmentId: 'segment-a', sequence: 0, text, charStart: 0,
|
||||||
|
charEnd: text.length, segmentSha256: hash(text), sourceVersionSha256: receipt.checksumSha256 };
|
||||||
|
const payload = { offset: 0, texts: [metadata], segments: [segment] };
|
||||||
|
|
||||||
|
describe('DOC0 extracted source client', () => {
|
||||||
|
it('reads scoped, checksum-bound segment text without caching or redirects', async () => {
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
expect(String(url)).toContain('/v1/doc0/documents/doc-a/versions/version-a/segments?limit=100&offset=0');
|
||||||
|
expect(new Headers(init?.headers).get('x-tenant-id')).toBe('tenant-a');
|
||||||
|
expect(new Headers(init?.headers).get('x-actor-id')).toBe('user-a');
|
||||||
|
expect(init?.redirect).toBe('error');
|
||||||
|
expect(init?.cache).toBe('no-store');
|
||||||
|
return Response.json(payload);
|
||||||
|
});
|
||||||
|
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl });
|
||||||
|
expect(await client.read(input)).toEqual({ sources: [{ id: 'segment-a', text }], reportedTextSha256: hash(text) });
|
||||||
|
});
|
||||||
|
it('rejects incomplete, forged, cross-tenant or inconsistent text instead of passing it to a model', async () => {
|
||||||
|
const bad = [
|
||||||
|
{ ...payload, texts: [{ ...metadata, segmentsTruncated: true }] },
|
||||||
|
{ ...payload, texts: [{ ...metadata, segmentCount: 2 }] },
|
||||||
|
{ ...payload, texts: [{ ...metadata, tenantId: 'tenant-b' }] },
|
||||||
|
{ ...payload, texts: [metadata, metadata] },
|
||||||
|
{ ...payload, segments: [{ ...segment, sourceVersionSha256: 'b'.repeat(64) }] },
|
||||||
|
{ ...payload, segments: [{ ...segment, text: 'fabricated' }] },
|
||||||
|
{ ...payload, segments: [{ ...segment, tenantId: 'tenant-b' }] },
|
||||||
|
{ ...payload, segments: [{ ...segment, sequence: 1 }] },
|
||||||
|
{ ...payload, segments: [{ ...segment, charEnd: 5 }] },
|
||||||
|
{ ...payload, offset: 1 },
|
||||||
|
];
|
||||||
|
for (const candidate of bad) {
|
||||||
|
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl: async () => Response.json(candidate) });
|
||||||
|
await expect(client.read(input)).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('rejects empty, oversized, unavailable and unparsed sources', async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => Response.json(payload));
|
||||||
|
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl });
|
||||||
|
await expect(client.read({ ...input, receipt: { ...receipt, pipelineStage: 'parse_failed' } })).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
for (const response of [Response.json({ ...payload, texts: [] }), new Response('x'.repeat(1024 * 1024 + 1)), new Response('', { status: 503 })]) {
|
||||||
|
const bad = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl: async () => response });
|
||||||
|
await expect(bad.read(input)).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('pins the original scope and validated credential during an in-flight read', async () => {
|
||||||
|
const mutable = structuredClone(input);
|
||||||
|
const config = { baseUrl: 'https://doc0.test', serviceToken: 'original', fetchImpl: async (_url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer original');
|
||||||
|
mutable.tenantId = 'tenant-b';
|
||||||
|
mutable.receipt.checksumSha256 = 'b'.repeat(64);
|
||||||
|
return Response.json(payload);
|
||||||
|
} };
|
||||||
|
const client = new Doc0SourceClient(config);
|
||||||
|
config.serviceToken = 'changed';
|
||||||
|
expect(await client.read(mutable)).toMatchObject({ sources: [{ id: 'segment-a', text }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
creditsForUsage,
|
||||||
|
findPack,
|
||||||
|
parseCreditPriceBook,
|
||||||
|
readCreditPriceBook,
|
||||||
|
} from '../../src/document-workbench/price-book';
|
||||||
|
|
||||||
|
const valid = {
|
||||||
|
version: '2026-11-01',
|
||||||
|
currency: 'SEK',
|
||||||
|
packs: [
|
||||||
|
{ id: 'starter', credits: 100, amountMinor: 9900 },
|
||||||
|
{ id: 'pro', credits: 2000, amountMinor: 129900 },
|
||||||
|
],
|
||||||
|
metering: { pagesPerCredit: 5, inputTokensPerCredit: 20000, outputTokensPerCredit: 4000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('credit price book', () => {
|
||||||
|
it('parses a valid server-owned price book', () => {
|
||||||
|
const book = parseCreditPriceBook(valid);
|
||||||
|
expect(book.packs).toHaveLength(2);
|
||||||
|
expect(findPack(book, 'pro')?.credits).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the env price book is absent or invalid', () => {
|
||||||
|
expect(readCreditPriceBook({} as unknown as NodeJS.ProcessEnv)).toBeUndefined();
|
||||||
|
expect(readCreditPriceBook({ OPT_CREDIT_PRICE_BOOK: '{not json' } as unknown as NodeJS.ProcessEnv)).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
readCreditPriceBook({
|
||||||
|
OPT_CREDIT_PRICE_BOOK: JSON.stringify({ ...valid, currency: 'sek' }),
|
||||||
|
} as unknown as NodeJS.ProcessEnv),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate pack ids, unknown currencies and non-integer money', () => {
|
||||||
|
expect(() => parseCreditPriceBook({ ...valid, packs: [valid.packs[0], valid.packs[0]] })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseCreditPriceBook({ ...valid, packs: [{ id: 'x', credits: 1, amountMinor: 1.5 }] }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds every metered component up to its billing block', () => {
|
||||||
|
const book = parseCreditPriceBook(valid);
|
||||||
|
expect(creditsForUsage(book, { pages: 0, inputTokens: 0, outputTokens: 0 })).toBe(0);
|
||||||
|
expect(creditsForUsage(book, { pages: 1, inputTokens: 1, outputTokens: 1 })).toBe(3);
|
||||||
|
expect(creditsForUsage(book, { pages: 6, inputTokens: 40001, outputTokens: 8001 })).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
import {
|
||||||
|
StripeCreditVerificationError,
|
||||||
|
verifyStripeCreditPurchase,
|
||||||
|
type StripeCheckoutReader,
|
||||||
|
} from '../../src/document-workbench/stripe-credit-verification';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
purchaseId: 'purchase-1',
|
||||||
|
checkoutSessionId: 'cs_test_1',
|
||||||
|
amountMinor: 9900,
|
||||||
|
currency: 'SEK',
|
||||||
|
credits: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripeReturning(session: unknown): StripeCheckoutReader {
|
||||||
|
return {
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
retrieve: async () => session as Stripe.Checkout.Session,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'cs_test_1',
|
||||||
|
metadata: {
|
||||||
|
kind: '0pt_credit_purchase',
|
||||||
|
tenant_id: 't_0pt',
|
||||||
|
purchase_id: 'purchase-1',
|
||||||
|
},
|
||||||
|
amount_total: 9900,
|
||||||
|
currency: 'sek',
|
||||||
|
payment_status: 'paid',
|
||||||
|
payment_intent: {
|
||||||
|
id: 'pi_123',
|
||||||
|
status: 'succeeded',
|
||||||
|
currency: 'sek',
|
||||||
|
amount_received: 9900,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Stripe credit verification', () => {
|
||||||
|
it('derives an idempotent verified-payment command from the re-read session', async () => {
|
||||||
|
const command = await verifyStripeCreditPurchase(stripeReturning(session()), snapshot, {
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
});
|
||||||
|
expect(command).toEqual({
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
idempotencyKey: 'credit_purchase:t_0pt:purchase-1',
|
||||||
|
kind: 'verified_payment',
|
||||||
|
reference: 'pi_123',
|
||||||
|
amount: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a session that belongs to another tenant or purchase', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyStripeCreditPurchase(
|
||||||
|
stripeReturning(
|
||||||
|
session({ metadata: { kind: '0pt_credit_purchase', tenant_id: 't_b', purchase_id: 'purchase-1' } }),
|
||||||
|
),
|
||||||
|
snapshot,
|
||||||
|
{ tenantId: 't_0pt' },
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(StripeCreditVerificationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an amount that differs from the server price snapshot', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyStripeCreditPurchase(stripeReturning(session({ amount_total: 100 })), snapshot, {
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ reason: 'mismatch' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unpaid session or a non-succeeded payment intent', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyStripeCreditPurchase(stripeReturning(session({ payment_status: 'unpaid' })), snapshot, {
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ reason: 'not_paid' });
|
||||||
|
await expect(
|
||||||
|
verifyStripeCreditPurchase(
|
||||||
|
stripeReturning(
|
||||||
|
session({
|
||||||
|
payment_intent: { id: 'pi_123', status: 'processing', currency: 'sek', amount_received: 0 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
snapshot,
|
||||||
|
{ tenantId: 't_0pt' },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ reason: 'not_paid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a mismatched caller tenant before any Stripe call', async () => {
|
||||||
|
const reader = {
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
retrieve: async () => {
|
||||||
|
throw new Error('should not be called');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as StripeCheckoutReader;
|
||||||
|
await expect(verifyStripeCreditPurchase(reader, snapshot, { tenantId: 't_b' })).rejects.toMatchObject({
|
||||||
|
reason: 'mismatch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,11 +162,24 @@ describe("checkQueue", () => {
|
|||||||
else process.env.REDIS_URL = saved;
|
else process.env.REDIS_URL = saved;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never claims ok — only not_configured or not_integrated (no Redis client wired in this repo)", () => {
|
it("reports not_configured when REDIS_URL is unset", async () => {
|
||||||
delete process.env.REDIS_URL;
|
delete process.env.REDIS_URL;
|
||||||
expect(checkQueue().status).toBe("not_configured");
|
expect((await checkQueue()).status).toBe("not_configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ok only when the real probe completes", async () => {
|
||||||
process.env.REDIS_URL = "redis://localhost:6379";
|
process.env.REDIS_URL = "redis://localhost:6379";
|
||||||
expect(checkQueue().status).toBe("not_integrated");
|
const result = await checkQueue(async () => undefined);
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports error when a configured queue cannot be reached", async () => {
|
||||||
|
process.env.REDIS_URL = "redis://localhost:6379";
|
||||||
|
const result = await checkQueue(async () => {
|
||||||
|
throw new Error("connection refused");
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("error");
|
||||||
|
expect(result.detail).toContain("connection refused");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,7 +218,7 @@ describe("hasDegradedDependency / isReady", () => {
|
|||||||
db: { status: "ok" },
|
db: { status: "ok" },
|
||||||
engine: { status: "not_configured" },
|
engine: { status: "not_configured" },
|
||||||
stripe: { status: "not_configured" },
|
stripe: { status: "not_configured" },
|
||||||
queue: { status: "not_integrated" },
|
queue: { status: "not_configured" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
OpenFgaRelationships,
|
||||||
|
isProjectAccessAllowed,
|
||||||
|
openFgaFromEnv,
|
||||||
|
type ProjectRelationshipScope,
|
||||||
|
} from '../../packages/siax-access/src/index';
|
||||||
|
|
||||||
|
const STORE = '01J8ZQ6K3M9V4T7R2X5C8B1N0A';
|
||||||
|
const MODEL = '01J8ZQ6K3M9V4T7R2X5C8B1N0B';
|
||||||
|
const TOKEN = 'test-service-token';
|
||||||
|
const scope: ProjectRelationshipScope = {
|
||||||
|
tenantId: 't_0pt',
|
||||||
|
projectId: 'prj_alpha',
|
||||||
|
actorId: 'user_123',
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(handler: (url: string, init?: RequestInit) => Response) {
|
||||||
|
return new OpenFgaRelationships({
|
||||||
|
baseUrl: 'https://fga.siax.io',
|
||||||
|
storeId: STORE,
|
||||||
|
modelId: MODEL,
|
||||||
|
serviceToken: TOKEN,
|
||||||
|
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) =>
|
||||||
|
handler(String(url), init)) as typeof fetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tupleResponse(allowed: boolean): Response {
|
||||||
|
return new Response(JSON.stringify({ allowed }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@siax/access OpenFgaRelationships', () => {
|
||||||
|
it('refuses non-https configuration and non-ULID ids', () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new OpenFgaRelationships({
|
||||||
|
baseUrl: 'http://fga.siax.io',
|
||||||
|
storeId: STORE,
|
||||||
|
modelId: MODEL,
|
||||||
|
serviceToken: TOKEN,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new OpenFgaRelationships({
|
||||||
|
baseUrl: 'https://fga.siax.io',
|
||||||
|
storeId: 'not-a-ulid',
|
||||||
|
modelId: MODEL,
|
||||||
|
serviceToken: TOKEN,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires both the tenant membership and the project parent tuple', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const c = client((_url, init) => {
|
||||||
|
const body = JSON.parse(String(init?.body)) as {
|
||||||
|
tuple_key: { relation: string };
|
||||||
|
};
|
||||||
|
seen.push(body.tuple_key.relation);
|
||||||
|
return tupleResponse(true);
|
||||||
|
});
|
||||||
|
await expect(c.checkProject(scope)).resolves.toBe('relationships_match');
|
||||||
|
expect(seen).toEqual(['member', 'tenant']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies the project when membership is denied even if the parent tuple allows', async () => {
|
||||||
|
let call = 0;
|
||||||
|
const c = client(() => tupleResponse(call++ === 1));
|
||||||
|
await expect(c.checkProject(scope)).resolves.toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unavailable (never allowed) when OpenFGA fails', async () => {
|
||||||
|
const c = client(() => new Response('boom', { status: 503 }));
|
||||||
|
await expect(c.checkProject(scope)).resolves.toBe('unavailable');
|
||||||
|
await expect(isProjectAccessAllowed(scope, { relationships: c })).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed scope values before any request', async () => {
|
||||||
|
let called = false;
|
||||||
|
const c = client(() => {
|
||||||
|
called = true;
|
||||||
|
return tupleResponse(true);
|
||||||
|
});
|
||||||
|
await expect(c.checkProject({ tenantId: 't/../x', projectId: 'p', actorId: 'a' })).resolves.toBe(
|
||||||
|
'denied',
|
||||||
|
);
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when OpenFGA is not configured', async () => {
|
||||||
|
await expect(isProjectAccessAllowed(scope, { relationships: undefined })).resolves.toBe(false);
|
||||||
|
expect(openFgaFromEnv({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the additional policy only after a relationship match', async () => {
|
||||||
|
const c = client(() => tupleResponse(true));
|
||||||
|
await expect(
|
||||||
|
isProjectAccessAllowed(scope, {
|
||||||
|
relationships: c,
|
||||||
|
additionalPolicy: () => false,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
await expect(
|
||||||
|
isProjectAccessAllowed(scope, {
|
||||||
|
relationships: c,
|
||||||
|
additionalPolicy: () => {
|
||||||
|
throw new Error('policy store down');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -5,6 +5,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
"include": ["src/**/*.ts", "tests/**/*.ts", "packages/*/src/**/*.ts"],
|
||||||
"exclude": ["node_modules", ".next"]
|
"exclude": ["node_modules", ".next"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user