From 20fbcda36fad7ae9551c4f9e0c3406bca06e975d Mon Sep 17 00:00:00 2001 From: harystyleseze <141352725+harystyleseze@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:49:08 -0700 Subject: [PATCH] feat(core): add fee breakdowns, authorization renewal, public status feed and seed data --- README.md | 4 + core/audit/audit.ts | 10 +- core/authorizations/__tests__/store.test.ts | 348 ++++++++++++++++++ core/authorizations/index.ts | 2 + core/authorizations/store.ts | 368 ++++++++++++++++++++ core/authorizations/types.ts | 114 ++++++ core/fees/__tests__/fees.test.ts | 226 ++++++++++++ core/fees/fees.ts | 158 +++++++++ core/fees/index.ts | 2 + core/fees/types.ts | 92 +++++ core/seed/__tests__/seed.test.ts | 187 ++++++++++ core/seed/data.ts | 234 +++++++++++++ core/seed/index.ts | 3 + core/seed/types.ts | 70 ++++ core/seed/validate.ts | 156 +++++++++ core/status/__tests__/feed.test.ts | 307 ++++++++++++++++ core/status/feed.ts | 285 +++++++++++++++ core/status/index.ts | 2 + core/status/types.ts | 114 ++++++ docs/AUDIT.md | 4 + docs/AUTHORIZATIONS.md | 77 ++++ docs/FEES.md | 72 ++++ docs/PUBLIC_STATUS.md | 77 ++++ docs/SEED_DATA.md | 69 ++++ package.json | 1 + scripts/verify-audit.mjs | 6 +- 26 files changed, 2986 insertions(+), 2 deletions(-) create mode 100644 core/authorizations/__tests__/store.test.ts create mode 100644 core/authorizations/index.ts create mode 100644 core/authorizations/store.ts create mode 100644 core/authorizations/types.ts create mode 100644 core/fees/__tests__/fees.test.ts create mode 100644 core/fees/fees.ts create mode 100644 core/fees/index.ts create mode 100644 core/fees/types.ts create mode 100644 core/seed/__tests__/seed.test.ts create mode 100644 core/seed/data.ts create mode 100644 core/seed/index.ts create mode 100644 core/seed/types.ts create mode 100644 core/seed/validate.ts create mode 100644 core/status/__tests__/feed.test.ts create mode 100644 core/status/feed.ts create mode 100644 core/status/index.ts create mode 100644 core/status/types.ts create mode 100644 docs/AUTHORIZATIONS.md create mode 100644 docs/FEES.md create mode 100644 docs/PUBLIC_STATUS.md create mode 100644 docs/SEED_DATA.md diff --git a/README.md b/README.md index 8e34bd7..8b5f675 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Infrastructure docs: [Telemetry](./docs/TELEMETRY.md) · [Reconciliation](./docs/RECONCILIATION.md) · [Audit trail](./docs/AUDIT.md) · [Tamper-evident ledger](./docs/LEDGER.md) · +[Fee calculation](./docs/FEES.md) · +[Authorization expiry](./docs/AUTHORIZATIONS.md) · +[Public status feed](./docs/PUBLIC_STATUS.md) · +[Seed data](./docs/SEED_DATA.md) · [API Contract](./docs/API_CONTRACT.md). ## Tools diff --git a/core/audit/audit.ts b/core/audit/audit.ts index bb6a5fb..b55d3d8 100644 --- a/core/audit/audit.ts +++ b/core/audit/audit.ts @@ -69,7 +69,15 @@ export const SENSITIVE_ACTIONS = [ /** A projection's active version was promoted or rolled back. */ "projection.activated", /** Administrative configuration was changed (or a change was refused). */ - "admin.config_changed" + "admin.config_changed", + /** A delegated permission was granted (or a grant was refused). */ + "authorization.granted", + /** A delegated permission was renewed (or a renewal was refused). */ + "authorization.renewed", + /** A delegated permission was revoked (or a revocation was refused). */ + "authorization.revoked", + /** A stale delegated permission was removed by cleanup (or cleanup was refused). */ + "authorization.cleaned_up" ] as const; export type AuditAction = (typeof SENSITIVE_ACTIONS)[number]; diff --git a/core/authorizations/__tests__/store.test.ts b/core/authorizations/__tests__/store.test.ts new file mode 100644 index 0000000..fff69b6 --- /dev/null +++ b/core/authorizations/__tests__/store.test.ts @@ -0,0 +1,348 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createIsolatedAuditTrail, type AuditActor, type AuditInput } from "@/core/audit/audit"; +import { createAuthorizationStore, DAY_MS, DEFAULT_RENEWAL_GRACE_MS, type AuthorizationStore } from "../store"; + +const T0 = Date.parse("2026-01-01T00:00:00.000Z"); + +const alice: AuditActor = { kind: "user", id: "alice" }; +const bob: AuditActor = { kind: "user", id: "bob" }; +const maintainer: AuditActor = { kind: "maintainer", id: "ada" }; +const system: AuditActor = { kind: "system" }; + +describe("stale authorization cleanup and renewal (#183)", () => { + let clock: number; + let trail: ReturnType; + let store: AuthorizationStore; + + /** A store backed by the isolated trail; `rejecting` makes one action's audit event fail. */ + function build(rejecting?: AuditInput["action"]): AuthorizationStore { + return createAuthorizationStore({ + now: () => clock, + record: (input) => { + if (input.action === rejecting) return undefined; + const result = trail.record(input); + return result.ok ? result.value : undefined; + } + }); + } + + function grantToBob(ttlMs = DAY_MS) { + const result = store.grant({ + actor: alice, + grantor: "alice", + grantee: "bob", + scope: "tickets.redeem", + resourceId: "event-1", + ttlMs + }); + if (!result.ok) throw new Error(`grant failed: ${result.code}`); + return result.value; + } + + const auditActions = () => trail.all().map((event) => `${event.action}:${event.outcome}`); + + beforeEach(() => { + clock = T0; + trail = createIsolatedAuditTrail(); + store = build(); + }); + + describe("active", () => { + it("allows a protected action while the authorization is active", () => { + const grant = grantToBob(); + expect(grant).toMatchObject({ status: "active", renewals: 0, expiresAt: "2026-01-02T00:00:00.000Z" }); + expect(store.stateOf(grant.id)).toBe("active"); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" })).toEqual({ + ok: true, + value: grant + }); + expect(auditActions()).toEqual(["authorization.granted:allowed"]); + }); + + it("limits a resource-specific authorization to that resource", () => { + grantToBob(); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-2" })).toEqual({ + ok: false, + code: "authorization_not_found" + }); + expect(store.authorize({ grantee: "bob", scope: "tickets.transfer", resourceId: "event-1" })).toEqual({ + ok: false, + code: "authorization_not_found" + }); + }); + + it("lets an authorization without a resource cover every resource under the scope", () => { + store.grant({ actor: alice, grantor: "alice", grantee: "bob", scope: "tickets.redeem", ttlMs: DAY_MS }); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-9" }).ok).toBe(true); + }); + + it("refuses a grant from someone other than the grantor or a maintainer", () => { + const result = store.grant({ + actor: bob, + grantor: "alice", + grantee: "bob", + scope: "tickets.redeem", + ttlMs: DAY_MS + }); + expect(result).toEqual({ ok: false, code: "grant_unauthorized" }); + expect(store.list()).toEqual([]); + expect(auditActions()).toEqual(["authorization.granted:denied"]); + }); + + it("rejects malformed grants", () => { + const base = { actor: alice, grantor: "alice", grantee: "bob", scope: "tickets.redeem", ttlMs: DAY_MS }; + for (const bad of [ + { ttlMs: 0 }, + { ttlMs: 1.5 }, + { ttlMs: 366 * DAY_MS }, + { scope: "Tickets Redeem" }, + { grantee: "alice" }, + { grantee: "" } + ]) { + expect(store.grant({ ...base, ...bad })).toEqual({ ok: false, code: "invalid_authorization" }); + } + }); + }); + + describe("grantor entitlement", () => { + it("refuses grants the grantor could not make, and wildcards from non-maintainers", () => { + let entitled = true; + store = createAuthorizationStore({ now: () => clock, record: (input) => { const r = trail.record(input); return r.ok ? r.value : undefined; }, canDelegate: () => entitled }); + const base = { actor: alice, grantor: "alice", grantee: "bob", scope: "tickets.redeem", ttlMs: DAY_MS }; + expect(store.grant({ ...base, resourceId: "event-1" }).ok).toBe(true); + expect(store.grant(base)).toEqual({ ok: false, code: "grant_unauthorized" }); + expect(store.grant({ ...base, actor: maintainer }).ok).toBe(true); + entitled = false; + expect(store.grant({ ...base, resourceId: "event-2" })).toEqual({ ok: false, code: "grant_unauthorized" }); + }); + + it("stops honouring a grant once its grantor loses the entitlement", () => { + let entitled = true; + store = createAuthorizationStore({ now: () => clock, record: (input) => { const r = trail.record(input); return r.ok ? r.value : undefined; }, canDelegate: () => entitled }); + store.grant({ actor: alice, grantor: "alice", grantee: "bob", scope: "tickets.redeem", resourceId: "event-1", ttlMs: DAY_MS }); + const check = { grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" }; + expect(store.authorize(check).ok).toBe(true); + entitled = false; + expect(store.authorize(check)).toEqual({ ok: false, code: "grantor_not_entitled" }); + }); + }); + + describe("expired", () => { + it("stops allowing protected actions at the moment of expiry, without any cleanup", () => { + const grant = grantToBob(); + clock = Date.parse(grant.expiresAt) - 1; + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" }).ok).toBe(true); + + clock = Date.parse(grant.expiresAt); + expect(store.stateOf(grant.id)).toBe("expired"); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" })).toEqual({ + ok: false, + code: "authorization_expired" + }); + }); + + it("uses a newer usable authorization over an older expired one", () => { + const first = grantToBob(DAY_MS); + clock = T0 + 2 * DAY_MS; + const second = grantToBob(DAY_MS); + const result = store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" }); + expect(result).toEqual({ ok: true, value: second }); + expect(store.stateOf(first.id)).toBe("expired"); + }); + }); + + describe("report and cleanup", () => { + it("reports expired, cleanable and soon-to-expire authorizations without changing them", () => { + const stale = grantToBob(DAY_MS); + clock = T0 + DAY_MS + 1; + expect(store.report().expired.map((entry) => entry.id)).toEqual([stale.id]); + expect(store.report().cleanable).toEqual([]); + + const fresh = grantToBob(2 * DAY_MS); + expect(store.report({ expiringWithinMs: DAY_MS }).expiringSoon).toEqual([]); + expect(store.report({ expiringWithinMs: 3 * DAY_MS }).expiringSoon.map((entry) => entry.id)).toEqual([ + fresh.id + ]); + + clock = Date.parse(stale.expiresAt) + DEFAULT_RENEWAL_GRACE_MS + 1; + const report = store.report(); + expect(report.generatedAt).toBe(new Date(clock).toISOString()); + expect(report.cleanable.map((entry) => entry.id)).toEqual([stale.id]); + expect(store.stateOf(stale.id)).toBe("expired"); + }); + + it("refuses cleanup from anyone but a maintainer or the system", () => { + grantToBob(); + expect(store.cleanup({ actor: alice })).toEqual({ ok: false, code: "cleanup_unauthorized" }); + expect(auditActions()).toEqual(["authorization.granted:allowed", "authorization.cleaned_up:denied"]); + }); + + it("leaves authorizations that are still inside the grace period renewable", () => { + const grant = grantToBob(); + clock = Date.parse(grant.expiresAt) + DEFAULT_RENEWAL_GRACE_MS; + expect(store.cleanup({ actor: maintainer })).toEqual({ ok: true, value: { cleaned: [], skipped: [] } }); + expect(store.stateOf(grant.id)).toBe("expired"); + }); + + it("cleans authorizations expired past the grace period and audits each one", () => { + const stale = grantToBob(); + clock = Date.parse(stale.expiresAt) + DEFAULT_RENEWAL_GRACE_MS + 1; + const active = grantToBob(); + + expect(store.cleanup({ actor: system })).toEqual({ ok: true, value: { cleaned: [stale.id], skipped: [] } }); + expect(store.stateOf(stale.id)).toBe("cleaned"); + expect(store.get(stale.id)?.cleanedAt).toBe(new Date(clock).toISOString()); + expect(store.stateOf(active.id)).toBe("active"); + + const cleanedEvents = trail.all().filter((event) => event.action === "authorization.cleaned_up"); + expect(cleanedEvents).toHaveLength(1); + expect(cleanedEvents[0]).toMatchObject({ + outcome: "allowed", + target: { kind: "authorization", id: stale.id }, + before: { state: "expired" }, + after: { state: "cleaned" } + }); + + // Cleaned authorizations stay refused, and a second run changes nothing. + expect(store.cleanup({ actor: maintainer })).toEqual({ ok: true, value: { cleaned: [], skipped: [] } }); + }); + + it("keeps an authorization in place when the audit trail rejects the cleanup event", () => { + store = build("authorization.cleaned_up"); + const grant = grantToBob(); + clock = Date.parse(grant.expiresAt) + DEFAULT_RENEWAL_GRACE_MS + 1; + expect(store.cleanup({ actor: maintainer })).toEqual({ + ok: true, + value: { cleaned: [], skipped: [grant.id] } + }); + expect(store.stateOf(grant.id)).toBe("expired"); + }); + }); + + describe("renewed", () => { + it("requires an explicit confirmation", () => { + const grant = grantToBob(); + expect(store.renew({ id: grant.id, actor: alice, confirmed: false, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "renewal_not_confirmed" + }); + expect(store.get(grant.id)).toEqual(grant); + expect(auditActions()).toEqual(["authorization.granted:allowed", "authorization.renewed:denied"]); + }); + + it("refuses a renewal from the grantee or a stranger", () => { + const grant = grantToBob(); + for (const actor of [bob, { kind: "user", id: "mallory" } as AuditActor, system]) { + expect(store.renew({ id: grant.id, actor, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "renewal_unauthorized" + }); + } + expect(store.get(grant.id)).toEqual(grant); + }); + + it("renews an expired authorization inside the grace period and audits before and after", () => { + const grant = grantToBob(); + clock = Date.parse(grant.expiresAt) + DAY_MS; + const renewed = store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: 3 * DAY_MS }); + expect(renewed).toMatchObject({ + ok: true, + value: { renewals: 1, expiresAt: new Date(clock + 3 * DAY_MS).toISOString(), lastRenewedAt: new Date(clock).toISOString() } + }); + expect(store.stateOf(grant.id)).toBe("renewed"); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" }).ok).toBe(true); + + const events = trail.all().filter((event) => event.action === "authorization.renewed"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + outcome: "allowed", + actor: alice, + scope: "own", + before: { state: "expired", renewals: 0 }, + after: { state: "renewed", renewals: 1 } + }); + }); + + it("extends from the current expiry when the authorization is still active", () => { + const grant = grantToBob(); + clock = T0 + DAY_MS / 2; + const renewed = store.renew({ id: grant.id, actor: maintainer, confirmed: true, extendByMs: DAY_MS }); + expect(renewed).toMatchObject({ ok: true, value: { expiresAt: "2026-01-03T00:00:00.000Z" } }); + }); + + it("refuses a renewal after the grace period, and after cleanup", () => { + const grant = grantToBob(); + clock = Date.parse(grant.expiresAt) + DEFAULT_RENEWAL_GRACE_MS + 1; + expect(store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "renewal_window_closed" + }); + store.cleanup({ actor: system }); + expect(store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "authorization_cleaned" + }); + }); + + it("rejects an invalid extension and an unknown authorization", () => { + const grant = grantToBob(); + expect(store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: 0 })).toEqual({ + ok: false, + code: "invalid_authorization" + }); + expect(store.renew({ id: "missing", actor: alice, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "authorization_not_found" + }); + }); + + it("applies nothing when the audit trail rejects the renewal event", () => { + store = build("authorization.renewed"); + const grant = grantToBob(); + expect(store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "authorization_audit_failed" + }); + expect(store.get(grant.id)).toEqual(grant); + }); + }); + + describe("revoked", () => { + it("lets the grantor revoke, after which every protected action is refused", () => { + const grant = grantToBob(); + const revoked = store.revoke({ id: grant.id, actor: alice, reason: "lost_device" }); + expect(revoked).toMatchObject({ ok: true, value: { status: "revoked", revokedAt: new Date(clock).toISOString() } }); + expect(store.stateOf(grant.id)).toBe("revoked"); + expect(store.authorize({ grantee: "bob", scope: "tickets.redeem", resourceId: "event-1" })).toEqual({ + ok: false, + code: "authorization_revoked" + }); + const event = trail.all().find((entry) => entry.action === "authorization.revoked"); + expect(event).toMatchObject({ outcome: "allowed", reason: "lost_device", after: { state: "revoked" } }); + }); + + it("refuses revocation by the grantee, and a second revocation", () => { + const grant = grantToBob(); + expect(store.revoke({ id: grant.id, actor: bob })).toEqual({ ok: false, code: "revoke_unauthorized" }); + expect(store.revoke({ id: grant.id, actor: maintainer }).ok).toBe(true); + expect(store.revoke({ id: grant.id, actor: maintainer })).toEqual({ ok: false, code: "authorization_revoked" }); + }); + + it("cannot be renewed", () => { + const grant = grantToBob(); + store.revoke({ id: grant.id, actor: alice }); + expect(store.renew({ id: grant.id, actor: alice, confirmed: true, extendByMs: DAY_MS })).toEqual({ + ok: false, + code: "authorization_revoked" + }); + }); + + it("is not cleaned up: a revoked authorization stays revoked", () => { + const grant = grantToBob(); + store.revoke({ id: grant.id, actor: alice }); + clock = Date.parse(grant.expiresAt) + DEFAULT_RENEWAL_GRACE_MS + 1; + expect(store.cleanup({ actor: system })).toEqual({ ok: true, value: { cleaned: [], skipped: [] } }); + expect(store.stateOf(grant.id)).toBe("revoked"); + }); + }); +}); diff --git a/core/authorizations/index.ts b/core/authorizations/index.ts new file mode 100644 index 0000000..3855748 --- /dev/null +++ b/core/authorizations/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./store"; diff --git a/core/authorizations/store.ts b/core/authorizations/store.ts new file mode 100644 index 0000000..de1d36d --- /dev/null +++ b/core/authorizations/store.ts @@ -0,0 +1,368 @@ +/** + * Stale authorization cleanup and renewal (#183). + * + * Delegated permissions must not live forever. This store keeps them + * time-boxed and moves them through a small, audited lifecycle: + * + * - an expired authorization is refused by `authorize` the instant it expires, + * whether or not cleanup has run, because expiry is derived from `expiresAt` + * and the clock, not from a flag a job must flip; + * - `report` lists what is expired, what cleanup would remove and what is about + * to expire, without changing anything; + * - `cleanup` (maintainer or system only) marks authorizations that have been + * expired longer than the grace period as `cleaned`; + * - `renew` needs the grantor (or a maintainer) and an explicit confirmation, + * and is only possible until cleanup removes the authorization; + * - every grant, renewal, revocation and cleanup is audited, and, as in the + * admin configuration store, the change is applied only after its audit event + * is on the trail. Refusals are recorded too. + * + * The clock, the id factory and the audit recorder are injected so a test can + * assert an exact outcome. + */ + +import { err, ok, type Result } from "@/core/result/result"; +import { + recordAudit, + type AuditActor, + type AuditEvent, + type AuditInput +} from "@/core/audit/audit"; +import type { + Authorization, + AuthorizationErrorCode, + AuthorizationState, + AuthorizeInput, + CleanupAuthorizationsInput, + CleanupResult, + GrantAuthorizationInput, + RenewAuthorizationInput, + RevokeAuthorizationInput, + StaleAuthorizationReport, + StaleAuthorizationReportOptions +} from "./types"; + +export const DAY_MS = 24 * 60 * 60 * 1000; +/** How long an expired authorization stays renewable before cleanup may remove it. */ +export const DEFAULT_RENEWAL_GRACE_MS = 7 * DAY_MS; +/** The longest lifetime a grant or a single renewal may add. */ +export const MAX_AUTHORIZATION_TTL_MS = 365 * DAY_MS; + +const SCOPE_PATTERN = /^[a-z0-9_.:-]{1,64}$/; +const REASON_PATTERN = /^[a-z0-9_.:-]{1,64}$/; + +function isLabel(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= 128; +} + +function isDuration(value: unknown): value is number { + return Number.isInteger(value) && (value as number) > 0 && (value as number) <= MAX_AUTHORIZATION_TTL_MS; +} + +/** The effective state of an authorization at `nowMs`. Pure. */ +export function authorizationState(authorization: Authorization, nowMs: number): AuthorizationState { + if (authorization.status === "revoked") return "revoked"; + if (authorization.status === "cleaned") return "cleaned"; + if (Date.parse(authorization.expiresAt) <= nowMs) return "expired"; + return authorization.renewals > 0 ? "renewed" : "active"; +} + +function isUsable(state: AuthorizationState): boolean { + return state === "active" || state === "renewed"; +} + +export interface AuthorizationStore { + /** Delegates a scope to a grantee for `ttlMs`. */ + readonly grant: (input: GrantAuthorizationInput) => Result; + /** + * The check protected actions must make. Returns the usable authorization, or + * the reason there is none: expired, revoked, cleaned or not found. + */ + readonly authorize: (input: AuthorizeInput) => Result; + readonly renew: (input: RenewAuthorizationInput) => Result; + readonly revoke: (input: RevokeAuthorizationInput) => Result; + /** Read-only report of stale authorizations. */ + readonly report: (options?: StaleAuthorizationReportOptions) => StaleAuthorizationReport; + /** Removes authorizations expired past the grace period. Maintainer or system only. */ + readonly cleanup: (input: CleanupAuthorizationsInput) => Result; + readonly get: (id: string) => Authorization | undefined; + readonly stateOf: (id: string) => AuthorizationState | undefined; + /** Every authorization, ordered by id. */ + readonly list: () => readonly Authorization[]; +} + +export interface AuthorizationStoreOptions { + readonly now?: () => number; + readonly newId?: () => string; + readonly gracePeriodMs?: number; + /** + * Entitlement hook: whether `grantor` itself holds `scope` on `resourceId` + * (`null` means every resource). When set, a grant the grantor could not make + * is refused, a grant without a resource is reserved for maintainers, and + * `authorize` stops honouring a grant once its grantor loses the entitlement. + * When omitted, the grantor's entitlement is the caller's responsibility. + */ + readonly canDelegate?: (grantor: string, scope: string, resourceId: string | null) => boolean; + /** + * Test seam: substitutes the audit recorder. When it returns `undefined` the + * store behaves as if the audit trail rejected the event and applies nothing. + */ + readonly record?: (input: AuditInput) => AuditEvent | undefined; +} + +/** The sensitive action each operation records; declared in `SENSITIVE_ACTIONS`. */ +const AUDITED = { + grant: { action: "authorization.granted" }, + renew: { action: "authorization.renewed" }, + revoke: { action: "authorization.revoked" }, + cleanup: { action: "authorization.cleaned_up" } +} as const; + +type AuthorizationAction = (typeof AUDITED)[keyof typeof AUDITED]["action"]; + +export function createAuthorizationStore(options: AuthorizationStoreOptions = {}): AuthorizationStore { + const now = options.now ?? (() => Date.now()); + const record = options.record ?? recordAudit; + const gracePeriodMs = options.gracePeriodMs ?? DEFAULT_RENEWAL_GRACE_MS; + let counter = 0; + const newId = options.newId ?? (() => `authorization-${++counter}`); + const records = new Map(); + + const iso = (ms: number): string => new Date(ms).toISOString(); + const copy = (value: Authorization): Authorization => ({ ...value }); + + function audit( + action: AuthorizationAction, + actor: AuditActor, + id: string, + outcome: "allowed" | "denied", + details: { + reason?: string; + errorCode?: AuthorizationErrorCode; + before?: Record; + after?: Record; + } = {} + ): AuditEvent | undefined { + return record({ + action, + actor, + scope: actor.kind === "user" ? "own" : "maintainer", + target: { kind: "authorization", id }, + reason: details.reason !== undefined && REASON_PATTERN.test(details.reason) ? details.reason : action.split(".")[1], + outcome, + before: details.before, + after: details.after, + at: iso(now()), + errorCode: details.errorCode + }); + } + + function refuse( + action: AuthorizationAction, + actor: AuditActor, + id: string, + code: AuthorizationErrorCode, + reason?: string + ): Result { + audit(action, actor, id, "denied", { reason, errorCode: code }); + return err(code); + } + + /** The grantor acting for itself, or a maintainer. */ + function mayManage(actor: AuditActor, authorization: { grantor: string }): boolean { + return actor.kind === "maintainer" || (actor.kind === "user" && actor.id === authorization.grantor); + } + + function snapshot(authorization: Authorization, at: number): Record { + return { + state: authorizationState(authorization, at), + expiresAt: authorization.expiresAt, + renewals: authorization.renewals + }; + } + + return { + grant(input) { + const { actor, grantor, grantee, scope, resourceId, ttlMs, reason } = input; + if ( + !isLabel(grantor) || + !isLabel(grantee) || + grantor === grantee || + typeof scope !== "string" || + !SCOPE_PATTERN.test(scope) || + (resourceId !== undefined && !isLabel(resourceId)) || + !isDuration(ttlMs) + ) { + return err("invalid_authorization"); + } + const id = newId(); + if (!mayManage(actor, { grantor })) return refuse(AUDITED.grant.action, actor, id, "grant_unauthorized", reason); + if ( + options.canDelegate && + (!options.canDelegate(grantor, scope, resourceId ?? null) || + (resourceId === undefined && actor.kind !== "maintainer")) + ) { + return refuse(AUDITED.grant.action, actor, id, "grant_unauthorized", reason); + } + + const issuedAt = now(); + const authorization: Authorization = { + id, + grantor, + grantee, + scope, + resourceId: resourceId ?? null, + status: "active", + issuedAt: iso(issuedAt), + expiresAt: iso(issuedAt + ttlMs), + renewals: 0 + }; + if (!audit(AUDITED.grant.action, actor, id, "allowed", { reason, after: snapshot(authorization, issuedAt) })) { + return err("authorization_audit_failed"); + } + records.set(id, authorization); + return ok(copy(authorization)); + }, + + authorize(input) { + const at = now(); + const candidates = [...records.values()].filter( + (entry) => + entry.grantee === input.grantee && + entry.scope === input.scope && + (entry.resourceId === null || entry.resourceId === (input.resourceId ?? null)) + ); + const live = candidates.filter((entry) => isUsable(authorizationState(entry, at))); + const usable = live.find( + (entry) => !options.canDelegate || options.canDelegate(entry.grantor, entry.scope, entry.resourceId) + ); + if (usable) return ok(copy(usable)); + if (live.length > 0) return err("grantor_not_entitled"); + if (candidates.length === 0) return err("authorization_not_found"); + + // Report why the most recently valid one is no longer usable. + const latest = [...candidates].sort( + (a, b) => Date.parse(b.expiresAt) - Date.parse(a.expiresAt) || (a.id < b.id ? -1 : 1) + )[0]; + return err(`authorization_${authorizationState(latest, at)}` as AuthorizationErrorCode); + }, + + renew(input) { + const { id, actor, confirmed, extendByMs, reason } = input; + const current = records.get(id); + if (!current) return err("authorization_not_found"); + + if (!mayManage(actor, current)) return refuse(AUDITED.renew.action, actor, id, "renewal_unauthorized", reason); + if (confirmed !== true) return refuse(AUDITED.renew.action, actor, id, "renewal_not_confirmed", reason); + + const at = now(); + const state = authorizationState(current, at); + if (state === "revoked") return refuse(AUDITED.renew.action, actor, id, "authorization_revoked", reason); + if (state === "cleaned") return refuse(AUDITED.renew.action, actor, id, "authorization_cleaned", reason); + if (state === "expired" && at - Date.parse(current.expiresAt) > gracePeriodMs) { + return refuse(AUDITED.renew.action, actor, id, "renewal_window_closed", reason); + } + if (!isDuration(extendByMs)) return err("invalid_authorization"); + + const renewed: Authorization = { + ...current, + expiresAt: iso(Math.max(at, Date.parse(current.expiresAt)) + extendByMs), + renewals: current.renewals + 1, + lastRenewedAt: iso(at) + }; + const event = audit(AUDITED.renew.action, actor, id, "allowed", { + reason, + before: snapshot(current, at), + after: snapshot(renewed, at) + }); + if (!event) return err("authorization_audit_failed"); + records.set(id, renewed); + return ok(copy(renewed)); + }, + + revoke(input) { + const { id, actor, reason } = input; + const current = records.get(id); + if (!current) return err("authorization_not_found"); + + if (!mayManage(actor, current)) return refuse(AUDITED.revoke.action, actor, id, "revoke_unauthorized", reason); + + const at = now(); + const state = authorizationState(current, at); + if (state === "revoked") return refuse(AUDITED.revoke.action, actor, id, "authorization_revoked", reason); + if (state === "cleaned") return refuse(AUDITED.revoke.action, actor, id, "authorization_cleaned", reason); + + const revoked: Authorization = { ...current, status: "revoked", revokedAt: iso(at) }; + const event = audit(AUDITED.revoke.action, actor, id, "allowed", { + reason, + before: snapshot(current, at), + after: snapshot(revoked, at) + }); + if (!event) return err("authorization_audit_failed"); + records.set(id, revoked); + return ok(copy(revoked)); + }, + + report(reportOptions = {}) { + const at = now(); + const all = [...records.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const expired = all.filter((entry) => authorizationState(entry, at) === "expired"); + const window = reportOptions.expiringWithinMs ?? 0; + return { + generatedAt: iso(at), + expired: expired.map(copy), + cleanable: expired.filter((entry) => at - Date.parse(entry.expiresAt) > gracePeriodMs).map(copy), + expiringSoon: all + .filter((entry) => isUsable(authorizationState(entry, at)) && Date.parse(entry.expiresAt) - at <= window) + .map(copy) + }; + }, + + cleanup(input) { + const { actor, reason } = input; + if (actor.kind !== "maintainer" && actor.kind !== "system") { + return refuse(AUDITED.cleanup.action, actor, "all", "cleanup_unauthorized", reason); + } + + const at = now(); + const cleaned: string[] = []; + const skipped: string[] = []; + const due = [...records.values()] + .filter( + (entry) => + authorizationState(entry, at) === "expired" && at - Date.parse(entry.expiresAt) > gracePeriodMs + ) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + for (const entry of due) { + const next: Authorization = { ...entry, status: "cleaned", cleanedAt: iso(at) }; + const event = audit(AUDITED.cleanup.action, actor, entry.id, "allowed", { + reason, + before: snapshot(entry, at), + after: snapshot(next, at) + }); + if (!event) { + skipped.push(entry.id); + continue; + } + records.set(entry.id, next); + cleaned.push(entry.id); + } + return ok({ cleaned, skipped }); + }, + + get(id) { + const found = records.get(id); + return found ? copy(found) : undefined; + }, + + stateOf(id) { + const found = records.get(id); + return found ? authorizationState(found, now()) : undefined; + }, + + list() { + return [...records.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(copy); + } + }; +} diff --git a/core/authorizations/types.ts b/core/authorizations/types.ts new file mode 100644 index 0000000..0af191d --- /dev/null +++ b/core/authorizations/types.ts @@ -0,0 +1,114 @@ +/** + * Types for stale authorization cleanup and renewal (#183). + * + * An authorization is a time-boxed permission one principal (the grantor) + * delegates to another (the grantee) for a scope, optionally on one resource. + */ + +import type { AuditActor } from "@/core/audit/audit"; + +/** What the store persists. Expiry is derived from `expiresAt`, never stored. */ +export type AuthorizationRecordStatus = "active" | "revoked" | "cleaned"; + +/** + * The effective state at an instant: + * - `active`: usable and never renewed; + * - `renewed`: usable and renewed at least once; + * - `expired`: past `expiresAt` and refused, but still renewable inside the grace period; + * - `cleaned`: removed by cleanup after the grace period; a new grant is needed; + * - `revoked`: withdrawn by the grantor or a maintainer. + */ +export type AuthorizationState = "active" | "renewed" | "expired" | "cleaned" | "revoked"; + +export interface Authorization { + readonly id: string; + readonly grantor: string; + readonly grantee: string; + /** What the authorization allows, e.g. `tickets.redeem`. */ + readonly scope: string; + /** The resource it is limited to, or `null` for every resource under the scope. */ + readonly resourceId: string | null; + readonly status: AuthorizationRecordStatus; + /** ISO-8601 instants. */ + readonly issuedAt: string; + readonly expiresAt: string; + readonly renewals: number; + readonly lastRenewedAt?: string; + readonly revokedAt?: string; + readonly cleanedAt?: string; +} + +export type AuthorizationErrorCode = + | "invalid_authorization" + | "authorization_not_found" + | "authorization_expired" + | "authorization_revoked" + | "authorization_cleaned" + | "grant_unauthorized" + | "grantor_not_entitled" + | "revoke_unauthorized" + | "cleanup_unauthorized" + | "renewal_unauthorized" + | "renewal_not_confirmed" + | "renewal_window_closed" + | "authorization_audit_failed"; + +export interface GrantAuthorizationInput { + readonly actor: AuditActor; + readonly grantor: string; + readonly grantee: string; + readonly scope: string; + readonly resourceId?: string; + /** Lifetime from now, in milliseconds. */ + readonly ttlMs: number; + readonly reason?: string; +} + +export interface AuthorizeInput { + readonly grantee: string; + readonly scope: string; + readonly resourceId?: string; +} + +export interface RenewAuthorizationInput { + readonly id: string; + readonly actor: AuditActor; + /** The authorized actor must explicitly confirm the renewal. */ + readonly confirmed: boolean; + /** How far past the current expiry (or now, if already expired) to extend, in milliseconds. */ + readonly extendByMs: number; + readonly reason?: string; +} + +export interface RevokeAuthorizationInput { + readonly id: string; + readonly actor: AuditActor; + readonly reason?: string; +} + +export interface CleanupAuthorizationsInput { + readonly actor: AuditActor; + readonly reason?: string; +} + +export interface StaleAuthorizationReportOptions { + /** Also list active authorizations that expire within this window. */ + readonly expiringWithinMs?: number; +} + +export interface StaleAuthorizationReport { + /** ISO-8601 instant the report describes. */ + readonly generatedAt: string; + /** Expired but still renewable. */ + readonly expired: readonly Authorization[]; + /** Expired for longer than the grace period; the next cleanup removes these. */ + readonly cleanable: readonly Authorization[]; + /** Still usable but expiring inside the requested window. */ + readonly expiringSoon: readonly Authorization[]; +} + +export interface CleanupResult { + readonly cleaned: readonly string[]; + /** Left in place because the audit trail rejected the event. */ + readonly skipped: readonly string[]; +} diff --git a/core/fees/__tests__/fees.test.ts b/core/fees/__tests__/fees.test.ts new file mode 100644 index 0000000..955da99 --- /dev/null +++ b/core/fees/__tests__/fees.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { + calculateFees, + explainFees, + formatAssetAmount, + parseAssetAmount, + reconcileFees +} from "../fees"; +import type { FeeAsset, FeeBreakdown, FeeSchedule } from "../types"; + +const XLM: FeeAsset = { code: "XLM", decimals: 7 }; + +const MULTI_LINE: FeeSchedule = { + lines: [ + { id: "protocol", label: "Protocol fee", basisPoints: 100 }, + { id: "organizer", label: "Organizer fee", basisPoints: 250 } + ] +}; + +function breakdown(amount: bigint, schedule: FeeSchedule, asset: FeeAsset = XLM): FeeBreakdown { + const result = calculateFees({ asset, amount, schedule }); + if (!result.ok) throw new Error(`expected a breakdown, got ${result.code}`); + return result.value; +} + +describe("fee calculation (#182)", () => { + it("charges nothing for a zero-rate schedule and for a zero amount", () => { + const free = breakdown(1_000_000_000n, { lines: [{ id: "protocol", label: "Protocol", basisPoints: 0 }] }); + expect(free.totalFees).toBe(0n); + expect(free.net).toBe(1_000_000_000n); + expect(free.lines[0].basis).toBe("zero"); + + const empty = breakdown(0n, { + lines: [{ id: "protocol", label: "Protocol", basisPoints: 100, minimum: 50_000n }] + }); + expect(empty.totalFees).toBe(0n); + expect(empty.lines[0].basis).toBe("zero"); + }); + + it("raises a line to its minimum when the rate amount is below it", () => { + const result = breakdown(1_000_000n, { + lines: [{ id: "protocol", label: "Protocol", basisPoints: 100, minimum: 50_000n }] + }); + expect(result.lines[0]).toMatchObject({ rateAmount: 10_000n, amount: 50_000n, basis: "minimum" }); + expect(result.net).toBe(950_000n); + }); + + it("keeps the rate amount when it is above the minimum", () => { + const result = breakdown(1_000_000_000n, { + lines: [{ id: "protocol", label: "Protocol", basisPoints: 100, minimum: 50_000n }] + }); + expect(result.lines[0]).toMatchObject({ amount: 10_000_000n, basis: "rate" }); + }); + + it("itemizes several lines that add up to the total", () => { + const result = breakdown(1_000_000_000n, MULTI_LINE); + expect(result.lines.map((line) => [line.id, line.amount])).toEqual([ + ["protocol", 10_000_000n], + ["organizer", 25_000_000n] + ]); + expect(result.totalFees).toBe(35_000_000n); + expect(result.net).toBe(965_000_000n); + expect(result.totalFees + result.net).toBe(result.gross); + }); + + it("rounds each line half up to the smallest unit", () => { + const tenPercent: FeeSchedule = { lines: [{ id: "fee", label: "Fee", basisPoints: 1000 }] }; + expect(breakdown(14n, tenPercent).lines[0].amount).toBe(1n); // 1.4 + expect(breakdown(15n, tenPercent).lines[0].amount).toBe(2n); // 1.5 + expect(breakdown(5n, tenPercent).lines[0].amount).toBe(1n); // 0.5 + expect(breakdown(4n, tenPercent).lines[0].amount).toBe(0n); // 0.4 + }); + + it("is deterministic for identical inputs", () => { + expect(breakdown(123_456_789n, MULTI_LINE)).toEqual(breakdown(123_456_789n, MULTI_LINE)); + expect(explainFees(breakdown(123_456_789n, MULTI_LINE))).toEqual( + explainFees(breakdown(123_456_789n, MULTI_LINE)) + ); + }); + + it("stays exact for amounts beyond the safe integer range", () => { + const result = breakdown(9_223_372_036_854_775_807n, { + lines: [{ id: "fee", label: "Fee", basisPoints: 10_000 }] + }); + expect(result.lines[0].amount).toBe(9_223_372_036_854_775_807n); + expect(result.net).toBe(0n); + }); + + it("rejects an invalid asset precision", () => { + expect(calculateFees({ asset: { code: "XLM", decimals: 19 }, amount: 1n, schedule: MULTI_LINE })).toEqual({ + ok: false, + code: "invalid_asset_precision" + }); + expect(calculateFees({ asset: { code: "XLM", decimals: 1.5 }, amount: 1n, schedule: MULTI_LINE })).toEqual({ + ok: false, + code: "invalid_asset_precision" + }); + expect(calculateFees({ asset: { code: "XLM", decimals: -1 }, amount: 1n, schedule: MULTI_LINE })).toEqual({ + ok: false, + code: "invalid_asset_precision" + }); + }); + + it("rejects an invalid asset code, amount or schedule", () => { + expect(calculateFees({ asset: { code: "", decimals: 7 }, amount: 1n, schedule: MULTI_LINE })).toEqual({ + ok: false, + code: "invalid_asset" + }); + expect(calculateFees({ asset: XLM, amount: -1n, schedule: MULTI_LINE })).toEqual({ + ok: false, + code: "invalid_amount" + }); + expect( + calculateFees({ + asset: XLM, + amount: 1n, + schedule: { lines: [{ id: "fee", label: "Fee", basisPoints: 10_001 }] } + }) + ).toEqual({ ok: false, code: "invalid_fee_schedule" }); + expect( + calculateFees({ + asset: XLM, + amount: 1n, + schedule: { + lines: [ + { id: "fee", label: "Fee", basisPoints: 1 }, + { id: "fee", label: "Again", basisPoints: 1 } + ] + } + }) + ).toEqual({ ok: false, code: "invalid_fee_schedule" }); + }); + + it("refuses fees that exceed the amount", () => { + expect( + calculateFees({ + asset: XLM, + amount: 100n, + schedule: { + lines: [ + { id: "a", label: "A", basisPoints: 6000 }, + { id: "b", label: "B", basisPoints: 6000 } + ] + } + }) + ).toEqual({ ok: false, code: "fees_exceed_amount" }); + expect( + calculateFees({ + asset: XLM, + amount: 10n, + schedule: { lines: [{ id: "a", label: "A", basisPoints: 100, minimum: 11n }] } + }) + ).toEqual({ ok: false, code: "fees_exceed_amount" }); + }); +}); + +describe("asset amounts", () => { + it("parses decimal text into smallest units", () => { + expect(parseAssetAmount(XLM, "100")).toEqual({ ok: true, value: 1_000_000_000n }); + expect(parseAssetAmount(XLM, "0.0000001")).toEqual({ ok: true, value: 1n }); + expect(parseAssetAmount({ code: "JPY", decimals: 0 }, "42")).toEqual({ ok: true, value: 42n }); + }); + + it("refuses digits beyond the asset precision instead of rounding them", () => { + expect(parseAssetAmount(XLM, "1.12345678")).toEqual({ ok: false, code: "invalid_asset_precision" }); + expect(parseAssetAmount({ code: "JPY", decimals: 0 }, "1.5")).toEqual({ + ok: false, + code: "invalid_asset_precision" + }); + }); + + it("refuses malformed text", () => { + for (const text of ["", "abc", "-1", "1,5", "1.", ".5"]) { + expect(parseAssetAmount(XLM, text)).toEqual({ ok: false, code: "invalid_amount" }); + } + }); + + it("formats with exactly the asset's decimals", () => { + expect(formatAssetAmount(XLM, 35_000_000n)).toBe("3.5000000"); + expect(formatAssetAmount(XLM, 1n)).toBe("0.0000001"); + expect(formatAssetAmount({ code: "JPY", decimals: 0 }, 42n)).toBe("42"); + }); +}); + +describe("explainability and reconciliation", () => { + it("explains every line, the minimum and the totals", () => { + const result = breakdown(1_000_000n, { + lines: [ + { id: "protocol", label: "Protocol fee", basisPoints: 100, minimum: 50_000n }, + { id: "organizer", label: "Organizer fee", basisPoints: 250 } + ] + }); + expect(explainFees(result)).toEqual([ + "Protocol fee: 1.00% of 0.1000000 XLM = 0.0050000 XLM (rate gives 0.0010000 XLM, raised to the minimum)", + "Organizer fee: 2.50% of 0.1000000 XLM = 0.0025000 XLM (rounded half up)", + "Total fees: 0.0075000 XLM", + "Net: 0.0925000 XLM" + ]); + }); + + it("matches a stored settlement that agrees with the breakdown", () => { + const result = breakdown(1_000_000_000n, MULTI_LINE); + expect( + reconcileFees(result, { + gross: 1_000_000_000n, + lines: { protocol: 10_000_000n, organizer: 25_000_000n }, + net: 965_000_000n + }) + ).toEqual({ matches: true, mismatches: [] }); + }); + + it("reports every field where a stored settlement disagrees", () => { + const result = breakdown(1_000_000_000n, MULTI_LINE); + const reconciliation = reconcileFees(result, { + gross: 1_000_000_000n, + lines: { protocol: 9_000_000n, legacy: 1n }, + net: 965_000_000n + }); + expect(reconciliation.matches).toBe(false); + expect(reconciliation.mismatches).toEqual([ + { field: "line:protocol", expected: 10_000_000n, actual: 9_000_000n }, + { field: "line:organizer", expected: 25_000_000n, actual: undefined }, + { field: "line:legacy", expected: undefined, actual: 1n } + ]); + }); +}); diff --git a/core/fees/fees.ts b/core/fees/fees.ts new file mode 100644 index 0000000..12909cc --- /dev/null +++ b/core/fees/fees.ts @@ -0,0 +1,158 @@ +/** + * Deterministic fee calculation with explainable breakdowns (#182). + * + * This is the one place fee arithmetic lives. The same asset, amount and + * schedule always produce the same breakdown, because: + * + * - amounts are `bigint` smallest units, never floats; + * - each line's rate amount is `amount * basisPoints / 10000`, rounded half up + * to the smallest unit, independently of every other line; + * - a line's minimum applies to any positive amount, and only then; + * - no clock, randomness or ambient state is read. + * + * `calculateFees` has no side effects, so calling it is the preview a user sees + * before final submission. `reconcileFees` then checks the stored settlement + * against a breakdown, so the explanation and the record cannot drift apart. + */ + +import { err, ok, type Result } from "@/core/result/result"; +import type { + FeeAsset, + FeeBreakdown, + FeeErrorCode, + FeeInput, + FeeLineItem, + FeeMismatch, + FeeReconciliation, + FeeSchedule, + StoredFeeSettlement +} from "./types"; + +export const BASIS_POINTS_DENOMINATOR = 10_000n; +export const MAX_ASSET_DECIMALS = 18; + +const ASSET_CODE = /^[A-Za-z0-9]{1,12}$/; +const DECIMAL_AMOUNT = /^(\d+)(?:\.(\d+))?$/; + +function validateAsset(asset: FeeAsset): FeeErrorCode | undefined { + if (!asset || typeof asset.code !== "string" || !ASSET_CODE.test(asset.code)) return "invalid_asset"; + if (!Number.isInteger(asset.decimals) || asset.decimals < 0 || asset.decimals > MAX_ASSET_DECIMALS) { + return "invalid_asset_precision"; + } + return undefined; +} + +function validateSchedule(schedule: FeeSchedule): boolean { + if (!schedule || !Array.isArray(schedule.lines)) return false; + const seen = new Set(); + for (const line of schedule.lines) { + if (typeof line.id !== "string" || line.id.length === 0 || seen.has(line.id)) return false; + seen.add(line.id); + if (!Number.isInteger(line.basisPoints) || line.basisPoints < 0 || line.basisPoints > 10_000) return false; + if (line.minimum !== undefined && (typeof line.minimum !== "bigint" || line.minimum < 0n)) return false; + } + return true; +} + +/** + * Parses a decimal string such as `"12.5"` into smallest units for the asset. + * More fractional digits than the asset has decimals is `invalid_asset_precision`: + * the digits are refused, never rounded away. + */ +export function parseAssetAmount(asset: FeeAsset, text: string): Result { + const assetError = validateAsset(asset); + if (assetError) return err(assetError); + const match = DECIMAL_AMOUNT.exec(typeof text === "string" ? text.trim() : ""); + if (!match) return err("invalid_amount"); + const [, whole, fraction = ""] = match; + if (fraction.length > asset.decimals) return err("invalid_asset_precision"); + return ok(BigInt(whole + fraction.padEnd(asset.decimals, "0"))); +} + +/** Formats smallest units with exactly `asset.decimals` decimal places. */ +export function formatAssetAmount(asset: FeeAsset, amount: bigint): string { + const negative = amount < 0n; + const digits = (negative ? -amount : amount).toString().padStart(asset.decimals + 1, "0"); + const split = digits.length - asset.decimals; + const text = asset.decimals === 0 ? digits : `${digits.slice(0, split)}.${digits.slice(split)}`; + return negative ? `-${text}` : text; +} + +/** Computes the breakdown for one gross amount. Pure and deterministic. */ +export function calculateFees(input: FeeInput): Result { + const assetError = validateAsset(input.asset); + if (assetError) return err(assetError); + if (typeof input.amount !== "bigint" || input.amount < 0n) return err("invalid_amount"); + if (!validateSchedule(input.schedule)) return err("invalid_fee_schedule"); + + const lines: FeeLineItem[] = input.schedule.lines.map((line) => { + const minimum = line.minimum ?? 0n; + const rateAmount = + (input.amount * BigInt(line.basisPoints) + BASIS_POINTS_DENOMINATOR / 2n) / BASIS_POINTS_DENOMINATOR; + const floor = input.amount > 0n ? minimum : 0n; + const amount = rateAmount > floor ? rateAmount : floor; + return { + id: line.id, + label: line.label, + basisPoints: line.basisPoints, + rateAmount, + minimum, + amount, + basis: amount === 0n ? "zero" : amount > rateAmount ? "minimum" : "rate" + }; + }); + + const totalFees = lines.reduce((sum, line) => sum + line.amount, 0n); + if (totalFees > input.amount) return err("fees_exceed_amount"); + + return ok({ + asset: input.asset, + gross: input.amount, + lines, + totalFees, + net: input.amount - totalFees, + rounding: "half_up" + }); +} + +/** One human-readable line per fee, then the totals. Stable for identical input. */ +export function explainFees(breakdown: FeeBreakdown): string[] { + const { asset } = breakdown; + const money = (value: bigint): string => `${formatAssetAmount(asset, value)} ${asset.code}`; + const lines = breakdown.lines.map((line) => { + const rate = `${(line.basisPoints / 100).toFixed(2)}% of ${money(breakdown.gross)}`; + const how = + line.basis === "minimum" + ? `rate gives ${money(line.rateAmount)}, raised to the minimum` + : line.basis === "zero" + ? "nothing charged" + : "rounded half up"; + return `${line.label}: ${rate} = ${money(line.amount)} (${how})`; + }); + lines.push(`Total fees: ${money(breakdown.totalFees)}`); + lines.push(`Net: ${money(breakdown.net)}`); + return lines; +} + +/** + * Compares a breakdown with the figures a settlement record stored. Any + * difference, including a missing or extra line, is reported field by field. + */ +export function reconcileFees(breakdown: FeeBreakdown, stored: StoredFeeSettlement): FeeReconciliation { + const mismatches: FeeMismatch[] = []; + const compare = (field: string, expected: bigint | undefined, actual: bigint | undefined): void => { + if (expected !== actual) mismatches.push({ field, expected, actual }); + }; + + compare("gross", breakdown.gross, stored.gross); + const expectedIds = new Set(breakdown.lines.map((line) => line.id)); + for (const line of breakdown.lines) { + compare(`line:${line.id}`, line.amount, stored.lines[line.id]); + } + for (const id of Object.keys(stored.lines)) { + if (!expectedIds.has(id)) compare(`line:${id}`, undefined, stored.lines[id]); + } + compare("net", breakdown.net, stored.net); + + return { matches: mismatches.length === 0, mismatches }; +} diff --git a/core/fees/index.ts b/core/fees/index.ts new file mode 100644 index 0000000..1c7b001 --- /dev/null +++ b/core/fees/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./fees"; diff --git a/core/fees/types.ts b/core/fees/types.ts new file mode 100644 index 0000000..e553d39 --- /dev/null +++ b/core/fees/types.ts @@ -0,0 +1,92 @@ +/** + * Types for the deterministic fee calculation module (#182). + * + * Every amount is a `bigint` count of the asset's smallest unit (stroops for a + * 7-decimal Stellar asset). Nothing in this module passes through `Number`. + */ + +export interface FeeAsset { + /** Stellar asset code: 1 to 12 alphanumeric characters. */ + readonly code: string; + /** Number of decimal places of the asset, an integer from 0 to 18. */ + readonly decimals: number; +} + +export type FeeErrorCode = + | "invalid_asset" + | "invalid_asset_precision" + | "invalid_amount" + | "invalid_fee_schedule" + | "fees_exceed_amount"; + +/** One named fee, charged as a rate in basis points with an optional floor. */ +export interface FeeLineDefinition { + /** Stable identifier, e.g. `protocol` or `organizer`. Unique within a schedule. */ + readonly id: string; + readonly label: string; + /** Rate in basis points: an integer from 0 (free) to 10000 (100%). */ + readonly basisPoints: number; + /** Floor in smallest units, applied to any positive amount. Defaults to 0. */ + readonly minimum?: bigint; +} + +export interface FeeSchedule { + readonly lines: readonly FeeLineDefinition[]; +} + +export interface FeeInput { + readonly asset: FeeAsset; + /** Gross amount in smallest units. Must not be negative. */ + readonly amount: bigint; + readonly schedule: FeeSchedule; +} + +/** + * How a line's final amount was reached: + * - `rate`: the rounded rate amount was charged; + * - `minimum`: the rate amount was below the floor, so the floor was charged; + * - `zero`: nothing was charged. + */ +export type FeeBasis = "rate" | "minimum" | "zero"; + +export interface FeeLineItem { + readonly id: string; + readonly label: string; + readonly basisPoints: number; + /** The rate amount after rounding, before the minimum is considered. */ + readonly rateAmount: bigint; + readonly minimum: bigint; + /** The amount actually charged for this line. */ + readonly amount: bigint; + readonly basis: FeeBasis; +} + +export interface FeeBreakdown { + readonly asset: FeeAsset; + readonly gross: bigint; + readonly lines: readonly FeeLineItem[]; + readonly totalFees: bigint; + /** `gross - totalFees`. */ + readonly net: bigint; + /** Each line's rate amount is rounded half up to the smallest unit. */ + readonly rounding: "half_up"; +} + +/** The fee figures a settlement record stores, keyed by line id. */ +export interface StoredFeeSettlement { + readonly gross: bigint; + readonly lines: Readonly>; + readonly net: bigint; +} + +export interface FeeMismatch { + /** `gross`, `net` or `line:`. */ + readonly field: string; + readonly expected: bigint | undefined; + readonly actual: bigint | undefined; +} + +export interface FeeReconciliation { + readonly matches: boolean; + readonly mismatches: readonly FeeMismatch[]; +} diff --git a/core/seed/__tests__/seed.test.ts b/core/seed/__tests__/seed.test.ts new file mode 100644 index 0000000..efb2908 --- /dev/null +++ b/core/seed/__tests__/seed.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { authorizationState } from "@/core/authorizations/store"; +import type { Event as DomainEvent, TicketStatus } from "@/core/invariants/types"; +import { createSeedDataset, SEED_NOW } from "../data"; +import type { SeedDataset } from "../types"; +import { overdueWorkflows, validateSeedDataset } from "../validate"; + +/** Replaces one part of the dataset, keeping the rest. */ +function withState(dataset: SeedDataset, patch: Partial): SeedDataset { + return { ...dataset, state: { ...dataset.state, ...patch } }; +} + +function codes(dataset: SeedDataset): string[] { + return validateSeedDataset(dataset).map((issue) => issue.code); +} + +describe("contributor-safe seed data (#185)", () => { + it("loads with no clock, network or credentials and is internally consistent", () => { + expect(validateSeedDataset(createSeedDataset())).toEqual([]); + }); + + it("is deterministic and resets to the same data", () => { + const first = createSeedDataset(); + expect(createSeedDataset()).toEqual(first); + expect(first.now).toBe(SEED_NOW); + + // Mutating one copy never touches the next: calling it again is the reset. + (first.state.events as Map).delete("ev-seed-summit"); + (first.state.blacklistedAddresses as Set).clear(); + const reset = createSeedDataset(); + expect(reset.state.events.has("ev-seed-summit")).toBe(true); + expect(reset.state.blacklistedAddresses.size).toBe(1); + expect(validateSeedDataset(reset)).toEqual([]); + }); + + it("contains no real keys or secrets, only G_SEED_* placeholders", () => { + const dataset = createSeedDataset(); + const text = JSON.stringify({ + events: [...dataset.state.events.values()], + tickets: [...dataset.state.tickets.values()], + transfers: dataset.state.transfers, + blacklist: [...dataset.state.blacklistedAddresses], + settlements: dataset.settlements, + authorizations: dataset.authorizations, + workflows: dataset.workflows + }); + expect(text).not.toMatch(/\b[GS][A-Z2-7]{55}\b/); + expect(text).toContain("G_SEED_"); + }); + + describe("edge cases", () => { + it("includes a failed settlement that moved no money", () => { + const dataset = createSeedDataset(); + const failed = dataset.settlements.filter((entry) => entry.status === "failed"); + expect(failed).toHaveLength(1); + expect(failed[0]).toMatchObject({ transferId: null, failureReason: "insufficient_funds" }); + // The ticket stayed with its seller and no transfer was recorded. + expect(dataset.state.tickets.get(failed[0].ticketId)).toMatchObject({ status: "issued" }); + expect(dataset.state.transfers.some((transfer) => transfer.ticketId === failed[0].ticketId)).toBe(false); + }); + + it("includes revoked access", () => { + const dataset = createSeedDataset(); + const states = dataset.authorizations.map((entry) => authorizationState(entry, dataset.now)); + expect(states).toContain("revoked"); + const locked = [...dataset.state.tickets.values()].find((entry) => entry.fraudFlag); + expect(locked).toMatchObject({ status: "locked" }); + expect(dataset.state.blacklistedAddresses.has(locked!.ownerAddress)).toBe(true); + }); + + it("includes an overdue workflow next to an on-time and a completed one", () => { + const dataset = createSeedDataset(); + expect(overdueWorkflows(dataset).map((workflow) => workflow.id)).toEqual(["wf-seed-overdue"]); + expect(dataset.workflows.map((workflow) => workflow.status).sort()).toEqual(["completed", "open", "open"]); + }); + + it("includes historical records", () => { + const dataset = createSeedDataset(); + expect(dataset.state.events.get("ev-seed-gala-2025")?.status).toBe("completed"); + const historic = dataset.state.transfers.filter((transfer) => transfer.timestamp < dataset.now - 365 * 86_400_000); + expect(historic).toHaveLength(1); + }); + + it("covers every authorization state and every ticket status", () => { + const dataset = createSeedDataset(); + expect(new Set(dataset.authorizations.map((entry) => authorizationState(entry, dataset.now)))).toEqual( + new Set(["active", "renewed", "expired", "cleaned", "revoked"]) + ); + const statuses: TicketStatus[] = ["issued", "transferred", "locked", "redeemed", "expired", "burned"]; + expect(new Set([...dataset.state.tickets.values()].map((entry) => entry.status))).toEqual(new Set(statuses)); + }); + + it("includes a sold-out event and events in every non-draft status", () => { + const events = [...createSeedDataset().state.events.values()]; + expect(events.some((event) => event.issuedCount === event.totalCapacity)).toBe(true); + expect(new Set(events.map((event) => event.status))).toEqual( + new Set(["active", "paused", "completed", "cancelled"]) + ); + }); + }); + + describe("validation catches inconsistent records", () => { + it("flags a settlement whose fees differ from the fee module", () => { + const dataset = createSeedDataset(); + const tampered = { + ...dataset, + settlements: dataset.settlements.map((entry) => + entry.id === "settle-seed-failed" + ? { ...entry, protocolFeeStroops: 1, sellerProceedsStroops: entry.sellerProceedsStroops + 1_999_999 } + : entry + ) + }; + expect(codes(tampered)).toEqual(["settlement_fee_mismatch"]); + }); + + it("flags a transfer that no settlement references, and a settlement that disagrees with its transfer", () => { + const dataset = createSeedDataset(); + expect(codes({ ...dataset, settlements: dataset.settlements.filter((entry) => entry.id !== "settle-seed-001") })).toEqual( + ["transfer_without_settlement"] + ); + const drifted = { + ...dataset, + settlements: dataset.settlements.map((entry) => + entry.id === "settle-seed-001" ? { ...entry, ticketId: "tkt-seed-001" } : entry + ) + }; + expect(codes(drifted)).toContain("settlement_transfer_mismatch"); + }); + + it("flags a failed settlement that claims a transfer or gives no reason", () => { + const dataset = createSeedDataset(); + for (const patch of [{ transferId: "tr-seed-001" }, { failureReason: undefined }]) { + const tampered = { + ...dataset, + settlements: dataset.settlements.map((entry) => + entry.id === "settle-seed-failed" ? { ...entry, ...patch } : entry + ) + }; + expect(codes(tampered)).toContain("failed_settlement_inconsistent"); + } + }); + + it("flags a domain invariant violation and a wrong issued count", () => { + const dataset = createSeedDataset(); + const events = new Map(dataset.state.events); + events.set("ev-seed-summit", { ...events.get("ev-seed-summit")!, issuedCount: 2 }); + expect(codes(withState(dataset, { events }))).toEqual(["event_issued_count_mismatch"]); + + const tickets = new Map(dataset.state.tickets); + tickets.set("tkt-seed-003", { ...tickets.get("tkt-seed-003")!, status: "issued" }); + expect(codes(withState(dataset, { tickets }))).toContain("invariant_violation"); + }); + + it("flags a non-placeholder address", () => { + const dataset = createSeedDataset(); + const events = new Map(dataset.state.events); + events.set("ev-seed-summit", { + ...events.get("ev-seed-summit")!, + organizerAddress: `G${"A".repeat(55)}` + }); + expect(codes(withState(dataset, { events }))).toEqual(["address_not_placeholder"]); + }); + + it("flags inconsistent authorizations and workflows", () => { + const dataset = createSeedDataset(); + const authorizations = dataset.authorizations.map((entry) => { + if (entry.id === "auth-seed-revoked") return { ...entry, revokedAt: undefined }; + if (entry.id === "auth-seed-active") return { ...entry, resourceId: "ev-missing" }; + return entry; + }); + expect(codes({ ...dataset, authorizations })).toEqual(["authorization_inconsistent", "authorization_inconsistent"]); + + const workflows = dataset.workflows.map((entry) => + entry.id === "wf-seed-overdue" ? { ...entry, ticketId: "tkt-missing" } : entry + ); + expect(codes({ ...dataset, workflows })).toEqual(["workflow_inconsistent"]); + }); + + it("flags records dated after the seed instant", () => { + const dataset = createSeedDataset(); + const workflows = dataset.workflows.map((entry) => + entry.id === "wf-seed-open" ? { ...entry, openedAt: dataset.now + 1000, dueAt: dataset.now + 2000 } : entry + ); + expect(codes({ ...dataset, workflows })).toEqual(["future_record"]); + }); + }); +}); diff --git a/core/seed/data.ts b/core/seed/data.ts new file mode 100644 index 0000000..dba3906 --- /dev/null +++ b/core/seed/data.ts @@ -0,0 +1,234 @@ +/** + * Deterministic seed data for contributors (#185). + * + * Everything here is a committed literal relative to one fixed instant. There + * is no clock, randomness, network or credential involved, and every address is + * an obvious `G_SEED_*` placeholder rather than a real Stellar key, so the data + * is safe to commit and to load anywhere. + * + * It covers the core workflows (issuance, transfer, redemption) and the edge + * cases contributors usually have to invent for themselves: + * + * - a failed settlement: `settle-seed-failed` moved no money and left the + * ticket with its seller; + * - revoked access: a fraud-locked ticket on a blacklisted account, and a + * revoked delegated authorization; + * - an overdue workflow: a refund review for a cancelled event, past its due date; + * - historical records: a completed event with redeemed, expired and + * transferred tickets, an expired authorization still inside its renewal + * grace period, and one already cleaned up. + * + * `createSeedDataset` returns a fresh, independent copy on every call, which is + * also how the dataset is reset. `validateSeedDataset` (see `validate.ts`) + * proves the records agree with each other. + */ + +import type { Authorization } from "@/core/authorizations/types"; +import type { FeeAsset, FeeSchedule } from "@/core/fees/types"; +import type { DomainState, Event, Ticket, TransferRecord } from "@/core/invariants/types"; +import type { SeedDataset, SeedSettlement, SeedWorkflow } from "./types"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The instant the dataset describes. */ +export const SEED_NOW = Date.parse("2026-06-01T00:00:00.000Z"); + +export const SEED_ASSET: FeeAsset = { code: "XLM", decimals: 7 }; + +/** The fee policy the seeded settlements were computed with. */ +export function seedFeeSchedule(organizerBasisPoints: number): FeeSchedule { + return { + lines: [ + { id: "protocol", label: "Protocol fee", basisPoints: 100, minimum: 100_000n }, + { id: "organizer", label: "Organizer fee", basisPoints: organizerBasisPoints } + ] + }; +} + +const ORGANIZER = "G_SEED_ORGANIZER_ALICE"; +const BOB = "G_SEED_ATTENDEE_BOB"; +const CAROL = "G_SEED_ATTENDEE_CAROL"; +const DAVE = "G_SEED_ATTENDEE_DAVE"; +const ERIN = "G_SEED_ATTENDEE_ERIN"; +const FRANK = "G_SEED_ATTENDEE_FRANK"; +const FRAUDSTER = "G_SEED_FRAUDSTER_EVE"; +const GATE_STAFF = "G_SEED_STAFF_GATE"; +const SCANNER_STAFF = "G_SEED_STAFF_SCANNER"; + +const EVENTS: readonly Event[] = [ + { id: "ev-seed-summit", organizerAddress: ORGANIZER, totalCapacity: 100, issuedCount: 3, status: "active", feeBasisPoints: 250 }, + { id: "ev-seed-workshop", organizerAddress: ORGANIZER, totalCapacity: 50, issuedCount: 1, status: "paused", feeBasisPoints: 250 }, + { id: "ev-seed-soldout", organizerAddress: ORGANIZER, totalCapacity: 2, issuedCount: 2, status: "active", feeBasisPoints: 300 }, + { id: "ev-seed-gala-2025", organizerAddress: ORGANIZER, totalCapacity: 20, issuedCount: 3, status: "completed", feeBasisPoints: 500 }, + { id: "ev-seed-cancelled", organizerAddress: ORGANIZER, totalCapacity: 10, issuedCount: 1, status: "cancelled", feeBasisPoints: 250 } +]; + +function ticket( + id: string, + eventId: string, + ownerAddress: string, + priceStroops: number, + status: Ticket["status"], + issuedDaysAgo: number, + validFromDaysAgo: number, + validUntilDaysAgo: number, + extra: Partial> = {} +): Ticket { + return { + id, + eventId, + tierId: "tier-general", + ownerAddress, + priceStroops, + status, + issuedAt: SEED_NOW - issuedDaysAgo * DAY_MS, + validFrom: SEED_NOW - validFromDaysAgo * DAY_MS, + validUntil: SEED_NOW - validUntilDaysAgo * DAY_MS, + fraudFlag: extra.fraudFlag ?? false, + nonce: extra.nonce ?? 0, + metadataHash: `seed-metadata-${id}` + }; +} + +const TICKETS: readonly Ticket[] = [ + ticket("tkt-seed-001", "ev-seed-summit", BOB, 200_000_000, "issued", 20, 10, -30), + ticket("tkt-seed-002", "ev-seed-summit", CAROL, 1_000_000_000, "transferred", 20, 10, -30, { nonce: 1 }), + ticket("tkt-seed-003", "ev-seed-summit", FRAUDSTER, 100_000_000, "locked", 15, 10, -30, { fraudFlag: true }), + ticket("tkt-seed-004", "ev-seed-workshop", BOB, 50_000_000, "issued", 12, 5, -20), + ticket("tkt-seed-005", "ev-seed-soldout", CAROL, 80_000_000, "issued", 9, 3, -10), + ticket("tkt-seed-006", "ev-seed-soldout", DAVE, 80_000_000, "issued", 9, 3, -10), + ticket("tkt-seed-007", "ev-seed-gala-2025", ERIN, 5_000_000, "redeemed", 400, 365, 364), + ticket("tkt-seed-008", "ev-seed-gala-2025", FRANK, 5_000_000, "transferred", 400, 365, 364, { nonce: 1 }), + ticket("tkt-seed-009", "ev-seed-gala-2025", ERIN, 5_000_000, "expired", 400, 365, 364), + ticket("tkt-seed-010", "ev-seed-cancelled", BOB, 75_000_000, "burned", 60, 45, 44) +]; + +const TRANSFERS: readonly TransferRecord[] = [ + { + id: "tr-seed-001", + ticketId: "tkt-seed-002", + fromAddress: DAVE, + toAddress: CAROL, + priceStroops: 1_000_000_000, + organizerFeeStroops: 25_000_000, + protocolFeeStroops: 10_000_000, + sellerProceedsStroops: 965_000_000, + nonce: "nonce-seed-001", + timestamp: SEED_NOW - 5 * DAY_MS + }, + { + id: "tr-seed-002", + ticketId: "tkt-seed-008", + fromAddress: ERIN, + toAddress: FRANK, + priceStroops: 5_000_000, + organizerFeeStroops: 250_000, + protocolFeeStroops: 100_000, + sellerProceedsStroops: 4_650_000, + nonce: "nonce-seed-002", + timestamp: SEED_NOW - 380 * DAY_MS + } +]; + +const SETTLEMENTS: readonly SeedSettlement[] = [ + { + id: "settle-seed-001", + ticketId: "tkt-seed-002", + transferId: "tr-seed-001", + status: "settled", + priceStroops: 1_000_000_000, + organizerFeeStroops: 25_000_000, + protocolFeeStroops: 10_000_000, + sellerProceedsStroops: 965_000_000, + attemptedAt: SEED_NOW - 5 * DAY_MS + }, + { + id: "settle-seed-002", + ticketId: "tkt-seed-008", + transferId: "tr-seed-002", + status: "settled", + priceStroops: 5_000_000, + organizerFeeStroops: 250_000, + protocolFeeStroops: 100_000, + sellerProceedsStroops: 4_650_000, + attemptedAt: SEED_NOW - 380 * DAY_MS + }, + { + id: "settle-seed-failed", + ticketId: "tkt-seed-001", + transferId: null, + status: "failed", + priceStroops: 200_000_000, + organizerFeeStroops: 5_000_000, + protocolFeeStroops: 2_000_000, + sellerProceedsStroops: 193_000_000, + attemptedAt: SEED_NOW - 2 * DAY_MS, + failureReason: "insufficient_funds" + } +]; + +function authorization( + id: string, + grantee: string, + resourceId: string, + issuedDaysAgo: number, + expiresInDays: number, + extra: Partial = {} +): Authorization { + return { + id, + grantor: ORGANIZER, + grantee, + scope: "tickets.redeem", + resourceId, + status: "active", + issuedAt: new Date(SEED_NOW - issuedDaysAgo * DAY_MS).toISOString(), + expiresAt: new Date(SEED_NOW + expiresInDays * DAY_MS).toISOString(), + renewals: 0, + ...extra + }; +} + +const AUTHORIZATIONS: readonly Authorization[] = [ + authorization("auth-seed-active", GATE_STAFF, "ev-seed-summit", 1, 6), + authorization("auth-seed-renewed", SCANNER_STAFF, "ev-seed-summit", 40, 20, { + renewals: 1, + lastRenewedAt: new Date(SEED_NOW - 10 * DAY_MS).toISOString() + }), + // Expired two days ago: refused, but still inside the seven-day renewal grace period. + authorization("auth-seed-expired", GATE_STAFF, "ev-seed-workshop", 30, -2), + // Expired long ago and already removed by cleanup. + authorization("auth-seed-cleaned", SCANNER_STAFF, "ev-seed-gala-2025", 420, -380, { + status: "cleaned", + cleanedAt: new Date(SEED_NOW - 370 * DAY_MS).toISOString() + }), + authorization("auth-seed-revoked", GATE_STAFF, "ev-seed-soldout", 8, 22, { + status: "revoked", + revokedAt: new Date(SEED_NOW - 3 * DAY_MS).toISOString() + }) +]; + +const WORKFLOWS: readonly SeedWorkflow[] = [ + // Open and past due: the overdue case. + { id: "wf-seed-overdue", kind: "refund_review", ticketId: "tkt-seed-010", status: "open", openedAt: SEED_NOW - 10 * DAY_MS, dueAt: SEED_NOW - 3 * DAY_MS }, + { id: "wf-seed-open", kind: "organizer_payout", ticketId: "tkt-seed-002", status: "open", openedAt: SEED_NOW - 2 * DAY_MS, dueAt: SEED_NOW + 5 * DAY_MS }, + { id: "wf-seed-done", kind: "organizer_payout", ticketId: "tkt-seed-008", status: "completed", openedAt: SEED_NOW - 375 * DAY_MS, dueAt: SEED_NOW - 370 * DAY_MS, completedAt: SEED_NOW - 372 * DAY_MS } +]; + +/** A fresh, independent copy of the dataset. Calling it again resets it. */ +export function createSeedDataset(): SeedDataset { + const state: DomainState = { + events: new Map(EVENTS.map((event) => [event.id, { ...event }])), + tickets: new Map(TICKETS.map((entry) => [entry.id, { ...entry }])), + transfers: TRANSFERS.map((transfer) => ({ ...transfer })), + processedNonces: new Set(), + blacklistedAddresses: new Set([FRAUDSTER]) + }; + return { + now: SEED_NOW, + state, + settlements: SETTLEMENTS.map((settlement) => ({ ...settlement })), + authorizations: AUTHORIZATIONS.map((entry) => ({ ...entry })), + workflows: WORKFLOWS.map((workflow) => ({ ...workflow })) + }; +} diff --git a/core/seed/index.ts b/core/seed/index.ts new file mode 100644 index 0000000..c9e8752 --- /dev/null +++ b/core/seed/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./data"; +export * from "./validate"; diff --git a/core/seed/types.ts b/core/seed/types.ts new file mode 100644 index 0000000..911fd7a --- /dev/null +++ b/core/seed/types.ts @@ -0,0 +1,70 @@ +/** + * Types for the contributor-safe seed dataset (#185). + * + * The dataset reuses the domain model (`core/invariants`) and the delegated + * permission model (`core/authorizations`), and adds the two record kinds the + * edge cases need: settlements and workflows. + */ + +import type { Authorization } from "@/core/authorizations/types"; +import type { DomainState } from "@/core/invariants/types"; + +export type SeedSettlementStatus = "settled" | "failed"; + +/** The money movement behind a transfer, including attempts that failed. */ +export interface SeedSettlement { + readonly id: string; + readonly ticketId: string; + /** The transfer this settled, or `null` when the settlement failed. */ + readonly transferId: string | null; + readonly status: SeedSettlementStatus; + readonly priceStroops: number; + readonly organizerFeeStroops: number; + readonly protocolFeeStroops: number; + readonly sellerProceedsStroops: number; + readonly attemptedAt: number; + /** Required when `status` is `failed`. */ + readonly failureReason?: string; +} + +export type SeedWorkflowKind = "refund_review" | "organizer_payout"; + +/** An operational task with a deadline. Open and past due means overdue. */ +export interface SeedWorkflow { + readonly id: string; + readonly kind: SeedWorkflowKind; + readonly ticketId: string; + readonly status: "open" | "completed"; + readonly openedAt: number; + readonly dueAt: number; + readonly completedAt?: number; +} + +export interface SeedDataset { + /** The fixed instant every record is relative to; never the wall clock. */ + readonly now: number; + readonly state: DomainState; + readonly settlements: readonly SeedSettlement[]; + readonly authorizations: readonly Authorization[]; + readonly workflows: readonly SeedWorkflow[]; +} + +export type SeedIssueCode = + | "invariant_violation" + | "address_not_placeholder" + | "ticket_event_missing" + | "event_issued_count_mismatch" + | "future_record" + | "transfer_ticket_missing" + | "transfer_without_settlement" + | "settlement_transfer_mismatch" + | "settlement_fee_mismatch" + | "failed_settlement_inconsistent" + | "authorization_inconsistent" + | "workflow_inconsistent"; + +export interface SeedIssue { + readonly code: SeedIssueCode; + readonly entityId: string; + readonly message: string; +} diff --git a/core/seed/validate.ts b/core/seed/validate.ts new file mode 100644 index 0000000..dbb97c4 --- /dev/null +++ b/core/seed/validate.ts @@ -0,0 +1,156 @@ +/** + * Consistency checks for the seed dataset (#185). + * + * `validateSeedDataset` returns every way the records disagree with each other, + * or an empty list when they agree. It builds on the existing domain + * invariants (`core/invariants`), recomputes every settlement's fees with the + * fee module (`core/fees`), and reuses the renewal grace period from + * `core/authorizations`, so the seed cannot silently drift from the rules the + * rest of the codebase enforces. + */ + +import { DEFAULT_RENEWAL_GRACE_MS } from "@/core/authorizations/store"; +import { calculateFees, reconcileFees } from "@/core/fees/fees"; +import { DomainInvariantChecker } from "@/core/invariants/checker"; +import { SEED_ASSET, seedFeeSchedule } from "./data"; +import type { SeedDataset, SeedIssue, SeedIssueCode } from "./types"; + +/** Seed addresses are placeholders: never a real, fundable Stellar key. */ +const PLACEHOLDER_ADDRESS = /^G_SEED_[A-Z_]+$/; + +export function validateSeedDataset(dataset: SeedDataset): SeedIssue[] { + const issues: SeedIssue[] = []; + const report = (code: SeedIssueCode, entityId: string, message: string): void => { + issues.push({ code, entityId, message }); + }; + const { now, state } = dataset; + + for (const violation of DomainInvariantChecker.verifyAll(state)) { + report("invariant_violation", violation.entityId, `${violation.invariantId}: ${violation.message}`); + } + + const addresses: Array<[string, string]> = []; + for (const event of state.events.values()) addresses.push([event.id, event.organizerAddress]); + for (const entry of state.tickets.values()) addresses.push([entry.id, entry.ownerAddress]); + for (const transfer of state.transfers) { + addresses.push([transfer.id, transfer.fromAddress], [transfer.id, transfer.toAddress]); + } + for (const address of state.blacklistedAddresses) addresses.push(["blacklist", address]); + for (const entry of dataset.authorizations) addresses.push([entry.id, entry.grantor], [entry.id, entry.grantee]); + for (const [entityId, address] of addresses) { + if (!PLACEHOLDER_ADDRESS.test(address)) { + report("address_not_placeholder", entityId, `${address} is not a G_SEED_* placeholder`); + } + } + + const ticketCounts = new Map(); + for (const entry of state.tickets.values()) { + if (!state.events.has(entry.eventId)) { + report("ticket_event_missing", entry.id, `Ticket references unknown event ${entry.eventId}`); + } + ticketCounts.set(entry.eventId, (ticketCounts.get(entry.eventId) ?? 0) + 1); + if (entry.issuedAt > now) report("future_record", entry.id, "Ticket was issued after the seed instant"); + } + for (const event of state.events.values()) { + const actual = ticketCounts.get(event.id) ?? 0; + if (event.issuedCount !== actual) { + report("event_issued_count_mismatch", event.id, `issuedCount ${event.issuedCount} but ${actual} tickets exist`); + } + } + + const settlementsByTransfer = new Map( + dataset.settlements.filter((entry) => entry.transferId !== null).map((entry) => [entry.transferId, entry]) + ); + for (const transfer of state.transfers) { + const settlement = settlementsByTransfer.get(transfer.id); + if (!state.tickets.has(transfer.ticketId)) { + report("transfer_ticket_missing", transfer.id, `Transfer references unknown ticket ${transfer.ticketId}`); + } + if (transfer.timestamp > now) report("future_record", transfer.id, "Transfer happened after the seed instant"); + if (!settlement) { + report("transfer_without_settlement", transfer.id, "No settlement references this transfer"); + } else if ( + settlement.status !== "settled" || + settlement.ticketId !== transfer.ticketId || + settlement.priceStroops !== transfer.priceStroops || + settlement.organizerFeeStroops !== transfer.organizerFeeStroops || + settlement.protocolFeeStroops !== transfer.protocolFeeStroops || + settlement.sellerProceedsStroops !== transfer.sellerProceedsStroops + ) { + report("settlement_transfer_mismatch", settlement.id, `Settlement disagrees with transfer ${transfer.id}`); + } + } + + for (const settlement of dataset.settlements) { + if (settlement.status === "settled") { + if (!settlement.transferId || !state.transfers.some((transfer) => transfer.id === settlement.transferId)) { + report("settlement_transfer_mismatch", settlement.id, "Settled settlement has no matching transfer"); + } + } else if (settlement.transferId !== null || !settlement.failureReason) { + report("failed_settlement_inconsistent", settlement.id, "A failed settlement has no transfer and states why"); + } + + const entry = state.tickets.get(settlement.ticketId); + const event = entry ? state.events.get(entry.eventId) : undefined; + if (!event) { + report("settlement_fee_mismatch", settlement.id, `Settlement references unknown ticket ${settlement.ticketId}`); + continue; + } + const breakdown = calculateFees({ + asset: SEED_ASSET, + amount: BigInt(settlement.priceStroops), + schedule: seedFeeSchedule(event.feeBasisPoints) + }); + const reconciliation = breakdown.ok + ? reconcileFees(breakdown.value, { + gross: BigInt(settlement.priceStroops), + lines: { + protocol: BigInt(settlement.protocolFeeStroops), + organizer: BigInt(settlement.organizerFeeStroops) + }, + net: BigInt(settlement.sellerProceedsStroops) + }) + : undefined; + if (!reconciliation?.matches) { + report("settlement_fee_mismatch", settlement.id, "Stored fees differ from the fee module's breakdown"); + } + if (settlement.attemptedAt > now) report("future_record", settlement.id, "Settlement is after the seed instant"); + } + + for (const entry of dataset.authorizations) { + const problems: string[] = []; + if (entry.resourceId !== null && !state.events.has(entry.resourceId)) problems.push("unknown resource"); + if (Date.parse(entry.issuedAt) > now) problems.push("issued after the seed instant"); + if (Date.parse(entry.expiresAt) <= Date.parse(entry.issuedAt)) problems.push("expires before it was issued"); + if (entry.status === "revoked" && !entry.revokedAt) problems.push("revoked without revokedAt"); + if (entry.status === "cleaned") { + if (!entry.cleanedAt) problems.push("cleaned without cleanedAt"); + else if (Date.parse(entry.cleanedAt) - Date.parse(entry.expiresAt) <= DEFAULT_RENEWAL_GRACE_MS) { + problems.push("cleaned inside the renewal grace period"); + } + } + if (entry.renewals > 0 && !entry.lastRenewedAt) problems.push("renewed without lastRenewedAt"); + for (const problem of problems) report("authorization_inconsistent", entry.id, problem); + } + + for (const workflow of dataset.workflows) { + if (!state.tickets.has(workflow.ticketId)) { + report("workflow_inconsistent", workflow.id, `Workflow references unknown ticket ${workflow.ticketId}`); + } + if (workflow.dueAt <= workflow.openedAt) report("workflow_inconsistent", workflow.id, "Due before it was opened"); + if (workflow.status === "completed" && workflow.completedAt === undefined) { + report("workflow_inconsistent", workflow.id, "Completed without completedAt"); + } + if (workflow.status === "open" && workflow.completedAt !== undefined) { + report("workflow_inconsistent", workflow.id, "Open with a completedAt"); + } + if (workflow.openedAt > now) report("future_record", workflow.id, "Workflow opened after the seed instant"); + } + + return issues; +} + +/** Open workflows whose due date has passed at the seed instant. */ +export function overdueWorkflows(dataset: SeedDataset) { + return dataset.workflows.filter((workflow) => workflow.status === "open" && workflow.dueAt < dataset.now); +} diff --git a/core/status/__tests__/feed.test.ts b/core/status/__tests__/feed.test.ts new file mode 100644 index 0000000..de00cdb --- /dev/null +++ b/core/status/__tests__/feed.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; +import { + buildPublicStatusFeed, + componentFromHealthIndicator, + maintenanceStateAt, + publishPublicStatusFeed, + STATUS_HISTORY_MS, + validatePublicStatusFeed +} from "../feed"; +import { + PUBLIC_STATUS_SCHEMA_VERSION, + type TrustedComponent, + type TrustedIncident, + type TrustedMaintenance, + type TrustedStatusState +} from "../types"; + +type FeedJson = Record & { + components: Array>; + incidents: Array>; + maintenance: Array> | unknown; +}; + +const NOW = Date.parse("2026-03-10T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const iso = (ms: number): string => new Date(ms).toISOString(); + +const api: TrustedComponent = { id: "api", name: "Ticket API", status: "operational", visibility: "public" }; +const redemption: TrustedComponent = { + id: "redemption", + name: "Redemption", + status: "operational", + visibility: "public" +}; +const queue: TrustedComponent = { + id: "worker-queue", + name: "Internal worker queue", + status: "major_outage", + visibility: "internal", + diagnostics: { host: "10.0.0.5", stack: "Error: ECONNRESET at worker.ts:42" } +}; + +function incident(overrides: Partial = {}): TrustedIncident { + return { + id: "inc-1", + title: "Redemption delays", + summary: "Some redemptions are slow.", + status: "investigating", + impact: "major", + startedAt: iso(NOW - 2 * HOUR), + updatedAt: iso(NOW - HOUR), + componentIds: ["redemption"], + visibility: "public", + ...overrides + }; +} + +function window(overrides: Partial = {}): TrustedMaintenance { + return { + id: "mnt-1", + title: "Database upgrade", + startsAt: iso(NOW - HOUR), + endsAt: iso(NOW + HOUR), + componentIds: ["api"], + visibility: "public", + ...overrides + }; +} + +function state(partial: Partial = {}): TrustedStatusState { + return { components: [api, redemption, queue], incidents: [], maintenance: [], ...partial }; +} + +describe("public status feed (#184)", () => { + it("reports a healthy system as operational and ignores internal components", () => { + const feed = buildPublicStatusFeed(state(), NOW); + expect(feed).toEqual({ + schemaVersion: PUBLIC_STATUS_SCHEMA_VERSION, + generatedAt: "2026-03-10T12:00:00.000Z", + status: "operational", + components: [ + { id: "api", name: "Ticket API", status: "operational" }, + { id: "redemption", name: "Redemption", status: "operational" } + ], + incidents: [], + maintenance: [] + }); + }); + + it("reports a degraded component as degraded", () => { + const feed = buildPublicStatusFeed( + state({ components: [{ ...api, status: "degraded" }, redemption, queue] }), + NOW + ); + expect(feed.status).toBe("degraded"); + expect(feed.components[0]).toEqual({ id: "api", name: "Ticket API", status: "degraded" }); + }); + + it("derives the overall status from unresolved incident impact", () => { + const at = (impact: "minor" | "major" | "critical") => + buildPublicStatusFeed(state({ incidents: [incident({ impact })] }), NOW).status; + expect(at("minor")).toBe("degraded"); + expect(at("major")).toBe("partial_outage"); + expect(at("critical")).toBe("major_outage"); + }); + + it("lists a resolved incident for a week without letting it affect the status", () => { + const resolved = incident({ + status: "resolved", + impact: "critical", + resolvedAt: iso(NOW - HOUR), + updatedAt: iso(NOW - HOUR) + }); + const recent = buildPublicStatusFeed(state({ incidents: [resolved] }), NOW); + expect(recent.status).toBe("operational"); + expect(recent.incidents).toHaveLength(1); + expect(recent.incidents[0]).toMatchObject({ id: "inc-1", status: "resolved", resolvedAt: iso(NOW - HOUR) }); + + const old = buildPublicStatusFeed(state({ incidents: [resolved] }), NOW + STATUS_HISTORY_MS + 2 * HOUR); + expect(old.incidents).toEqual([]); + }); + + it("orders incidents newest first and ties by id", () => { + const feed = buildPublicStatusFeed( + state({ + incidents: [ + incident({ id: "inc-b", startedAt: iso(NOW - 3 * HOUR) }), + incident({ id: "inc-c", startedAt: iso(NOW - HOUR) }), + incident({ id: "inc-a", startedAt: iso(NOW - 3 * HOUR) }) + ] + }), + NOW + ); + expect(feed.incidents.map((entry) => entry.id)).toEqual(["inc-c", "inc-a", "inc-b"]); + }); + + describe("maintenance windows", () => { + it("moves from scheduled to in progress to completed with the clock alone", () => { + const planned = window({ startsAt: iso(NOW + HOUR), endsAt: iso(NOW + 2 * HOUR) }); + expect(maintenanceStateAt(planned, NOW)).toBe("scheduled"); + expect(maintenanceStateAt(planned, NOW + HOUR)).toBe("in_progress"); + expect(maintenanceStateAt(planned, NOW + 2 * HOUR)).toBe("completed"); + + const before = buildPublicStatusFeed(state({ maintenance: [planned] }), NOW); + expect(before.status).toBe("operational"); + expect(before.maintenance[0]).toMatchObject({ state: "scheduled" }); + expect(before.components[0].status).toBe("operational"); + + const during = buildPublicStatusFeed(state({ maintenance: [planned] }), NOW + HOUR + 1); + expect(during.status).toBe("maintenance"); + expect(during.maintenance[0]).toMatchObject({ state: "in_progress" }); + expect(during.components.find((entry) => entry.id === "api")?.status).toBe("under_maintenance"); + expect(during.components.find((entry) => entry.id === "redemption")?.status).toBe("operational"); + + const after = buildPublicStatusFeed(state({ maintenance: [planned] }), NOW + 3 * HOUR); + expect(after.status).toBe("operational"); + expect(after.maintenance[0]).toMatchObject({ state: "completed" }); + }); + + it("lets an outage outrank maintenance, and drops old or cancelled windows", () => { + const outage = buildPublicStatusFeed( + state({ maintenance: [window()], incidents: [incident({ impact: "critical" })] }), + NOW + ); + expect(outage.status).toBe("major_outage"); + + const dropped = buildPublicStatusFeed( + state({ + maintenance: [ + window({ id: "mnt-cancelled", cancelled: true }), + window({ id: "mnt-old", startsAt: iso(NOW - 20 * 24 * HOUR), endsAt: iso(NOW - 19 * 24 * HOUR) }) + ] + }), + NOW + ); + expect(dropped.maintenance).toEqual([]); + expect(dropped.status).toBe("operational"); + }); + }); + + describe("private data filtering", () => { + it("never publishes internal items, diagnostics or internal notes", () => { + const feed = buildPublicStatusFeed( + state({ + incidents: [ + incident({ internalNotes: "root cause: leaked key in worker-queue" }), + incident({ id: "inc-private", visibility: "internal", title: "Private incident" }) + ], + maintenance: [ + window({ internalNotes: "rotate credentials on 10.0.0.5" }), + window({ id: "mnt-private", visibility: "internal", title: "Private window" }) + ] + }), + NOW + ); + const published = JSON.stringify(feed); + for (const secret of [ + "10.0.0.5", + "ECONNRESET", + "worker-queue", + "Internal worker queue", + "leaked key", + "rotate credentials", + "inc-private", + "Private incident", + "mnt-private", + "Private window", + "diagnostics", + "internalNotes" + ]) { + expect(published).not.toContain(secret); + } + expect(validatePublicStatusFeed(feed).ok).toBe(true); + }); + + it("does not reveal an internal component through an incident or window", () => { + const feed = buildPublicStatusFeed( + state({ + incidents: [incident({ componentIds: ["redemption", "worker-queue", "redemption"] })], + maintenance: [window({ componentIds: ["worker-queue", "api"] })] + }), + NOW + ); + expect(feed.incidents[0].componentIds).toEqual(["redemption"]); + expect(feed.maintenance[0].componentIds).toEqual(["api"]); + }); + + it("does not let an internal component move the overall status", () => { + expect(buildPublicStatusFeed(state({ components: [api, queue] }), NOW).status).toBe("operational"); + }); + + it("keeps a health indicator internal unless a public id and name are given", () => { + const indicator = { + name: "Network: Horizon Testnet", + status: "unhealthy" as const, + message: "Invalid endpoint URL", + details: { url: "https://user:pass@horizon.internal" }, + lastChecked: new Date(NOW) + }; + const hidden = componentFromHealthIndicator(indicator); + expect(hidden).toMatchObject({ visibility: "internal", status: "major_outage" }); + expect(buildPublicStatusFeed(state({ components: [hidden] }), NOW).components).toEqual([]); + + const shown = componentFromHealthIndicator(indicator, { id: "network", name: "Stellar network" }); + const feed = buildPublicStatusFeed(state({ components: [shown] }), NOW); + expect(feed.components).toEqual([{ id: "network", name: "Stellar network", status: "major_outage" }]); + expect(feed.status).toBe("major_outage"); + expect(JSON.stringify(feed)).not.toContain("horizon.internal"); + expect(JSON.stringify(feed)).not.toContain("Invalid endpoint URL"); + }); + }); + + describe("determinism", () => { + it("produces the same feed for the same state and instant, whatever the input order", () => { + const incidents = [incident({ id: "inc-a" }), incident({ id: "inc-b", startedAt: iso(NOW - 5 * HOUR) })]; + const windows = [window({ id: "mnt-b" }), window({ id: "mnt-a" })]; + const forward = buildPublicStatusFeed(state({ incidents, maintenance: windows }), NOW); + const reversed = buildPublicStatusFeed( + state({ + components: [queue, redemption, api], + incidents: [...incidents].reverse(), + maintenance: [...windows].reverse() + }), + new Date(NOW) + ); + expect(reversed).toEqual(forward); + }); + }); + + describe("schema validation", () => { + const valid = () => buildPublicStatusFeed(state({ incidents: [incident()], maintenance: [window()] }), NOW); + + it("accepts what the builder produces and publishes it", () => { + expect(validatePublicStatusFeed(valid())).toEqual({ ok: true, value: valid() }); + expect(publishPublicStatusFeed(state(), NOW).ok).toBe(true); + }); + + it("accepts a feed that went through JSON", () => { + expect(validatePublicStatusFeed(JSON.parse(JSON.stringify(valid()))).ok).toBe(true); + }); + + it("rejects anything that is not exactly the published shape", () => { + const feed = JSON.parse(JSON.stringify(valid())) as FeedJson; + const cases: Array<[string, (copy: FeedJson) => void]> = [ + ["$", (copy) => void (copy.extra = true)], + ["schemaVersion", (copy) => void (copy.schemaVersion = "0.9.0")], + ["generatedAt", (copy) => void (copy.generatedAt = "yesterday")], + ["status", (copy) => void (copy.status = "on_fire")], + ["components[0]", (copy) => void (copy.components[0].diagnostics = { host: "10.0.0.5" })], + ["components[1]", (copy) => void (copy.components[1].status = "unhealthy")], + ["incidents[0]", (copy) => void (copy.incidents[0].internalNotes = "secret")], + ["incidents[0]", (copy) => void (copy.incidents[0].startedAt = "2026-03-10")], + ["incidents[0]", (copy) => void (copy.incidents[0].resolvedAt = undefined)], + ["maintenance[0]", (copy) => void ((copy.maintenance as Array>)[0].state = "cancelled")], + ["maintenance", (copy) => void (copy.maintenance = {})] + ]; + for (const [path, mutate] of cases) { + const copy = JSON.parse(JSON.stringify(feed)) as FeedJson; + mutate(copy); + expect(validatePublicStatusFeed(copy)).toEqual({ ok: false, code: "invalid_status_feed", detail: path }); + } + for (const bad of [null, undefined, "feed", [], 7]) { + expect(validatePublicStatusFeed(bad)).toEqual({ ok: false, code: "invalid_status_feed", detail: "$" }); + } + }); + }); +}); diff --git a/core/status/feed.ts b/core/status/feed.ts new file mode 100644 index 0000000..dfc37c7 --- /dev/null +++ b/core/status/feed.ts @@ -0,0 +1,285 @@ +/** + * Schema-backed public status feed (#184). + * + * `buildPublicStatusFeed` turns trusted internal state into the published + * feed. It is a pure function of that state and the instant it is given, so a + * feed is reproducible and incident and maintenance states move + * deterministically with time: + * + * - only `public` items are published, and only the schema's own fields are + * copied, so diagnostics and internal notes cannot leak by accident; + * - component ids on an incident or maintenance window are limited to published + * components, so an internal component is not revealed by reference; + * - a maintenance window is `scheduled` before it starts, `in_progress` while it + * runs and `completed` afterwards, from the clock alone; + * - the overall status is the worst of the published components, unresolved + * incidents and running maintenance. + * + * `validatePublicStatusFeed` is the schema. It accepts exactly the published + * shape and nothing else, and `publishPublicStatusFeed` runs it over the + * builder's output so a feed that does not validate is never published. + */ + +import { err, ok, type Result } from "@/core/result/result"; +import type { HealthIndicator, HealthStatus } from "@/core/health/types"; +import { + COMPONENT_STATUSES, + INCIDENT_IMPACTS, + INCIDENT_STATUSES, + MAINTENANCE_STATES, + OVERALL_STATUSES, + PUBLIC_STATUS_SCHEMA_VERSION, + type ComponentStatus, + type IncidentImpact, + type MaintenanceState, + type OverallStatus, + type PublicComponent, + type PublicIncident, + type PublicMaintenance, + type PublicStatusFeed, + type StatusFeedErrorCode, + type TrustedComponent, + type TrustedMaintenance, + type TrustedStatusState +} from "./types"; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** How long a resolved incident or completed maintenance window stays in the feed. */ +export const STATUS_HISTORY_MS = 7 * DAY_MS; + +const COMPONENT_RANK: Record = { + operational: "operational", + degraded: "degraded", + partial_outage: "partial_outage", + major_outage: "major_outage", + under_maintenance: "maintenance" +}; + +const IMPACT_RANK: Record = { + minor: "degraded", + major: "partial_outage", + critical: "major_outage" +}; + +const HEALTH_TO_COMPONENT: Record = { + healthy: "operational", + degraded: "degraded", + unhealthy: "major_outage" +}; + +function byId(a: T, b: T): number { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +function worst(statuses: readonly OverallStatus[]): OverallStatus { + let rank = 0; + for (const status of statuses) rank = Math.max(rank, OVERALL_STATUSES.indexOf(status)); + return OVERALL_STATUSES[rank]; +} + +/** The state of a maintenance window at `nowMs`, from the clock alone. */ +export function maintenanceStateAt(window: Pick, nowMs: number): MaintenanceState { + if (nowMs < Date.parse(window.startsAt)) return "scheduled"; + if (nowMs < Date.parse(window.endsAt)) return "in_progress"; + return "completed"; +} + +/** + * Maps a health indicator to trusted state. The component is internal unless + * the caller names the public id and name to publish it under; the indicator's + * message and details stay in `diagnostics`. + */ +export function componentFromHealthIndicator( + indicator: HealthIndicator, + published?: { readonly id: string; readonly name: string } +): TrustedComponent { + return { + id: published?.id ?? indicator.name, + name: published?.name ?? indicator.name, + status: HEALTH_TO_COMPONENT[indicator.status], + visibility: published ? "public" : "internal", + diagnostics: { message: indicator.message, ...indicator.details } + }; +} + +/** Builds the published feed for the instant `now`. */ +export function buildPublicStatusFeed(state: TrustedStatusState, now: number | Date): PublicStatusFeed { + const nowMs = typeof now === "number" ? now : now.getTime(); + + const windows = state.maintenance + .filter((entry) => entry.visibility === "public" && !entry.cancelled) + .map((entry) => ({ entry, state: maintenanceStateAt(entry, nowMs) })); + const running = windows.filter((window) => window.state === "in_progress"); + + const publicComponents = state.components.filter((component) => component.visibility === "public"); + const publishedIds = new Set(publicComponents.map((component) => component.id)); + const underMaintenance = new Set(running.flatMap((window) => window.entry.componentIds)); + const publishedOnly = (ids: readonly string[]): string[] => [...new Set(ids)].filter((id) => publishedIds.has(id)).sort(); + + const components: PublicComponent[] = publicComponents + .map((component) => ({ + id: component.id, + name: component.name, + status: underMaintenance.has(component.id) ? ("under_maintenance" as const) : component.status + })) + .sort(byId); + + const incidentEntries = state.incidents.filter((incident) => { + if (incident.visibility !== "public") return false; + if (incident.status !== "resolved") return true; + return nowMs - Date.parse(incident.resolvedAt ?? incident.updatedAt) <= STATUS_HISTORY_MS; + }); + const incidents: PublicIncident[] = incidentEntries + .map((incident) => ({ + id: incident.id, + title: incident.title, + summary: incident.summary, + status: incident.status, + impact: incident.impact, + startedAt: incident.startedAt, + updatedAt: incident.updatedAt, + resolvedAt: incident.resolvedAt ?? null, + componentIds: publishedOnly(incident.componentIds) + })) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt) || byId(a, b)); + + const maintenance: PublicMaintenance[] = windows + .filter((window) => window.state !== "completed" || nowMs - Date.parse(window.entry.endsAt) <= STATUS_HISTORY_MS) + .map(({ entry, state: windowState }) => ({ + id: entry.id, + title: entry.title, + startsAt: entry.startsAt, + endsAt: entry.endsAt, + state: windowState, + componentIds: publishedOnly(entry.componentIds) + })) + .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt) || byId(a, b)); + + const status = worst([ + ...components.map((component) => COMPONENT_RANK[component.status]), + ...incidentEntries.filter((incident) => incident.status !== "resolved").map((incident) => IMPACT_RANK[incident.impact]), + ...(running.length > 0 ? (["maintenance"] as const) : []) + ]); + + return { + schemaVersion: PUBLIC_STATUS_SCHEMA_VERSION, + generatedAt: new Date(nowMs).toISOString(), + status, + components, + incidents, + maintenance + }; +} + +type Fields = Record; + +function isRecord(value: unknown): value is Fields { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isIso(value: unknown): value is string { + if (typeof value !== "string") return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === value; +} + +function isText(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 500; +} + +function isOneOf(values: readonly T[], value: unknown): value is T { + return typeof value === "string" && (values as readonly string[]).includes(value); +} + +function isIdList(value: unknown): boolean { + return Array.isArray(value) && value.every(isText); +} + +/** `true` when `value` has exactly `keys`: nothing missing and nothing extra. */ +function hasExactKeys(value: Fields, keys: readonly string[]): boolean { + const own = Object.keys(value); + return own.length === keys.length && keys.every((key) => key in value); +} + +function firstInvalid(items: unknown, check: (item: Fields) => boolean, path: string): string | undefined { + if (!Array.isArray(items)) return path; + const index = items.findIndex((item) => !isRecord(item) || !check(item)); + return index === -1 ? undefined : `${path}[${index}]`; +} + +/** + * Validates a value against the public schema. The error detail is the path of + * the first invalid field, never the offending value. + */ +export function validatePublicStatusFeed(value: unknown): Result { + const invalid = (path: string) => err("invalid_status_feed" as const, path); + if (!isRecord(value)) return invalid("$"); + if (!hasExactKeys(value, ["schemaVersion", "generatedAt", "status", "components", "incidents", "maintenance"])) { + return invalid("$"); + } + if (value.schemaVersion !== PUBLIC_STATUS_SCHEMA_VERSION) return invalid("schemaVersion"); + if (!isIso(value.generatedAt)) return invalid("generatedAt"); + if (!isOneOf(OVERALL_STATUSES, value.status)) return invalid("status"); + + const component = firstInvalid( + value.components, + (item) => + hasExactKeys(item, ["id", "name", "status"]) && + isText(item.id) && + isText(item.name) && + isOneOf(COMPONENT_STATUSES, item.status), + "components" + ); + if (component) return invalid(component); + + const incident = firstInvalid( + value.incidents, + (item) => + hasExactKeys(item, [ + "id", + "title", + "summary", + "status", + "impact", + "startedAt", + "updatedAt", + "resolvedAt", + "componentIds" + ]) && + isText(item.id) && + isText(item.title) && + isText(item.summary) && + isOneOf(INCIDENT_STATUSES, item.status) && + isOneOf(INCIDENT_IMPACTS, item.impact) && + isIso(item.startedAt) && + isIso(item.updatedAt) && + (item.resolvedAt === null || isIso(item.resolvedAt)) && + isIdList(item.componentIds), + "incidents" + ); + if (incident) return invalid(incident); + + const window = firstInvalid( + value.maintenance, + (item) => + hasExactKeys(item, ["id", "title", "startsAt", "endsAt", "state", "componentIds"]) && + isText(item.id) && + isText(item.title) && + isIso(item.startsAt) && + isIso(item.endsAt) && + isOneOf(MAINTENANCE_STATES, item.state) && + isIdList(item.componentIds), + "maintenance" + ); + if (window) return invalid(window); + + return ok(value as unknown as PublicStatusFeed); +} + +/** Builds the feed and publishes it only if it validates against the schema. */ +export function publishPublicStatusFeed( + state: TrustedStatusState, + now: number | Date +): Result { + return validatePublicStatusFeed(buildPublicStatusFeed(state, now)); +} diff --git a/core/status/index.ts b/core/status/index.ts new file mode 100644 index 0000000..2ded46c --- /dev/null +++ b/core/status/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./feed"; diff --git a/core/status/types.ts b/core/status/types.ts new file mode 100644 index 0000000..54049b0 --- /dev/null +++ b/core/status/types.ts @@ -0,0 +1,114 @@ +/** + * Types for the schema-backed public status feed (#184). + * + * Two shapes live here on purpose. The `Trusted*` types are internal state and + * may carry diagnostics. The `Public*` types are the published schema; the feed + * builder copies only their fields, so internal data cannot reach the feed. + */ + +export const PUBLIC_STATUS_SCHEMA_VERSION = "1.0.0"; + +export const COMPONENT_STATUSES = [ + "operational", + "degraded", + "partial_outage", + "major_outage", + "under_maintenance" +] as const; +export const OVERALL_STATUSES = ["operational", "degraded", "maintenance", "partial_outage", "major_outage"] as const; +export const INCIDENT_STATUSES = ["investigating", "identified", "monitoring", "resolved"] as const; +export const INCIDENT_IMPACTS = ["minor", "major", "critical"] as const; +export const MAINTENANCE_STATES = ["scheduled", "in_progress", "completed"] as const; + +export type ComponentStatus = (typeof COMPONENT_STATUSES)[number]; +/** Ordered from best to worst, so a higher index outranks a lower one. */ +export type OverallStatus = (typeof OVERALL_STATUSES)[number]; +export type IncidentStatus = (typeof INCIDENT_STATUSES)[number]; +export type IncidentImpact = (typeof INCIDENT_IMPACTS)[number]; +export type MaintenanceState = (typeof MAINTENANCE_STATES)[number]; + +export type StatusVisibility = "public" | "internal"; + +export interface TrustedComponent { + readonly id: string; + readonly name: string; + readonly status: ComponentStatus; + readonly visibility: StatusVisibility; + /** Internal-only: raw health details, endpoints, error text. Never published. */ + readonly diagnostics?: Readonly>; +} + +export interface TrustedIncident { + readonly id: string; + /** Written for the public. */ + readonly title: string; + readonly summary: string; + readonly status: IncidentStatus; + readonly impact: IncidentImpact; + /** ISO-8601 instants. */ + readonly startedAt: string; + readonly updatedAt: string; + readonly resolvedAt?: string; + readonly componentIds: readonly string[]; + readonly visibility: StatusVisibility; + /** Internal-only: timeline, root cause, mitigation. Never published. */ + readonly internalNotes?: string; +} + +export interface TrustedMaintenance { + readonly id: string; + readonly title: string; + readonly startsAt: string; + readonly endsAt: string; + readonly componentIds: readonly string[]; + readonly visibility: StatusVisibility; + /** A cancelled window is never published. */ + readonly cancelled?: boolean; + /** Internal-only. Never published. */ + readonly internalNotes?: string; +} + +export interface TrustedStatusState { + readonly components: readonly TrustedComponent[]; + readonly incidents: readonly TrustedIncident[]; + readonly maintenance: readonly TrustedMaintenance[]; +} + +export interface PublicComponent { + readonly id: string; + readonly name: string; + readonly status: ComponentStatus; +} + +export interface PublicIncident { + readonly id: string; + readonly title: string; + readonly summary: string; + readonly status: IncidentStatus; + readonly impact: IncidentImpact; + readonly startedAt: string; + readonly updatedAt: string; + readonly resolvedAt: string | null; + readonly componentIds: readonly string[]; +} + +export interface PublicMaintenance { + readonly id: string; + readonly title: string; + readonly startsAt: string; + readonly endsAt: string; + readonly state: MaintenanceState; + readonly componentIds: readonly string[]; +} + +export interface PublicStatusFeed { + readonly schemaVersion: typeof PUBLIC_STATUS_SCHEMA_VERSION; + /** The instant the feed describes. */ + readonly generatedAt: string; + readonly status: OverallStatus; + readonly components: readonly PublicComponent[]; + readonly incidents: readonly PublicIncident[]; + readonly maintenance: readonly PublicMaintenance[]; +} + +export type StatusFeedErrorCode = "invalid_status_feed"; diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 8ea2ab7..96054be 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -51,6 +51,10 @@ cannot disagree about coverage: | `quota.override_revoked` | `core/quota/quota.ts` (`revokeOverride`) | | `projection.replayed` | `core/replay/replay.ts` (`replayProjection`) | | `projection.activated` | `core/replay/replay.ts` (`promoteProjection`, `rollbackProjection`) | +| `authorization.granted` | `core/authorizations/store.ts` (`grant`) | +| `authorization.renewed` | `core/authorizations/store.ts` (`renew`) | +| `authorization.revoked` | `core/authorizations/store.ts` (`revoke`) | +| `authorization.cleaned_up` | `core/authorizations/store.ts` (`cleanup`) | ### Which lifecycle moves are audited diff --git a/docs/AUTHORIZATIONS.md b/docs/AUTHORIZATIONS.md new file mode 100644 index 0000000..525c852 --- /dev/null +++ b/docs/AUTHORIZATIONS.md @@ -0,0 +1,77 @@ +# Authorization expiry, cleanup and renewal + +A delegated permission must not live forever. `core/authorizations/` keeps +authorizations time-boxed and moves them through a small, audited lifecycle. +(Cross-device session revocation is separate; see `core/sessions/`.) + +An authorization says: the **grantor** lets the **grantee** use a **scope** +(e.g. `tickets.redeem`), optionally limited to one **resource**, until +`expiresAt`. + +## States + +State is derived from the record and the clock, never from a flag a job must +remember to flip. + +| State | Meaning | Allows the action | +| --------- | --------------------------------------------------------------------- | ----------------- | +| `active` | Before `expiresAt`, never renewed | yes | +| `renewed` | Before `expiresAt`, renewed at least once | yes | +| `expired` | At or past `expiresAt`; still renewable during the grace period | no | +| `cleaned` | Removed by cleanup after the grace period; a new grant is required | no | +| `revoked` | Withdrawn by the grantor or a maintainer | no | + +`authorize({ grantee, scope, resourceId })` is the check a protected action +makes. An expired authorization is refused the instant it expires, whether or +not cleanup has run. The error says why: `authorization_expired`, +`authorization_revoked`, `authorization_cleaned` or `authorization_not_found`. + +## Cleanup + +- `report()` is read-only. It lists `expired` authorizations, the `cleanable` + ones (expired longer than the grace period, default 7 days) and, with + `expiringWithinMs`, those about to expire. +- `cleanup({ actor })` marks every cleanable authorization `cleaned`. Only a + `maintainer` or `system` actor may run it (`cleanup_unauthorized` otherwise). + It never touches revoked or still-usable authorizations. + +## Renewal + +`renew({ id, actor, confirmed: true, extendByMs })` requires: + +- the **grantor** or a **maintainer** as the actor (`renewal_unauthorized` + otherwise, so the grantee cannot extend its own permission); +- `confirmed: true` (`renewal_not_confirmed` otherwise); +- an authorization that is not revoked or cleaned, and, if expired, still + inside the grace period (`renewal_window_closed` after it). + +The new expiry is the later of now and the current expiry, plus `extendByMs`. +`extendByMs` and a grant's `ttlMs` are capped at 365 days. + +## Audit + +Every grant, renewal, revocation and cleanup is recorded in the audit trail +(see [AUDIT.md](./AUDIT.md)) as `authorization.granted`, `authorization.renewed`, +`authorization.revoked` and `authorization.cleaned_up`, with before and after +state. Refusals are recorded as `denied` events. As in the admin configuration +store, the change is applied only after its audit event is on the trail; if the +trail rejects the event the store returns `authorization_audit_failed` (cleanup +reports the authorization as `skipped`) and changes nothing. + +## Grantor entitlement + +The repository has no per-principal scope entitlement model (`core/auth` only +maps fixed roles to permissions), so the store does not decide what a grantor +may delegate. Pass the optional `canDelegate(grantor, scope, resourceId)` hook +to enforce it: a grant the grantor could not make is refused with +`grant_unauthorized`, a grant without a resource (every resource under the +scope) is reserved for maintainers, and `authorize` returns +`grantor_not_entitled` once the grantor no longer holds the entitlement. Without +the hook, the grantor's entitlement is the caller's responsibility. + +## Scope of this module + +The store is in-memory and takes its clock, id factory and audit recorder as +options. Wiring `authorize` into each protected action, and scheduling +`cleanup`, are done at the call sites and in the worker framework as those paths +land. diff --git a/docs/FEES.md b/docs/FEES.md new file mode 100644 index 0000000..c1b49a8 --- /dev/null +++ b/docs/FEES.md @@ -0,0 +1,72 @@ +# Fee calculation + +`core/fees/` is the one place fee arithmetic lives. The same asset, amount and +schedule always produce the same breakdown, and every fee comes with a line +item that explains how it was reached. + +## Rules + +- **Smallest units, `bigint`.** An amount is a count of the asset's smallest + unit (stroops for a 7-decimal Stellar asset). Nothing passes through `Number`. +- **Per-line rounding.** Each line's rate amount is + `amount * basisPoints / 10000`, rounded **half up** to the smallest unit, + independently of every other line. +- **Minimums.** A line's `minimum` applies to any positive amount and only then. + A zero amount is never charged a minimum. +- **Fees can never exceed the amount.** If the lines add up to more than the + gross amount, the calculation is refused with `fees_exceed_amount`. +- **No hidden inputs.** No clock, randomness or ambient state is read. + +## Using it + +```ts +import { calculateFees, explainFees, parseAssetAmount } from "@/core/fees"; + +const asset = { code: "XLM", decimals: 7 }; +const amount = parseAssetAmount(asset, "100"); // { ok: true, value: 1000000000n } + +const result = calculateFees({ + asset, + amount: amount.value, + schedule: { + lines: [ + { id: "protocol", label: "Protocol fee", basisPoints: 100, minimum: 100_000n }, + { id: "organizer", label: "Organizer fee", basisPoints: 250 } + ] + } +}); + +if (result.ok) { + explainFees(result.value); + // [ + // "Protocol fee: 1.00% of 100.0000000 XLM = 1.0000000 XLM (rounded half up)", + // "Organizer fee: 2.50% of 100.0000000 XLM = 2.5000000 XLM (rounded half up)", + // "Total fees: 3.5000000 XLM", + // "Net: 96.5000000 XLM" + // ] +} +``` + +`calculateFees` has no side effects, so calling it is the preview a user sees +before final submission; submitting stores the same figures. + +## Matching stored settlements + +`reconcileFees(breakdown, stored)` compares a breakdown with the figures a +settlement record stored (`gross`, one amount per line id, `net`) and reports +every field that differs, including a missing or extra line. The seed dataset +(see [SEED_DATA.md](./SEED_DATA.md)) uses it to prove its settlements agree with +the module. + +## Errors + +| Code | Meaning | +| ------------------------- | -------------------------------------------------------------------- | +| `invalid_asset` | The asset code is not 1-12 alphanumeric characters | +| `invalid_asset_precision` | The asset's decimals are not an integer from 0 to 18, or an amount has more fractional digits than the asset allows | +| `invalid_amount` | The amount is negative or the text is not a plain decimal | +| `invalid_fee_schedule` | A duplicate or empty line id, a rate outside 0-10000 bps, or a negative minimum | +| `fees_exceed_amount` | The lines add up to more than the gross amount | + +`parseAssetAmount` refuses extra fractional digits instead of rounding them +away, so a user is never charged against an amount they did not type. diff --git a/docs/PUBLIC_STATUS.md b/docs/PUBLIC_STATUS.md new file mode 100644 index 0000000..acb7d18 --- /dev/null +++ b/docs/PUBLIC_STATUS.md @@ -0,0 +1,77 @@ +# Public status feed + +`core/status/` publishes a machine-readable view of protocol health, incidents +and maintenance windows for external users and maintainers, without exposing +internal diagnostics. + +## Two shapes + +- **Trusted state** (`TrustedStatusState`) is internal. Components may carry + `diagnostics`, incidents and maintenance windows may carry `internalNotes`, + and every item has a `visibility` of `public` or `internal`. +- **The public feed** (`PublicStatusFeed`) is the published schema. The builder + copies only the schema's own fields, so nothing else can leak by accident. + +## Schema (version `1.0.0`) + +```jsonc +{ + "schemaVersion": "1.0.0", + "generatedAt": "2026-03-10T12:00:00.000Z", + "status": "operational | degraded | maintenance | partial_outage | major_outage", + "components": [ + { "id": "api", "name": "Ticket API", + "status": "operational | degraded | partial_outage | major_outage | under_maintenance" } + ], + "incidents": [ + { "id": "inc-1", "title": "...", "summary": "...", + "status": "investigating | identified | monitoring | resolved", + "impact": "minor | major | critical", + "startedAt": "...", "updatedAt": "...", "resolvedAt": "... | null", + "componentIds": ["api"] } + ], + "maintenance": [ + { "id": "mnt-1", "title": "...", "startsAt": "...", "endsAt": "...", + "state": "scheduled | in_progress | completed", "componentIds": ["api"] } + ] +} +``` + +`validatePublicStatusFeed(value)` is the schema: it accepts exactly this shape +(no missing and no extra fields, ISO-8601 instants, known enum values) and +returns `invalid_status_feed` with the path of the first invalid field. +`publishPublicStatusFeed(state, now)` builds the feed and runs it through that +validator, so a feed that does not validate is never published. + +## Deterministic states + +The feed is a pure function of the trusted state and the instant `now`: + +- A maintenance window is `scheduled` before `startsAt`, `in_progress` until + `endsAt`, and `completed` after. Its components show `under_maintenance` + while it runs. Cancelled windows are never published. +- The overall `status` is the worst of the published components, unresolved + incidents (`minor` is degraded, `major` partial outage, `critical` major + outage) and running maintenance. An outage outranks maintenance. +- Resolved incidents and completed windows stay in the feed for 7 days. +- Lists are sorted (components by id, incidents newest first, windows by start), + so identical input always produces an identical feed. + +## What is filtered out + +- Items with `visibility: "internal"`, and anything cancelled. +- `diagnostics` and `internalNotes`, always. +- Internal component ids: incident and window `componentIds` are limited to + published components, so an internal component is not revealed by reference. +- Internal components never move the overall status. + +`componentFromHealthIndicator(indicator, { id, name })` turns an entry of the +operational health summary (see [OPERATIONAL_HEALTH.md](./OPERATIONAL_HEALTH.md)) +into a trusted component. Without a public `id` and `name` the component stays +internal; the indicator's message and details only ever land in `diagnostics`. + +## Serving the feed + +The module returns plain JSON-serializable data. Serving it over HTTP, and +loading incidents and maintenance windows from wherever maintainers record them, +are left to the route or job that calls `publishPublicStatusFeed`. diff --git a/docs/SEED_DATA.md b/docs/SEED_DATA.md new file mode 100644 index 0000000..1534dbd --- /dev/null +++ b/docs/SEED_DATA.md @@ -0,0 +1,69 @@ +# Contributor seed data + +`core/seed/` is a deterministic dataset for local development and tests. It +needs no production credentials, network or clock, and every address is a +`G_SEED_*` placeholder rather than a real Stellar key, so it is safe to commit +and to load anywhere. + +## What it contains + +Relative to one fixed instant, `SEED_NOW` (2026-06-01T00:00:00Z): + +| Records | Edge cases covered | +| ---------------- | ----------------------------------------------------------------------------------- | +| 5 events | active, paused, sold out, completed (historical) and cancelled | +| 10 tickets | every status: issued, transferred, locked, redeemed, expired, burned | +| 2 transfers | one recent, one over a year old (historical) | +| 3 settlements | two settled, and `settle-seed-failed`, which moved no money and left the ticket with its seller | +| 5 authorizations | active, renewed, expired (still renewable), cleaned and revoked | +| 3 workflows | on time, completed, and `wf-seed-overdue` (a refund review past its due date) | + +Revoked access appears twice: a fraud-locked ticket owned by a blacklisted +account, and a revoked delegated authorization. + +Fees in the settlements were computed with the fee module (see +[FEES.md](./FEES.md)) using a 1% protocol fee with a 0.01 XLM minimum plus each +event's organizer fee. + +## Loading and resetting + +```ts +import { createSeedDataset, validateSeedDataset } from "@/core/seed"; + +const dataset = createSeedDataset(); // fresh, independent copy +validateSeedDataset(dataset); // [] when the records agree +``` + +`dataset.state` is a `DomainState` (see `core/invariants`), so it can be passed +to `DomainInvariantChecker` or the invariant-checked mutations directly. + +Nothing is written anywhere. **Resetting is calling `createSeedDataset()` again**: +every call returns a pristine copy, and mutating one never affects another. + +## Validation + +```bash +npm run verify:seed +``` + +runs `core/seed/__tests__/seed.test.ts`, which loads the dataset and confirms it +is internally consistent. `validateSeedDataset` returns every inconsistency as an +issue with a code, an entity id and a message. It checks that: + +- the domain invariants hold (`DomainInvariantChecker.verifyAll`); +- every address is a `G_SEED_*` placeholder; +- tickets reference real events and each event's `issuedCount` matches its tickets; +- every transfer has exactly one matching settled settlement, and every settled + settlement has its transfer; +- a failed settlement has no transfer and states why; +- every settlement's stored fees equal the fee module's breakdown; +- authorizations are internally consistent (resources exist, revoked and cleaned + records carry their timestamps, cleaned ones were past the renewal grace period); +- workflows reference real tickets and have coherent dates; +- nothing is dated after `SEED_NOW`. + +## Extending it + +Add records as literals in `core/seed/data.ts`, keep them relative to +`SEED_NOW`, use `G_SEED_*` placeholders, and run `npm run verify:seed`. A +settlement's fees should be computed with the fee module, not hand-estimated. diff --git a/package.json b/package.json index 5f8ed55..5f05cbc 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "verify:replay-concurrency": "vitest run core/replay/__tests__/replay.deterministic.test.ts --reporter=verbose", "new:feature": "node scripts/new-feature.mjs", "postinstall": "node scripts/generate-registry.mjs", + "verify:seed": "vitest run core/seed", "verify:fixtures": "vitest run core/testing/__tests__/fixtures.contract.test.ts --reporter=verbose", "verify:readiness": "node scripts/release-readiness-gate.mjs", "check": "npm run lint && npm run test && npm run verify:features && npm run verify:boundaries && npm run verify:issues && npm run verify:telemetry && npm run verify:reconciliation && npm run verify:audit && npm run verify:ledger && npm run verify:readiness && npm run verify:fixtures && npm run build", diff --git a/scripts/verify-audit.mjs b/scripts/verify-audit.mjs index 1234b9d..619dc3b 100644 --- a/scripts/verify-audit.mjs +++ b/scripts/verify-audit.mjs @@ -51,7 +51,11 @@ const boundaries = [ { file: "core/quota/quota.ts", emitter: "quota.override_revoked" }, { file: "core/replay/replay.ts", emitter: "projection.replayed" }, { file: "core/replay/replay.ts", emitter: "projection.activated" }, - { file: "core/admin-config/store.ts", emitter: "admin.config_changed" } + { file: "core/admin-config/store.ts", emitter: "admin.config_changed" }, + { file: "core/authorizations/store.ts", emitter: "authorization.granted" }, + { file: "core/authorizations/store.ts", emitter: "authorization.renewed" }, + { file: "core/authorizations/store.ts", emitter: "authorization.revoked" }, + { file: "core/authorizations/store.ts", emitter: "authorization.cleaned_up" } ]; const sources = new Map();