feat(@siax/access): universell idempotenskontrakt #489

Merged
admin merged 2 commits from feat/universal-idempotency-tuple into main 2026-11-06 10:02:00 +00:00
4 changed files with 431 additions and 235 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* @siax/access idempotency primitives — the universal SIAX contract for any
* operation that must either happen exactly once or replay its receipt.
*
* This package is derived from the pattern proven in 0pt's durable upload
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
* generalized so DOC0, N0D and future products can adopt the identical shape
* instead of re-implementing it.
*
* Rules encoded here:
* 1. An operation identity binds EVERY result-affecting input (tenant,
* project, actor, payload version). A changed payload under the same key
* is a CONFLICT, never a replay of someone else's receipt.
* 2. One claimant per key: returning `false` from `start` means another
* caller owns the dispatch, and only the stored receipt (or bounded
* observation) may be answered.
* 3. A stored receipt is replayed verbatim; the caller never invents one.
* 4. Commit boundaries: NEVER write a network command before the claim is
* durably persisted, and never lose the claim after the command may have
* committed.
*/
import { createHash } from "node:crypto";
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
return createHash("sha256")
.update(JSON.stringify([version, ...parts]))
.digest("hex");
}
export interface OperationIdentity {
tenantId: string;
projectId: string;
actorId: string;
/** Client-generated, transport-level key (<=256 visible ASCII). */
idempotencyKey: string;
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
payload: readonly unknown[];
}
export function operationIdentity(operation: OperationIdentity): {
tenantId: string;
projectId: string;
actorId: string;
idempotencyKey: string;
fingerprint: string;
} {
if (
!operation ||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
) ||
!Array.isArray(operation.payload)
) {
throw new Error("Invalid idempotent operation identity");
}
void operation.payload;
return {
tenantId: operation.tenantId,
projectId: operation.projectId,
actorId: operation.actorId,
idempotencyKey: operation.idempotencyKey,
fingerprint: operationFingerprint([
operation.tenantId,
operation.projectId,
operation.actorId,
...operation.payload,
]),
};
}
export type OperationOutcome<R> =
| { kind: "claimed"; execute: () => Promise<R> }
| { kind: "replayed"; receipt: R }
| { kind: "conflict"; expected?: string };
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
export interface IdempotencyStore<Identity, Receipt> {
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
start(identity: Identity, fingerprint: string): Promise<boolean>;
/** Persist after a verified downstream commit. Must never be called before. */
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
/** Load a stored receipt for replay (exact fingerprint match). */
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
}
export type IdempotentStoreItemIdentity<I> = {
identity: I;
fingerprint: string;
};
export async function runIdempotentOperation<I, R>(input: {
store: IdempotencyStore<I, R>;
identity: I;
fingerprint: string;
/** Must be safe to call exactly once; a second call is never attempted here. */
dispatch: () => Promise<R>;
}): Promise<OperationOutcome<R>> {
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
try {
const won = await input.store.start(input.identity, input.fingerprint);
if (!won) {
const stored = await input.store.receipt(input.identity, input.fingerprint);
return stored !== undefined
? { kind: "replayed", receipt: stored }
: { kind: "replayed", receipt: undefined as unknown as R };
}
} catch (error) {
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
}
const receipt = await input.dispatch();
try {
await input.store.save(input.identity, input.fingerprint, receipt);
} catch {
/* The downstream commit is real; a journal-write failure must not turn a
real receipt into an error. The next retry replays via the stored receipt
if the save ever succeeds before a downstream resend. */
}
return { kind: "claimed", execute: async () => receipt };
}
+2 -235
View File
@@ -1,235 +1,2 @@
/**
* @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;
}
}
export * from './relationships';
export * from './idempotency';
+235
View File
@@ -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;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
describe("@siax/access universal idempotency", () => {
it("binds every result-affecting input into the fingerprint", () => {
const a = operationFingerprint(["t", "p", "u", "instruction-extrahera"]);
const b = operationFingerprint(["t", "p", "u", "instruction-extrahera-belopp"]);
expect(a).not.toBe(b);
});
it("rejects malformed identity (same shape 0pt/DOC0 both enforce)", () => {
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "../bad", payload: [] })).toThrow();
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "ok", payload: "not-array" as never })).toThrow();
});
it("conflicts when a reused key carries a changed payload", () => {
const fp1 = operationFingerprint(["t", "p", "u", "A"]);
const fp2 = operationFingerprint(["t", "p", "u", "B"]);
expect(fp1).not.toBe(fp2);
});
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
const stored = { x: 1 };
const store: any = {
start: async () => false,
save: async () => {},
receipt: async () => stored,
};
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { throw new Error("must not rerun"); },
});
expect(outcome.kind).toBe("replayed");
if (outcome.kind === "replayed") expect(outcome.receipt).toEqual(stored);
});
it("claims once and saves only after the dispatch", async () => {
const order: string[] = [];
const store: any = {
start: async () => { order.push("start"); return true; },
save: async () => { order.push("save"); },
receipt: async () => { order.push("receipt"); return undefined; },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { order.push("dispatch"); return { x: ++dispatched }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
expect(order).toEqual(["start", "dispatch", "save"]);
});
it("reports a store error as conflict, never as a fresh start", async () => {
const store: any = {
start: async () => { throw new Error("db down"); },
save: async () => {},
receipt: async () => { throw new Error("db down"); },
};
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("conflict");
expect(dispatched).toBe(0);
});
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
const store: any = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",
dispatch: async () => { dispatched++; return { x: 1 }; },
});
expect(outcome.kind).toBe("claimed");
expect(dispatched).toBe(1);
});
});