This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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()
|
||||
Reference in New Issue
Block a user