Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
-- CreateEnum
CREATE TYPE "DocumentSigningMode" AS ENUM ('threshold', 'parties');

-- CreateEnum
CREATE TYPE "ContractFieldKind" AS ENUM ('signature', 'initials', 'date', 'text', 'checkbox');

-- CreateEnum
CREATE TYPE "SignatureMethod" AS ENUM ('cip8Wallet', 'ausweisApp', 'eudiWallet');

-- AlterTable
ALTER TABLE "Document" ADD COLUMN "signingMode" "DocumentSigningMode" NOT NULL DEFAULT 'threshold';

-- AlterTable
ALTER TABLE "DocumentReview" ADD COLUMN "method" "SignatureMethod" NOT NULL DEFAULT 'cip8Wallet',
ADD COLUMN "partyId" TEXT;

-- CreateTable
CREATE TABLE "ContractParty" (
"id" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"email" TEXT,
"address" TEXT,
"signingOrder" INTEGER NOT NULL DEFAULT 0,
"inviteTokenHash" TEXT,
"invitedAt" TIMESTAMP(3),
"inviteExpiresAt" TIMESTAMP(3),
"inviteConsumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ContractParty_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "ContractField" (
"id" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"partyId" TEXT NOT NULL,
"kind" "ContractFieldKind" NOT NULL,
"label" TEXT,
"anchor" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT true,
"value" TEXT,
"filledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ContractField_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ContractParty_inviteTokenHash_key" ON "ContractParty"("inviteTokenHash");

-- CreateIndex
CREATE INDEX "ContractParty_documentId_idx" ON "ContractParty"("documentId");

-- CreateIndex
CREATE INDEX "ContractParty_address_idx" ON "ContractParty"("address");

-- CreateIndex
CREATE INDEX "ContractParty_inviteExpiresAt_idx" ON "ContractParty"("inviteExpiresAt");

-- CreateIndex
CREATE UNIQUE INDEX "ContractParty_documentId_address_key" ON "ContractParty"("documentId", "address");

-- CreateIndex
CREATE INDEX "ContractField_partyId_idx" ON "ContractField"("partyId");

-- CreateIndex
CREATE UNIQUE INDEX "ContractField_documentId_anchor_key" ON "ContractField"("documentId", "anchor");

-- CreateIndex
CREATE INDEX "DocumentReview_partyId_idx" ON "DocumentReview"("partyId");

-- CreateIndex
CREATE UNIQUE INDEX "DocumentReview_versionId_partyId_key" ON "DocumentReview"("versionId", "partyId");

-- AddForeignKey
ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE NO ACTION ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContractParty" ADD CONSTRAINT "ContractParty_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE CASCADE ON UPDATE CASCADE;


-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest
-- and the per-table blocks in 20260805090000_add_document_signoff: RLS on
-- unconditionally, deny-all policies for the PostgREST roles when those roles
-- exist, Prisma continues to bypass as the table owner / service role.
--
-- In this migration rather than a follow-up because ContractParty holds the only
-- identifiable third-party data in the document stack (a counterparty's email),
-- and because 20260823090000 is the precedent for what happens when it is
-- forgotten: two tables shipped with RLS off and needed a second migration.
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN
SELECT unnest(ARRAY['ContractParty', 'ContractField'])
LOOP
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);

IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
EXECUTE format(
'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)',
tbl, tbl
);
END IF;

IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
EXECUTE format(
'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)',
tbl, tbl
);
END IF;
END IF;
END LOOP;
END $$;
190 changes: 190 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,13 @@ model Document {
events DocumentEvent[]
draft DocumentDraft?

/// Which rule decides this document's rounds. Defaulting to `threshold` is
/// what makes this migration a no-op for every document that already exists.
signingMode DocumentSigningMode @default(threshold)

parties ContractParty[]
contractFields ContractField[]

@@index([walletId])
@@index([walletId, status])
@@index([createdBy])
Expand Down Expand Up @@ -524,9 +531,35 @@ model DocumentReview {
signedAt DateTime // client-asserted, server-validated against a window
createdAt DateTime @default(now())

/// The party this signature was made as; null in threshold mode, where the
/// signer is a wallet signer and signs in no particular capacity.
///
/// NoAction, not Prisma's default. The default for an optional relation is
/// SetNull, which would silently strip the capacity off an already-signed,
/// already-exported review on an append-only table. Cascade is worse: it
/// deletes the signature itself, with none of deleteDocument's retype-the-
/// title guard or its pre-delete AuditLog row, and would revert a fully
/// executed contract to InReview on the next recount. NoAction is checked at
/// the end of the statement, so deleting a whole document still cascades
/// cleanly while a party who has signed cannot be deleted on their own.
partyId String?
party ContractParty? @relation(fields: [partyId], references: [id], onDelete: NoAction)

/// How this signature was produced. Only `cip8Wallet` is written today.
///
/// This column is not yet part of the signed bytes, and until it is it is an
/// index rather than evidence — see the party-signing work. Nothing but the
/// default is written while parties mode is unimplemented, so there is
/// nothing to misrepresent yet.
method SignatureMethod @default(cip8Wallet)

@@unique([versionId, signerAddress])
/// One review per party per version. NULLs are distinct in Postgres, so every
/// existing threshold-mode row (partyId null) is unaffected.
@@unique([versionId, partyId])
@@index([versionId])
@@index([signerAddress])
@@index([partyId])
}

// The signer set and threshold that applied when the round started. Frozen so
Expand Down Expand Up @@ -721,3 +754,160 @@ model DocumentDraft {
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}

// ---------------------------------------------------------------------------
// Contracts — named parties on top of Document Sign-Off.
//
// Sign-off is M-of-N over a wallet's own signers. A contract is N-of-N over
// named parties who are usually NOT members of the wallet. The roster lives on
// the Document so it survives a re-issue; everything per-round stays where it
// already is — frozen in DocumentSignerSnapshot, proved by DocumentReview — so
// the two can never drift.
// ---------------------------------------------------------------------------

/// Which rule decides a version's outcome.
enum DocumentSigningMode {
/// M-of-N over the wallet's signers, read from the Wallet row at startReview.
threshold
/// Every named party signs. startReview builds the snapshot from the parties
/// with requiredSigners = the party count, so `evaluateThreshold` yields
/// N-of-N with no second evaluator and no change to the verifier.
parties
}

/// What a party is expected to put at an anchor in the body.
enum ContractFieldKind {
signature
initials
date
text
checkbox
}

/// How a signature was produced. Only `cip8Wallet` is implemented; the others
/// exist now because adding an enum value later is its own migration, and
/// migrations reach production through an action that does not self-retry.
///
/// Note these describe two different things that will need separating: how the
/// bytes were signed, and how the human was identified. AusweisApp and EUDI are
/// identification protocols — a signature made after an eID check may still be
/// a CIP-8 one.
enum SignatureMethod {
/// CIP-8 COSE_Sign1 from a Cardano wallet, including utxos.dev passkey wallets.
cip8Wallet
/// German eID. Not implemented.
ausweisApp
/// EUDI wallet. Not implemented.
eudiWallet
}

/// A named party to a contract: who they are, in what capacity they sign, and
/// the invite that lets a non-member of the wallet reach the review at all.
///
/// Deliberately holds NO per-round state — no status, no viewedAt, no
/// decidedAt. `uploadVersion` and `publishDraft` supersede the version and
/// reset approvals to zero without touching this row, so a per-round column
/// here would survive a reset it has no business surviving: an ordering gate
/// reading a stale "signed" would wave through a countersignature on a version
/// the first party has never seen. Whether a party signed is not an opinion
/// this table stores — it is the existence of a DocumentReview carrying their
/// signature over the canonical payload.
model ContractParty {
id String @id @default(cuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

/// Free text, because every contract names its parties differently ("Buyer",
/// "Lessee", "Witness"). Copied with displayName into the frozen snapshot's
/// signersDescriptions at startReview — that is how the capacity a signature
/// was made in reaches the exported proof without a proof-format bump.
role String
displayName String
/// Where the invite is sent. The only identifiable third-party data in the
/// document stack, which is why this table gets its RLS block in the same
/// migration rather than a follow-up.
email String?

/// Null until the party redeems their invite and connects a wallet. A round
/// cannot start while any party is null here: the snapshot freezes addresses
/// and the signed payload binds signerAddress, so a party with no address at
/// startReview could never act on that version.
address String?

/// Parties with equal values sign in parallel; a party may act only once
/// every party with a strictly lower value has a review on the version being
/// signed. Enforced in submitSignerAction — it is a rule about rows in
/// another table, so no constraint here can express it.
signingOrder Int @default(0)

/// sha256 of a single-use invite token; the token itself is never stored.
/// This is what makes the feature reachable at all — a party is not in
/// wallet.signersAddresses, so assertWalletAccess rejects them.
inviteTokenHash String? @unique

invitedAt DateTime?
/// Expiry and consumption, mirroring EmailVerificationToken and
/// BotClaimToken. Without them the invite is a bearer credential valid
/// forever: whoever holds the link last claims the party slot, so a mistyped
/// or later-compromised mailbox could bind a stranger's wallet to "Buyer".
inviteExpiresAt DateTime?
inviteConsumedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

fields ContractField[]
reviews DocumentReview[]

/// One wallet address holds at most one role on a document. Two parties
/// sharing an address is not a collision, it is an unrecoverable round:
/// approvals cap at N-1 so Approved is unreachable, while rejections never
/// make the threshold unreachable either. Relaxing DocumentReview's address
/// unique does not help — the proof verifier dedupes on signerAddress and
/// would report the finished contract as invalid. NULL addresses stay
/// distinct in Postgres, so any number of un-redeemed parties coexist.
@@unique([documentId, address])
@@index([documentId])
@@index([address])
@@index([inviteExpiresAt])
}

/// A per-party placeholder in the body: which anchor belongs to whom, and what
/// kind of thing goes there.
///
/// `value` is PRE-PUBLISH input only. A field is a term of the contract, and
/// the whole model rests on a signature binding an exact contentHash — so
/// values must be rendered into the body by publishDraft and hashed with it. A
/// value left live after the freeze would be an unsigned, mutable contract
/// term: change 50000 to 5000 after signing and every signature still verifies.
model ContractField {
id String @id @default(cuid())

/// Denormalised from the party so anchor uniqueness can be enforced across
/// the whole body, which spans parties. Two fields on one anchor are
/// unresolvable for the renderer, and ContractField has no other path to the
/// document. Same pattern DocumentAttestation uses for documentId.
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

partyId String
party ContractParty @relation(fields: [partyId], references: [id], onDelete: Cascade)

kind ContractFieldKind
label String?
/// Stable marker in the body that the renderer resolves.
anchor String
required Boolean @default(true)

/// Pre-publish input. publishDraft renders these into the markdown body
/// before hashing, so contentHash covers them; nothing may write here once
/// the document's latest version has a snapshot.
value String?
filledAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([documentId, anchor])
@@index([partyId])
}
Loading