Files

114 lines
5.7 KiB
TypeScript

// 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 `<module>:<problem code>`: 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 `<module>:<problem code>`. */
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])