From f77a4fec9662a77ef9b22b4a835a5401838a00db Mon Sep 17 00:00:00 2001 From: sax3l Date: Fri, 6 Nov 2026 10:47:59 +0100 Subject: [PATCH 1/2] feat(@siax/access): universal idempotency contract operationIdentity binds tenant/project/actor + every result-affecting input into a stable fingerprint; a changed payload under the same key is a CONFLICT, never a replay of someone else's receipt. runIdempotentOperation encodes the 0pt-proven order (durable claim -> downstream dispatch -> save receipt -> replay) and the commit boundaries. Structural IdempotencyStore lets each product ship its own Postgres adapter while sharing the contract. Derived from 0pt's durable upload attempts; DOC0/N0D can adopt the same shape instead of re-implementing it. --- packages/siax-access/src/idempotency.ts | 121 ++++++++++++ packages/siax-access/src/index.ts | 237 +----------------------- tests/siax-access/idempotency.test.ts | 73 ++++++++ 3 files changed, 196 insertions(+), 235 deletions(-) create mode 100644 packages/siax-access/src/idempotency.ts create mode 100644 tests/siax-access/idempotency.test.ts diff --git a/packages/siax-access/src/idempotency.ts b/packages/siax-access/src/idempotency.ts new file mode 100644 index 0000000..1b3fe46 --- /dev/null +++ b/packages/siax-access/src/idempotency.ts @@ -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 = + | { kind: "claimed"; execute: () => Promise } + | { kind: "replayed"; receipt: R } + | { kind: "conflict"; expected?: string }; + +/** Structural contract for an attempt store. Matched by Postgres adapters per product. */ +export interface IdempotencyStore { + /** Returns true when this caller won the right to dispatch; false = reuse replay path. */ + start(identity: Identity, fingerprint: string): Promise; + /** Persist after a verified downstream commit. Must never be called before. */ + save(identity: Identity, fingerprint: string, receipt: Receipt): Promise; + /** Load a stored receipt for replay (exact fingerprint match). */ + receipt(identity: Identity, fingerprint: string): Promise; +} + +export type IdempotentStoreItemIdentity = { + identity: I; + fingerprint: string; +}; + +export async function runIdempotentOperation(input: { + store: IdempotencyStore; + identity: I; + fingerprint: string; + /** Must be safe to call exactly once; a second call is never attempted here. */ + dispatch: () => Promise; +}): Promise> { + 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 }; +} diff --git a/packages/siax-access/src/index.ts b/packages/siax-access/src/index.ts index 6a2bcc8..e2eae2e 100644 --- a/packages/siax-access/src/index.ts +++ b/packages/siax-access/src/index.ts @@ -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 { - 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 { - 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; -} - -/** Fail closed: only an explicit match with no policy objection is allowed. */ -export async function isProjectAccessAllowed( - scope: ProjectRelationshipScope, - options: ProjectAccessOptions, -): Promise { - 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"; diff --git a/tests/siax-access/idempotency.test.ts b/tests/siax-access/idempotency.test.ts new file mode 100644 index 0000000..b85f5fe --- /dev/null +++ b/tests/siax-access/idempotency.test.ts @@ -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); + }); +}); -- 2.54.0 From a424cf15819ecb7034c9bc83d946170ce659f548 Mon Sep 17 00:00:00 2001 From: sax3l Date: Fri, 6 Nov 2026 10:49:11 +0100 Subject: [PATCH 2/2] fix(@siax/access): restore relationships.ts that disappeared when the package split into two files b323948 shipped the OpenFGA relationship client inside src/index.ts; when src/idempotency.ts was added, index.ts was rewritten as the diff of both, losing every relationship export and breaking the existing tests. Move the original module to src/relationships.ts and let index.ts re-export both. Restores 7 pre-existing relationship tests (both suites 14 passed). --- packages/siax-access/src/index.ts | 4 +- packages/siax-access/src/relationships.ts | 235 ++++++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 packages/siax-access/src/relationships.ts diff --git a/packages/siax-access/src/index.ts b/packages/siax-access/src/index.ts index e2eae2e..739f633 100644 --- a/packages/siax-access/src/index.ts +++ b/packages/siax-access/src/index.ts @@ -1,2 +1,2 @@ -export * from "./relationships"; -export * from "./idempotency"; +export * from './relationships'; +export * from './idempotency'; diff --git a/packages/siax-access/src/relationships.ts b/packages/siax-access/src/relationships.ts new file mode 100644 index 0000000..6a2bcc8 --- /dev/null +++ b/packages/siax-access/src/relationships.ts @@ -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 { + 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 { + 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; +} + +/** Fail closed: only an explicit match with no policy objection is allowed. */ +export async function isProjectAccessAllowed( + scope: ProjectRelationshipScope, + options: ProjectAccessOptions, +): Promise { + 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; + } +} -- 2.54.0