// The account self-service procedures: export my data, and delete my account (joint contract §12). // // All three are user-scoped: they take no organization and no id, so the only account they reach is // the signed-in person's own, and an organization's API key cannot call them at all (the kernel // refuses a user scope to a pinned credential). The export and the deletion are `effect`s because // every installed module contributes its share through kit-tenancy's `tenancy.account` point, and // the shares of guides and support are calls to the platform. import { procedure } from "@conusai-kit/core" import { z } from "zod" import { ACCOUNT_EXPORT_FORMAT } from "./constants.js" import { DELETION_STATES } from "./entities.js" /** * One module's share of an export. `failed` and `not_exported` are explicit: an export never * silently leaves a module out (joint contract §12). `data: null` is a module that holds nothing * about the person in this deployment (its service offline or not configured). */ export const exportSection = z.discriminatedUnion("status", [ z.object({ status: z.literal("ok"), data: z.record(z.string(), z.unknown()).nullable() }), z.object({ status: z.literal("failed"), code: z.string(), retryable: z.boolean() }), z.object({ status: z.literal("not_exported") }), ]) export type ExportSection = z.output export const accountExport = z.object({ format: z.literal(ACCOUNT_EXPORT_FORMAT), exportedAt: z.string(), /** The app user id every section is keyed by. */ subject: z.string(), /** Every section exported. False names the sections in `failed` (or one with `not_exported`). */ complete: z.boolean(), failed: z.array(z.string()), sections: z.record(z.string(), exportSection), }) export type AccountExport = z.output export const accountDataExport = procedure("account.data.export") .kind("effect") .scope("user") .permission("none") .rateLimit("account") // An empty object: what a browser's JSON call sends for "no input" (`{}`), refused with anything in it. .input(z.object({}).strict()) .output(accountExport) .contract() const organizationRef = z.object({ slug: z.string(), name: z.string() }) export const deletionView = z.object({ state: z.enum(DELETION_STATES), erased: z.array(z.string()), pending: z.array(z.string()), abandoned: z.array(z.string()), requestedAt: z.string(), completedAt: z.string().nullable(), }) export type DeletionView = z.output export const deletionStatusOutput = z.object({ /** A deletion already requested for this account, or null. */ deletion: deletionView.nullable(), /** A person signed in to this app (not an API credential): the only caller a deletion accepts. */ interactive: z.boolean(), /** The session's sign-in is recent enough to delete the account without signing in again. */ recentSignIn: z.boolean(), signedInAt: z.string().nullable(), recentSignInSeconds: z.number().int().positive(), /** Organizations whose last owner this person is while others are members: they block a deletion. */ lastOwnerOf: z.array(organizationRef), /** Organizations this person is the only member of: a deletion suspends them. */ onlyMemberOf: z.array(organizationRef), /** The modules whose data a deletion erases, besides this app's own rows. */ modules: z.array(z.string()), }) export type DeletionStatus = z.output export const accountDeletionStatus = procedure("account.deletion.status") .kind("read") .scope("user") .permission("none") .output(deletionStatusOutput) .contract() /** * Deletes the signed-in person's account. The person confirms by typing the account's e-mail address, * from a session whose sign-in is recent (RECENT_SIGN_IN_SECONDS). Calling it again for an account * whose deletion has begun resumes that deletion and answers its state, whatever the input. */ export const accountDeletionRequest = procedure("account.deletion.request") .kind("effect") .scope("user") .permission("none") .rateLimit("account") .input(z.object({ confirm: z.string().min(1).max(320) })) .output(deletionView) .contract() // The platform's analytics and observability data about the signed-in person (ADR-049): what the // app's visitors leave in the platform's own stores (replay, errors, proxy logs, traces, logs, // analytics), erased or exported by one platform job. The browser never sees the deployment token // or the platform's request id in a form it could swap for another person's: it holds a `ticket`, // the request id signed for this user by the server (core/analytics.ts). export const ANALYTICS_REQUEST_KINDS = ["erase", "export"] as const export const ANALYTICS_STATUSES = ["pending", "running", "completed", "failed", "expired"] as const export const analyticsStore = z.object({ status: z.enum(["completed", "not_keyed", "failed"]), deleted: z.number().int().nonnegative(), anonymized: z.number().int().nonnegative(), exported: z.number().int().nonnegative(), }) export const analyticsView = z.object({ ticket: z.string(), kind: z.enum(ANALYTICS_REQUEST_KINDS), status: z.enum(ANALYTICS_STATUSES), /** False when the platform answered the job already open for the same kind and person. */ created: z.boolean(), stores: z.record(z.string(), analyticsStore), requestedAt: z.string(), finishedAt: z.string().nullable(), /** Exports only: when the platform deletes the export document. */ expiresAt: z.string().nullable(), }) export type AnalyticsView = z.output const ticketInput = z.object({ ticket: z.string().min(1).max(512) }).strict() /** Starts the erasure or the export of the person's platform data, or answers the one already open. */ export const accountAnalyticsRequest = procedure("account.analytics.request") .kind("effect") .scope("user") .permission("none") .rateLimit("account") .input(z.object({ kind: z.enum(ANALYTICS_REQUEST_KINDS) }).strict()) .output(analyticsView) .contract() /** The state of a request this person started, by its ticket. */ export const accountAnalyticsStatus = procedure("account.analytics.status") .kind("effect") .scope("user") .permission("none") .input(ticketInput) .output(analyticsView) .contract() /** A completed export's document, by its ticket; a conflict until the export completed. */ export const accountAnalyticsExport = procedure("account.analytics.export") .kind("effect") .scope("user") .permission("none") .rateLimit("account") .input(ticketInput) .output(z.object({ exportedAt: z.string(), document: z.record(z.string(), z.unknown()) })) .contract()