This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
// The account module end to end over the memory adapter: the export composes every installed
|
||||
// module's share and names a failed one; the deletion keeps the erasure reference before anything is
|
||||
// removed, revokes sessions first, is idempotent and resumable, and survives a module's removal.
|
||||
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth"
|
||||
import { AppError, buildRegistry } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import {
|
||||
accountDataOf,
|
||||
appUserIdentities,
|
||||
findAppUser,
|
||||
organizations,
|
||||
orgMemberships,
|
||||
requireAppUser,
|
||||
users,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { AccountExport, DeletionStatus, DeletionView } from "../index.js"
|
||||
import {
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
accountDeletions,
|
||||
RECENT_SIGN_IN_SECONDS,
|
||||
recordProviderAccountEvent,
|
||||
} from "../index.js"
|
||||
import {
|
||||
failingSessions,
|
||||
flakyModule,
|
||||
handleHashOf,
|
||||
ISSUER,
|
||||
makeApp,
|
||||
modulesWithout,
|
||||
newFlaky,
|
||||
type Person,
|
||||
} from "./harness.js"
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const withFlaky = (flaky = newFlaky()) => ({ flaky, app: makeApp({ modules: [...appModules, flakyModule(flaky)] }) })
|
||||
|
||||
/** The modules of this app that erase something besides its own rows, as the registry has them. */
|
||||
const erasing = (registry: Parameters<typeof accountDataOf>[0]) =>
|
||||
accountDataOf(registry)
|
||||
.filter((contribution) => contribution.erase)
|
||||
.map((contribution) => contribution.key)
|
||||
|
||||
async function orgOf(person: Person, slug: string): Promise<string> {
|
||||
const created = (await person.call().callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
||||
return created.orgId
|
||||
}
|
||||
|
||||
const exportOf = (person: Person) => person.call().callByName("account.data.export", {}) as Promise<AccountExport>
|
||||
const statusOf = (person: Person) =>
|
||||
person.call().callByName("account.deletion.status", undefined) as Promise<DeletionStatus>
|
||||
const deleteAs = (person: Person, confirm = person.email) =>
|
||||
person.call().callByName("account.deletion.request", { confirm }) as Promise<DeletionView>
|
||||
|
||||
const problemOf = async (run: Promise<unknown>) => {
|
||||
const error = await run.then(
|
||||
() => undefined,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
return (error as AppError).problem
|
||||
}
|
||||
|
||||
describe("account.data.export", () => {
|
||||
it("is one section per installed module's share, this app's own rows among them, and complete", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await orgOf(ada, "acme")
|
||||
const exported = await exportOf(ada)
|
||||
|
||||
expect(Object.keys(exported.sections).sort()).toEqual(
|
||||
accountDataOf(app.registry)
|
||||
.map((c) => c.key)
|
||||
.sort(),
|
||||
)
|
||||
expect(exported).toMatchObject({
|
||||
format: "conusai.account-export.v1",
|
||||
subject: ada.actor.userId,
|
||||
complete: true,
|
||||
failed: [],
|
||||
})
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: {
|
||||
profile: { userId: ada.actor.userId, email: "ada@example.test" },
|
||||
identities: [{ issuer: ISSUER, subject: ada.subject }],
|
||||
organizations: [{ slug: "acme", role: "owner" }],
|
||||
},
|
||||
})
|
||||
expect(exported.sections.flaky).toEqual({ status: "ok", data: { subject: ada.actor.userId, items: 1 } })
|
||||
// No credential of the person is in it: not the session handle's hash, not the sealed tokens.
|
||||
const text = JSON.stringify(exported)
|
||||
expect(text).not.toContain(handleHashOf(ada.email))
|
||||
expect(text).not.toContain("sealed-tokens-of-the-test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("includes the guides module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.guides).toEqual({
|
||||
status: "ok",
|
||||
data: { end_user: null, contexts: [], progress: [], events: [] },
|
||||
})
|
||||
expect(app.guides.calls).toEqual([{ method: "exportEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("includes the support module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.support).toMatchObject({ status: "ok", data: { processing_purposes: ["support"] } })
|
||||
expect(app.support.calls).toEqual([{ method: "exportContact", subject: { subject: ada.actor.userId } }])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("reports a module whose export failed, by name, instead of leaving it out", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.exportFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.complete).toBe(false)
|
||||
expect(exported.failed).toEqual(["flaky"])
|
||||
expect(exported.sections.flaky).toEqual({ status: "failed", code: "flaky_unavailable", retryable: true })
|
||||
// The other sections are still there.
|
||||
expect(exported.sections.account?.status).toBe("ok")
|
||||
})
|
||||
|
||||
it("is the person's own: an API credential acting as them is refused", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const problem = await problemOf(ada.call({ interactive: false }).callByName("account.data.export", {}))
|
||||
expect(problem.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the modules' shares of an account are server-internal", () => {
|
||||
// The account procedures refuse an API credential (above); the modules' own export and erasure are
|
||||
// reachable only through the account flow (tenancy.account), never as procedures a bearer token,
|
||||
// an API key or even the person's browser could call by name.
|
||||
const hooks: string[] = []
|
||||
hooks.push("guides.account.export", "guides.account.erase") // kit:guides
|
||||
hooks.push("support.account.export", "support.account.erase") // kit:support
|
||||
|
||||
it("answers no procedure of that name to a bearer or API credential, or to the person", async () => {
|
||||
const app = makeApp()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
for (const name of hooks) {
|
||||
expect(app.registry.procedures.has(name), name).toBe(false)
|
||||
for (const credential of [{ interactive: false }, { interactive: true, sessionId: ada.sessionId }]) {
|
||||
const problem = await problemOf(ada.call(credential).callByName(name, undefined))
|
||||
expect(problem.status, `${name} as ${credential.interactive ? "the person" : "a bearer"}`).toBe(404)
|
||||
}
|
||||
}
|
||||
expect(app.guides.calls).toEqual([]) // kit:guides
|
||||
expect(app.support.calls).toEqual([]) // kit:support
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.status", () => {
|
||||
it("names the modules a deletion erases, a recent sign-in, and the organizations it affects", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
await orgOf(ada, "solo")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const status = await statusOf(ada)
|
||||
expect(status).toMatchObject({
|
||||
deletion: null,
|
||||
interactive: true,
|
||||
recentSignIn: true,
|
||||
recentSignInSeconds: RECENT_SIGN_IN_SECONDS,
|
||||
lastOwnerOf: [{ slug: "shared" }],
|
||||
onlyMemberOf: [{ slug: "solo" }],
|
||||
modules: erasing(app.registry),
|
||||
})
|
||||
expect(status.modules).toContain("flaky")
|
||||
expect(status.modules).not.toContain("account")
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: what it refuses", () => {
|
||||
const untouched = async (app: ReturnType<typeof makeApp>, person: Person) => {
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect((await app.data.global(sessions).get(person.sessionId))?.revokedAt).toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: person.subject })).not.toBeNull()
|
||||
}
|
||||
|
||||
it("an address that is not the account's", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await problemOf(deleteAs(ada, "grace@example.test"))).code).toBe("confirmation_mismatch")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a sign-in older than the recent-sign-in window, until the person signs in again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
expect((await statusOf(ada)).recentSignIn).toBe(false)
|
||||
expect((await problemOf(deleteAs(ada))).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a credential with no person behind it, and a session of somebody else", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const asKey = ada.call({ interactive: false }).callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(asKey)).status).toBe(403)
|
||||
const borrowed = ada
|
||||
.call({ interactive: true, sessionId: grace.sessionId })
|
||||
.callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(borrowed)).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("the last owner of an organization others belong to", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const problem = await problemOf(deleteAs(ada))
|
||||
expect(problem).toMatchObject({ status: 409, code: "last_owner" })
|
||||
expect(problem.detail).toContain("shared")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: the order", () => {
|
||||
it("keeps the erasure reference and revokes every session before any module erases, and the user row until all did", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const orgId = await orgOf(ada, "solo")
|
||||
const key = (await ada.call().callByName("auth.apiKey.create", { org: "solo", name: "ci" })) as {
|
||||
key: { id: string }
|
||||
}
|
||||
// A second device of the same person.
|
||||
const first = await app.data.global(sessions).require(ada.sessionId)
|
||||
const second = await app.data.global(sessions).insert({
|
||||
userId: first.userId,
|
||||
issuer: first.issuer,
|
||||
subject: first.subject,
|
||||
handleHash: handleHashOf("second-device"),
|
||||
tokens: first.tokens,
|
||||
accessTokenExpiresAt: first.accessTokenExpiresAt,
|
||||
lastSeenAt: first.lastSeenAt,
|
||||
absoluteExpiresAt: first.absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
})
|
||||
|
||||
const seen: Record<string, unknown>[] = []
|
||||
flaky.onErase = async (subject) => {
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
seen.push({
|
||||
subject,
|
||||
ledger: ledger.map((row) => ({ userId: row.userId, state: row.state, pending: row.pending })),
|
||||
sessionsRevoked: (await app.data.global(sessions).list({})).items.every((row) => row.revokedAt !== null),
|
||||
keyRevoked:
|
||||
(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(apiKeys).get(key.key.id))
|
||||
?.revokedAt !== null,
|
||||
mapping: (await app.data.global(appUserIdentities).list({})).items.length,
|
||||
signIn: await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject }),
|
||||
userRow: (await app.data.global(users).get(ada.actor.userId))?.email ?? null,
|
||||
member: (await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()) === 1,
|
||||
})
|
||||
}
|
||||
|
||||
const view = await deleteAs(ada)
|
||||
expect(view).toMatchObject({ state: "completed", pending: [], abandoned: [], erased: erasing(app.registry) })
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
subject: ada.actor.userId,
|
||||
ledger: [{ userId: ada.actor.userId, state: "erasing", pending: expect.arrayContaining(["flaky"]) }],
|
||||
sessionsRevoked: true,
|
||||
keyRevoked: true,
|
||||
mapping: 0,
|
||||
signIn: null,
|
||||
// The row is still there, under the same id, holding no address of the person's.
|
||||
userRow: `${ada.actor.userId}@deleted.invalid`,
|
||||
member: true,
|
||||
},
|
||||
])
|
||||
expect(second.id).not.toBe(ada.sessionId)
|
||||
|
||||
// Afterwards: the user row, the memberships and the session rows are gone; the organization that
|
||||
// had no other member is suspended; the ledger row stays, with the opaque id and module names only.
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
expect(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()).toBe(0)
|
||||
expect((await app.data.global(organizations).get(orgId))?.status).toBe("suspended")
|
||||
expect((await app.data.global(sessions).list({})).items).toEqual([])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ userId: ada.actor.userId, state: "completed", lastFailure: null })
|
||||
expect(JSON.stringify(row)).not.toContain("ada@example.test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("erases the guides module's data by the subject, while the user row still exists", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.guides.calls).toEqual([{ method: "eraseEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("revokes the subject's widget sessions server-side first, then erases the support contact", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } },
|
||||
{ method: "eraseContact", subject: { subject: ada.actor.userId } },
|
||||
])
|
||||
})
|
||||
|
||||
it("revokes the widget sessions even while the support erasure is held up, and not again once confirmed", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { eraseContact: unknown }
|
||||
const erase = client.eraseContact
|
||||
client.eraseContact = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
expect((await deleteAs(ada)).pending).toContain("support")
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions"])
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
// The ledger recorded the revocation as done: the sweep resumes at the erasure.
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
})
|
||||
|
||||
it("repeats a widget-session revocation the platform refused, on the next resumption, without holding the deletion", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { revokeWidgetSessions: unknown; eraseContact: unknown }
|
||||
const [revoke, erase] = [client.revokeWidgetSessions, client.eraseContact]
|
||||
const down = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
client.revokeWidgetSessions = down
|
||||
client.eraseContact = down
|
||||
const held = await deleteAs(ada)
|
||||
expect(held.pending).toEqual(["support"])
|
||||
client.revokeWidgetSessions = revoke
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.state).toBe("completed")
|
||||
expect(row?.steps).toContain("service-sessions")
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("lets the same provider identity sign up again as a new, unrelated user", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
const again = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(again.id).not.toBe(ada.actor.userId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: an unavailable module", () => {
|
||||
it("holds the deletion with the person signed out, and the worker's sweep finishes it once the module is back", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const held = await deleteAs(ada)
|
||||
expect(held).toMatchObject({ state: "erasing", pending: ["flaky"] })
|
||||
expect(held.erased).toEqual(erasing(app.registry).filter((key) => key !== "flaky"))
|
||||
// Signed out everywhere and unreachable by a sign-in, but the subject is kept for the retry.
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).not.toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ attempts: 1, lastFailure: "flaky:flaky_unavailable" })
|
||||
// A first sign-in with the same provider identity now is a new person: the held row's legacy
|
||||
// issuer/subject columns were cleared with the mapping, so the resolver cannot map it back.
|
||||
const stranger = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(stranger.id).not.toBe(ada.actor.userId)
|
||||
expect((await app.data.global(appUserIdentities).list({})).items.map((alias) => alias.userId)).not.toContain(
|
||||
ada.actor.userId,
|
||||
)
|
||||
|
||||
// The sweep while the module is still down: it retries, fails loudly, and changes nothing else.
|
||||
await expect(app.sweep()).rejects.toMatchObject({ status: 503 })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({ state: "erasing", attempts: 2 })
|
||||
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
erased: expect.arrayContaining(["flaky"]),
|
||||
})
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
|
||||
// Idempotent from here on: another sweep, or the person's own retry, changes nothing.
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
})
|
||||
|
||||
it("is one deletion however often it is asked for, even at once", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const both = await Promise.all([deleteAs(ada), deleteAs(ada)])
|
||||
expect(both.map((view) => view.state)).toEqual(["erasing", "erasing"])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
// A retry resumes, whatever is typed: the account's address is no longer on the row.
|
||||
flaky.eraseFails = false
|
||||
expect((await deleteAs(ada, "anything")).state).toBe("completed")
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not wait for a module removed from the app after the deletion began, and keeps its name for the operator", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
|
||||
// The same database, the app redeployed without the module.
|
||||
const redeployed = makeApp({ data: app.data })
|
||||
await redeployed.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
abandoned: ["flaky"],
|
||||
})
|
||||
expect(flaky.erased).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("with an opt-in module removed", () => {
|
||||
for (const removed of ["guides", "support"]) {
|
||||
it(`without ${removed}, neither the export nor the deletion names it`, async () => {
|
||||
const app = makeApp({ modules: modulesWithout(removed) })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(Object.keys(exported.sections)).not.toContain(removed)
|
||||
expect(exported.complete).toBe(true)
|
||||
expect((await statusOf(ada)).modules).not.toContain(removed)
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
})
|
||||
}
|
||||
|
||||
it("with only this app's own rows, the export is the account section and the deletion completes", async () => {
|
||||
const app = makeApp({ modules: modulesWithout("guides", "support") })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect(Object.keys((await exportOf(ada)).sections)).toEqual(["account"])
|
||||
expect(await deleteAs(ada)).toMatchObject({ state: "completed", erased: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("an external provider's session and account events (Better Auth, joint contract §14)", () => {
|
||||
/** A browser session of an external provider: no kit row, the sign-in time the provider reported. */
|
||||
const external = (signedInAt: number) => ({ interactive: true, signedInAt: new Date(signedInAt).toISOString() })
|
||||
|
||||
it("judges a recent sign-in by the time the provider reported, without a kit session row", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const stale = ada.call(external(Date.now() - (RECENT_SIGN_IN_SECONDS + 60) * 1000))
|
||||
expect(((await stale.callByName("account.deletion.status", undefined)) as DeletionStatus).recentSignIn).toBe(false)
|
||||
expect((await problemOf(stale.callByName("account.deletion.request", { confirm: ada.email }))).code).toBe(
|
||||
"reauthentication_required",
|
||||
)
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
const fresh = ada.call(external(Date.now() - 1000))
|
||||
expect(((await fresh.callByName("account.deletion.request", { confirm: ada.email })) as DeletionView).state).toBe(
|
||||
"completed",
|
||||
)
|
||||
})
|
||||
|
||||
it("ends the account at the provider, found by the identity mapping, before the identity is detached", async () => {
|
||||
const ended: Array<{ providerSubject: string; mapped: boolean }> = []
|
||||
const flaky = newFlaky()
|
||||
const holder: { app?: ReturnType<typeof makeApp> } = {}
|
||||
const app = makeApp({
|
||||
modules: [...appModules, flakyModule(flaky)],
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject) {
|
||||
const mapped = await findAppUser(holder.app?.data as never, { issuer: ISSUER, subject: providerSubject })
|
||||
ended.push({ providerSubject, mapped: mapped !== null })
|
||||
},
|
||||
},
|
||||
})
|
||||
holder.app = app
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(ended).toEqual([{ providerSubject: ada.subject, mapped: true }])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.provider).toBe("test-provider")
|
||||
expect(row?.steps).toEqual(["credentials", "service-sessions", "provider-account", "identity"])
|
||||
})
|
||||
|
||||
it("erases an account the provider deleted, without a confirmation, a recent sign-in or a hand-over, once", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(ledger.map((row) => [row.userId, row.state])).toEqual([[ada.actor.userId, "completed"]])
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
// The other person's account is untouched.
|
||||
expect((await app.data.global(sessions).get(grace.sessionId))?.revokedAt).toBeNull()
|
||||
})
|
||||
|
||||
it("leaves a failed module to the sweep, as a deletion the person asked for", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.pending).toContain("flaky")
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("holds the deletion, rather than detach the identity, when no identity maps under the provider", async () => {
|
||||
const ended: string[] = []
|
||||
const app = makeApp({
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: "another-namespace",
|
||||
endAccount: async (s) => void ended.push(s),
|
||||
},
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// A namespace that differs between processes must not end nothing and go on.
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.lastFailure).toBe("provider-account:not_mapped")
|
||||
expect(ended).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
|
||||
it("revokes every credential of an account the provider disabled, and erases nothing", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "user-disabled", userId: ada.actor.userId })
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(flaky.erased).toEqual([])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
// kit:support:begin
|
||||
expect(app.support.calls).toEqual([{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } }])
|
||||
// kit:support:end
|
||||
})
|
||||
})
|
||||
|
||||
describe("the deletion ledger records each step, so a resumption runs exactly the ones left", () => {
|
||||
/** A provider whose `endAccount` records the subjects it ended and can be made to fail. */
|
||||
const provider = () => {
|
||||
const state = { ended: [] as string[], fails: false }
|
||||
return {
|
||||
state,
|
||||
provider: {
|
||||
id: "convex-better-auth",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject: string) {
|
||||
if (state.fails) throw new TypeError("fetch failed")
|
||||
state.ended.push(providerSubject)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const failingApp = (options: Parameters<typeof makeApp>[0] = {}) => {
|
||||
const modules = [...appModules]
|
||||
const memory = createMemoryDataSource({ entities: entitiesOf(buildRegistry(modules), "postgres") })
|
||||
const { data, failing } = failingSessions(memory)
|
||||
return { app: makeApp({ ...options, modules, data }), failing }
|
||||
}
|
||||
const ledgerOf = async (app: ReturnType<typeof makeApp>) => (await app.data.global(accountDeletions).list({})).items
|
||||
|
||||
it("ends the provider account on the worker's retry of a deletion that failed before that step", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
const [held] = await ledgerOf(app)
|
||||
expect([held?.state, held?.provider, held?.steps ?? []]).toEqual(["erasing", "convex-better-auth", []])
|
||||
expect(state.ended).toEqual([])
|
||||
failing.sessions = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
const [done] = await ledgerOf(app)
|
||||
expect(done?.state).toBe("completed")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
})
|
||||
|
||||
it("waits at the provider step in a process with no provider bound, rather than detach the identity", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ requestIdentityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
failing.sessions = false
|
||||
// The sweep runs in the worker's container, which binds no provider in this app.
|
||||
await expect(app.sweep()).rejects.toThrow(/wait/)
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.state).toBe("erasing")
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(held?.steps).toEqual(["credentials", "service-sessions"])
|
||||
// The identity the account is found by is still mapped, and the account is not ended yet.
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
expect(state.ended).toEqual([])
|
||||
// The person's retry, from a process that binds the provider, finishes it.
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("holds at the provider step while the provider is down, and resumes there", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
state.fails = true
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
state.fails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("does not end the provider account again when a later step held the deletion up", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("ends the account for a ledger row written before steps and the provider were recorded", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// The row as the previous ledger wrote it: no steps, no provider, a module held up.
|
||||
await app.data.global(accountDeletions).insert({
|
||||
userId: ada.actor.userId,
|
||||
state: "erasing",
|
||||
pending: erasing(app.registry),
|
||||
erased: [],
|
||||
abandoned: [],
|
||||
attempts: 1,
|
||||
lastFailure: "flaky:flaky_unavailable",
|
||||
requestedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
steps: null,
|
||||
provider: null,
|
||||
})
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("ends every account the person has under the provider's namespace", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await app.data.global(appUserIdentities).insert({
|
||||
userId: ada.actor.userId,
|
||||
issuer: ISSUER,
|
||||
subject: "second-account-of-ada",
|
||||
migrationId: null,
|
||||
})
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect([...state.ended].sort()).toEqual([ada.subject, "second-account-of-ada"].sort())
|
||||
})
|
||||
|
||||
it("holds a deletion whose provider cannot be built, and exports all the same", async () => {
|
||||
const app = makeApp()
|
||||
app.root.register(ACCOUNT_IDENTITY_PROVIDER, () => {
|
||||
throw new Error("BETTER_AUTH_CONVEX_ADMIN_KEY is not set")
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await exportOf(ada)).sections.account?.status).toBe("ok")
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
// The account page's platform analytics erasure and export (ADR-049) over a fake privacy port: the
|
||||
// subject is always the signed-in user, a repeated request answers the open job, a ticket reaches
|
||||
// only its own user's job, the export downloads when completed, and nothing runs for an API
|
||||
// credential or without the platform service.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { requestIdOf, TICKET_KEY_INFO, TICKET_LIFETIME_MS, TICKET_VERSION, ticketFor } from "../core/analytics.js"
|
||||
import type { AccountPrivacy, AccountPrivacyRequest, AnalyticsView } from "../index.js"
|
||||
import { makeApp, type Person } from "./harness.js"
|
||||
|
||||
/** A privacy service like the platform's: one open job per kind and user, settled by `finish`. */
|
||||
function fakePrivacy() {
|
||||
const jobs = new Map<string, AccountPrivacyRequest & { userId: string }>()
|
||||
const calls: { op: string; userId?: string; id?: string }[] = []
|
||||
let next = 1
|
||||
const open = (kind: "erase" | "export", userId: string) => {
|
||||
calls.push({ op: kind, userId })
|
||||
for (const job of jobs.values()) {
|
||||
if (job.kind === kind && job.userId === userId && (job.status === "pending" || job.status === "running")) {
|
||||
return { request: job, created: false }
|
||||
}
|
||||
}
|
||||
const id = `00000000-0000-4000-8000-${String(next++).padStart(12, "0")}`
|
||||
const job = { id, kind, userId, status: "pending" as const, stores: {}, created_at: "2026-10-26T08:00:00Z" }
|
||||
jobs.set(id, job)
|
||||
return { request: job, created: true }
|
||||
}
|
||||
const privacy: AccountPrivacy = {
|
||||
ticketKey: "dt_fixture_secret",
|
||||
eraseSubject: async ({ userId }) => open("erase", userId),
|
||||
exportSubject: async ({ userId }) => open("export", userId),
|
||||
getRequest: async (id) => {
|
||||
calls.push({ op: "get", id })
|
||||
const job = jobs.get(id)
|
||||
if (!job) throw Object.assign(new Error("not found"), { status: 404 })
|
||||
return job
|
||||
},
|
||||
downloadExport: async (id) => {
|
||||
calls.push({ op: "download", id })
|
||||
const job = jobs.get(id)
|
||||
if (!job) throw Object.assign(new Error("not found"), { status: 404 })
|
||||
if (job.kind !== "export" || job.status !== "completed")
|
||||
throw Object.assign(new Error("not ready"), { status: 409 })
|
||||
return { request_id: id, stores: { analytics: { events: [] } } }
|
||||
},
|
||||
}
|
||||
const finish = (id: string) => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) throw new Error("no such job")
|
||||
jobs.set(id, {
|
||||
...job,
|
||||
status: "completed",
|
||||
finished_at: "2026-10-26T08:05:00Z",
|
||||
stores: { analytics: { status: "completed", deleted: 2, anonymized: 40 }, logs: { status: "not_keyed" } },
|
||||
})
|
||||
}
|
||||
return { privacy, calls, finish, jobs }
|
||||
}
|
||||
|
||||
const requestAs = (person: Person, kind: "erase" | "export") =>
|
||||
person.call().callByName("account.analytics.request", { kind }) as Promise<AnalyticsView>
|
||||
const statusAs = (person: Person, ticket: string) =>
|
||||
person.call().callByName("account.analytics.status", { ticket }) as Promise<AnalyticsView>
|
||||
const exportAs = (person: Person, ticket: string) =>
|
||||
person.call().callByName("account.analytics.export", { ticket }) as Promise<{ document: Record<string, unknown> }>
|
||||
|
||||
const problemOf = async (run: Promise<unknown>) => {
|
||||
const error = await run.then(
|
||||
() => undefined,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
return (error as AppError).problem
|
||||
}
|
||||
|
||||
/** The id of the one job the fake platform holds; fails the test when there is none. */
|
||||
function onlyJob(fake: { jobs: Map<string, unknown> }): string {
|
||||
const [id] = fake.jobs.keys()
|
||||
if (id === undefined) throw new Error("the fake platform holds no job")
|
||||
return id
|
||||
}
|
||||
|
||||
describe("account.analytics tickets", () => {
|
||||
const T0 = Date.parse("2026-10-26T08:00:00Z")
|
||||
const notFound = { problem: { status: 404 } }
|
||||
|
||||
it("name the request for one user only, and refuse a forged or swapped one", async () => {
|
||||
const ticket = await ticketFor("key", "user-a", "req-1", T0)
|
||||
await expect(requestIdOf("key", "user-a", ticket, T0)).resolves.toBe("req-1")
|
||||
await expect(requestIdOf("key", "user-b", ticket, T0)).rejects.toMatchObject(notFound)
|
||||
await expect(requestIdOf("key", "user-a", ticket.replace(".req-1.", ".req-2."), T0)).rejects.toMatchObject(notFound)
|
||||
await expect(requestIdOf("key", "user-a", "no-dot", T0)).rejects.toMatchObject(notFound)
|
||||
// A flipped MAC digit, and a ticket of the old `<id>.<mac>` shape, are forgeries too.
|
||||
const last = ticket.at(-1) === "0" ? "1" : "0"
|
||||
await expect(requestIdOf("key", "user-a", `${ticket.slice(0, -1)}${last}`, T0)).rejects.toMatchObject(notFound)
|
||||
await expect(requestIdOf("key", "user-a", `req-1.${ticket.split(".").at(-1)}`, T0)).rejects.toMatchObject(notFound)
|
||||
})
|
||||
|
||||
it("carry the version and an expiry inside the signed payload", async () => {
|
||||
const ticket = await ticketFor("key", "user-a", "req-1", T0)
|
||||
const [version, requestId, expiresAt, mac] = ticket.split(".")
|
||||
expect(version).toBe(TICKET_VERSION)
|
||||
expect(requestId).toBe("req-1")
|
||||
expect(Number(expiresAt)).toBe(T0 + TICKET_LIFETIME_MS)
|
||||
expect(mac).toMatch(/^[0-9a-f]{64}$/)
|
||||
// Extending the expiry, or claiming another version, breaks the MAC.
|
||||
const extended = [version, requestId, String(T0 + 10 * TICKET_LIFETIME_MS), mac].join(".")
|
||||
await expect(requestIdOf("key", "user-a", extended, T0)).rejects.toMatchObject(notFound)
|
||||
const otherVersion = ["v2", requestId, expiresAt, mac].join(".")
|
||||
await expect(requestIdOf("key", "user-a", otherVersion, T0)).rejects.toMatchObject(notFound)
|
||||
})
|
||||
|
||||
it("expire after a day, with the same NotFound as a forged ticket", async () => {
|
||||
const ticket = await ticketFor("key", "user-a", "req-1", T0)
|
||||
await expect(requestIdOf("key", "user-a", ticket, T0 + TICKET_LIFETIME_MS - 1)).resolves.toBe("req-1")
|
||||
const expired = await problemOf(requestIdOf("key", "user-a", ticket, T0 + TICKET_LIFETIME_MS))
|
||||
const forged = await problemOf(requestIdOf("key", "user-a", `${ticket.slice(0, -4)}beef`, T0))
|
||||
expect(expired).toEqual(forged)
|
||||
expect(expired.status).toBe(404)
|
||||
})
|
||||
|
||||
it("are MAC'd under a key derived from the secret, never under the secret itself", async () => {
|
||||
const ticket = await ticketFor("key", "user-a", "req-1", T0)
|
||||
const [version, requestId, expiresAt, mac] = ticket.split(".") as [string, string, string, string]
|
||||
const payload = `${version}|${expiresAt}|${"user-a".length}:user-a|${requestId}`
|
||||
const sign = async (raw: Uint8Array<ArrayBuffer>) => {
|
||||
const key = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
|
||||
const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)))
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
expect(mac).not.toBe(await sign(new TextEncoder().encode("key")))
|
||||
const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode("key"), "HKDF", false, ["deriveBits"])
|
||||
const derived = await crypto.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(TICKET_KEY_INFO) },
|
||||
ikm,
|
||||
256,
|
||||
)
|
||||
expect(TICKET_KEY_INFO).toBe("conusai-template/account-privacy-ticket/v1")
|
||||
expect(mac).toBe(await sign(new Uint8Array(derived)))
|
||||
})
|
||||
|
||||
it("stop verifying when the deployment token rotates; asking again answers the open job with a fresh ticket", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const before = await requestAs(ada, "export")
|
||||
|
||||
// Rotation (documented in core/analytics.ts): the old ticket is NotFound, nothing breaks.
|
||||
;(fake.privacy as { ticketKey: string }).ticketKey = "dt_rotated_secret"
|
||||
expect((await problemOf(statusAs(ada, before.ticket))).status).toBe(404)
|
||||
const again = await requestAs(ada, "export")
|
||||
|
||||
expect(again.created).toBe(false)
|
||||
expect(fake.jobs.size).toBe(1)
|
||||
expect((await statusAs(ada, again.ticket)).status).toBe("pending")
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.analytics.request", () => {
|
||||
it("erases the signed-in user's data by their user id, and a repeated click answers the open job", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const first = await requestAs(ada, "erase")
|
||||
const again = await requestAs(ada, "erase")
|
||||
|
||||
expect(first).toMatchObject({ kind: "erase", status: "pending", created: true })
|
||||
expect(again.created).toBe(false)
|
||||
// The same job; each answer carries its own freshly dated ticket for it.
|
||||
const key = fake.privacy.ticketKey
|
||||
const [firstId, againId] = await Promise.all(
|
||||
[first, again].map((view) => requestIdOf(key, ada.actor.userId, view.ticket, Date.now())),
|
||||
)
|
||||
expect(againId).toBe(firstId)
|
||||
expect(fake.calls.filter((call) => call.op === "erase")).toEqual([
|
||||
{ op: "erase", userId: ada.actor.userId },
|
||||
{ op: "erase", userId: ada.actor.userId },
|
||||
])
|
||||
expect(fake.jobs.size).toBe(1)
|
||||
})
|
||||
|
||||
it("follows the job to completed with its per-store counts", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const started = await requestAs(ada, "erase")
|
||||
fake.finish(onlyJob(fake))
|
||||
|
||||
const done = await statusAs(ada, started.ticket)
|
||||
|
||||
expect(done.status).toBe("completed")
|
||||
expect(done.stores.analytics).toEqual({ status: "completed", deleted: 2, anonymized: 40, exported: 0 })
|
||||
expect(done.stores.logs?.status).toBe("not_keyed")
|
||||
})
|
||||
|
||||
it("is refused to an API credential acting as the person", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const problem = await problemOf(
|
||||
ada.call({ interactive: false }).callByName("account.analytics.request", { kind: "erase" }),
|
||||
)
|
||||
|
||||
expect(problem.status).toBe(403)
|
||||
expect(fake.calls).toEqual([])
|
||||
})
|
||||
|
||||
it("answers unavailable when the deployment has no privacy service (the fake platform)", async () => {
|
||||
const app = makeApp()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const problem = await problemOf(requestAs(ada, "export"))
|
||||
|
||||
expect(problem.status).toBe(503)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.analytics.export", () => {
|
||||
it("is a conflict until the export completed, then the document", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const started = await requestAs(ada, "export")
|
||||
|
||||
expect((await problemOf(exportAs(ada, started.ticket))).status).toBe(409)
|
||||
fake.finish(onlyJob(fake))
|
||||
const downloaded = await exportAs(ada, started.ticket)
|
||||
|
||||
expect(downloaded.document).toMatchObject({ stores: { analytics: { events: [] } } })
|
||||
})
|
||||
|
||||
it("never reaches another person's export: their ticket is NotFound and no platform call is made", async () => {
|
||||
const fake = fakePrivacy()
|
||||
const app = makeApp({ privacy: fake.privacy })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const bob = await app.signUp("bob@example.test")
|
||||
const started = await requestAs(ada, "export")
|
||||
fake.finish(onlyJob(fake))
|
||||
const before = fake.calls.length
|
||||
|
||||
expect((await problemOf(exportAs(bob, started.ticket))).status).toBe(404)
|
||||
expect((await problemOf(statusAs(bob, started.ticket))).status).toBe(404)
|
||||
expect(fake.calls.length).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// Why the account module's procedures need no tenant-isolation fixture, for the generated suite's
|
||||
// `exempt` list (test/isolation.test.ts): all three are user-scoped and take no organization and no id.
|
||||
|
||||
export const accountIsolationExempt: Readonly<Record<string, string>> = {
|
||||
"account.data.export": "it takes no organization and no id: the only account it exports is the signed-in user's",
|
||||
"account.deletion.status": "it takes no organization and no id: it answers only about the signed-in user's account",
|
||||
"account.deletion.request": "it takes no organization and no id: the only account it deletes is the signed-in user's",
|
||||
"account.analytics.request": "it takes no organization and no id: the only subject it names is the signed-in user",
|
||||
"account.analytics.status":
|
||||
"it takes no organization; its ticket is signed for the signed-in user, and another's answers NotFound",
|
||||
"account.analytics.export":
|
||||
"it takes no organization; its ticket is signed for the signed-in user, and another's answers NotFound",
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// The app wired the way apps/web and apps/worker wire the account module -- the registry from the
|
||||
// manifests, a memory data source, the tenancy hooks, the account ports and the opt-in modules'
|
||||
// recording platforms -- plus `flaky`, a module whose share of an account can be made to fail, so
|
||||
// the partial-failure and resume paths are proven whichever opt-in modules this app kept.
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { sessions } from "@conusai-kit/auth"
|
||||
import {
|
||||
type Actor,
|
||||
AppError,
|
||||
buildRegistry,
|
||||
Container,
|
||||
contribute,
|
||||
createCaller,
|
||||
defineModule,
|
||||
type ModuleManifest,
|
||||
type Registry,
|
||||
type TransportCaller,
|
||||
} from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
|
||||
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
|
||||
import { recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
|
||||
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
|
||||
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
|
||||
import { accountData, createTenancyHooks, requireAppUser, TENANCY } from "@conusai-kit/tenancy"
|
||||
import { appModules } from "../../index.js"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
ACCOUNT_PRIVACY,
|
||||
ACCOUNT_REGISTRY,
|
||||
type AccountCredential,
|
||||
type AccountIdentityProvider,
|
||||
type AccountPrivacy,
|
||||
} from "../index.js"
|
||||
|
||||
export const ISSUER = "https://id.example.test"
|
||||
|
||||
/** A stand-in for the hash of a person's session handle, distinct per person as the real one is. */
|
||||
export const handleHashOf = (email: string): string => createHash("sha256").update(email).digest("hex")
|
||||
|
||||
/**
|
||||
* The app's modules without `removed`, as `kit template:prune --without` leaves them: the module, and
|
||||
* every other module's contribution to an extension point the removed module declared.
|
||||
*/
|
||||
export function modulesWithout(...removed: string[]): ModuleManifest[] {
|
||||
return appModules
|
||||
.filter((module) => !removed.includes(module.id))
|
||||
.map((module) => ({
|
||||
...module,
|
||||
extends: (module.extends ?? []).filter(
|
||||
(contribution) => !removed.includes(contribution.point.id.split(".")[0] ?? ""),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/** What `flaky` does when asked, and what it saw. */
|
||||
export interface FlakyState {
|
||||
exportFails: boolean
|
||||
eraseFails: boolean
|
||||
readonly exported: string[]
|
||||
readonly erased: string[]
|
||||
/** Called inside each erasure, before it answers: a test looks at the stored rows at that moment. */
|
||||
onErase?: (subject: string) => Promise<void>
|
||||
}
|
||||
|
||||
const unavailable = () =>
|
||||
new AppError({ type: "/problems/flaky_unavailable", title: "Unavailable", status: 503, code: "flaky_unavailable" })
|
||||
|
||||
export function flakyModule(state: FlakyState): ModuleManifest {
|
||||
return defineModule({
|
||||
id: "flaky",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
extends: [
|
||||
contribute(accountData, [
|
||||
{
|
||||
key: "flaky",
|
||||
async export({ subject }) {
|
||||
if (state.exportFails) throw unavailable()
|
||||
state.exported.push(subject)
|
||||
return { subject, items: 1 }
|
||||
},
|
||||
async erase({ subject }) {
|
||||
await state.onErase?.(subject)
|
||||
if (state.eraseFails) throw unavailable()
|
||||
state.erased.push(subject)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory data source, with the session table's listing made to fail while `failing.sessions` is
|
||||
* set: the first step of a deletion (revoking the person's live sessions) then throws, as a database
|
||||
* outage would, after the ledger row was written.
|
||||
*/
|
||||
export function failingSessions(data: MemoryData): { data: MemoryData; failing: { sessions: boolean } } {
|
||||
const failing = { sessions: false }
|
||||
const outage = () => {
|
||||
throw new AppError({ type: "/problems/unavailable", title: "Unavailable", status: 503, code: "unavailable" })
|
||||
}
|
||||
const wrapped = new Proxy(data, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver)
|
||||
if (property !== "global") return typeof value === "function" ? value.bind(target) : value
|
||||
return (entity: Parameters<MemoryData["global"]>[0]) => {
|
||||
const repository = target.global(entity)
|
||||
if ((entity as unknown) !== sessions) return repository
|
||||
return new Proxy(repository, {
|
||||
get(repo, name, r) {
|
||||
const method = Reflect.get(repo, name, r)
|
||||
if (typeof method !== "function") return method
|
||||
return (...args: unknown[]) => (failing.sessions && name === "list" ? outage() : method.apply(repo, args))
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
return { data: wrapped, failing }
|
||||
}
|
||||
|
||||
export const newFlaky = (): FlakyState => ({ exportFails: false, eraseFails: false, exported: [], erased: [] })
|
||||
|
||||
export type MemoryData = ReturnType<typeof createMemoryDataSource>
|
||||
|
||||
export interface Person {
|
||||
readonly actor: Actor & { readonly userId: string }
|
||||
readonly email: string
|
||||
readonly subject: string
|
||||
readonly sessionId: string
|
||||
/** The person's caller, as the web binds it for a browser session (or the credential given). */
|
||||
call(credential?: AccountCredential): TransportCaller
|
||||
}
|
||||
|
||||
export function makeApp(
|
||||
options: {
|
||||
modules?: readonly ModuleManifest[]
|
||||
data?: MemoryData
|
||||
/** Bound in the process container: every request and the worker's sweep have it. */
|
||||
identityProvider?: AccountIdentityProvider
|
||||
/** Bound in each request's container only: a sweep in this app runs without it. */
|
||||
requestIdentityProvider?: AccountIdentityProvider
|
||||
/** The platform's privacy service (ADR-049), bound in the process container. */
|
||||
privacy?: AccountPrivacy
|
||||
} = {},
|
||||
) {
|
||||
const registry: Registry = buildRegistry([...(options.modules ?? appModules)])
|
||||
const data = options.data ?? createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(ACCOUNT_REGISTRY, registry)
|
||||
if (options.identityProvider) root.value(ACCOUNT_IDENTITY_PROVIDER, options.identityProvider)
|
||||
if (options.privacy) root.value(ACCOUNT_PRIVACY, options.privacy)
|
||||
const guides = recordingGuidesPlatform() // kit:guides
|
||||
root.value(GUIDES_PLATFORM, guides) // kit:guides
|
||||
root.value(GUIDES_REGISTRY, registry) // kit:guides
|
||||
const support = recordingSupportPlatform() // kit:support
|
||||
root.value(SUPPORT_PLATFORM, support) // kit:support
|
||||
|
||||
const callerFor = (actor: Actor, credential: AccountCredential): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
container.value(ACCOUNT_CREDENTIAL, credential)
|
||||
if (options.requestIdentityProvider) container.value(ACCOUNT_IDENTITY_PROVIDER, options.requestIdentityProvider)
|
||||
return createCaller({ registry, container, tenancy }, { actor })
|
||||
}
|
||||
|
||||
/** A first sign-in, as kit-auth's callback makes it: the user, the identity mapping, a session row. */
|
||||
const signUp = async (email: string): Promise<Person> => {
|
||||
const subject = `sub-${email}`
|
||||
const user = await requireAppUser(data, {
|
||||
issuer: ISSUER,
|
||||
subject,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
const now = Date.now()
|
||||
const session = await data.global(sessions).insert({
|
||||
userId: user.id,
|
||||
issuer: ISSUER,
|
||||
subject,
|
||||
handleHash: handleHashOf(email),
|
||||
tokens: "sealed-tokens-of-the-test",
|
||||
accessTokenExpiresAt: toInstant(now + 3600_000),
|
||||
lastSeenAt: toInstant(now),
|
||||
absoluteExpiresAt: toInstant(now + 14 * 86400_000),
|
||||
revokedAt: null,
|
||||
})
|
||||
const actor = { userId: user.id }
|
||||
const browser: AccountCredential = { interactive: true, sessionId: session.id }
|
||||
return {
|
||||
actor,
|
||||
email,
|
||||
subject,
|
||||
sessionId: session.id,
|
||||
call: (credential = browser) => callerFor(actor, credential),
|
||||
}
|
||||
}
|
||||
|
||||
/** The worker's run of the sweep job: a scopeless job, in a container with no credential. */
|
||||
const sweep = async (): Promise<void> => {
|
||||
const job = registry.jobs["account.deletion.sweep"]
|
||||
if (!job) throw new Error("the account module declares no sweep job")
|
||||
await job.def.handler(null, { container: root.child(), correlationId: "test-sweep", attempt: 1 })
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
data,
|
||||
root,
|
||||
signUp,
|
||||
sweep,
|
||||
guides, // kit:guides
|
||||
support, // kit:support
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Every string the account page shows is a message in English and in Lithuanian, the label builders
|
||||
// ask for no key either language lacks, and no message is left that nothing asks for.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { analyticsLabels, deletionLabels, exportLabels, pageLabels } from "../ui/labels.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const en = catalogues.en?.account ?? {}
|
||||
const lt = catalogues.lt?.account ?? {}
|
||||
|
||||
/** The keys the builders ask for, recorded by running them with a lookup that notes each one. */
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
pageLabels(t)
|
||||
exportLabels(t, "failed")
|
||||
analyticsLabels(t, "failed")
|
||||
deletionLabels(
|
||||
t,
|
||||
"failed",
|
||||
{ modules: ["guides"], lastOwnerOf: [{ slug: "a", name: "A" }], onlyMemberOf: [{ slug: "b", name: "B" }] },
|
||||
"ada@example.test",
|
||||
)
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the account catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none of them empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = new Set(keysUsed())
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing account.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing account.${key}`).toBeTruthy()
|
||||
}
|
||||
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("keep the placeholders the components fill in themselves", () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
let text = String(en[key as keyof typeof en])
|
||||
for (const [name, value] of Object.entries(values ?? {})) text = text.replace(`{${name}}`, String(value))
|
||||
return text
|
||||
}
|
||||
expect(exportLabels(t, "x").partial).toContain("{sections}")
|
||||
expect(deletionLabels(t, "x", { modules: [], lastOwnerOf: [], onlyMemberOf: [] }, "a@b.test").erasing).toContain(
|
||||
"{modules}",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it
|
||||
// added (decision AT-D20: the entity definition is the one source of a column). Forward-only: the
|
||||
// first file stays the ledger's as first shipped, the revocation outbox is the second, and the
|
||||
// ledger's step record (`steps`, `provider`) is the third, two added columns. All global tables: an
|
||||
// account and an app session belong to no organization, so they carry no tenant column and no policy.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ACCOUNT_ENTITIES, accountDeletions, accountModule, accountSessionRevocations } from "../index.js"
|
||||
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
describe("the migration of the account module", () => {
|
||||
const migration = (name: string) => readFileSync(new URL(`../migrations/${name}`, import.meta.url), "utf8")
|
||||
|
||||
// A migration that has run is never edited: the columns added since are a file of their own, and
|
||||
// the files together are exactly the entity.
|
||||
it("is its own file, the ledger table as first shipped, and a third file adds the step record", () => {
|
||||
const added = ',\n "steps" jsonb,\n "provider" text\n'
|
||||
const generated = tablesOf([accountDeletions])
|
||||
expect(generated).toContain(added)
|
||||
expect(generated.replace(added, "\n")).toBe(migration("0001_account.sql"))
|
||||
expect(migration("0003_deletion_steps.sql")).toBe(
|
||||
'alter table "account_deletion" add column "steps" jsonb;\n\nalter table "account_deletion" add column "provider" text;\n',
|
||||
)
|
||||
})
|
||||
|
||||
it("adds the revocation outbox in a second file, exactly its table", async () => {
|
||||
await expect(tablesOf([accountSessionRevocations])).toMatchFileSnapshot("../migrations/0002_session_revocation.sql")
|
||||
expect(ACCOUNT_ENTITIES).toEqual([accountDeletions, accountSessionRevocations])
|
||||
const sql = readFileSync(new URL("../migrations/0002_session_revocation.sql", import.meta.url), "utf8")
|
||||
expect(sql).not.toContain("org_id")
|
||||
})
|
||||
|
||||
it("is declared by the manifest for Postgres; on Convex the schema comes from the same entities", () => {
|
||||
expect(accountModule.migrations).toEqual({ postgres: "migrations" })
|
||||
expect(accountModule.profiles).toEqual(["postgres", "convex"])
|
||||
const sql = readFileSync(new URL("../migrations/0001_account.sql", import.meta.url), "utf8")
|
||||
expect(sql).toContain('create table "account_deletion"')
|
||||
expect(sql).not.toContain("org_id")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// The analytics panel's markup, rendered on the server the way the account page renders it, in both
|
||||
// languages: both actions are named buttons, the irreversible one is marked, and no confirmation,
|
||||
// status or table shows before the person asked for anything.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { TransportProvider } from "@conusai-kit/ui"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { AccountAnalyticsPanel, analyticsLabels } from "../ui/index.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
|
||||
const render = (locale: "en" | "lt") => {
|
||||
const i18n = createI18n(locale, { [locale]: { account: catalogues[locale]?.account ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
|
||||
const labels = analyticsLabels(t, "That did not work: {detail}")
|
||||
const html = renderToStaticMarkup(
|
||||
<TransportProvider call={async () => undefined} describe={() => "x"}>
|
||||
<AccountAnalyticsPanel labels={labels} />
|
||||
</TransportProvider>,
|
||||
)
|
||||
return { html, labels }
|
||||
}
|
||||
|
||||
describe("the analytics panel", () => {
|
||||
it.each(["en", "lt"] as const)("offers the download and the erasure as named buttons (%s)", (locale) => {
|
||||
const { html, labels } = render(locale)
|
||||
expect(html).toContain(`<h2 id=`)
|
||||
expect(html).toContain(labels.heading)
|
||||
expect(html).toContain(labels.exportSubmit)
|
||||
expect(html).toContain(labels.erase)
|
||||
expect(html).toMatch(/<button[^>]*class="app-danger"[^>]*>/)
|
||||
})
|
||||
|
||||
it("asks for nothing yet: no confirmation, no status and no table before a click", () => {
|
||||
const { html, labels } = render("en")
|
||||
expect(html).not.toContain(labels.confirmErase)
|
||||
expect(html).not.toContain('role="alertdialog"')
|
||||
expect(html).not.toContain('role="status"')
|
||||
expect(html).not.toContain("kit-table")
|
||||
})
|
||||
|
||||
it("says the erasure cannot be undone in its confirmation, in both languages", () => {
|
||||
expect(render("en").labels.confirmErase).toMatch(/cannot be undone/)
|
||||
expect(render("lt").labels.confirmErase).toMatch(/atšaukti negalima/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
// The handlers of the module's procedures, its sweep job and its own share of an account: each
|
||||
// resolves the ports from the container it runs with and calls one use case. No rule lives here.
|
||||
|
||||
import { AppError, type Container, forbidden, implement, type JobDef } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { type AccountDataContribution, accountDataOf } from "@conusai-kit/tenancy"
|
||||
import { z } from "zod"
|
||||
import { ACCOUNT_SECTION, DELETION_SWEEP_JOB, REVOCATION_JOB } from "../contract/constants.js"
|
||||
import {
|
||||
accountAnalyticsExport,
|
||||
accountAnalyticsRequest,
|
||||
accountAnalyticsStatus,
|
||||
accountDataExport,
|
||||
accountDeletionRequest,
|
||||
accountDeletionStatus,
|
||||
} from "../contract/procedures.js"
|
||||
import { analyticsExport, analyticsStatus, requestAnalytics } from "../core/analytics.js"
|
||||
import {
|
||||
deleteForProvider,
|
||||
deletionStatus,
|
||||
disableForProvider,
|
||||
requestDeletion,
|
||||
sweepDeletions,
|
||||
} from "../core/deletion.js"
|
||||
import { exportAccount } from "../core/export.js"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
ACCOUNT_PRIVACY,
|
||||
ACCOUNT_REGISTRY,
|
||||
ACCOUNT_STORE,
|
||||
type AccountCredential,
|
||||
type AccountDeps,
|
||||
type AccountPrivacy,
|
||||
type AccountStore,
|
||||
REVOCATION_STORE,
|
||||
} from "../core/ports.js"
|
||||
import { type RevocationDeps, revokeOnSignOut, sweepSessionRevocations } from "../core/revocation.js"
|
||||
import { createRevocationStore } from "./revocations.js"
|
||||
import { createAccountStore } from "./store.js"
|
||||
|
||||
/** The bound store (the Convex profile's actions), or the one over the container's data source. */
|
||||
const storeOf = (container: Container): AccountStore =>
|
||||
container.has(ACCOUNT_STORE)
|
||||
? container.resolve(ACCOUNT_STORE)
|
||||
: createAccountStore({ data: container.resolve(DATA_SOURCE), system: container.resolve(SYSTEM_DATA) })
|
||||
|
||||
const depsOf = (container: Container, signal?: AbortSignal): AccountDeps => ({
|
||||
store: storeOf(container),
|
||||
contributions: accountDataOf(container.resolve(ACCOUNT_REGISTRY)),
|
||||
container,
|
||||
now: Date.now,
|
||||
...(signal ? { signal } : {}),
|
||||
...(container.has(ACCOUNT_IDENTITY_PROVIDER)
|
||||
? { identityProvider: () => container.resolve(ACCOUNT_IDENTITY_PROVIDER) }
|
||||
: {}),
|
||||
})
|
||||
|
||||
/** A process or a test that bound no credential is a caller with no interactive session. */
|
||||
const credentialOf = (container: Container): AccountCredential =>
|
||||
container.has(ACCOUNT_CREDENTIAL) ? container.resolve(ACCOUNT_CREDENTIAL) : { interactive: false }
|
||||
|
||||
/**
|
||||
* This app's own rows about the person, as a section of the export. It has no erasure: those rows go
|
||||
* last, in the deletion's own final step, after every other module confirmed its erasure.
|
||||
*/
|
||||
export const accountOwnData: AccountDataContribution = {
|
||||
key: ACCOUNT_SECTION,
|
||||
export: ({ subject, container }) => storeOf(container).localExport(subject),
|
||||
}
|
||||
|
||||
export const accountDataExportImpl = implement(accountDataExport, async ({ ctx }) => {
|
||||
const deps = depsOf(ctx.container, ctx.signal)
|
||||
// Only the person, never a bearer acting as them: an export is their data, in full.
|
||||
if (!credentialOf(ctx.container).interactive) {
|
||||
throw forbidden("exporting an account needs the person signed in to this app, not an API credential")
|
||||
}
|
||||
return exportAccount(deps, ctx.scope.actor.userId)
|
||||
})
|
||||
|
||||
export const accountDeletionStatusImpl = implement(accountDeletionStatus, async ({ ctx }) =>
|
||||
deletionStatus(depsOf(ctx.container, ctx.signal), ctx.scope.actor.userId, credentialOf(ctx.container)),
|
||||
)
|
||||
|
||||
export const accountDeletionRequestImpl = implement(accountDeletionRequest, async ({ input, ctx }) =>
|
||||
requestDeletion(depsOf(ctx.container, ctx.signal), {
|
||||
userId: ctx.scope.actor.userId,
|
||||
credential: credentialOf(ctx.container),
|
||||
confirm: input.confirm,
|
||||
}),
|
||||
)
|
||||
|
||||
const privacyOf = (container: Container): AccountPrivacy | undefined =>
|
||||
container.has(ACCOUNT_PRIVACY) ? container.resolve(ACCOUNT_PRIVACY) : undefined
|
||||
|
||||
/** The person's platform data is theirs alone: a bearer acting as them may neither erase nor take it. */
|
||||
const requireInteractive = (container: Container, what: string): void => {
|
||||
if (!credentialOf(container).interactive) {
|
||||
throw forbidden(`${what} needs the person signed in to this app, not an API credential`)
|
||||
}
|
||||
}
|
||||
|
||||
export const accountAnalyticsRequestImpl = implement(accountAnalyticsRequest, async ({ input, ctx }) => {
|
||||
requireInteractive(ctx.container, input.kind === "erase" ? "erasing analytics data" : "exporting analytics data")
|
||||
return requestAnalytics(privacyOf(ctx.container), ctx.scope.actor.userId, input.kind, Date.now)
|
||||
})
|
||||
|
||||
export const accountAnalyticsStatusImpl = implement(accountAnalyticsStatus, async ({ input, ctx }) => {
|
||||
requireInteractive(ctx.container, "reading an analytics request")
|
||||
return analyticsStatus(privacyOf(ctx.container), ctx.scope.actor.userId, input.ticket, Date.now)
|
||||
})
|
||||
|
||||
export const accountAnalyticsExportImpl = implement(accountAnalyticsExport, async ({ input, ctx }) => {
|
||||
requireInteractive(ctx.container, "downloading analytics data")
|
||||
return analyticsExport(privacyOf(ctx.container), ctx.scope.actor.userId, input.ticket, Date.now)
|
||||
})
|
||||
|
||||
export const ACCOUNT_PROCEDURES = [
|
||||
accountDataExportImpl,
|
||||
accountDeletionStatusImpl,
|
||||
accountDeletionRequestImpl,
|
||||
accountAnalyticsRequestImpl,
|
||||
accountAnalyticsStatusImpl,
|
||||
accountAnalyticsExportImpl,
|
||||
]
|
||||
|
||||
const revocationDepsOf = (container: Container, log: RevocationDeps["log"], callMs?: number): RevocationDeps => ({
|
||||
// The bound outbox (the Convex profile's actions), or the one over the container's data source.
|
||||
store: container.has(REVOCATION_STORE)
|
||||
? container.resolve(REVOCATION_STORE)
|
||||
: createRevocationStore(container.resolve(DATA_SOURCE)),
|
||||
contributions: accountDataOf(container.resolve(ACCOUNT_REGISTRY)),
|
||||
container,
|
||||
now: Date.now,
|
||||
log,
|
||||
...(callMs === undefined ? {} : { callMs }),
|
||||
})
|
||||
|
||||
/**
|
||||
* kit-auth's `onSessionRevoked` hook for this app (apps/web): records the revocation of an app
|
||||
* session in the revocation outbox and delivers it to every module that minted derived sessions,
|
||||
* bounded; what is not delivered the worker's REVOCATION_JOB retries (on the Convex profile, the
|
||||
* backend's `account:revokeSession` action runs it and a one-minute cron the job). Throws only when the row could
|
||||
* not be written. `container` binds the data source, the registry and the modules' platform ports.
|
||||
*/
|
||||
export function recordSessionRevocation(
|
||||
container: Container,
|
||||
session: string,
|
||||
options: { log: RevocationDeps["log"]; callMs?: number },
|
||||
): Promise<void> {
|
||||
return revokeOnSignOut(revocationDepsOf(container, options.log, options.callMs), session)
|
||||
}
|
||||
|
||||
/**
|
||||
* An account event of the app's external identity provider, delivered server-side (the Better Auth
|
||||
* revocation relay): `account-deleted` erases the account through the same ledger as a deletion the
|
||||
* person asks for, `user-disabled` revokes every credential and derived service session of it. The
|
||||
* user is the mapped app user; an event for a subject that never signed in to this app has none
|
||||
* and is not delivered here. Idempotent, so a replayed event is harmless.
|
||||
*/
|
||||
export async function recordProviderAccountEvent(
|
||||
container: Container,
|
||||
event: { readonly reason: "account-deleted" | "user-disabled"; readonly userId: string },
|
||||
): Promise<void> {
|
||||
const deps = depsOf(container)
|
||||
if (event.reason === "account-deleted") {
|
||||
await deleteForProvider(deps, event.userId)
|
||||
return
|
||||
}
|
||||
await disableForProvider(deps, event.userId)
|
||||
}
|
||||
|
||||
/** The jobs: scopeless (an account and a session belong to no organization), scheduled by the worker. */
|
||||
export const ACCOUNT_JOBS: Record<string, JobDef<unknown>> = {
|
||||
[DELETION_SWEEP_JOB]: {
|
||||
payload: z.unknown(),
|
||||
handler: async (_payload, ctx) => {
|
||||
await sweepDeletions(depsOf(ctx.container, ctx.signal))
|
||||
},
|
||||
},
|
||||
[REVOCATION_JOB]: {
|
||||
payload: z.unknown(),
|
||||
handler: async (_payload, ctx) => {
|
||||
// Each row carries its own backoff, so the run is not what is retried; a run that left rows
|
||||
// waiting fails, which is the line the worker logs and an operator is alerted on.
|
||||
const sweep = await sweepSessionRevocations(revocationDepsOf(ctx.container, { warn: () => undefined }))
|
||||
if (sweep.waiting > 0) {
|
||||
throw new AppError({
|
||||
type: "/problems/account_revocations_waiting",
|
||||
title: "Session revocations are waiting",
|
||||
status: 503,
|
||||
code: "account_revocations_waiting",
|
||||
detail: `${sweep.waiting} session revocation(s) wait for a module's platform`,
|
||||
retryAfter: 60,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// The revocation outbox over kit-data: one global table, every change written against the row's
|
||||
// version, so a claim or a settlement that lost a race to another process writes nothing.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import { accountSessionRevocations } from "../contract/entities.js"
|
||||
import type { RevocationRecord, RevocationStore } from "../core/ports.js"
|
||||
|
||||
const isConflict = (error: unknown): boolean => error instanceof AppError && error.status === 409
|
||||
const isGone = (error: unknown): boolean => error instanceof AppError && error.status === 404
|
||||
|
||||
const recordOf = (row: {
|
||||
id: string
|
||||
version: number
|
||||
session: string
|
||||
state: RevocationRecord["state"]
|
||||
pending: string[]
|
||||
delivered: string[]
|
||||
attempts: number
|
||||
nextAttemptAt: string
|
||||
claimedUntil: string | null
|
||||
lastFailure: string | null
|
||||
requestedAt: string
|
||||
completedAt: string | null
|
||||
}): RevocationRecord => ({
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
session: row.session,
|
||||
state: row.state,
|
||||
pending: row.pending,
|
||||
delivered: row.delivered,
|
||||
attempts: row.attempts,
|
||||
nextAttemptAt: row.nextAttemptAt,
|
||||
claimedUntil: row.claimedUntil,
|
||||
lastFailure: row.lastFailure,
|
||||
requestedAt: row.requestedAt,
|
||||
completedAt: row.completedAt,
|
||||
})
|
||||
|
||||
/** The most finished rows one purge removes; the next run removes the rest. */
|
||||
const PURGE_LIMIT = 200
|
||||
|
||||
export function createRevocationStore(data: DataSource): RevocationStore {
|
||||
const table = () => data.global(accountSessionRevocations)
|
||||
return {
|
||||
async record({ session, modules, at, claimedUntil }) {
|
||||
try {
|
||||
return recordOf(
|
||||
await table().insert({
|
||||
session,
|
||||
state: "pending",
|
||||
pending: [...modules],
|
||||
delivered: [],
|
||||
attempts: 0,
|
||||
nextAttemptAt: at,
|
||||
claimedUntil,
|
||||
lastFailure: null,
|
||||
requestedAt: at,
|
||||
completedAt: null,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
// Unique on the session: a second notification for the same revocation.
|
||||
if (isConflict(error)) return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async due(now, limit) {
|
||||
const page = await table().list({
|
||||
where: [
|
||||
{ field: "state", op: "eq", value: "pending" },
|
||||
{ field: "nextAttemptAt", op: "range", lte: now },
|
||||
],
|
||||
order: [{ field: "nextAttemptAt", direction: "asc" }],
|
||||
limit,
|
||||
})
|
||||
return page.items.map(recordOf).filter((record) => record.claimedUntil === null || record.claimedUntil <= now)
|
||||
},
|
||||
|
||||
async change(record, patch) {
|
||||
const { pending, delivered, ...rest } = patch
|
||||
try {
|
||||
return recordOf(
|
||||
await table().update(
|
||||
record.id,
|
||||
{
|
||||
...rest,
|
||||
...(pending ? { pending: [...pending] } : {}),
|
||||
...(delivered ? { delivered: [...delivered] } : {}),
|
||||
},
|
||||
record.version,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
if (isConflict(error) || isGone(error)) return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async purge(before) {
|
||||
let removed = 0
|
||||
for (const state of ["delivered", "lapsed"] as const) {
|
||||
const page = await table().list({
|
||||
where: [
|
||||
{ field: "state", op: "eq", value: state },
|
||||
{ field: "completedAt", op: "range", lt: before },
|
||||
],
|
||||
limit: PURGE_LIMIT,
|
||||
})
|
||||
for (const row of page.items) {
|
||||
try {
|
||||
await table().delete(row.id)
|
||||
removed += 1
|
||||
} catch (error) {
|
||||
if (!isGone(error)) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// The account store over kit-data: this app's own rows about a person -- the kit's users, identity
|
||||
// mappings, memberships, sessions and API keys -- and the module's deletion ledger.
|
||||
//
|
||||
// The tables are the kit's (kit-tenancy, kit-auth); this adapter reads and changes them through the
|
||||
// same repositories and scopes the kit's own use cases use, so the tenant wall holds: what crosses
|
||||
// organizations is the one read the global reader is granted (which organizations a user is in), and
|
||||
// every change inside an organization is made in that organization's scope. The actor of such a scope
|
||||
// is the person whose account it is: an account deletion is their own act, resumed by the worker on
|
||||
// their behalf.
|
||||
|
||||
// The entities alone: the store also runs in the Convex runtime, where kit-auth's OIDC code cannot load.
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth/entities"
|
||||
import { AppError, type TenantScope } from "@conusai-kit/core"
|
||||
import type { DataSource, SystemDataSource } from "@conusai-kit/data"
|
||||
import {
|
||||
appUserIdentities,
|
||||
invitations,
|
||||
memberRemoved,
|
||||
organizations,
|
||||
orgMemberships,
|
||||
users,
|
||||
workspaceMemberships,
|
||||
workspaces,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { DETACHED_DISPLAY_NAME, DETACHED_ISSUER } from "../contract/constants.js"
|
||||
import { accountDeletions, type DeletionStep } from "../contract/entities.js"
|
||||
import { applyDeletionChange } from "../core/deletion.js"
|
||||
import type { AccountMembership, AccountStore, DeletionRecord } from "../core/ports.js"
|
||||
|
||||
/** The most rows of one kind an account is read for at once (the kit's own `tenancy.me.get` reads 200). */
|
||||
const LIMIT = 200
|
||||
|
||||
/**
|
||||
* The scope the identity transaction runs in. The rows it changes are global (no tenant column), so
|
||||
* the organization of the scope only sets the session's tenant variable, to an id no organization has;
|
||||
* kit-tenancy's own resolver does the same.
|
||||
*/
|
||||
const IDENTITY_SCOPE_ORG = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
const isConflict = (error: unknown): boolean => error instanceof AppError && error.status === 409
|
||||
|
||||
/** A delete that a concurrent run of the same step made first is done, not an error. */
|
||||
async function gone(run: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await run()
|
||||
} catch (error) {
|
||||
if (!(error instanceof AppError && error.status === 404)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
const byUser = (userId: string) => [{ field: "userId", op: "eq", value: userId }] as const
|
||||
|
||||
const recordOf = (row: {
|
||||
userId: string
|
||||
state: DeletionRecord["state"]
|
||||
pending: string[]
|
||||
erased: string[]
|
||||
abandoned: string[]
|
||||
attempts: number
|
||||
lastFailure: string | null
|
||||
requestedAt: string
|
||||
completedAt: string | null
|
||||
steps: DeletionStep[] | null
|
||||
provider: string | null
|
||||
}): DeletionRecord => ({
|
||||
userId: row.userId,
|
||||
state: row.state,
|
||||
pending: row.pending,
|
||||
erased: row.erased,
|
||||
abandoned: row.abandoned,
|
||||
attempts: row.attempts,
|
||||
lastFailure: row.lastFailure,
|
||||
requestedAt: row.requestedAt,
|
||||
completedAt: row.completedAt,
|
||||
// A row written before the ledger recorded its steps has none: every step runs again, idempotently.
|
||||
steps: row.steps ?? [],
|
||||
provider: row.provider,
|
||||
})
|
||||
|
||||
export function createAccountStore(deps: { data: DataSource; system: SystemDataSource }): AccountStore {
|
||||
const { data, system } = deps
|
||||
const scopeOf = (userId: string, orgId: string): TenantScope => ({ level: "org", orgId, actor: { userId } })
|
||||
const ledger = () => data.global(accountDeletions)
|
||||
|
||||
const membershipRows = async (userId: string) =>
|
||||
(await system.acrossTenants(orgMemberships).list({ where: byUser(userId), limit: LIMIT })).items
|
||||
|
||||
const ledgerRow = async (userId: string) => (await ledger().list({ where: byUser(userId), limit: 1 })).items[0]
|
||||
|
||||
/** Every live session row of the person, page by page. */
|
||||
const liveSessions = async (userId: string) => {
|
||||
const out = []
|
||||
const where = [...byUser(userId), { field: "revokedAt", op: "isNull", isNull: true }] as const
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await data.global(sessions).list({ where, limit: LIMIT, ...(cursor ? { cursor } : {}) })
|
||||
out.push(...page.items)
|
||||
cursor = page.cursor
|
||||
} while (cursor)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity mapping and the profile go in one transaction. Apart, a sign-in between the two would
|
||||
* find no mapping but the user row's legacy issuer/subject columns, and the resolver's backfill would
|
||||
* map the provider identity to this row again. A concurrent change of the row (a profile sync, the
|
||||
* same step of another run) is a version conflict: read again and retry. The id stays: it is the
|
||||
* erasure reference.
|
||||
*/
|
||||
const detach = async (userId: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
try {
|
||||
await data.unitOfWork.run(scopeOf(userId, IDENTITY_SCOPE_ORG), async (tx) => {
|
||||
const user = await tx.global(users).get(userId)
|
||||
if (user && user.issuer !== DETACHED_ISSUER) {
|
||||
await tx.global(users).update(
|
||||
user.id,
|
||||
{
|
||||
issuer: DETACHED_ISSUER,
|
||||
subject: user.id,
|
||||
email: `${user.id}@deleted.invalid`,
|
||||
displayName: DETACHED_DISPLAY_NAME,
|
||||
},
|
||||
user.version,
|
||||
)
|
||||
}
|
||||
for (const alias of (await tx.global(appUserIdentities).list({ where: byUser(userId), limit: LIMIT }))
|
||||
.items) {
|
||||
await tx.global(appUserIdentities).delete(alias.id)
|
||||
}
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
if (!isConflict(error) && !(error instanceof AppError && error.status === 404)) throw error
|
||||
}
|
||||
}
|
||||
throw new AppError({ type: "/problems/conflict", title: "Conflict", status: 409, code: "conflict" })
|
||||
}
|
||||
|
||||
const memberships = async (userId: string): Promise<AccountMembership[]> => {
|
||||
const out: AccountMembership[] = []
|
||||
for (const membership of await membershipRows(userId)) {
|
||||
const organization = await data.global(organizations).get(membership.orgId)
|
||||
if (!organization) continue
|
||||
const inOrg = data.forScope(scopeOf(userId, membership.orgId)).of(orgMemberships)
|
||||
const [owners, members] = await Promise.all([
|
||||
inOrg.count({ where: [{ field: "role", op: "eq", value: "owner" }] }),
|
||||
inOrg.count(),
|
||||
])
|
||||
out.push({
|
||||
orgId: organization.id,
|
||||
slug: organization.slug,
|
||||
name: organization.name,
|
||||
role: membership.role,
|
||||
owners,
|
||||
members,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => (a.slug < b.slug ? -1 : 1))
|
||||
}
|
||||
|
||||
return {
|
||||
async emailOf(userId) {
|
||||
return (await data.global(users).get(userId))?.email ?? null
|
||||
},
|
||||
|
||||
async sessionSignedInAt(userId, sessionId) {
|
||||
const row = await data.global(sessions).get(sessionId)
|
||||
// A row of another user, or one already revoked, answers nothing: it is not this person's live sign-in.
|
||||
if (!row || row.userId !== userId || row.revokedAt !== null) return null
|
||||
return row.createdAt
|
||||
},
|
||||
|
||||
memberships,
|
||||
|
||||
async localExport(userId) {
|
||||
const user = await data.global(users).get(userId)
|
||||
if (!user) return null
|
||||
const identities = (await data.global(appUserIdentities).list({ where: byUser(userId), limit: LIMIT })).items
|
||||
const ownSessions = (await data.global(sessions).list({ where: byUser(userId), limit: LIMIT })).items
|
||||
const organizationsOut = []
|
||||
const workspacesOut = []
|
||||
const keysOut = []
|
||||
for (const membership of await memberships(userId)) {
|
||||
const inOrg = data.forScope(scopeOf(userId, membership.orgId))
|
||||
organizationsOut.push({ slug: membership.slug, name: membership.name, role: membership.role })
|
||||
for (const row of (await inOrg.of(workspaceMemberships).list({ where: byUser(userId), limit: LIMIT })).items) {
|
||||
const workspace = await inOrg.of(workspaces).get(row.workspaceId)
|
||||
if (workspace)
|
||||
workspacesOut.push({ organization: membership.slug, workspace: workspace.slug, role: row.role })
|
||||
}
|
||||
for (const key of (await inOrg.of(apiKeys).list({ where: byUser(userId), limit: LIMIT })).items) {
|
||||
// The key's readable head and its dates; never its hash.
|
||||
keysOut.push({
|
||||
organization: membership.slug,
|
||||
name: key.name,
|
||||
prefix: key.prefix,
|
||||
createdAt: key.createdAt,
|
||||
expiresAt: key.expiresAt,
|
||||
revokedAt: key.revokedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
profile: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
locale: user.locale,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
identities: identities.map((identity) => ({
|
||||
issuer: identity.issuer,
|
||||
subject: identity.subject,
|
||||
linkedAt: identity.createdAt,
|
||||
})),
|
||||
organizations: organizationsOut,
|
||||
workspaces: workspacesOut,
|
||||
// When and how long each device was signed in; never the handle or the provider's tokens.
|
||||
sessions: ownSessions.map((session) => ({
|
||||
signedInAt: session.createdAt,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
expiresAt: session.absoluteExpiresAt,
|
||||
revokedAt: session.revokedAt,
|
||||
})),
|
||||
apiKeys: keysOut,
|
||||
}
|
||||
},
|
||||
|
||||
async deletion(userId) {
|
||||
const row = await ledgerRow(userId)
|
||||
return row ? recordOf(row) : null
|
||||
},
|
||||
|
||||
async openDeletions(limit) {
|
||||
const page = await ledger().list({ where: [{ field: "state", op: "eq", value: "erasing" }], limit })
|
||||
return page.items.map(recordOf).sort((a, b) => (a.requestedAt < b.requestedAt ? -1 : 1))
|
||||
},
|
||||
|
||||
async startDeletion({ userId, modules, provider, at }) {
|
||||
try {
|
||||
return recordOf(
|
||||
await ledger().insert({
|
||||
userId,
|
||||
state: "erasing",
|
||||
pending: [...modules],
|
||||
erased: [],
|
||||
abandoned: [],
|
||||
attempts: 0,
|
||||
lastFailure: null,
|
||||
requestedAt: at,
|
||||
completedAt: null,
|
||||
steps: [],
|
||||
provider,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
// Two requests at once: the second answers the row the first wrote (unique on userId).
|
||||
if (!isConflict(error)) throw error
|
||||
const row = await ledgerRow(userId)
|
||||
if (!row) throw error
|
||||
return recordOf(row)
|
||||
}
|
||||
},
|
||||
|
||||
async changeDeletion(userId, change) {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const row = await ledgerRow(userId)
|
||||
if (!row)
|
||||
throw new AppError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
|
||||
const patch = applyDeletionChange(recordOf(row), change)
|
||||
if (patch === null) return recordOf(row)
|
||||
try {
|
||||
const { pending, erased, abandoned, steps, ...rest } = patch
|
||||
return recordOf(
|
||||
await ledger().update(
|
||||
row.id,
|
||||
{
|
||||
...rest,
|
||||
...(pending ? { pending: [...pending] } : {}),
|
||||
...(erased ? { erased: [...erased] } : {}),
|
||||
...(abandoned ? { abandoned: [...abandoned] } : {}),
|
||||
...(steps ? { steps: [...steps] } : {}),
|
||||
},
|
||||
row.version,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) throw error
|
||||
}
|
||||
}
|
||||
throw new AppError({ type: "/problems/conflict", title: "Conflict", status: 409, code: "conflict" })
|
||||
},
|
||||
|
||||
async identities(userId) {
|
||||
const aliases = (await data.global(appUserIdentities).list({ where: byUser(userId), limit: LIMIT })).items
|
||||
const user = await data.global(users).get(userId)
|
||||
const own = user && user.issuer !== DETACHED_ISSUER ? [{ issuer: user.issuer, subject: user.subject }] : []
|
||||
// The user row's own identity is usually also an alias row: each pair once.
|
||||
const seen = new Set<string>()
|
||||
return [...aliases.map(({ issuer, subject }) => ({ issuer, subject })), ...own].filter((identity) => {
|
||||
const key = JSON.stringify([identity.issuer, identity.subject])
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
},
|
||||
|
||||
async revokeCredentials(userId, at) {
|
||||
// Every device: deleting an account ends all of its sessions, not only the one that asked. The
|
||||
// live rows are asked for by the database (revoked ones are kept for the record and could fill a
|
||||
// page), and the passes repeat until none is left: a request that slid a session's idle window
|
||||
// meanwhile makes its row's version move, and the next pass revokes it.
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
const live = await liveSessions(userId)
|
||||
if (live.length === 0) break
|
||||
for (const row of live) {
|
||||
try {
|
||||
await data.global(sessions).update(row.id, { revokedAt: at }, row.version)
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((await liveSessions(userId)).length > 0) {
|
||||
// Still live after every pass: the step is not done, and is not recorded as done.
|
||||
throw new AppError({ type: "/problems/conflict", title: "Conflict", status: 409, code: "conflict" })
|
||||
}
|
||||
for (const membership of await membershipRows(userId)) {
|
||||
const keys = data.forScope(scopeOf(userId, membership.orgId)).of(apiKeys)
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await keys.list({ where: byUser(userId), limit: LIMIT, ...(cursor ? { cursor } : {}) })
|
||||
for (const key of page.items) {
|
||||
if (key.revokedAt !== null) continue
|
||||
try {
|
||||
await keys.update(key.id, { revokedAt: at }, key.version)
|
||||
} catch (error) {
|
||||
// Revoked by a concurrent run of this step, or by an administrator: either way, revoked.
|
||||
if (!isConflict(error)) throw error
|
||||
}
|
||||
}
|
||||
cursor = page.cursor
|
||||
} while (cursor)
|
||||
}
|
||||
},
|
||||
|
||||
async detachIdentity(userId) {
|
||||
const user = await data.global(users).get(userId)
|
||||
if (user && user.issuer !== DETACHED_ISSUER) {
|
||||
// The open invitations to the address, while the row still names it: one would be a way back in.
|
||||
for (const membership of await membershipRows(userId)) {
|
||||
const open = data.forScope(scopeOf(userId, membership.orgId)).of(invitations)
|
||||
for (const invitation of (
|
||||
await open.list({ where: [{ field: "email", op: "eq", value: user.email }], limit: LIMIT })
|
||||
).items) {
|
||||
if (invitation.acceptedAt === null) await gone(() => open.delete(invitation.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
await detach(userId)
|
||||
},
|
||||
|
||||
async removeLocalData(userId) {
|
||||
for (const membership of await membershipRows(userId)) {
|
||||
await data.unitOfWork.run(scopeOf(userId, membership.orgId), async (tx) => {
|
||||
for (const row of (await tx.of(workspaceMemberships).list({ where: byUser(userId), limit: LIMIT })).items) {
|
||||
await gone(() => tx.of(workspaceMemberships).delete(row.id))
|
||||
}
|
||||
const own = (await tx.of(orgMemberships).list({ where: byUser(userId), limit: 1 })).items[0]
|
||||
if (!own) return
|
||||
await gone(() => tx.of(orgMemberships).delete(own.id))
|
||||
await tx.publish(memberRemoved, { orgId: membership.orgId, userId, removedBy: userId })
|
||||
if ((await tx.of(orgMemberships).count()) > 0) return
|
||||
// Nobody is left to administer it: suspended (reads only, no scheduled work), its content
|
||||
// kept for the operator, who decides what happens to an organization without members.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const organization = await tx.global(organizations).get(membership.orgId)
|
||||
if (!organization || organization.status === "suspended") return
|
||||
try {
|
||||
await tx.global(organizations).update(organization.id, { status: "suspended" }, organization.version)
|
||||
return
|
||||
} catch (error) {
|
||||
// Another run of this step (the request and the sweep at once) suspended it first.
|
||||
if (!isConflict(error)) throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Every session row, revoked or not, a page at a time, and any identity mapping still naming the
|
||||
// user: nothing may be left pointing at a user row that no longer exists.
|
||||
for (;;) {
|
||||
const page = await data.global(sessions).list({ where: byUser(userId), limit: LIMIT })
|
||||
if (page.items.length === 0) break
|
||||
for (const row of page.items) await gone(() => data.global(sessions).delete(row.id))
|
||||
}
|
||||
for (const alias of (await data.global(appUserIdentities).list({ where: byUser(userId), limit: LIMIT })).items) {
|
||||
await gone(() => data.global(appUserIdentities).delete(alias.id))
|
||||
}
|
||||
await gone(() => data.global(users).delete(userId))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The module's names and limits, in a file with no import: a browser bundle can take them without
|
||||
// loading the data layer the schemas live beside.
|
||||
|
||||
/** The format an export names itself with, so a reader of the file knows which shape it holds. */
|
||||
export const ACCOUNT_EXPORT_FORMAT = "conusai.account-export.v1"
|
||||
|
||||
/** The section of an export that holds this app's own rows about the person. */
|
||||
export const ACCOUNT_SECTION = "account"
|
||||
|
||||
/**
|
||||
* How recent the sign-in of the session that asks for a deletion must be. Deleting an account is the
|
||||
* owner action the template spec's session policy asks a re-authentication for (§7): a session that
|
||||
* was left open on a shared machine is not enough.
|
||||
*/
|
||||
export const RECENT_SIGN_IN_SECONDS = 15 * 60
|
||||
|
||||
/** The job that resumes a deletion a module's erasure held up, and how often the worker runs it. */
|
||||
export const DELETION_SWEEP_JOB = "account.deletion.sweep"
|
||||
export const DELETION_SWEEP_CRON = "*/5 * * * *"
|
||||
|
||||
/** What a deleted user row is left holding until the deletion completes: no provider identity, no address. */
|
||||
export const DETACHED_ISSUER = "urn:conusai:account:deleted"
|
||||
export const DETACHED_DISPLAY_NAME = "Deleted account"
|
||||
|
||||
/** Where the account page lives: `settings` is a reserved organization slug, so no organization shadows it. */
|
||||
export const ACCOUNT_PATH = "/settings/account"
|
||||
|
||||
/**
|
||||
* The job that delivers the revocations of app sessions whose derived service sessions a module's
|
||||
* platform has not confirmed ending yet, and how often the worker runs it (pg-boss's finest cron).
|
||||
*/
|
||||
export const REVOCATION_JOB = "account.sessions.revoke"
|
||||
export const REVOCATION_CRON = "* * * * *"
|
||||
|
||||
/** How long one module's revocation call may take before it counts as failed (a sign-out waits no longer). */
|
||||
export const REVOCATION_CALL_MS = 3000
|
||||
|
||||
/** How long a delivery pass holds its row: well above one bounded call, so a pass that died is retried. */
|
||||
export const REVOCATION_CLAIM_MS = 30_000
|
||||
|
||||
/** The first retry waits this long, each next one twice as long, up to REVOCATION_BACKOFF_MAX_MS. */
|
||||
export const REVOCATION_BACKOFF_MS = 30_000
|
||||
export const REVOCATION_BACKOFF_MAX_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* The longest a derived service session can outlive its app session's revocation: a Support widget
|
||||
* session lives 60 minutes from its mint (the platform's fixed lifetime), plus a margin for clocks.
|
||||
* A revocation still undelivered after this has nothing left to end: it is `lapsed`, not retried.
|
||||
*/
|
||||
export const DERIVED_SESSION_LIFETIME_MS = 65 * 60_000
|
||||
|
||||
/** Delivered and lapsed revocation rows are removed this long after they finished. */
|
||||
export const REVOCATION_RETENTION_MS = 24 * 60 * 60_000
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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])
|
||||
@@ -0,0 +1,48 @@
|
||||
// The public API of the account module: what a page, another module and the OpenAPI document may
|
||||
// import.
|
||||
|
||||
export {
|
||||
ACCOUNT_EXPORT_FORMAT,
|
||||
ACCOUNT_PATH,
|
||||
ACCOUNT_SECTION,
|
||||
DELETION_SWEEP_CRON,
|
||||
DELETION_SWEEP_JOB,
|
||||
DERIVED_SESSION_LIFETIME_MS,
|
||||
DETACHED_DISPLAY_NAME,
|
||||
DETACHED_ISSUER,
|
||||
RECENT_SIGN_IN_SECONDS,
|
||||
REVOCATION_CRON,
|
||||
REVOCATION_JOB,
|
||||
} from "./constants.js"
|
||||
export {
|
||||
ACCOUNT_ENTITIES,
|
||||
accountDeletions,
|
||||
accountSessionRevocations,
|
||||
DELETION_STATES,
|
||||
DELETION_STEPS,
|
||||
type DeletionState,
|
||||
type DeletionStep,
|
||||
REVOCATION_STATES,
|
||||
type RevocationState,
|
||||
} from "./entities.js"
|
||||
export {
|
||||
type AccountExport,
|
||||
ANALYTICS_REQUEST_KINDS,
|
||||
ANALYTICS_STATUSES,
|
||||
type AnalyticsView,
|
||||
accountAnalyticsExport,
|
||||
accountAnalyticsRequest,
|
||||
accountAnalyticsStatus,
|
||||
accountDataExport,
|
||||
accountDeletionRequest,
|
||||
accountDeletionStatus,
|
||||
accountExport,
|
||||
analyticsStore,
|
||||
analyticsView,
|
||||
type DeletionStatus,
|
||||
type DeletionView,
|
||||
deletionStatusOutput,
|
||||
deletionView,
|
||||
type ExportSection,
|
||||
exportSection,
|
||||
} from "./procedures.js"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,188 @@
|
||||
// The person's platform analytics and observability data (ADR-049): start its erasure or export,
|
||||
// follow the job, take the export away. Pure TypeScript over the ACCOUNT_PRIVACY port.
|
||||
//
|
||||
// The platform's GET routes answer every request made by this project's deployment tokens, so a
|
||||
// request id from the browser would let one person read another's job or export. The browser
|
||||
// therefore only ever holds a ticket, signed here:
|
||||
//
|
||||
// `v1.<request id>.<expires at, ms since the epoch>.<HMAC-SHA256(K, payload)>`
|
||||
// payload = `v1|<expires at>|<len(user id)>:<user id>|<request id>`
|
||||
//
|
||||
// K is not the deployment token itself: it is HKDF-SHA256 over `ticketKey` (the token) with the info
|
||||
// label TICKET_KEY_INFO, so the bearer credential never doubles as a MAC key and the ticket MAC is
|
||||
// useless for anything else. The version and the expiry are inside the signed payload, so neither
|
||||
// can be edited. A ticket is accepted only for the user it was signed for and only until it
|
||||
// expires (TICKET_LIFETIME_MS); a ticket of somebody else, an expired one or a forged one is the
|
||||
// same NotFound.
|
||||
//
|
||||
// Rotation: rotating the deployment token changes K, so every outstanding ticket stops verifying
|
||||
// (NotFound). That is deliberate and harmless: nothing is stored, a ticket only lives in the open
|
||||
// page, and asking again answers the open job (the platform's own idempotency) with a fresh ticket,
|
||||
// or starts a new export once the last one finished. A reload forgets the ticket the same way.
|
||||
|
||||
import { AppError, conflict, notFound } from "@conusai-kit/core"
|
||||
import type { AnalyticsView } from "../contract/procedures.js"
|
||||
import type { AccountPrivacy, AccountPrivacyRequest } from "./ports.js"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
/** The ticket format's version; part of both the ticket and its signed payload. */
|
||||
export const TICKET_VERSION = "v1"
|
||||
/** The HKDF info label that makes the ticket MAC key purpose-specific. */
|
||||
export const TICKET_KEY_INFO = "conusai-template/account-privacy-ticket/v1"
|
||||
/** How long a ticket names its request: a day, longer than any page stays open on a job. */
|
||||
export const TICKET_LIFETIME_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const unavailable = (detail: string) =>
|
||||
new AppError({
|
||||
type: "/problems/unavailable",
|
||||
title: "Service unavailable",
|
||||
status: 503,
|
||||
code: "unavailable",
|
||||
detail,
|
||||
})
|
||||
|
||||
function hex(bytes: ArrayBuffer): string {
|
||||
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
/** HKDF-SHA256(secret, salt = empty, info = TICKET_KEY_INFO) as a non-extractable HMAC key. */
|
||||
async function ticketMacKey(secret: string): Promise<CryptoKey> {
|
||||
const ikm = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(TICKET_KEY_INFO) },
|
||||
ikm,
|
||||
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
||||
false,
|
||||
["sign"],
|
||||
)
|
||||
}
|
||||
|
||||
async function signature(secret: string, userId: string, requestId: string, expiresAt: number): Promise<string> {
|
||||
// A length prefix, so ("ab", "c") and ("a", "bc") never sign alike; the request id is last and the
|
||||
// other fields are fixed-form, so the payload parses one way only.
|
||||
const payload = `${TICKET_VERSION}|${expiresAt}|${userId.length}:${userId}|${requestId}`
|
||||
return hex(await crypto.subtle.sign("HMAC", await ticketMacKey(secret), encoder.encode(payload)))
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two hex strings. */
|
||||
function sameHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
return diff === 0
|
||||
}
|
||||
|
||||
/** A ticket naming `requestId` for `userId`, valid for TICKET_LIFETIME_MS from `now`. */
|
||||
export async function ticketFor(secret: string, userId: string, requestId: string, now: number): Promise<string> {
|
||||
const expiresAt = now + TICKET_LIFETIME_MS
|
||||
return `${TICKET_VERSION}.${requestId}.${expiresAt}.${await signature(secret, userId, requestId, expiresAt)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The request id a ticket names, when it was signed for this user under this secret and has not
|
||||
* expired at `now`; NotFound otherwise (one answer for forged, foreign and expired tickets).
|
||||
*/
|
||||
export async function requestIdOf(secret: string, userId: string, ticket: string, now: number): Promise<string> {
|
||||
const missing = () => notFound("no such request for this account")
|
||||
const parts = ticket.split(".")
|
||||
// The request id may in principle hold dots: version first, expiry and MAC last.
|
||||
if (parts.length < 4 || parts[0] !== TICKET_VERSION) throw missing()
|
||||
const given = parts.at(-1)
|
||||
const expiresText = parts.at(-2)
|
||||
const requestId = parts.slice(1, -2).join(".")
|
||||
if (!given || !expiresText || !requestId || !/^[0-9]{1,16}$/.test(expiresText)) throw missing()
|
||||
const expiresAt = Number(expiresText)
|
||||
const expected = await signature(secret, userId, requestId, expiresAt)
|
||||
// The MAC first and always, then the clock: an expired ticket answers exactly like a forged one.
|
||||
const authentic = sameHex(given, expected)
|
||||
if (!authentic || !(now < expiresAt)) throw missing()
|
||||
return requestId
|
||||
}
|
||||
|
||||
/** The platform's refusals as the app's problems; the deployment token never shows in one. */
|
||||
function platformFailure(error: unknown): AppError {
|
||||
const status = typeof (error as { status?: unknown })?.status === "number" ? (error as { status: number }).status : 0
|
||||
if (status === 404) return notFound("no such request for this account")
|
||||
if (status === 409) return conflict("the export is not ready, has expired, or failed")
|
||||
if (status === 403) return unavailable("this app's deployment token does not hold privacy:subjects")
|
||||
return unavailable("the platform's privacy service did not answer")
|
||||
}
|
||||
|
||||
const view = async (
|
||||
privacy: AccountPrivacy,
|
||||
userId: string,
|
||||
request: AccountPrivacyRequest,
|
||||
created: boolean,
|
||||
now: number,
|
||||
): Promise<AnalyticsView> => ({
|
||||
ticket: await ticketFor(privacy.ticketKey, userId, request.id, now),
|
||||
kind: request.kind,
|
||||
status: request.status,
|
||||
created,
|
||||
stores: Object.fromEntries(
|
||||
Object.entries(request.stores).map(([store, outcome]) => [
|
||||
store,
|
||||
{
|
||||
status: outcome.status === "not_keyed" || outcome.status === "failed" ? outcome.status : "completed",
|
||||
deleted: outcome.deleted ?? 0,
|
||||
anonymized: outcome.anonymized ?? 0,
|
||||
exported: outcome.exported ?? 0,
|
||||
},
|
||||
]),
|
||||
) as AnalyticsView["stores"],
|
||||
requestedAt: request.created_at,
|
||||
finishedAt: request.finished_at ?? null,
|
||||
expiresAt: request.export_expires_at ?? null,
|
||||
})
|
||||
|
||||
const required = (privacy: AccountPrivacy | undefined): AccountPrivacy => {
|
||||
if (!privacy) throw unavailable("this deployment has no platform privacy service")
|
||||
return privacy
|
||||
}
|
||||
|
||||
export async function requestAnalytics(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
kind: AnalyticsView["kind"],
|
||||
now: () => number,
|
||||
): Promise<AnalyticsView> {
|
||||
const port = required(privacy)
|
||||
try {
|
||||
const { request, created } =
|
||||
kind === "erase" ? await port.eraseSubject({ userId }) : await port.exportSubject({ userId })
|
||||
return await view(port, userId, request, created, now())
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyticsStatus(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
ticket: string,
|
||||
now: () => number,
|
||||
): Promise<AnalyticsView> {
|
||||
const port = required(privacy)
|
||||
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
|
||||
try {
|
||||
return await view(port, userId, await port.getRequest(requestId), false, now())
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyticsExport(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
ticket: string,
|
||||
now: () => number,
|
||||
): Promise<{ exportedAt: string; document: Record<string, unknown> }> {
|
||||
const port = required(privacy)
|
||||
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
|
||||
try {
|
||||
return { exportedAt: new Date(now()).toISOString(), document: await port.downloadExport(requestId) }
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// "Delete my account" (joint contract §12, §14 "Lifecycle, CSP and revocation").
|
||||
//
|
||||
// The order is the contract's, and every step can be repeated:
|
||||
//
|
||||
// 0. the person confirms (the account's e-mail address) from a session whose sign-in is recent;
|
||||
// the last owner of an organization others belong to hands it over first;
|
||||
// 1. the ledger row is written: the erasure reference (the app user id), the modules to erase and
|
||||
// the identity provider whose account ends with it, before anything of the account is removed,
|
||||
// so nothing that follows can lose the subject;
|
||||
// 2. `credentials`: every session and API key of the person is revoked -- nothing acts as them
|
||||
// from here on;
|
||||
// 3. `service-sessions`: so, server-side, is every service session a module minted for them (a
|
||||
// Support widget session), so none lives on to its own expiry while that module's erasure
|
||||
// waits. A module that cannot be reached leaves the step open, and does not hold the deletion up;
|
||||
// 4. `provider-account`: an external provider that owns the account (Better Auth) ends it there,
|
||||
// found by the identity mapping, so its sessions cannot map to a new, empty user afterwards. A
|
||||
// failure, or a process with no such provider bound, holds the deletion here: the next step
|
||||
// would remove the mapping the account is found by;
|
||||
// 5. `identity`: the identity mapping is removed (with the user row's provider identity, in one
|
||||
// transaction), and the sessions are revoked once more, so no sign-in reaches this account again
|
||||
// and no new service session is minted for its subject (a new sign-in makes a new, unrelated user);
|
||||
// 6. each installed module erases its share by the subject; a module that is unavailable stays
|
||||
// pending and the worker's sweep retries it (DELETION_SWEEP_JOB);
|
||||
// 7. only when every module confirmed: the memberships and the user row go, and the row is marked
|
||||
// completed. The ledger row stays, holding the opaque id, module and provider names only.
|
||||
//
|
||||
// The ledger records each of steps 2-5 when it completed (DeletionRecord.steps) and each module's
|
||||
// confirmed erasure, so a deletion interrupted anywhere -- a crash, a refused erasure, two requests
|
||||
// at once -- resumes from the ledger alone with exactly the steps still to do, and a step that
|
||||
// completed is not repeated. Each step is idempotent, so two runs at once are harmless.
|
||||
|
||||
import { AppError, forbidden, notFound } from "@conusai-kit/core"
|
||||
import { RECENT_SIGN_IN_SECONDS } from "../contract/constants.js"
|
||||
import type { DeletionStep } from "../contract/entities.js"
|
||||
import type { DeletionStatus, DeletionView } from "../contract/procedures.js"
|
||||
import { refusalOf } from "./export.js"
|
||||
import type {
|
||||
AccountCredential,
|
||||
AccountDeps,
|
||||
AccountIdentityProvider,
|
||||
AccountMembership,
|
||||
DeletionChange,
|
||||
DeletionPatch,
|
||||
DeletionRecord,
|
||||
OrganizationRef,
|
||||
} from "./ports.js"
|
||||
|
||||
/** A clock this far ahead of the session's is still "recent", not a refusal. */
|
||||
const SKEW_MS = 60_000
|
||||
|
||||
const problem = (status: number, code: string, title: string, detail: string): AppError =>
|
||||
new AppError({ type: `/problems/${code}`, title, status, code, detail })
|
||||
|
||||
export const reauthenticationRequired = (): AppError =>
|
||||
problem(403, "reauthentication_required", "Sign in again", "sign in again to delete this account")
|
||||
|
||||
const refOf = (membership: AccountMembership): OrganizationRef => ({ slug: membership.slug, name: membership.name })
|
||||
|
||||
/** Organizations that would be left without an owner while others are still members. */
|
||||
export const lastOwnerOf = (memberships: readonly AccountMembership[]): OrganizationRef[] =>
|
||||
memberships.filter((m) => m.role === "owner" && m.owners <= 1 && m.members > 1).map(refOf)
|
||||
|
||||
/** Organizations that would be left without any member: a deletion suspends them. */
|
||||
export const onlyMemberOf = (memberships: readonly AccountMembership[]): OrganizationRef[] =>
|
||||
memberships.filter((m) => m.members <= 1).map(refOf)
|
||||
|
||||
/** The modules a deletion erases, in module order. */
|
||||
export const erasingModules = (deps: AccountDeps): string[] =>
|
||||
deps.contributions.filter((contribution) => contribution.erase !== undefined).map((contribution) => contribution.key)
|
||||
|
||||
const recent = (signedInAt: string | null, now: number): boolean => {
|
||||
if (signedInAt === null) return false
|
||||
const age = now - Date.parse(signedInAt)
|
||||
return age >= -SKEW_MS && age <= RECENT_SIGN_IN_SECONDS * 1000
|
||||
}
|
||||
|
||||
const normalized = (address: string): string => address.trim().toLowerCase()
|
||||
|
||||
export const viewOf = (record: DeletionRecord): DeletionView => ({
|
||||
state: record.state,
|
||||
erased: [...record.erased],
|
||||
pending: [...record.pending],
|
||||
abandoned: [...record.abandoned],
|
||||
requestedAt: record.requestedAt,
|
||||
completedAt: record.completedAt,
|
||||
})
|
||||
|
||||
/**
|
||||
* When the asking session signed in: its kit row's, or -- for an external provider's session, which
|
||||
* has none -- the time the provider reported with the session it validated for this request.
|
||||
*/
|
||||
const signedInAtOf = async (deps: AccountDeps, userId: string, credential: AccountCredential) => {
|
||||
if (!credential.interactive) return null
|
||||
if (credential.sessionId) return deps.store.sessionSignedInAt(userId, credential.sessionId)
|
||||
return credential.signedInAt ?? null
|
||||
}
|
||||
|
||||
export async function deletionStatus(
|
||||
deps: AccountDeps,
|
||||
userId: string,
|
||||
credential: AccountCredential,
|
||||
): Promise<DeletionStatus> {
|
||||
const [deletion, memberships, signedInAt] = await Promise.all([
|
||||
deps.store.deletion(userId),
|
||||
deps.store.memberships(userId),
|
||||
signedInAtOf(deps, userId, credential),
|
||||
])
|
||||
return {
|
||||
deletion: deletion ? viewOf(deletion) : null,
|
||||
interactive: credential.interactive,
|
||||
recentSignIn: recent(signedInAt, deps.now()),
|
||||
signedInAt,
|
||||
recentSignInSeconds: RECENT_SIGN_IN_SECONDS,
|
||||
lastOwnerOf: lastOwnerOf(memberships),
|
||||
onlyMemberOf: onlyMemberOf(memberships),
|
||||
modules: erasingModules(deps),
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeletionRequest {
|
||||
readonly userId: string
|
||||
readonly credential: AccountCredential
|
||||
readonly confirm: string
|
||||
}
|
||||
|
||||
export async function requestDeletion(deps: AccountDeps, request: DeletionRequest): Promise<DeletionView> {
|
||||
const { userId, credential } = request
|
||||
if (!credential.interactive) {
|
||||
throw forbidden("deleting an account needs the person signed in to this app, not an API credential")
|
||||
}
|
||||
// A deletion that has begun is resumed, whatever was typed: the person may be retrying it.
|
||||
const existing = await deps.store.deletion(userId)
|
||||
if (existing) return continueDeletion(deps, existing)
|
||||
|
||||
const email = await deps.store.emailOf(userId)
|
||||
if (email === null) throw notFound("no such user")
|
||||
if (normalized(request.confirm) !== normalized(email)) {
|
||||
throw problem(400, "confirmation_mismatch", "Not confirmed", "type the account's e-mail address to confirm")
|
||||
}
|
||||
if (!recent(await signedInAtOf(deps, userId, credential), deps.now())) throw reauthenticationRequired()
|
||||
const blocking = lastOwnerOf(await deps.store.memberships(userId))
|
||||
if (blocking.length > 0) {
|
||||
throw problem(
|
||||
409,
|
||||
"last_owner",
|
||||
"Hand over your organizations first",
|
||||
`make another member an owner of ${blocking.map((org) => org.slug).join(", ")} first`,
|
||||
)
|
||||
}
|
||||
|
||||
return continueDeletion(deps, await startDeletion(deps, userId))
|
||||
}
|
||||
|
||||
/** The bound provider's id, or null: a provider that cannot be built still names a provider step. */
|
||||
function providerIdOf(deps: AccountDeps): string | null {
|
||||
if (!deps.identityProvider) return null
|
||||
try {
|
||||
return deps.identityProvider().id
|
||||
} catch {
|
||||
return UNRESOLVED_PROVIDER
|
||||
}
|
||||
}
|
||||
|
||||
/** What the ledger records when the bound provider could not be built: the step is held until it can. */
|
||||
const UNRESOLVED_PROVIDER = "unresolved"
|
||||
|
||||
const startDeletion = (deps: AccountDeps, userId: string): Promise<DeletionRecord> =>
|
||||
deps.store.startDeletion({
|
||||
userId,
|
||||
modules: erasingModules(deps),
|
||||
provider: providerIdOf(deps),
|
||||
at: new Date(deps.now()).toISOString(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The rule of every ledger change, on the row as it is now: null when the change is already made (a
|
||||
* concurrent run made it first), so the store writes nothing. Pure, so every store applies the same.
|
||||
*/
|
||||
export function applyDeletionChange(row: DeletionRecord, change: DeletionChange): DeletionPatch | null {
|
||||
switch (change.kind) {
|
||||
case "step":
|
||||
return row.steps.includes(change.step) ? null : { steps: [...row.steps, change.step] }
|
||||
case "erased":
|
||||
return row.pending.includes(change.module)
|
||||
? { pending: row.pending.filter((key) => key !== change.module), erased: [...row.erased, change.module] }
|
||||
: null
|
||||
case "abandoned":
|
||||
return row.pending.includes(change.module)
|
||||
? { pending: row.pending.filter((key) => key !== change.module), abandoned: [...row.abandoned, change.module] }
|
||||
: null
|
||||
case "attempt":
|
||||
return { attempts: row.attempts + 1, lastFailure: change.failure }
|
||||
case "completed":
|
||||
return row.state === "completed" ? null : { state: "completed", completedAt: change.at, lastFailure: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps 2-7 for one ledger row. Safe to run again at any point, by the request or by the sweep. */
|
||||
export async function continueDeletion(deps: AccountDeps, record: DeletionRecord): Promise<DeletionView> {
|
||||
if (record.state === "completed") return viewOf(record)
|
||||
const { userId } = record
|
||||
const at = new Date(deps.now()).toISOString()
|
||||
let current = record
|
||||
const done = (step: DeletionStep) => current.steps.includes(step)
|
||||
const complete = async (step: DeletionStep) => {
|
||||
current = await deps.store.changeDeletion(userId, { kind: "step", step })
|
||||
}
|
||||
const held = async (failure: string | null) => {
|
||||
current = await deps.store.changeDeletion(userId, { kind: "attempt", failure })
|
||||
return viewOf(current)
|
||||
}
|
||||
|
||||
if (!done("credentials")) {
|
||||
await deps.store.revokeCredentials(userId, at)
|
||||
await complete("credentials")
|
||||
}
|
||||
if (!done("service-sessions") && (await revokeModuleSessions(deps, userId))) await complete("service-sessions")
|
||||
// A row the ledger recorded no provider for (written before it did, or by a process that bound
|
||||
// none) still ends the account when this process binds one: an app that has an external provider
|
||||
// binds it in every process, and skipping the step would leave the account behind.
|
||||
const providerDue = current.provider !== null || deps.identityProvider !== undefined
|
||||
if (providerDue && !done("provider-account")) {
|
||||
const failure = await endProviderAccount(deps, current)
|
||||
if (failure !== null) return held(failure)
|
||||
await complete("provider-account")
|
||||
}
|
||||
if (!done("identity")) {
|
||||
// Sessions first, then the identity, then sessions again: a sign-in that completed between the
|
||||
// two opened a session this pass ends; after the detach no sign-in reaches the account.
|
||||
await deps.store.detachIdentity(userId)
|
||||
await deps.store.revokeCredentials(userId, at)
|
||||
await complete("identity")
|
||||
}
|
||||
|
||||
let failure: string | null = null
|
||||
for (const key of current.pending) {
|
||||
const contribution = deps.contributions.find((candidate) => candidate.key === key)
|
||||
if (!contribution?.erase) {
|
||||
// Removed from the app after the deletion began: this app can no longer reach its service. The
|
||||
// ledger keeps the module and the subject, so an operator erases it there by `userId`.
|
||||
current = await deps.store.changeDeletion(userId, { kind: "abandoned", module: key })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await contribution.erase({
|
||||
subject: userId,
|
||||
container: deps.container,
|
||||
...(deps.signal ? { signal: deps.signal } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
failure = `${key}:${refusalOf(error).code}`
|
||||
continue
|
||||
}
|
||||
current = await deps.store.changeDeletion(userId, { kind: "erased", module: key })
|
||||
}
|
||||
|
||||
if (current.pending.length > 0) return held(failure)
|
||||
await deps.store.removeLocalData(userId)
|
||||
current = await deps.store.changeDeletion(userId, { kind: "completed", at })
|
||||
return viewOf(current)
|
||||
}
|
||||
|
||||
/** The problem code of a provider step that could not end the account, or null once it did. */
|
||||
async function endProviderAccount(deps: AccountDeps, record: DeletionRecord): Promise<string | null> {
|
||||
let provider: AccountIdentityProvider | undefined
|
||||
try {
|
||||
provider = deps.identityProvider?.()
|
||||
} catch {
|
||||
provider = undefined
|
||||
}
|
||||
// The process that resumes the deletion must reach the provider the deletion began with: going on
|
||||
// without it would detach the identity the account is found by, and leave the account behind.
|
||||
if (!provider) return "provider-account:provider_unavailable"
|
||||
if (record.provider !== null && record.provider !== UNRESOLVED_PROVIDER && record.provider !== provider.id) {
|
||||
return "provider-account:provider_unavailable"
|
||||
}
|
||||
try {
|
||||
const identities = await deps.store.identities(record.userId)
|
||||
const subjects = identities.filter((identity) => identity.issuer === provider.issuer).map(({ subject }) => subject)
|
||||
// Identities, but none under the provider's namespace: a namespace that differs between processes
|
||||
// would otherwise "succeed" and detach the identity the account is found by. Fail closed.
|
||||
if (subjects.length === 0 && identities.length > 0) return "provider-account:not_mapped"
|
||||
// Every account of the person at the provider (several aliases may map under one namespace).
|
||||
for (const subject of new Set(subjects)) await provider.endAccount(subject)
|
||||
return null
|
||||
} catch (error) {
|
||||
return `provider-account:${refusalOf(error).code}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed module's derived service sessions of the subject, revoked server-side; true when
|
||||
* every module confirmed. A module that cannot be reached does not hold the deletion up: its
|
||||
* erasure ends the same sessions (and is retried until it does), and a resumption repeats this step
|
||||
* until every module confirmed it.
|
||||
*/
|
||||
async function revokeModuleSessions(deps: AccountDeps, subject: string): Promise<boolean> {
|
||||
const settled = await Promise.allSettled(
|
||||
deps.contributions
|
||||
.filter((contribution) => contribution.revokeSessions)
|
||||
.map((contribution) =>
|
||||
contribution.revokeSessions?.({
|
||||
subject,
|
||||
container: deps.container,
|
||||
...(deps.signal ? { signal: deps.signal } : {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return settled.every((result) => result.status === "fulfilled")
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity provider deleted the account itself (Better Auth's own deletion, delivered by its
|
||||
* revocation relay): the same erasure as `requestDeletion`, without the person's confirmation, the
|
||||
* recent sign-in or the last-owner hand-over -- the account is already gone at the provider, so
|
||||
* there is nothing left to confirm with and nothing to wait for. The ledger row comes first, as
|
||||
* always, so the worker's sweep finishes what a failing module holds up. Idempotent.
|
||||
*/
|
||||
export async function deleteForProvider(deps: AccountDeps, userId: string): Promise<DeletionView> {
|
||||
const existing = await deps.store.deletion(userId)
|
||||
if (existing) return continueDeletion(deps, existing)
|
||||
if ((await deps.store.emailOf(userId)) === null) throw notFound("no such user")
|
||||
return continueDeletion(deps, await startDeletion(deps, userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity provider disabled the account (an administrator's ban): every session and API key of
|
||||
* the person is revoked here, and every service session a module minted for them, server-side. The
|
||||
* provider refuses the person's sign-in from now on; nothing of the account is erased.
|
||||
*/
|
||||
export async function disableForProvider(deps: AccountDeps, userId: string): Promise<void> {
|
||||
await deps.store.revokeCredentials(userId, new Date(deps.now()).toISOString())
|
||||
await revokeModuleSessions(deps, userId)
|
||||
}
|
||||
|
||||
/** The worker's sweep: resumes every deletion still erasing. Fails, after trying all, when some still wait. */
|
||||
export async function sweepDeletions(deps: AccountDeps, limit = 100): Promise<{ resumed: number; completed: number }> {
|
||||
const open = await deps.store.openDeletions(limit)
|
||||
let completed = 0
|
||||
for (const record of open) {
|
||||
try {
|
||||
if ((await continueDeletion(deps, record)).state === "completed") completed += 1
|
||||
} catch {
|
||||
// Counted below: one row's failure must not keep the others from being resumed.
|
||||
}
|
||||
}
|
||||
const waiting = open.length - completed
|
||||
if (waiting > 0) {
|
||||
// A failed run is what the worker reports and an operator is alerted on; the next run retries.
|
||||
throw new AppError({
|
||||
type: "/problems/account_deletion_waiting",
|
||||
title: "Account deletions are waiting",
|
||||
status: 503,
|
||||
code: "account_deletion_waiting",
|
||||
detail: `${waiting} account deletion(s) wait for a module's erasure`,
|
||||
retryAfter: 300,
|
||||
})
|
||||
}
|
||||
return { resumed: open.length, completed }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// "Export my data": every installed module's share of the account, composed into one document.
|
||||
//
|
||||
// Each module that keeps something about the person contributes an export to kit-tenancy's
|
||||
// `tenancy.account` point -- this module's own rows among them -- and every one of them is a section.
|
||||
// A module that fails is a `failed` section and the export is `complete: false`, naming it; a module
|
||||
// that can erase but not export is `not_exported`. Nothing is left out without saying so (joint
|
||||
// contract §12).
|
||||
|
||||
import { AppError, notFound } from "@conusai-kit/core"
|
||||
import { ACCOUNT_EXPORT_FORMAT } from "../contract/constants.js"
|
||||
import type { AccountExport, ExportSection } from "../contract/procedures.js"
|
||||
import type { AccountDeps } from "./ports.js"
|
||||
|
||||
/** The problem code of a refusal, and whether trying again later may help. Never its detail. */
|
||||
export function refusalOf(error: unknown): { code: string; retryable: boolean } {
|
||||
if (error instanceof AppError) return { code: error.code, retryable: error.status >= 500 || error.status === 429 }
|
||||
return { code: "unavailable", retryable: true }
|
||||
}
|
||||
|
||||
export async function exportAccount(deps: AccountDeps, userId: string): Promise<AccountExport> {
|
||||
if ((await deps.store.emailOf(userId)) === null) throw notFound("no such user")
|
||||
const sections = await Promise.all(
|
||||
deps.contributions.map(async (contribution): Promise<[string, ExportSection]> => {
|
||||
if (!contribution.export) return [contribution.key, { status: "not_exported" }]
|
||||
try {
|
||||
const data = await contribution.export({
|
||||
subject: userId,
|
||||
container: deps.container,
|
||||
...(deps.signal ? { signal: deps.signal } : {}),
|
||||
})
|
||||
return [contribution.key, { status: "ok", data }]
|
||||
} catch (error) {
|
||||
return [contribution.key, { status: "failed", ...refusalOf(error) }]
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failed = sections.filter(([, section]) => section.status === "failed").map(([key]) => key)
|
||||
return {
|
||||
format: ACCOUNT_EXPORT_FORMAT,
|
||||
exportedAt: new Date(deps.now()).toISOString(),
|
||||
subject: userId,
|
||||
complete: sections.every(([, section]) => section.status === "ok"),
|
||||
failed,
|
||||
sections: Object.fromEntries(sections),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// The ports of the account module. The use cases (export.ts, deletion.ts) are pure TypeScript over
|
||||
// these; adapters/store.ts implements the store over kit-data, and the composition roots bind the
|
||||
// two tokens.
|
||||
|
||||
import { type Container, type Registry, token } from "@conusai-kit/core"
|
||||
import type { AccountDataContribution, OrgRole } from "@conusai-kit/tenancy"
|
||||
import type { DeletionState, DeletionStep, RevocationState } from "../contract/entities.js"
|
||||
|
||||
export interface OrganizationRef {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** One organization the person belongs to, with what a deletion has to know about it. */
|
||||
export interface AccountMembership extends OrganizationRef {
|
||||
readonly orgId: string
|
||||
readonly role: OrgRole
|
||||
/** Owners of the organization, the person included. */
|
||||
readonly owners: number
|
||||
/** Members of the organization, the person included. */
|
||||
readonly members: number
|
||||
}
|
||||
|
||||
export interface DeletionRecord {
|
||||
readonly userId: string
|
||||
readonly state: DeletionState
|
||||
readonly pending: readonly string[]
|
||||
readonly erased: readonly string[]
|
||||
readonly abandoned: readonly string[]
|
||||
readonly attempts: number
|
||||
readonly lastFailure: string | null
|
||||
readonly requestedAt: string
|
||||
readonly completedAt: string | null
|
||||
/** The steps before the erasures that completed (DELETION_STEPS), so a resumption skips them. */
|
||||
readonly steps: readonly DeletionStep[]
|
||||
/** The identity provider whose account the deletion ends (AccountIdentityProvider.id), or null. */
|
||||
readonly provider: string | null
|
||||
}
|
||||
|
||||
export type DeletionPatch = Partial<Omit<DeletionRecord, "userId" | "requestedAt" | "provider">>
|
||||
|
||||
/**
|
||||
* One change of a ledger row, as data: what a use case asks of the store, which applies it to the
|
||||
* row's current version (`applyDeletionChange`, core/deletion.ts) and retries a concurrent change.
|
||||
* Data, not a callback, so a store in another process (the Convex profile's internal mutations)
|
||||
* applies exactly the same rule.
|
||||
*/
|
||||
export type DeletionChange =
|
||||
| { readonly kind: "step"; readonly step: DeletionStep }
|
||||
| { readonly kind: "erased"; readonly module: string }
|
||||
| { readonly kind: "abandoned"; readonly module: string }
|
||||
| { readonly kind: "attempt"; readonly failure: string | null }
|
||||
| { readonly kind: "completed"; readonly at: string }
|
||||
|
||||
/** This app's own rows about a person, and the deletion ledger. */
|
||||
export interface AccountStore {
|
||||
/** The account's e-mail address, which the person types to confirm a deletion; null for no such user. */
|
||||
emailOf(userId: string): Promise<string | null>
|
||||
/** When the person signed in to the session that asks, or null for a session of somebody else, revoked or gone. */
|
||||
sessionSignedInAt(userId: string, sessionId: string): Promise<string | null>
|
||||
memberships(userId: string): Promise<readonly AccountMembership[]>
|
||||
/** The rows of this app the person can take away: profile, identities, memberships, sessions, API keys. */
|
||||
localExport(userId: string): Promise<Record<string, unknown> | null>
|
||||
|
||||
deletion(userId: string): Promise<DeletionRecord | null>
|
||||
/** Deletions still erasing, oldest first. */
|
||||
openDeletions(limit: number): Promise<readonly DeletionRecord[]>
|
||||
/** Writes the ledger row, or answers the one a concurrent request wrote first. */
|
||||
startDeletion(input: {
|
||||
userId: string
|
||||
modules: readonly string[]
|
||||
provider: string | null
|
||||
at: string
|
||||
}): Promise<DeletionRecord>
|
||||
/** Applies one change to the row (a read-modify-write that retries a concurrent change); a change already made writes nothing. */
|
||||
changeDeletion(userId: string, change: DeletionChange): Promise<DeletionRecord>
|
||||
/**
|
||||
* The person's identities that still map to the account: every identity-mapping row, and the user
|
||||
* row's own provider identity unless it was detached. Empty once the identity step ran.
|
||||
*/
|
||||
identities(userId: string): Promise<readonly { readonly issuer: string; readonly subject: string }[]>
|
||||
|
||||
/** Revokes every session (every device) and every API key of the person. Idempotent. */
|
||||
revokeCredentials(userId: string, at: string): Promise<void>
|
||||
/**
|
||||
* Removes the identity-provider mapping and the profile from the user row, so no sign-in reaches
|
||||
* the account again (a new sign-in with the same provider identity makes a new, unrelated user).
|
||||
* The row itself stays, under the same id, until every module's erasure is confirmed. Idempotent.
|
||||
*/
|
||||
detachIdentity(userId: string): Promise<void>
|
||||
/** Leaves every organization (suspending one left with no member) and deletes the user row. Idempotent. */
|
||||
removeLocalData(userId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** What the use cases run with: the store, the installed modules' shares and the container they resolve ports from. */
|
||||
export interface AccountDeps {
|
||||
readonly store: AccountStore
|
||||
readonly contributions: readonly AccountDataContribution[]
|
||||
readonly container: Container
|
||||
readonly now: () => number
|
||||
readonly signal?: AbortSignal
|
||||
/**
|
||||
* The external provider whose account a deletion ends (ACCOUNT_IDENTITY_PROVIDER), when one is bound:
|
||||
* resolved only when the step runs, so a provider that cannot be built (a missing variable) holds a
|
||||
* deletion at that step instead of failing every export and deletion.
|
||||
*/
|
||||
readonly identityProvider?: () => AccountIdentityProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential of the request that asks, as the composition root knows it: whether a person signed
|
||||
* in to this app is behind it, and the kit session row it is bound to. A process that binds nothing
|
||||
* (the worker, a test) is a caller with no interactive session.
|
||||
*/
|
||||
export interface AccountCredential {
|
||||
readonly interactive: boolean
|
||||
readonly sessionId?: string
|
||||
/**
|
||||
* When the person signed in to the session that asks, for a session with no kit row (an external
|
||||
* provider's, such as Better Auth's): the provider reports it with the session it validated.
|
||||
*/
|
||||
readonly signedInAt?: string
|
||||
}
|
||||
|
||||
export const ACCOUNT_CREDENTIAL = token<AccountCredential>("account.credential")
|
||||
|
||||
/**
|
||||
* The app's external sign-in provider, when it owns its own accounts (Better Auth): a deletion ends
|
||||
* the person's account there before it detaches the identity, so no live provider session maps to a
|
||||
* new, empty user afterwards. The deletion records the provider's `id` when it begins, and every
|
||||
* process that may resume one (the web, the worker's sweep, the Convex profile's actions) binds the
|
||||
* same provider: a resumption without it waits at that step. The account is found by the identity
|
||||
* mapping under `issuer`, which is why the step runs before the identity is detached. `endAccount`
|
||||
* is idempotent: an account already gone is not an error. An app that binds none (OIDC, whose
|
||||
* provider account is the identity service's own) has no such step.
|
||||
*/
|
||||
export interface AccountIdentityProvider {
|
||||
/** The provider's name, as the ledger records it (`convex-better-auth`). */
|
||||
readonly id: string
|
||||
/** The issuer the identity mapping holds the provider's subjects under (AUTH_IDENTITY_NAMESPACE). */
|
||||
readonly issuer: string
|
||||
/** Ends the account of this provider subject, sessions first. */
|
||||
endAccount(providerSubject: string): Promise<void>
|
||||
}
|
||||
|
||||
export const ACCOUNT_IDENTITY_PROVIDER = token<AccountIdentityProvider>("account.identityProvider")
|
||||
|
||||
/**
|
||||
* The store a use case runs with, when the composition root binds one: the Convex profile's actions
|
||||
* bind a store whose every method is one internal query or mutation (convex/account.ts). Unbound,
|
||||
* the store is built over the container's data source (adapters/store.ts).
|
||||
*/
|
||||
export const ACCOUNT_STORE = token<AccountStore>("account.store")
|
||||
|
||||
/** The registry the installed modules' `tenancy.account` contributions are read from. */
|
||||
export const ACCOUNT_REGISTRY = token<Registry>("account.registry")
|
||||
|
||||
/** One revoked app session whose derived service sessions are still to be ended (the revocation outbox). */
|
||||
export interface RevocationRecord {
|
||||
readonly id: string
|
||||
readonly version: number
|
||||
readonly session: string
|
||||
readonly state: RevocationState
|
||||
readonly pending: readonly string[]
|
||||
readonly delivered: readonly string[]
|
||||
readonly attempts: number
|
||||
readonly nextAttemptAt: string
|
||||
readonly claimedUntil: string | null
|
||||
readonly lastFailure: string | null
|
||||
readonly requestedAt: string
|
||||
readonly completedAt: string | null
|
||||
}
|
||||
|
||||
export type RevocationPatch = Partial<
|
||||
Pick<
|
||||
RevocationRecord,
|
||||
"state" | "pending" | "delivered" | "attempts" | "nextAttemptAt" | "claimedUntil" | "lastFailure" | "completedAt"
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* The revocation outbox a delivery runs with, when the composition root binds one: the Convex
|
||||
* profile's actions bind a store whose every method is one internal query or mutation
|
||||
* (convex/account.ts). Unbound, it is built over the container's data source (adapters/revocations.ts).
|
||||
*/
|
||||
export const REVOCATION_STORE = token<RevocationStore>("account.revocations")
|
||||
|
||||
/** The revocation outbox. Every change is written against the row's version: a lost race writes nothing. */
|
||||
export interface RevocationStore {
|
||||
/** Writes the row, claimed by the caller until `claimedUntil`; null when the session is already recorded. */
|
||||
record(input: {
|
||||
session: string
|
||||
modules: readonly string[]
|
||||
at: string
|
||||
claimedUntil: string
|
||||
}): Promise<RevocationRecord | null>
|
||||
/** Pending rows whose next attempt is due at `now` and that no live claim holds, oldest first. */
|
||||
due(now: string, limit: number): Promise<readonly RevocationRecord[]>
|
||||
/** The row with `patch` applied if it is still at `record.version`, else null (another process changed it). */
|
||||
change(record: RevocationRecord, patch: RevocationPatch): Promise<RevocationRecord | null>
|
||||
/** Removes delivered and lapsed rows that finished before `before`; answers how many. */
|
||||
purge(before: string): Promise<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's per-subject erasure and export (ADR-049), as the account module needs it: a
|
||||
* structural subset of `@conusai-kit/platform`'s `Privacy` (createPrivacy), bound by the web's
|
||||
* composition root over the deployment token (`privacy:subjects` in conusai.json). `ticketKey` is the
|
||||
* server-side secret (the deployment token) the module derives its ticket MAC key from with HKDF
|
||||
* (core/analytics.ts): the token itself never keys a MAC, and rotating it invalidates open tickets.
|
||||
* Unbound (the fake platform, a test), the analytics panel answers "unavailable".
|
||||
*/
|
||||
export interface AccountPrivacyRequest {
|
||||
readonly id: string
|
||||
readonly kind: "erase" | "export"
|
||||
readonly status: "pending" | "running" | "completed" | "failed" | "expired"
|
||||
readonly stores: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{ readonly status: string; readonly deleted?: number; readonly anonymized?: number; readonly exported?: number }
|
||||
>
|
||||
>
|
||||
readonly created_at: string
|
||||
readonly finished_at?: string | null
|
||||
readonly export_expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface AccountPrivacy {
|
||||
readonly ticketKey: string
|
||||
eraseSubject(subject: { userId: string }): Promise<{ request: AccountPrivacyRequest; created: boolean }>
|
||||
exportSubject(subject: { userId: string }): Promise<{ request: AccountPrivacyRequest; created: boolean }>
|
||||
getRequest(requestId: string): Promise<AccountPrivacyRequest>
|
||||
downloadExport(requestId: string): Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const ACCOUNT_PRIVACY = token<AccountPrivacy>("account.privacy")
|
||||
@@ -0,0 +1,183 @@
|
||||
// The revocation outbox (joint contract §14 "Lifecycle, CSP and revocation"): when kit-auth revokes an
|
||||
// app session -- a sign-out, a refused refresh, an expiry -- every installed module that mints derived
|
||||
// service sessions (a Support widget session records its parent app session) ends them on its
|
||||
// platform. A platform that is down at that moment must not leave them alive until their own expiry,
|
||||
// and must not hold the sign-out up either:
|
||||
//
|
||||
// 1. the revocation is written as a row (the modules still to tell), claimed by this process;
|
||||
// 2. each module is asked at once, bounded (REVOCATION_CALL_MS); the ones that confirmed leave
|
||||
// `pending`, and a row with nothing pending is `delivered`;
|
||||
// 3. a row left pending is retried by the worker's revocation job (REVOCATION_JOB), with backoff,
|
||||
// until every module confirmed -- or until no session it minted can still be alive (`lapsed`);
|
||||
// 4. a delivery pass claims its row with a versioned write first, so two processes (the web's
|
||||
// first attempt and the worker, or two workers) never deliver one row at once, and a module that
|
||||
// confirmed is never asked again.
|
||||
//
|
||||
// Nothing here throws into a sign-out: `revokeOnSignOut` answers once the row is written and the first
|
||||
// attempt has settled or timed out. Only a refused write of the row itself is reported (thrown), so
|
||||
// the caller can fall back to asking the modules directly.
|
||||
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import type { AccountDataContribution } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
DERIVED_SESSION_LIFETIME_MS,
|
||||
REVOCATION_BACKOFF_MAX_MS,
|
||||
REVOCATION_BACKOFF_MS,
|
||||
REVOCATION_CALL_MS,
|
||||
REVOCATION_CLAIM_MS,
|
||||
REVOCATION_RETENTION_MS,
|
||||
} from "../contract/constants.js"
|
||||
import { refusalOf } from "./export.js"
|
||||
import type { RevocationRecord, RevocationStore } from "./ports.js"
|
||||
|
||||
export interface RevocationDeps {
|
||||
readonly store: RevocationStore
|
||||
readonly contributions: readonly AccountDataContribution[]
|
||||
/** The container each module resolves its platform port from. */
|
||||
readonly container: Container
|
||||
readonly now: () => number
|
||||
readonly log: { warn(message: string): void }
|
||||
/** How long one module's call may take; REVOCATION_CALL_MS by default. */
|
||||
readonly callMs?: number
|
||||
}
|
||||
|
||||
const iso = (ms: number): string => new Date(ms).toISOString()
|
||||
|
||||
/** The modules that mint derived service sessions, in module order. */
|
||||
export const revokingModules = (contributions: readonly AccountDataContribution[]): string[] =>
|
||||
contributions
|
||||
.filter((contribution) => contribution.revokeSessions !== undefined)
|
||||
.map((contribution) => contribution.key)
|
||||
|
||||
/** When the retry after `attempts` failed passes is due: REVOCATION_BACKOFF_MS doubling, capped. */
|
||||
export function revocationBackoffMs(attempts: number): number {
|
||||
return Math.min(REVOCATION_BACKOFF_MAX_MS, REVOCATION_BACKOFF_MS * 2 ** Math.max(0, Math.min(attempts - 1, 20)))
|
||||
}
|
||||
|
||||
async function bounded(run: () => Promise<void> | undefined, ms: number): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timed out")), ms)
|
||||
})
|
||||
try {
|
||||
await Promise.race([run(), timeout])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One delivery pass over a row this process has claimed: every pending module is asked, bounded; the
|
||||
* row is then written against the claimed version -- the confirmed modules leave `pending`, a row with
|
||||
* none left is `delivered`, otherwise the next attempt is scheduled with backoff and the claim released.
|
||||
*/
|
||||
async function deliver(deps: RevocationDeps, claimed: RevocationRecord): Promise<RevocationRecord | null> {
|
||||
const callMs = deps.callMs ?? REVOCATION_CALL_MS
|
||||
const outcomes = await Promise.all(
|
||||
claimed.pending.map(async (key): Promise<{ key: string; failure: string | null }> => {
|
||||
const contribution = deps.contributions.find((candidate) => candidate.key === key)
|
||||
// Removed from the app since the revocation was recorded: nothing here can reach its platform,
|
||||
// and its sessions end at their own expiry.
|
||||
if (!contribution?.revokeSessions) return { key, failure: null }
|
||||
try {
|
||||
await bounded(
|
||||
() => contribution.revokeSessions?.({ session: claimed.session, container: deps.container.child() }),
|
||||
callMs,
|
||||
)
|
||||
return { key, failure: null }
|
||||
} catch (error) {
|
||||
return { key, failure: `${key}:${refusalOf(error).code}` }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failed = outcomes.filter((outcome) => outcome.failure !== null)
|
||||
const confirmed = outcomes.filter((outcome) => outcome.failure === null).map((outcome) => outcome.key)
|
||||
const now = deps.now()
|
||||
if (failed.length === 0) {
|
||||
return deps.store.change(claimed, {
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: [...claimed.delivered, ...confirmed],
|
||||
claimedUntil: null,
|
||||
lastFailure: null,
|
||||
completedAt: iso(now),
|
||||
})
|
||||
}
|
||||
for (const { key } of failed)
|
||||
deps.log.warn(`auth: ${key} did not revoke the sessions of a revoked app session; retrying`)
|
||||
const attempts = claimed.attempts + 1
|
||||
return deps.store.change(claimed, {
|
||||
pending: failed.map((outcome) => outcome.key),
|
||||
delivered: [...claimed.delivered, ...confirmed],
|
||||
attempts,
|
||||
nextAttemptAt: iso(now + revocationBackoffMs(attempts)),
|
||||
claimedUntil: null,
|
||||
lastFailure: failed[0]?.failure ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* kit-auth's revocation hook, durable: records the revocation, then delivers it at once. Answers
|
||||
* within about one bounded call; a module that could not be reached is retried by the worker. Throws
|
||||
* only when the row could not be written (nothing was recorded).
|
||||
*/
|
||||
export async function revokeOnSignOut(deps: RevocationDeps, session: string): Promise<void> {
|
||||
const modules = revokingModules(deps.contributions)
|
||||
if (modules.length === 0) return
|
||||
const now = deps.now()
|
||||
const recorded = await deps.store.record({
|
||||
session,
|
||||
modules,
|
||||
at: iso(now),
|
||||
claimedUntil: iso(now + REVOCATION_CLAIM_MS),
|
||||
})
|
||||
// Already recorded (kit-auth told twice about one session): the row that exists is delivered by
|
||||
// whoever holds it, or by the worker.
|
||||
if (recorded === null) return
|
||||
try {
|
||||
await deliver(deps, recorded)
|
||||
} catch {
|
||||
// The row stays claimed until its claim expires; the worker then delivers it.
|
||||
deps.log.warn("auth: a revocation was recorded but not yet delivered; the worker retries it")
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevocationSweep {
|
||||
/** Rows this pass claimed. */
|
||||
readonly claimed: number
|
||||
/** Rows every module has now confirmed. */
|
||||
readonly delivered: number
|
||||
/** Rows still pending, scheduled again. */
|
||||
readonly waiting: number
|
||||
/** Rows given up: no session they name can still be alive. */
|
||||
readonly lapsed: number
|
||||
/** Finished rows removed. */
|
||||
readonly purged: number
|
||||
}
|
||||
|
||||
/** The worker's revocation job: every due row, claimed one by one and delivered. */
|
||||
export async function sweepSessionRevocations(deps: RevocationDeps, limit = 100): Promise<RevocationSweep> {
|
||||
const start = deps.now()
|
||||
const due = await deps.store.due(iso(start), limit)
|
||||
let claimed = 0
|
||||
let delivered = 0
|
||||
let waiting = 0
|
||||
let lapsed = 0
|
||||
for (const row of due) {
|
||||
const now = deps.now()
|
||||
if (now - Date.parse(row.requestedAt) >= DERIVED_SESSION_LIFETIME_MS) {
|
||||
// Every service session minted for that app session has expired by now: nothing is left to end.
|
||||
if (await deps.store.change(row, { state: "lapsed", claimedUntil: null, completedAt: iso(now) })) lapsed += 1
|
||||
continue
|
||||
}
|
||||
const mine = await deps.store.change(row, { claimedUntil: iso(now + REVOCATION_CLAIM_MS) })
|
||||
// Another process claimed or settled it first: that one delivers it.
|
||||
if (mine === null) continue
|
||||
claimed += 1
|
||||
const settled = await deliver(deps, mine).catch(() => null)
|
||||
if (settled?.state === "delivered") delivered += 1
|
||||
else waiting += 1
|
||||
}
|
||||
const purged = await deps.store.purge(iso(start - REVOCATION_RETENTION_MS))
|
||||
return { claimed, delivered, waiting, lapsed, purged }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// English messages of the account module. Every string its page shows is a key here and in lt.ts;
|
||||
// `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Your account",
|
||||
intro: "What this app keeps about you, and your account itself.",
|
||||
link: "Account",
|
||||
profile: {
|
||||
heading: "Profile",
|
||||
name: "Name",
|
||||
email: "E-mail address",
|
||||
},
|
||||
export: {
|
||||
heading: "Export your data",
|
||||
body: "A JSON file with what this app and its services keep about you: your profile, your organizations, your sign-ins and API keys, and each service's own data.",
|
||||
submit: "Export my data",
|
||||
busy: "Preparing your export",
|
||||
done: "Your export is downloaded.",
|
||||
partial:
|
||||
"Your export is downloaded, but some parts of it could not be exported: {sections}. Try again later for a complete one.",
|
||||
filename: "account-export",
|
||||
},
|
||||
sections: {
|
||||
account: "your account in this app",
|
||||
guides: "in-app guides",
|
||||
support: "support chat",
|
||||
},
|
||||
analytics: {
|
||||
heading: "Your analytics data",
|
||||
body: "What the platform this app runs on records about your visits: session recordings, errors, access logs, traces, logs and usage analytics. You can download it, or have it erased. Erased usage events stay counted, with nothing in them that points to you.",
|
||||
exportSubmit: "Download my analytics data",
|
||||
erase: "Erase my analytics data",
|
||||
busy: "Asking the platform",
|
||||
confirmErase:
|
||||
"Erasing your analytics data cannot be undone: your session recordings, errors, access logs, traces and logs are deleted, and your usage events are anonymized. Erase it now?",
|
||||
confirm: "Yes, erase it",
|
||||
cancel: "Cancel",
|
||||
download: "Download the export",
|
||||
filename: "analytics-export",
|
||||
status: {
|
||||
pending: "Your request is queued.",
|
||||
running: "Your request is being worked on.",
|
||||
completed: "Your request is done.",
|
||||
failed: "Your request could not be completed. Try again later.",
|
||||
expired: "Your export has expired. Ask for a new one.",
|
||||
},
|
||||
store: "Data",
|
||||
storeStatus: "Status",
|
||||
deleted: "Deleted",
|
||||
anonymized: "Anonymized",
|
||||
exported: "Exported",
|
||||
stores: {
|
||||
session_replay: "session recordings",
|
||||
errors: "errors",
|
||||
proxy_logs: "access logs",
|
||||
traces: "traces",
|
||||
logs: "logs",
|
||||
analytics: "usage analytics",
|
||||
},
|
||||
},
|
||||
deletion: {
|
||||
heading: "Delete your account",
|
||||
body: "Deleting your account signs you out on every device, revokes your API keys, removes you from your organizations and erases what this app's services keep about you. It cannot be undone.",
|
||||
services: "Services whose data is erased: {modules}.",
|
||||
lastOwner:
|
||||
"You are the last owner of {organizations}. Make another member an owner before you delete your account.",
|
||||
onlyMember:
|
||||
"You are the only member of {organizations}. Deleting your account suspends it; its content stays with the app's operator.",
|
||||
reauth: "To delete your account, sign in again first: a sign-in older than {minutes} minutes is not enough.",
|
||||
reauthLink: "Sign in again",
|
||||
notInteractive: "Only you, signed in to this app, can delete your account.",
|
||||
confirmLabel: "Type your e-mail address to confirm",
|
||||
confirmHint: "Your address is {email}.",
|
||||
submit: "Delete my account",
|
||||
busy: "Deleting your account",
|
||||
completed: "Your account is deleted, and you are signed out.",
|
||||
erasing:
|
||||
"Your account is being deleted, and you are signed out. What {modules} keep about you is erased as soon as the service is available again; nothing more is needed from you.",
|
||||
home: "Go to the start page",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The account module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
|
||||
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,85 @@
|
||||
// Lietuviški account modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
|
||||
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Jūsų paskyra",
|
||||
intro: "Ką ši programa saugo apie jus, ir pati jūsų paskyra.",
|
||||
link: "Paskyra",
|
||||
profile: {
|
||||
heading: "Profilis",
|
||||
name: "Vardas",
|
||||
email: "El. pašto adresas",
|
||||
},
|
||||
export: {
|
||||
heading: "Eksportuoti savo duomenis",
|
||||
body: "JSON failas su tuo, ką ši programa ir jos paslaugos saugo apie jus: jūsų profilis, organizacijos, prisijungimai ir API raktai bei kiekvienos paslaugos duomenys.",
|
||||
submit: "Eksportuoti mano duomenis",
|
||||
busy: "Ruošiamas eksportas",
|
||||
done: "Jūsų eksportas atsisiųstas.",
|
||||
partial:
|
||||
"Jūsų eksportas atsisiųstas, bet kai kurių jo dalių eksportuoti nepavyko: {sections}. Vėliau bandykite dar kartą, kad gautumėte visą.",
|
||||
filename: "paskyros-eksportas",
|
||||
},
|
||||
sections: {
|
||||
account: "jūsų paskyra šioje programoje",
|
||||
guides: "programos vedliai",
|
||||
support: "pagalbos pokalbiai",
|
||||
},
|
||||
analytics: {
|
||||
heading: "Jūsų analitikos duomenys",
|
||||
body: "Ką platforma, kurioje veikia ši programa, įrašo apie jūsų apsilankymus: seansų įrašus, klaidas, prieigos žurnalus, pėdsakus, žurnalus ir naudojimo analitiką. Galite juos atsisiųsti arba paprašyti ištrinti. Ištrinti naudojimo įvykiai lieka suskaičiuoti, bet juose nelieka nieko, kas rodytų į jus.",
|
||||
exportSubmit: "Atsisiųsti mano analitikos duomenis",
|
||||
erase: "Ištrinti mano analitikos duomenis",
|
||||
busy: "Kreipiamasi į platformą",
|
||||
confirmErase:
|
||||
"Analitikos duomenų ištrynimo atšaukti negalima: jūsų seansų įrašai, klaidos, prieigos žurnalai, pėdsakai ir žurnalai bus ištrinti, o naudojimo įvykiai nuasmeninti. Ištrinti dabar?",
|
||||
confirm: "Taip, ištrinti",
|
||||
cancel: "Atšaukti",
|
||||
download: "Atsisiųsti eksportą",
|
||||
filename: "analitikos-eksportas",
|
||||
status: {
|
||||
pending: "Jūsų prašymas laukia eilėje.",
|
||||
running: "Jūsų prašymas vykdomas.",
|
||||
completed: "Jūsų prašymas įvykdytas.",
|
||||
failed: "Jūsų prašymo įvykdyti nepavyko. Bandykite vėliau.",
|
||||
expired: "Jūsų eksporto galiojimas baigėsi. Paprašykite naujo.",
|
||||
},
|
||||
store: "Duomenys",
|
||||
storeStatus: "Būsena",
|
||||
deleted: "Ištrinta",
|
||||
anonymized: "Nuasmeninta",
|
||||
exported: "Eksportuota",
|
||||
stores: {
|
||||
session_replay: "seansų įrašai",
|
||||
errors: "klaidos",
|
||||
proxy_logs: "prieigos žurnalai",
|
||||
traces: "pėdsakai",
|
||||
logs: "žurnalai",
|
||||
analytics: "naudojimo analitika",
|
||||
},
|
||||
},
|
||||
deletion: {
|
||||
heading: "Ištrinti paskyrą",
|
||||
body: "Ištrynus paskyrą, būsite atjungti visuose įrenginiuose, jūsų API raktai bus atšaukti, būsite pašalinti iš organizacijų, o tai, ką apie jus saugo šios programos paslaugos, bus ištrinta. To atšaukti negalima.",
|
||||
services: "Paslaugos, kurių duomenys ištrinami: {modules}.",
|
||||
lastOwner: "Esate paskutinis {organizations} savininkas. Prieš ištrindami paskyrą, paskirkite savininku kitą narį.",
|
||||
onlyMember:
|
||||
"Esate vienintelis {organizations} narys. Ištrynus paskyrą, organizacija bus sustabdyta; jos turinys liks programos operatoriui.",
|
||||
reauth:
|
||||
"Norėdami ištrinti paskyrą, pirmiausia prisijunkite iš naujo: senesnio nei {minutes} min. prisijungimo nepakanka.",
|
||||
reauthLink: "Prisijungti iš naujo",
|
||||
notInteractive: "Paskyrą ištrinti galite tik jūs, prisijungę prie šios programos.",
|
||||
confirmLabel: "Patvirtinkite įvesdami savo el. pašto adresą",
|
||||
confirmHint: "Jūsų adresas: {email}.",
|
||||
submit: "Ištrinti mano paskyrą",
|
||||
busy: "Paskyra trinama",
|
||||
completed: "Jūsų paskyra ištrinta, o jūs atjungti.",
|
||||
erasing:
|
||||
"Jūsų paskyra trinama, o jūs atjungti. Tai, ką apie jus saugo {modules}, bus ištrinta, kai tik paslauga vėl veiks; iš jūsų daugiau nieko nereikia.",
|
||||
home: "Į pradžios puslapį",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// The account module's public entry: its manifest, its contract and the ports the composition roots
|
||||
// bind. A page imports `./ui`.
|
||||
|
||||
export { recordProviderAccountEvent, recordSessionRevocation } from "./adapters/procedures.js"
|
||||
export { createRevocationStore } from "./adapters/revocations.js"
|
||||
export { createAccountStore } from "./adapters/store.js"
|
||||
export * from "./contract/index.js"
|
||||
export {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
ACCOUNT_PRIVACY,
|
||||
ACCOUNT_REGISTRY,
|
||||
ACCOUNT_STORE,
|
||||
type AccountCredential,
|
||||
type AccountIdentityProvider,
|
||||
type AccountPrivacy,
|
||||
type AccountPrivacyRequest,
|
||||
type AccountStore,
|
||||
type DeletionChange,
|
||||
REVOCATION_STORE,
|
||||
type RevocationStore,
|
||||
} from "./core/ports.js"
|
||||
export { accountModule } from "./module.js"
|
||||
export { ACCOUNT_SCHEDULES } from "./schedules.js"
|
||||
@@ -0,0 +1,19 @@
|
||||
create table "account_deletion" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"user_id" uuid not null,
|
||||
"state" text not null,
|
||||
"pending" jsonb not null,
|
||||
"erased" jsonb not null,
|
||||
"abandoned" jsonb not null,
|
||||
"attempts" integer not null,
|
||||
"last_failure" text,
|
||||
"requested_at" text not null,
|
||||
"completed_at" text
|
||||
);
|
||||
|
||||
alter table "account_deletion" add constraint "account_deletion_user_id_key" unique nulls not distinct ("user_id");
|
||||
|
||||
create index "account_deletion_state_idx" on "account_deletion" ("state");
|
||||
@@ -0,0 +1,20 @@
|
||||
create table "account_session_revocation" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"session" text not null,
|
||||
"state" text not null,
|
||||
"pending" jsonb not null,
|
||||
"delivered" jsonb not null,
|
||||
"attempts" integer not null,
|
||||
"next_attempt_at" text not null,
|
||||
"claimed_until" text,
|
||||
"last_failure" text,
|
||||
"requested_at" text not null,
|
||||
"completed_at" text
|
||||
);
|
||||
|
||||
alter table "account_session_revocation" add constraint "account_session_revocation_session_key" unique nulls not distinct ("session");
|
||||
|
||||
create index "account_session_revocation_state_next_attempt_at_idx" on "account_session_revocation" ("state", "next_attempt_at");
|
||||
@@ -0,0 +1,3 @@
|
||||
alter table "account_deletion" add column "steps" jsonb;
|
||||
|
||||
alter table "account_deletion" add column "provider" text;
|
||||
@@ -0,0 +1,34 @@
|
||||
// The account module's manifest (joint contract §12): the person's own account -- export my data, and
|
||||
// delete my account -- over every installed module's share of it.
|
||||
//
|
||||
// A module that keeps something about a person contributes its export and erasure to kit-tenancy's
|
||||
// `tenancy.account` point (guides and support do, from their own manifests); this module composes
|
||||
// them, adds this app's own rows, and owns the deletion ledger that makes a deletion durable. Nothing
|
||||
// here names another module, so removing guides or support (`kit template:prune --without`) removes
|
||||
// their share and leaves this module as it is.
|
||||
|
||||
import { contribute, defineModule } from "@conusai-kit/core"
|
||||
import { repositoryBindings } from "@conusai-kit/data"
|
||||
import { accountData } from "@conusai-kit/tenancy"
|
||||
import { ACCOUNT_JOBS, ACCOUNT_PROCEDURES, accountOwnData } from "./adapters/procedures.js"
|
||||
import { ACCOUNT_ENTITIES } from "./contract/index.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
const tables = repositoryBindings(...ACCOUNT_ENTITIES)
|
||||
|
||||
export const accountModule = defineModule({
|
||||
id: "account",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy", "auth"],
|
||||
procedures: ACCOUNT_PROCEDURES,
|
||||
repositories: { postgres: tables, convex: tables },
|
||||
migrations: { postgres: "migrations" },
|
||||
// Both profiles. On Convex the tables come from the entities (convex/schema.ts), the export and the
|
||||
// deletion are generated actions whose store is one internal query or mutation per step
|
||||
// (convex/account.ts), and the sweep is a Convex cron instead of the worker's job.
|
||||
profiles: ["postgres", "convex"],
|
||||
jobs: ACCOUNT_JOBS,
|
||||
extends: [contribute(accountData, [accountOwnData])],
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// When the worker resumes the deletions a module's erasure held up: every five minutes, with no
|
||||
// organization scope (an account belongs to none); and when it retries the revocations of derived
|
||||
// service sessions a platform has not confirmed. The worker's composition root turns it on
|
||||
// (apps/worker/src/schedules.ts), as it does the files module's recovery.
|
||||
|
||||
import { DELETION_SWEEP_CRON, DELETION_SWEEP_JOB, REVOCATION_CRON, REVOCATION_JOB } from "./contract/constants.js"
|
||||
|
||||
export const ACCOUNT_SCHEDULES = Object.freeze([
|
||||
Object.freeze({ job: DELETION_SWEEP_JOB, cron: DELETION_SWEEP_CRON, scope: null }),
|
||||
// Every minute: the revocations of app sessions whose derived service sessions a module's platform
|
||||
// has not confirmed ending yet (the revocation outbox, core/revocation.ts), each on its own backoff.
|
||||
Object.freeze({ job: REVOCATION_JOB, cron: REVOCATION_CRON, scope: null }),
|
||||
])
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
// "Your analytics data" (ADR-049): what the platform's own stores keep about the person's visits --
|
||||
// session replay, errors, access logs, traces, logs and analytics -- erased or exported by one
|
||||
// platform job. Both buttons call this app's server (account.analytics.*), which calls the platform
|
||||
// with the deployment token; the browser holds only the ticket of the job it started, in memory.
|
||||
// The erasure asks for a confirmation first, because it cannot be undone. While a job is open the
|
||||
// panel polls its status; a completed export offers the download.
|
||||
|
||||
import { DataTable, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useCallback, useEffect, useId, useState } from "react"
|
||||
import type { AnalyticsView } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountAnalyticsLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
readonly erase: string
|
||||
readonly exportSubmit: string
|
||||
readonly busy: string
|
||||
readonly confirmErase: string
|
||||
readonly confirm: string
|
||||
readonly cancel: string
|
||||
readonly download: string
|
||||
readonly filename: string
|
||||
/** One per job status: pending, running, completed, failed, expired. */
|
||||
readonly status: Readonly<Record<AnalyticsView["status"], string>>
|
||||
readonly store: string
|
||||
readonly storeStatus: string
|
||||
readonly deleted: string
|
||||
readonly anonymized: string
|
||||
readonly exported: string
|
||||
/** A readable name per store key; a key without one is shown as it is. */
|
||||
readonly storeNames: Readonly<Record<string, string>>
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** How often an open job is asked about. */
|
||||
export const ANALYTICS_POLL_MS = 5_000
|
||||
|
||||
type Busy = "erase" | "export" | "download" | null
|
||||
|
||||
function save(document: Record<string, unknown>, filename: string, exportedAt: string): void {
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }))
|
||||
const link = window.document.createElement("a")
|
||||
link.href = href
|
||||
link.download = `${filename}-${exportedAt.slice(0, 10)}.json`
|
||||
window.document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
}
|
||||
|
||||
const isOpen = (job: AnalyticsView | null) => job !== null && (job.status === "pending" || job.status === "running")
|
||||
|
||||
export function AccountAnalyticsPanel({
|
||||
labels,
|
||||
pollMs = ANALYTICS_POLL_MS,
|
||||
}: {
|
||||
labels: AccountAnalyticsLabels
|
||||
pollMs?: number
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const [jobs, setJobs] = useState<{ erase: AnalyticsView | null; export: AnalyticsView | null }>({
|
||||
erase: null,
|
||||
export: null,
|
||||
})
|
||||
const [busy, setBusy] = useState<Busy>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
|
||||
const remember = useCallback((job: AnalyticsView) => setJobs((current) => ({ ...current, [job.kind]: job })), [])
|
||||
|
||||
const start = async (kind: AnalyticsView["kind"]): Promise<void> => {
|
||||
setBusy(kind)
|
||||
setFailure(null)
|
||||
setConfirming(false)
|
||||
try {
|
||||
remember((await call("account.analytics.request", { kind })) as AnalyticsView)
|
||||
} catch (error) {
|
||||
setFailure(describe(error))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async (job: AnalyticsView): Promise<void> => {
|
||||
setBusy("download")
|
||||
setFailure(null)
|
||||
try {
|
||||
const answer = (await call("account.analytics.export", { ticket: job.ticket })) as {
|
||||
exportedAt: string
|
||||
document: Record<string, unknown>
|
||||
}
|
||||
save(answer.document, labels.filename, answer.exportedAt)
|
||||
} catch (error) {
|
||||
setFailure(describe(error))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow every open job until it settles.
|
||||
useEffect(() => {
|
||||
const open = [jobs.erase, jobs.export].filter(isOpen) as AnalyticsView[]
|
||||
if (open.length === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
for (const job of open) {
|
||||
call("account.analytics.status", { ticket: job.ticket })
|
||||
.then((next) => remember(next as AnalyticsView))
|
||||
.catch((error: unknown) => setFailure(describe(error)))
|
||||
}
|
||||
}, pollMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [jobs, call, describe, remember, pollMs])
|
||||
|
||||
const rows = (job: AnalyticsView) =>
|
||||
Object.entries(job.stores).map(([store, outcome]) => ({
|
||||
id: store,
|
||||
store: labels.storeNames[store] ?? store,
|
||||
status: outcome.status,
|
||||
deleted: outcome.deleted,
|
||||
anonymized: outcome.anonymized,
|
||||
exported: outcome.exported,
|
||||
}))
|
||||
|
||||
const jobView = (job: AnalyticsView | null) =>
|
||||
job === null ? null : (
|
||||
<div className="app-stack" data-kind={job.kind}>
|
||||
<p role="status">{labels.status[job.status]}</p>
|
||||
{Object.keys(job.stores).length > 0 ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ id: "store", header: labels.store },
|
||||
{ id: "status", header: labels.storeStatus },
|
||||
...(job.kind === "erase"
|
||||
? [
|
||||
{ id: "deleted" as const, header: labels.deleted },
|
||||
{ id: "anonymized" as const, header: labels.anonymized },
|
||||
]
|
||||
: [{ id: "exported" as const, header: labels.exported }]),
|
||||
]}
|
||||
rows={rows(job)}
|
||||
/>
|
||||
) : null}
|
||||
{job.kind === "export" && job.status === "completed" ? (
|
||||
<button type="button" onClick={() => download(job)} disabled={busy !== null} aria-busy={busy === "download"}>
|
||||
{busy === "download" ? labels.busy : labels.download}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
<div className="app-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => start("export")}
|
||||
disabled={busy !== null || isOpen(jobs.export)}
|
||||
aria-busy={busy === "export"}
|
||||
>
|
||||
{busy === "export" ? labels.busy : labels.exportSubmit}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-danger"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={busy !== null || confirming || isOpen(jobs.erase)}
|
||||
>
|
||||
{busy === "erase" ? labels.busy : labels.erase}
|
||||
</button>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div role="alertdialog" aria-labelledby={`${headingId}-confirm`} className="app-confirm">
|
||||
<p id={`${headingId}-confirm`}>{labels.confirmErase}</p>
|
||||
<button type="button" className="app-danger" onClick={() => start("erase")}>
|
||||
{labels.confirm}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)}>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{jobView(jobs.export)}
|
||||
{jobView(jobs.erase)}
|
||||
{failure === null ? null : (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", failure)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
// "Delete my account": the person types the account's e-mail address and confirms, from a session
|
||||
// whose sign-in is recent (the server checks both; this form only says so first). The answer is the
|
||||
// deletion's state: completed, or erasing while a module's service is unavailable -- in both cases the
|
||||
// person is signed out everywhere, and the app's browser modules clear what they kept (`onDeleted`).
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { DeletionView } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountDeletionLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
/** The services whose data is erased, already named; empty when there are none besides the app. */
|
||||
readonly services: string
|
||||
/** Set when the person is the last owner of an organization others belong to: the form is closed. */
|
||||
readonly lastOwner?: string
|
||||
/** Set when an organization would be left without members. */
|
||||
readonly onlyMember?: string
|
||||
readonly reauth: string
|
||||
readonly reauthLink: string
|
||||
readonly notInteractive: string
|
||||
readonly confirmLabel: string
|
||||
readonly confirmHint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly completed: string
|
||||
/** With `{modules}` still in it. */
|
||||
readonly erasing: string
|
||||
readonly home: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
readonly sectionNames: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface AccountDeletionProps {
|
||||
readonly labels: AccountDeletionLabels
|
||||
/** Whether the session may delete now: a person, signed in recently, owning no organization alone. */
|
||||
readonly interactive: boolean
|
||||
readonly recentSignIn: boolean
|
||||
/** Where a person signs in again and comes back to this page. */
|
||||
readonly reauthHref: string
|
||||
/** A deletion already under way, when the page was rendered. */
|
||||
readonly deletion: DeletionView | null
|
||||
/** Runs once the deletion answered, before the result is shown: the app ends its browser modules' context. */
|
||||
readonly onDeleted?: (deletion: DeletionView) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly kind: "idle" | "busy" }
|
||||
| { readonly kind: "deleted"; readonly deletion: DeletionView }
|
||||
| { readonly kind: "failed"; readonly detail: string; readonly code: string }
|
||||
|
||||
const codeOf = (error: unknown): string => {
|
||||
const problem = (error as { problem?: { code?: unknown } } | null)?.problem
|
||||
return typeof problem?.code === "string" ? problem.code : "unknown"
|
||||
}
|
||||
|
||||
export function AccountDeletionPanel(props: AccountDeletionProps): ReactElement {
|
||||
const { labels } = props
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>(
|
||||
props.deletion ? { kind: "deleted", deletion: props.deletion } : { kind: "idle" },
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const confirm = String(new FormData(event.currentTarget).get("confirm") ?? "").trim()
|
||||
if (confirm.length === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const deletion = (await call("account.deletion.request", { confirm })) as DeletionView
|
||||
// The session is revoked now: what the browser modules kept for it goes too (contract §12).
|
||||
await Promise.resolve(props.onDeleted?.(deletion)).catch(() => undefined)
|
||||
setState({ kind: "deleted", deletion })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error), code: codeOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const reauth = (
|
||||
<p role="status">
|
||||
{labels.reauth} <a href={props.reauthHref}>{labels.reauthLink}</a>
|
||||
</p>
|
||||
)
|
||||
|
||||
if (state.kind === "deleted") {
|
||||
const pending = state.deletion.pending.map((key) => labels.sectionNames[key] ?? key).join(", ")
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p role="status">
|
||||
{state.deletion.state === "completed" ? labels.completed : labels.erasing.replace("{modules}", pending)}
|
||||
</p>
|
||||
<a href="/">{labels.home}</a>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = !props.interactive || labels.lastOwner !== undefined
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
{labels.services ? <p>{labels.services}</p> : null}
|
||||
{labels.onlyMember ? <p>{labels.onlyMember}</p> : null}
|
||||
{labels.lastOwner ? <p role="status">{labels.lastOwner}</p> : null}
|
||||
{!props.interactive ? <p role="status">{labels.notInteractive}</p> : null}
|
||||
{props.interactive && !props.recentSignIn ? reauth : null}
|
||||
{blocked ? null : (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={inputId}>{labels.confirmLabel}</label>
|
||||
<input
|
||||
id={inputId}
|
||||
name="confirm"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={hintId}
|
||||
/>
|
||||
<p id={hintId}>{labels.confirmHint}</p>
|
||||
</div>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{state.kind === "failed" && state.code === "reauthentication_required" ? reauth : null}
|
||||
{state.kind === "failed" && state.code !== "reauthentication_required" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
// "Export my data": one call, and the answer saved as a JSON file in the browser. A section that
|
||||
// could not be exported is named on the page as well as in the file, so a person never takes a
|
||||
// partial export for a whole one.
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useId, useState } from "react"
|
||||
import type { AccountExport } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountExportLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
/** With `{sections}` still in it. */
|
||||
readonly partial: string
|
||||
readonly filename: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
/** A readable name per section key; a key without one is shown as it is. */
|
||||
readonly sectionNames: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly kind: "idle" | "busy" | "done" }
|
||||
| { readonly kind: "partial"; readonly sections: string }
|
||||
| { readonly kind: "failed"; readonly detail: string }
|
||||
|
||||
function save(exported: AccountExport, filename: string): void {
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: "application/json" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = href
|
||||
link.download = `${filename}-${exported.exportedAt.slice(0, 10)}.json`
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
}
|
||||
|
||||
export function AccountExportPanel({ labels }: { labels: AccountExportLabels }): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const exported = (await call("account.data.export", {})) as AccountExport
|
||||
save(exported, labels.filename)
|
||||
const missing = Object.entries(exported.sections)
|
||||
.filter(([, section]) => section.status !== "ok")
|
||||
.map(([key]) => labels.sectionNames[key] ?? key)
|
||||
setState(exported.complete ? { kind: "done" } : { kind: "partial", sections: missing.join(", ") })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
<button type="button" onClick={run} disabled={state.kind === "busy"} aria-busy={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
|
||||
{state.kind === "partial" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.partial.replace("{sections}", state.sections)}
|
||||
</p>
|
||||
) : null}
|
||||
{state.kind === "failed" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// The account module's components: the export and the deletion panels, and the label builders the
|
||||
// app's account page (`apps/web/app/settings/account`) builds them with.
|
||||
|
||||
export {
|
||||
type AccountAnalyticsLabels,
|
||||
AccountAnalyticsPanel,
|
||||
ANALYTICS_POLL_MS,
|
||||
} from "./account-analytics.js"
|
||||
export { type AccountDeletionLabels, AccountDeletionPanel, type AccountDeletionProps } from "./account-deletion.js"
|
||||
export { type AccountExportLabels, AccountExportPanel } from "./account-export.js"
|
||||
export { analyticsLabels, deletionLabels, exportLabels, pageLabels, sectionNames, type Translate } from "./labels.js"
|
||||
@@ -0,0 +1,111 @@
|
||||
// The labels each component takes, built from a message lookup. Every key the account page uses is
|
||||
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks -- the page itself holds no string.
|
||||
//
|
||||
// `t` takes a key relative to the module (`export.heading`); the page binds it to
|
||||
// `i18n.message("account." + key)`. The app's `app.failed` is passed in already looked up.
|
||||
|
||||
import { RECENT_SIGN_IN_SECONDS } from "../contract/constants.js"
|
||||
import type { DeletionStatus } from "../contract/procedures.js"
|
||||
import type { AccountAnalyticsLabels } from "./account-analytics.js"
|
||||
import type { AccountDeletionLabels } from "./account-deletion.js"
|
||||
import type { AccountExportLabels } from "./account-export.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The sections this module has a name for; another module's key is shown as it is. */
|
||||
const NAMED_SECTIONS = ["account", "guides", "support"] as const
|
||||
|
||||
export function sectionNames(t: Translate): Record<string, string> {
|
||||
return Object.fromEntries(NAMED_SECTIONS.map((key) => [key, t(`sections.${key}`)]))
|
||||
}
|
||||
|
||||
const named = (keys: readonly string[], names: Readonly<Record<string, string>>): string =>
|
||||
keys.map((key) => names[key] ?? key).join(", ")
|
||||
|
||||
export function pageLabels(t: Translate) {
|
||||
return {
|
||||
title: t("title"),
|
||||
intro: t("intro"),
|
||||
link: t("link"),
|
||||
profileHeading: t("profile.heading"),
|
||||
name: t("profile.name"),
|
||||
email: t("profile.email"),
|
||||
}
|
||||
}
|
||||
|
||||
export function exportLabels(t: Translate, failed: string): AccountExportLabels {
|
||||
return {
|
||||
heading: t("export.heading"),
|
||||
body: t("export.body"),
|
||||
submit: t("export.submit"),
|
||||
busy: t("export.busy"),
|
||||
done: t("export.done"),
|
||||
partial: t("export.partial", { sections: "{sections}" }),
|
||||
filename: t("export.filename"),
|
||||
failed,
|
||||
sectionNames: sectionNames(t),
|
||||
}
|
||||
}
|
||||
|
||||
export function deletionLabels(
|
||||
t: Translate,
|
||||
failed: string,
|
||||
status: Pick<DeletionStatus, "modules" | "lastOwnerOf" | "onlyMemberOf">,
|
||||
email: string,
|
||||
): AccountDeletionLabels {
|
||||
const names = sectionNames(t)
|
||||
const organizations = (list: readonly { name: string }[]) => list.map((org) => org.name).join(", ")
|
||||
return {
|
||||
heading: t("deletion.heading"),
|
||||
body: t("deletion.body"),
|
||||
services: status.modules.length > 0 ? t("deletion.services", { modules: named(status.modules, names) }) : "",
|
||||
...(status.lastOwnerOf.length > 0
|
||||
? { lastOwner: t("deletion.lastOwner", { organizations: organizations(status.lastOwnerOf) }) }
|
||||
: {}),
|
||||
...(status.onlyMemberOf.length > 0
|
||||
? { onlyMember: t("deletion.onlyMember", { organizations: organizations(status.onlyMemberOf) }) }
|
||||
: {}),
|
||||
reauth: t("deletion.reauth", { minutes: Math.round(RECENT_SIGN_IN_SECONDS / 60) }),
|
||||
reauthLink: t("deletion.reauthLink"),
|
||||
notInteractive: t("deletion.notInteractive"),
|
||||
confirmLabel: t("deletion.confirmLabel"),
|
||||
confirmHint: t("deletion.confirmHint", { email }),
|
||||
submit: t("deletion.submit"),
|
||||
busy: t("deletion.busy"),
|
||||
completed: t("deletion.completed"),
|
||||
erasing: t("deletion.erasing", { modules: "{modules}" }),
|
||||
home: t("deletion.home"),
|
||||
failed,
|
||||
sectionNames: names,
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform stores an analytics request reports on (ADR-049 §3). */
|
||||
const ANALYTICS_STORES = ["session_replay", "errors", "proxy_logs", "traces", "logs", "analytics"] as const
|
||||
const ANALYTICS_STATUSES = ["pending", "running", "completed", "failed", "expired"] as const
|
||||
|
||||
export function analyticsLabels(t: Translate, failed: string): AccountAnalyticsLabels {
|
||||
return {
|
||||
heading: t("analytics.heading"),
|
||||
body: t("analytics.body"),
|
||||
erase: t("analytics.erase"),
|
||||
exportSubmit: t("analytics.exportSubmit"),
|
||||
busy: t("analytics.busy"),
|
||||
confirmErase: t("analytics.confirmErase"),
|
||||
confirm: t("analytics.confirm"),
|
||||
cancel: t("analytics.cancel"),
|
||||
download: t("analytics.download"),
|
||||
filename: t("analytics.filename"),
|
||||
status: Object.fromEntries(
|
||||
ANALYTICS_STATUSES.map((key) => [key, t(`analytics.status.${key}`)]),
|
||||
) as AccountAnalyticsLabels["status"],
|
||||
store: t("analytics.store"),
|
||||
storeStatus: t("analytics.storeStatus"),
|
||||
deleted: t("analytics.deleted"),
|
||||
anonymized: t("analytics.anonymized"),
|
||||
exported: t("analytics.exported"),
|
||||
storeNames: Object.fromEntries(ANALYTICS_STORES.map((key) => [key, t(`analytics.stores.${key}`)])),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user