159 lines
6.5 KiB
TypeScript
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()
|