// The one table of the account module: the deletion ledger. // // A row is written before anything of the account is removed, and it outlives the user row: it is // the erasure reference the joint contract (§12) asks a deletion to keep, so a module whose erasure // was unavailable is erased later by the same subject, and a deletion interrupted at any step resumes // from the row rather than from the person, who can no longer sign in. It holds the app user id (an // opaque id) and module ids, never an e-mail address, a name or a provider subject. // // `global` in kit-data's sense: an account belongs to no organization. import { defineEntity, idSchema, instantSchema } from "@conusai-kit/data" import { z } from "zod" export const DELETION_STATES = ["erasing", "completed"] as const export type DeletionState = (typeof DELETION_STATES)[number] /** * The steps of a deletion before the modules' erasures, in their order (core/deletion.ts). The ledger * records each one when it completed, so a resumption -- the person's retry or the worker's sweep -- * runs exactly the steps still to do, and never one it already finished: * * credentials every session and API key of the person revoked * service-sessions every module's derived service sessions revoked (a module that could not be * reached leaves it open; it does not hold the deletion up) * provider-account the account ended at the app's external identity provider (Better Auth), when * the deletion recorded one in `provider` * identity the identity mapping detached, and the sessions revoked once more */ export const DELETION_STEPS = ["credentials", "service-sessions", "provider-account", "identity"] as const export type DeletionStep = (typeof DELETION_STEPS)[number] const moduleIds = z.array(z.string().min(1).max(64)).max(64) export const accountDeletions = defineEntity({ name: "accountDeletion", table: "account_deletion", tenancy: "global", fields: { /** The app user id: the subject every module's erasure is keyed by. */ userId: idSchema, state: z.enum(DELETION_STATES), /** The modules whose erasure has not been confirmed yet. */ pending: moduleIds, /** The modules that confirmed their erasure. */ erased: moduleIds, /** Modules that were removed from the app before their erasure ran: an operator erases them by `userId`. */ abandoned: moduleIds, /** Attempts that left a module pending. */ attempts: z.number().int().nonnegative(), /** The last refusal, as `:`: no detail, which could carry a person's data. */ lastFailure: z.string().max(200).nullable(), requestedAt: instantSchema, completedAt: instantSchema.nullable(), /** The steps of DELETION_STEPS that completed, in the order they did; null for a row written before steps were recorded. */ steps: z.array(z.enum(DELETION_STEPS)).max(DELETION_STEPS.length).nullable(), /** * The external identity provider whose account this deletion ends (`convex-better-auth`), or null: * the provider bound where the deletion began. A resumption that has no such provider bound waits * at that step rather than detach the identity it needs to find the account by. */ provider: z.string().min(1).max(64).nullable(), }, unique: [["userId"]], indexes: [["state"]], }) export const REVOCATION_STATES = ["pending", "delivered", "lapsed"] as const export type RevocationState = (typeof REVOCATION_STATES)[number] /** * The revocation outbox: one row per revoked app session (a sign-out, a refused refresh, an expiry) * whose derived service sessions -- a Support widget session minted for that device -- the installed * modules still have to end on their platform (joint contract §14 "Lifecycle, CSP and revocation"). * * Written when kit-auth reports the revocation, before its session row is marked revoked, and * delivered at once; a module that could not be reached stays `pending`, and the worker's revocation * job retries it with backoff (REVOCATION_JOB) until its platform confirms, or until no session it * minted can still be alive (`lapsed`, DERIVED_SESSION_LIFETIME_MS after the request). A claim * (`claimedUntil`, written with the row's version) keeps two processes from delivering one row at once. * * It holds the app session's opaque reference (kit-auth `sessionRefKey`: its provider label and row * id, never a token or cookie) and module ids: nothing about the person. `global`: a session belongs * to no organization. */ export const accountSessionRevocations = defineEntity({ name: "accountSessionRevocation", table: "account_session_revocation", tenancy: "global", fields: { /** The revoked app session's opaque reference (kit-auth `sessionRefKey`). */ session: z.string().min(1).max(512), state: z.enum(REVOCATION_STATES), /** The modules whose platform has not confirmed the revocation yet. */ pending: moduleIds, /** The modules whose platform confirmed it. */ delivered: moduleIds, /** Delivery passes that left a module pending. */ attempts: z.number().int().nonnegative(), /** When the next delivery pass is due. */ nextAttemptAt: instantSchema, /** A delivery pass holds the row until then; another process leaves it alone. */ claimedUntil: instantSchema.nullable(), /** The last refusal, as `:`. */ lastFailure: z.string().max(200).nullable(), requestedAt: instantSchema, completedAt: instantSchema.nullable(), }, unique: [["session"]], indexes: [["state", "nextAttemptAt"]], }) /** Every table this module owns, in the order a migration creates them. */ export const ACCOUNT_ENTITIES = Object.freeze([accountDeletions, accountSessionRevocations])