Files

159 lines
6.5 KiB
TypeScript

// 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<typeof exportSection>
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<typeof accountExport>
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<typeof deletionView>
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<typeof deletionStatusOutput>
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<typeof analyticsView>
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()