114 lines
5.7 KiB
TypeScript
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])
|