@softure-ai/privacy 0.0.0-stage → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +272 -2
  3. package/dist/contract.d.ts +58 -0
  4. package/dist/contract.d.ts.map +1 -0
  5. package/dist/contract.js +2 -0
  6. package/dist/contract.js.map +1 -0
  7. package/dist/index.d.ts +90 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +52 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/messages/en.d.ts +46 -0
  12. package/dist/messages/en.d.ts.map +1 -0
  13. package/dist/messages/en.js +46 -0
  14. package/dist/messages/en.js.map +1 -0
  15. package/dist/messages/index.d.ts +99 -0
  16. package/dist/messages/index.d.ts.map +1 -0
  17. package/dist/messages/index.js +14 -0
  18. package/dist/messages/index.js.map +1 -0
  19. package/dist/messages/pl.d.ts +3 -0
  20. package/dist/messages/pl.d.ts.map +1 -0
  21. package/dist/messages/pl.js +46 -0
  22. package/dist/messages/pl.js.map +1 -0
  23. package/dist/next/actions.d.ts +4 -0
  24. package/dist/next/actions.d.ts.map +1 -0
  25. package/dist/next/actions.js +57 -0
  26. package/dist/next/actions.js.map +1 -0
  27. package/dist/next/context.d.ts +4 -0
  28. package/dist/next/context.d.ts.map +1 -0
  29. package/dist/next/context.js +13 -0
  30. package/dist/next/context.js.map +1 -0
  31. package/dist/next/index.d.ts +5 -0
  32. package/dist/next/index.d.ts.map +1 -0
  33. package/dist/next/index.js +7 -0
  34. package/dist/next/index.js.map +1 -0
  35. package/dist/next/messages.d.ts +5 -0
  36. package/dist/next/messages.d.ts.map +1 -0
  37. package/dist/next/messages.js +7 -0
  38. package/dist/next/messages.js.map +1 -0
  39. package/dist/next/pages.d.ts +2 -0
  40. package/dist/next/pages.d.ts.map +1 -0
  41. package/dist/next/pages.js +22 -0
  42. package/dist/next/pages.js.map +1 -0
  43. package/dist/next/route.d.ts +6 -0
  44. package/dist/next/route.d.ts.map +1 -0
  45. package/dist/next/route.js +49 -0
  46. package/dist/next/route.js.map +1 -0
  47. package/dist/next/session-cookie.d.ts +3 -0
  48. package/dist/next/session-cookie.d.ts.map +1 -0
  49. package/dist/next/session-cookie.js +17 -0
  50. package/dist/next/session-cookie.js.map +1 -0
  51. package/dist/options.d.ts +27 -0
  52. package/dist/options.d.ts.map +1 -0
  53. package/dist/options.js +70 -0
  54. package/dist/options.js.map +1 -0
  55. package/dist/schema.d.ts +162 -0
  56. package/dist/schema.d.ts.map +1 -0
  57. package/dist/schema.js +17 -0
  58. package/dist/schema.js.map +1 -0
  59. package/dist/server/collect.d.ts +16 -0
  60. package/dist/server/collect.d.ts.map +1 -0
  61. package/dist/server/collect.js +44 -0
  62. package/dist/server/collect.js.map +1 -0
  63. package/dist/server/consents-contributor.d.ts +14 -0
  64. package/dist/server/consents-contributor.d.ts.map +1 -0
  65. package/dist/server/consents-contributor.js +40 -0
  66. package/dist/server/consents-contributor.js.map +1 -0
  67. package/dist/server/consents.d.ts +42 -0
  68. package/dist/server/consents.d.ts.map +1 -0
  69. package/dist/server/consents.js +117 -0
  70. package/dist/server/consents.js.map +1 -0
  71. package/dist/server/context.d.ts +5 -0
  72. package/dist/server/context.d.ts.map +1 -0
  73. package/dist/server/context.js +2 -0
  74. package/dist/server/context.js.map +1 -0
  75. package/dist/server/contributors.d.ts +23 -0
  76. package/dist/server/contributors.d.ts.map +1 -0
  77. package/dist/server/contributors.js +38 -0
  78. package/dist/server/contributors.js.map +1 -0
  79. package/dist/server/erase.d.ts +10 -0
  80. package/dist/server/erase.d.ts.map +1 -0
  81. package/dist/server/erase.js +42 -0
  82. package/dist/server/erase.js.map +1 -0
  83. package/dist/server/health.d.ts +3 -0
  84. package/dist/server/health.d.ts.map +1 -0
  85. package/dist/server/health.js +11 -0
  86. package/dist/server/health.js.map +1 -0
  87. package/dist/server/index.d.ts +11 -0
  88. package/dist/server/index.d.ts.map +1 -0
  89. package/dist/server/index.js +12 -0
  90. package/dist/server/index.js.map +1 -0
  91. package/dist/server/legal-documents.d.ts +9 -0
  92. package/dist/server/legal-documents.d.ts.map +1 -0
  93. package/dist/server/legal-documents.js +18 -0
  94. package/dist/server/legal-documents.js.map +1 -0
  95. package/dist/server/options.d.ts +15 -0
  96. package/dist/server/options.d.ts.map +1 -0
  97. package/dist/server/options.js +20 -0
  98. package/dist/server/options.js.map +1 -0
  99. package/dist/server/rate-limits.d.ts +13 -0
  100. package/dist/server/rate-limits.d.ts.map +1 -0
  101. package/dist/server/rate-limits.js +28 -0
  102. package/dist/server/rate-limits.js.map +1 -0
  103. package/dist/server/registration-consent.d.ts +19 -0
  104. package/dist/server/registration-consent.d.ts.map +1 -0
  105. package/dist/server/registration-consent.js +28 -0
  106. package/dist/server/registration-consent.js.map +1 -0
  107. package/dist/server/self-service.d.ts +29 -0
  108. package/dist/server/self-service.d.ts.map +1 -0
  109. package/dist/server/self-service.js +37 -0
  110. package/dist/server/self-service.js.map +1 -0
  111. package/dist/ui/delete-account-form.d.ts +16 -0
  112. package/dist/ui/delete-account-form.d.ts.map +1 -0
  113. package/dist/ui/delete-account-form.js +19 -0
  114. package/dist/ui/delete-account-form.js.map +1 -0
  115. package/dist/ui/index.d.ts +4 -0
  116. package/dist/ui/index.d.ts.map +1 -0
  117. package/dist/ui/index.js +7 -0
  118. package/dist/ui/index.js.map +1 -0
  119. package/dist/ui/legal-document.d.ts +51 -0
  120. package/dist/ui/legal-document.d.ts.map +1 -0
  121. package/dist/ui/legal-document.js +50 -0
  122. package/dist/ui/legal-document.js.map +1 -0
  123. package/dist/ui/legal-footer.d.ts +18 -0
  124. package/dist/ui/legal-footer.d.ts.map +1 -0
  125. package/dist/ui/legal-footer.js +13 -0
  126. package/dist/ui/legal-footer.js.map +1 -0
  127. package/migrations/0001_create_consents.sql +37 -0
  128. package/module.json +15 -0
  129. package/package.json +64 -4
  130. package/src/contract.ts +70 -0
  131. package/src/index.ts +73 -0
  132. package/src/messages/en.ts +45 -0
  133. package/src/messages/index.ts +18 -0
  134. package/src/messages/pl.ts +47 -0
  135. package/src/next/actions.ts +61 -0
  136. package/src/next/context.ts +14 -0
  137. package/src/next/index.ts +6 -0
  138. package/src/next/messages.ts +9 -0
  139. package/src/next/next-modules.d.ts +12 -0
  140. package/src/next/pages.tsx +36 -0
  141. package/src/next/route.ts +50 -0
  142. package/src/next/session-cookie.ts +18 -0
  143. package/src/options.ts +87 -0
  144. package/src/schema.ts +18 -0
  145. package/src/server/collect.ts +58 -0
  146. package/src/server/consents-contributor.ts +48 -0
  147. package/src/server/consents.ts +142 -0
  148. package/src/server/context.ts +5 -0
  149. package/src/server/contributors.ts +58 -0
  150. package/src/server/erase.ts +42 -0
  151. package/src/server/health.ts +12 -0
  152. package/src/server/index.ts +41 -0
  153. package/src/server/legal-documents.ts +24 -0
  154. package/src/server/options.ts +33 -0
  155. package/src/server/rate-limits.ts +32 -0
  156. package/src/server/registration-consent.ts +43 -0
  157. package/src/server/self-service.ts +59 -0
  158. package/src/ui/delete-account-form.tsx +58 -0
  159. package/src/ui/index.ts +21 -0
  160. package/src/ui/legal-document.tsx +189 -0
  161. package/src/ui/legal-footer.tsx +50 -0
package/src/options.ts ADDED
@@ -0,0 +1,87 @@
1
+ // The options an app passes to `privacy({ ... })` in softure.config.ts, parsed at startup.
2
+ import type { PrivacyContributor } from "@softure-ai/core";
3
+ import { z } from "zod";
4
+
5
+ /** Kebab-case, like a module id: contributor ids are the keys of the export. */
6
+ export const CONTRIBUTOR_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
7
+
8
+ /** A document version as the app writes it, e.g. `2026-10-01` or `1.2`. */
9
+ export const DOCUMENT_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/;
10
+
11
+ const MEBIBYTE = 1024 * 1024;
12
+
13
+ /** The export size limit when the app sets none. */
14
+ export const DEFAULT_EXPORT_MAX_BYTES = 10 * MEBIBYTE;
15
+
16
+ type ExportUserData = NonNullable<PrivacyContributor["exportUserData"]>;
17
+ type DeleteUserData = NonNullable<PrivacyContributor["deleteUserData"]>;
18
+
19
+ const isFunction = (value: unknown) => typeof value === "function";
20
+
21
+ const appContributorSchema = z
22
+ .strictObject({
23
+ /** Names the contributor's part of the export and its log lines, e.g. `profile`. */
24
+ id: z.string().max(64, "must be at most 64 characters").regex(CONTRIBUTOR_ID_PATTERN, "must be kebab-case, e.g. user-profile"),
25
+ /** The user's data held by the app, as plain JSON values (dates become ISO strings). */
26
+ exportUserData: z.custom<ExportUserData>(isFunction, "must be a function").optional(),
27
+ /** Deletes (or anonymises) that data. An `Err` refuses the deletion of the whole account. */
28
+ deleteUserData: z.custom<DeleteUserData>(isFunction, "must be a function").optional(),
29
+ })
30
+ .refine((contributor) => contributor.exportUserData !== undefined || contributor.deleteUserData !== undefined, {
31
+ message: "needs exportUserData, deleteUserData or both",
32
+ });
33
+
34
+ const legalDocumentSchema = z.strictObject({
35
+ /** Names the document in consent records and in `getLegalDocument`, e.g. `terms`. */
36
+ id: z.string().max(64, "must be at most 64 characters").regex(CONTRIBUTOR_ID_PATTERN, "must be kebab-case, e.g. privacy-policy"),
37
+ /**
38
+ * The version in force, stamped on every consent given to the document. Change it whenever the
39
+ * published text changes, e.g. to the date the new text takes effect.
40
+ */
41
+ version: z.string().regex(DOCUMENT_VERSION_PATTERN, "must be 1-32 letters, digits, '.', '_' or '-', e.g. 2026-10-01"),
42
+ });
43
+
44
+ export const privacyOptionsSchema = z
45
+ .strictObject({
46
+ /**
47
+ * The app's own contributors, next to the ones of its modules. They export after the modules
48
+ * and delete before them, so app tables that reference module tables go first.
49
+ */
50
+ contributors: z.array(appContributorSchema).default([]),
51
+ /** The app's legal documents (terms, privacy policy) and their current versions. */
52
+ documents: z.array(legalDocumentSchema).default([]),
53
+ export: z
54
+ .strictObject({
55
+ /** The largest export, in bytes of JSON; a larger one is refused instead of sent. */
56
+ maxBytes: z
57
+ .number()
58
+ .int()
59
+ .min(1024)
60
+ .max(100 * MEBIBYTE)
61
+ .default(DEFAULT_EXPORT_MAX_BYTES),
62
+ /** The download's file name before the date, e.g. `account-data-2026-10-03.json`. */
63
+ fileName: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "must be 1-64 lowercase letters, digits or -").default("account-data"),
64
+ })
65
+ .prefault({}),
66
+ })
67
+ .superRefine((options, context) => {
68
+ const seen = new Set<string>();
69
+ options.contributors.forEach((contributor, index) => {
70
+ if (seen.has(contributor.id)) {
71
+ context.addIssue({ code: "custom", path: ["contributors", index, "id"], message: `"${contributor.id}" is registered twice` });
72
+ }
73
+ seen.add(contributor.id);
74
+ });
75
+ const documentIds = new Set<string>();
76
+ options.documents.forEach((document, index) => {
77
+ if (documentIds.has(document.id)) {
78
+ context.addIssue({ code: "custom", path: ["documents", index, "id"], message: `"${document.id}" is declared twice` });
79
+ }
80
+ documentIds.add(document.id);
81
+ });
82
+ });
83
+
84
+ export type PrivacyOptionsInput = z.input<typeof privacyOptionsSchema>;
85
+ export type PrivacyOptions = z.output<typeof privacyOptionsSchema>;
86
+ export type AppPrivacyContributor = PrivacyOptions["contributors"][number];
87
+ export type LegalDocumentDeclaration = PrivacyOptions["documents"][number];
package/src/schema.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Drizzle view of the module's table (migrations/0001_create_consents.sql). The migration is the
2
+ // source of truth; this file only types the queries.
3
+ import { users } from "@softure-ai/auth";
4
+ import { bigint, boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
5
+
6
+ export const privacySchema = pgSchema("privacy");
7
+
8
+ export const consents = privacySchema.table("consents", {
9
+ id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
10
+ userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
11
+ emailKey: text("email_key"),
12
+ purpose: text("purpose").notNull(),
13
+ granted: boolean("granted").notNull(),
14
+ documentId: text("document_id"),
15
+ documentVersion: text("document_version"),
16
+ source: text("source").notNull(),
17
+ recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
18
+ });
@@ -0,0 +1,58 @@
1
+ // The export: every contributor's part of one user's data, read in one transaction so the parts
2
+ // agree with each other, and bounded in size before it is handed to a response.
3
+ import { err, ok, type Err, type Ok } from "@softure-ai/core";
4
+ import type { PrivacyExport } from "../contract.js";
5
+ import type { PrivacyContext } from "./context.js";
6
+ import { getExportingContributors } from "./contributors.js";
7
+ import { getPrivacyOptions } from "./options.js";
8
+
9
+ export interface CollectedUserData {
10
+ readonly document: PrivacyExport;
11
+ /** The document as JSON, the bytes a download sends; measured against `export.maxBytes`. */
12
+ readonly json: string;
13
+ }
14
+
15
+ export type CollectUserDataResult = Ok<CollectedUserData> | Err<"privacy.export_failed" | "privacy.export_too_large">;
16
+
17
+ /**
18
+ * Collects every exporting contributor's part for `userId`. A contributor's `Err` fails the whole
19
+ * export (`privacy.export_failed`, logged with the contributor's id and code): a file with a part
20
+ * missing would look complete. Thrown errors (a database failure) propagate after the rollback.
21
+ */
22
+ export async function collectUserData(ctx: PrivacyContext, userId: string): Promise<CollectUserDataResult> {
23
+ const contributors = getExportingContributors(ctx.config);
24
+ const exportedAt = ctx.clock.now();
25
+
26
+ const collected = await ctx.db.transaction(
27
+ async (tx): Promise<Ok<Record<string, unknown>> | Err<"privacy.export_failed">> => {
28
+ const txCtx: PrivacyContext = { ...ctx, db: tx };
29
+ const data: Record<string, unknown> = {};
30
+ for (const contributor of contributors) {
31
+ const part = await contributor.exportUserData(txCtx, userId);
32
+ if (!part.ok) {
33
+ console.error(`@softure-ai/privacy: contributor "${contributor.id}" failed the export: ${part.error}`);
34
+ return err("privacy.export_failed");
35
+ }
36
+ data[contributor.id] = part.value;
37
+ }
38
+ return ok(data);
39
+ },
40
+ { isolationLevel: "repeatable read", accessMode: "read only" },
41
+ );
42
+ if (!collected.ok) return collected;
43
+
44
+ const document: PrivacyExport = {
45
+ format: "softure.privacy-export",
46
+ version: 1,
47
+ userId,
48
+ exportedAt: exportedAt.toISOString(),
49
+ data: collected.value,
50
+ };
51
+ const json = JSON.stringify(document, null, 2);
52
+ const { maxBytes } = getPrivacyOptions(ctx.config).export;
53
+ if (Buffer.byteLength(json, "utf8") > maxBytes) {
54
+ console.error(`@softure-ai/privacy: an export exceeded export.maxBytes (${String(maxBytes)})`);
55
+ return err("privacy.export_too_large");
56
+ }
57
+ return ok({ document, json });
58
+ }
@@ -0,0 +1,48 @@
1
+ // The privacy module's own part of a GDPR export and deletion: the consent ledger. A user's
2
+ // consents are the rows of their account and the rows of their email address recorded before
3
+ // the account existed (a waitlist sign-up).
4
+ import { users } from "@softure-ai/auth";
5
+ import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
6
+ import type { Queryable } from "@softure-ai/db";
7
+ import { asc, eq, or, type SQL } from "drizzle-orm";
8
+ import type { ConsentRecord } from "../contract.js";
9
+ import { consents } from "../schema.js";
10
+ import { getEmailKey, toConsentRecord } from "./consents.js";
11
+
12
+ /** What privacy holds about one user, as it appears in their export. */
13
+ export interface PrivacyUserData {
14
+ /** Every consent given or withdrawn, oldest first; `subject` says whether it names the account or its email. */
15
+ readonly consents: readonly (ConsentRecord & { readonly subject: "account" | "email" })[];
16
+ }
17
+
18
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+
20
+ /** The rows of the account and of its email; null when no account has this id. */
21
+ async function matchUserConsents(db: Queryable, userId: string): Promise<SQL | null> {
22
+ if (!UUID.test(userId)) return null;
23
+ const [account] = await db.select({ email: users.email }).from(users).where(eq(users.id, userId));
24
+ if (account === undefined) return eq(consents.userId, userId);
25
+ return or(eq(consents.userId, userId), eq(consents.emailKey, getEmailKey(account.email))) ?? null;
26
+ }
27
+
28
+ export async function exportConsentUserData(context: ModuleContext, userId: string): Promise<Ok<PrivacyUserData>> {
29
+ // Core types `db` as unknown; privacy passes the @softure-ai/db handle or its transaction.
30
+ const db = context.db as Queryable;
31
+ const condition = await matchUserConsents(db, userId);
32
+ if (condition === null) return ok({ consents: [] });
33
+ const rows = await db.select().from(consents).where(condition).orderBy(asc(consents.recordedAt), asc(consents.id));
34
+ return ok({ consents: rows.map((row) => ({ ...toConsentRecord(row), subject: row.userId === null ? "email" : "account" })) });
35
+ }
36
+
37
+ /** Deletes the user's consents: with the data gone there is nothing left to prove consent for. */
38
+ export async function deleteConsentUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
39
+ const db = context.db as Queryable;
40
+ const condition = await matchUserConsents(db, userId);
41
+ if (condition !== null) await db.delete(consents).where(condition);
42
+ return ok();
43
+ }
44
+
45
+ export const privacyConsentsContributor: PrivacyContributor = {
46
+ exportUserData: exportConsentUserData,
47
+ deleteUserData: deleteConsentUserData,
48
+ };
@@ -0,0 +1,142 @@
1
+ // The consent ledger: recording a consent or its withdrawal, and reading the current state and the
2
+ // history of a subject. Rows are only ever inserted (the table refuses UPDATE); the current state
3
+ // of a purpose is its latest row.
4
+ import { createHash } from "node:crypto";
5
+ import { err, ok, type Err, type Ok } from "@softure-ai/core";
6
+ import { and, asc, desc, eq, type SQL } from "drizzle-orm";
7
+ import { z } from "zod";
8
+ import type { ConsentRecord, ConsentState, ConsentSubject } from "../contract.js";
9
+ import { CONTRIBUTOR_ID_PATTERN } from "../options.js";
10
+ import { consents } from "../schema.js";
11
+ import type { PrivacyContext } from "./context.js";
12
+ import { findLegalDocument } from "./legal-documents.js";
13
+
14
+ const MAX_NAME_LENGTH = 64;
15
+ const MAX_EMAIL_LENGTH = 254;
16
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ /** A SHA-256 digest in unpadded base64url, the shape of every stored email key. */
18
+ const EMAIL_KEY = /^[A-Za-z0-9_-]{43}$/;
19
+ const emailSchema = z.email();
20
+
21
+ export interface RecordConsentInput {
22
+ readonly subject: ConsentSubject;
23
+ /** What the consent is for, kebab-case, e.g. `terms` or `newsletter`. */
24
+ readonly purpose: string;
25
+ /** True to give consent, false to withdraw it (a new row; the earlier one stays as evidence). */
26
+ readonly granted: boolean;
27
+ /** The id of a document declared in `privacy({ documents })`; its configured version is recorded. */
28
+ readonly document?: string;
29
+ /** Where the consent was given, kebab-case, e.g. `registration`, `waitlist`, `account`. */
30
+ readonly source: string;
31
+ }
32
+
33
+ export type RecordConsentResult = Ok<ConsentRecord> | Err<"privacy.consent_invalid" | "privacy.document_unknown">;
34
+
35
+ export interface ConsentQuery {
36
+ readonly subject: ConsentSubject;
37
+ readonly purpose: string;
38
+ }
39
+
40
+ /** The key an email subject is stored under: base64url SHA-256 of the trimmed, lowercased address. */
41
+ export function getEmailKey(email: string): string {
42
+ return createHash("sha256").update(email.trim().toLowerCase()).digest("base64url");
43
+ }
44
+
45
+ const isName = (value: string) => value.length <= MAX_NAME_LENGTH && CONTRIBUTOR_ID_PATTERN.test(value);
46
+
47
+ /** The subject's column values, or null when it cannot name anyone (not a UUID, an address or a key). */
48
+ function toSubjectColumns(subject: ConsentSubject): { userId: string; emailKey: null } | { userId: null; emailKey: string } | null {
49
+ if ("userId" in subject) return UUID.test(subject.userId) ? { userId: subject.userId, emailKey: null } : null;
50
+ if ("emailKey" in subject) return EMAIL_KEY.test(subject.emailKey) ? { userId: null, emailKey: subject.emailKey } : null;
51
+ const email = subject.email.trim().toLowerCase();
52
+ if (email.length > MAX_EMAIL_LENGTH || !emailSchema.safeParse(email).success) return null;
53
+ return { userId: null, emailKey: getEmailKey(email) };
54
+ }
55
+
56
+ function matchSubject(subject: ConsentSubject): SQL | null {
57
+ const columns = toSubjectColumns(subject);
58
+ if (columns === null) return null;
59
+ return columns.userId === null ? eq(consents.emailKey, columns.emailKey) : eq(consents.userId, columns.userId);
60
+ }
61
+
62
+ type ConsentRow = typeof consents.$inferSelect;
63
+
64
+ export function toConsentRecord(row: ConsentRow): ConsentRecord {
65
+ return {
66
+ purpose: row.purpose,
67
+ granted: row.granted,
68
+ document: row.documentId === null || row.documentVersion === null ? null : { id: row.documentId, version: row.documentVersion },
69
+ source: row.source,
70
+ recordedAt: row.recordedAt,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Records a consent at `recordedAt`. For `recordConsent` (the clock's now) and the registration
76
+ * hook (the account's creation time).
77
+ */
78
+ export async function insertConsent(ctx: PrivacyContext, input: RecordConsentInput, recordedAt: Date): Promise<RecordConsentResult> {
79
+ const subject = toSubjectColumns(input.subject);
80
+ if (subject === null || !isName(input.purpose) || !isName(input.source)) return err("privacy.consent_invalid");
81
+
82
+ let document: { id: string; version: string } | null = null;
83
+ if (input.document !== undefined) {
84
+ const declared = findLegalDocument(ctx.config, input.document);
85
+ if (declared === undefined) return err("privacy.document_unknown");
86
+ document = { id: declared.id, version: declared.version };
87
+ }
88
+
89
+ const [row] = await ctx.db
90
+ .insert(consents)
91
+ .values({
92
+ ...subject,
93
+ purpose: input.purpose,
94
+ granted: input.granted,
95
+ documentId: document?.id ?? null,
96
+ documentVersion: document?.version ?? null,
97
+ source: input.source,
98
+ recordedAt,
99
+ })
100
+ .returning();
101
+ // An INSERT … RETURNING without a conflict clause returns its row or throws.
102
+ if (row === undefined) throw new Error("@softure-ai/privacy: recording a consent returned no row");
103
+ return ok(toConsentRecord(row));
104
+ }
105
+
106
+ /**
107
+ * Records that `subject` gave (or withdrew) consent to `purpose`, now. With `document`, the
108
+ * document's configured version is recorded with it. Database errors propagate.
109
+ */
110
+ export function recordConsent(ctx: PrivacyContext, input: RecordConsentInput): Promise<RecordConsentResult> {
111
+ return insertConsent(ctx, input, ctx.clock.now());
112
+ }
113
+
114
+ /** The current state of one purpose for one subject (its latest record), or null when none was recorded. */
115
+ export async function getConsent(ctx: PrivacyContext, query: ConsentQuery): Promise<ConsentState | null> {
116
+ const subject = matchSubject(query.subject);
117
+ if (subject === null) return null;
118
+ const [row] = await ctx.db
119
+ .select()
120
+ .from(consents)
121
+ .where(and(subject, eq(consents.purpose, query.purpose)))
122
+ .orderBy(desc(consents.recordedAt), desc(consents.id))
123
+ .limit(1);
124
+ if (row === undefined) return null;
125
+ const record = toConsentRecord(row);
126
+ const isCurrentVersion = record.document === null || findLegalDocument(ctx.config, record.document.id)?.version === record.document.version;
127
+ return { ...record, isCurrentVersion };
128
+ }
129
+
130
+ /** Whether the latest record of the purpose grants it, for the document version in force. */
131
+ export async function hasConsent(ctx: PrivacyContext, query: ConsentQuery): Promise<boolean> {
132
+ const state = await getConsent(ctx, query);
133
+ return state !== null && state.granted && state.isCurrentVersion;
134
+ }
135
+
136
+ /** Every record of the subject, oldest first: the evidence of what was given and withdrawn, when. */
137
+ export async function listConsents(ctx: PrivacyContext, subject: ConsentSubject): Promise<ConsentRecord[]> {
138
+ const condition = matchSubject(subject);
139
+ if (condition === null) return [];
140
+ const rows = await ctx.db.select().from(consents).where(condition).orderBy(asc(consents.recordedAt), asc(consents.id));
141
+ return rows.map(toConsentRecord);
142
+ }
@@ -0,0 +1,5 @@
1
+ import type { ModuleContext } from "@softure-ai/core";
2
+ import type { Queryable } from "@softure-ai/db";
3
+
4
+ /** What the privacy server functions receive; contributors get the same with `db` as a transaction. */
5
+ export type PrivacyContext = ModuleContext<Queryable>;
@@ -0,0 +1,58 @@
1
+ // The registry: every part of a user's data the app holds, as contributors. The enabled modules
2
+ // contribute through `defineModule({ privacy })` (core checks it against their manifest), the app
3
+ // through `privacy({ contributors })`. Nothing is listed by hand, so a module enabled in the config
4
+ // is in the export and the deletion by being enabled.
5
+ import type { PrivacyContributor, SoftureConfig } from "@softure-ai/core";
6
+ import { getPrivacyOptions } from "./options.js";
7
+
8
+ export interface RegisteredContributor extends PrivacyContributor {
9
+ /** A module id or an app contributor id; the key of its part of the export. */
10
+ readonly id: string;
11
+ readonly source: "module" | "app";
12
+ }
13
+
14
+ export type ExportingContributor = RegisteredContributor & Required<Pick<PrivacyContributor, "exportUserData">>;
15
+ export type DeletingContributor = RegisteredContributor & Required<Pick<PrivacyContributor, "deleteUserData">>;
16
+
17
+ const registries = new WeakMap<SoftureConfig, readonly RegisteredContributor[]>();
18
+
19
+ /**
20
+ * Every contributor in export order: the modules in the config's dependency order (dependencies
21
+ * first), then the app's in the order it lists them. Throws when an app contributor takes the id of
22
+ * an enabled module: both parts would land under one key.
23
+ */
24
+ export function getPrivacyContributors(config: SoftureConfig): readonly RegisteredContributor[] {
25
+ const cached = registries.get(config);
26
+ if (cached !== undefined) return cached;
27
+
28
+ const modules: RegisteredContributor[] = config.modules.flatMap((module) =>
29
+ module.privacy === null ? [] : [{ ...module.privacy, id: module.id, source: "module" as const }],
30
+ );
31
+ const moduleIds = new Set(config.modules.map((module) => module.id));
32
+ const app: RegisteredContributor[] = getPrivacyOptions(config).contributors.map((contributor) => {
33
+ if (moduleIds.has(contributor.id)) {
34
+ throw new Error(`@softure-ai/privacy: app contributor "${contributor.id}" has the id of an enabled module; give it another id`);
35
+ }
36
+ return { ...contributor, source: "app" as const };
37
+ });
38
+
39
+ const registry = Object.freeze([...modules, ...app]);
40
+ registries.set(config, registry);
41
+ return registry;
42
+ }
43
+
44
+ /** The contributors with an export, in export order. */
45
+ export function getExportingContributors(config: SoftureConfig): readonly ExportingContributor[] {
46
+ return getPrivacyContributors(config).filter((contributor): contributor is ExportingContributor => contributor.exportUserData !== undefined);
47
+ }
48
+
49
+ /**
50
+ * The contributors with a deletion, in deletion order: the reverse of the export order. The app's
51
+ * own data goes first, then each module before the modules it depends on, so a row is always
52
+ * deleted before the rows it references, whatever the foreign key's ON DELETE action.
53
+ */
54
+ export function getDeletingContributors(config: SoftureConfig): readonly DeletingContributor[] {
55
+ return getPrivacyContributors(config)
56
+ .filter((contributor): contributor is DeletingContributor => contributor.deleteUserData !== undefined)
57
+ .toReversed();
58
+ }
@@ -0,0 +1,42 @@
1
+ // The deletion: every contributor deletes its part of one user's data, in one transaction and in
2
+ // deletion order (the app's data first, auth's account last). A refusal or a failure anywhere
3
+ // rolls back every contributor: an account is deleted whole or not at all.
4
+ import { err, ok, type Err, type ErrorCode, type Ok } from "@softure-ai/core";
5
+ import type { PrivacyContext } from "./context.js";
6
+ import { getDeletingContributors } from "./contributors.js";
7
+
8
+ export type EraseUserDataResult = Ok<undefined> | Err<"privacy.deletion_refused">;
9
+
10
+ /** Thrown inside the transaction to roll it back when a contributor refuses. */
11
+ class DeletionRefusal extends Error {
12
+ constructor(
13
+ readonly contributorId: string,
14
+ readonly code: ErrorCode,
15
+ ) {
16
+ super(`contributor "${contributorId}" refused the deletion: ${code}`);
17
+ this.name = "DeletionRefusal";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Deletes everything the app holds about `userId`. A contributor that must keep data (legal
23
+ * retention) returns an `Err`: nothing is deleted, the result is `privacy.deletion_refused`, and
24
+ * the contributor's id and code are logged. Thrown errors propagate after the rollback.
25
+ */
26
+ export async function eraseUserData(ctx: PrivacyContext, userId: string): Promise<EraseUserDataResult> {
27
+ const contributors = getDeletingContributors(ctx.config);
28
+ try {
29
+ await ctx.db.transaction(async (tx) => {
30
+ const txCtx: PrivacyContext = { ...ctx, db: tx };
31
+ for (const contributor of contributors) {
32
+ const result = await contributor.deleteUserData(txCtx, userId);
33
+ if (!result.ok) throw new DeletionRefusal(contributor.id, result.error);
34
+ }
35
+ });
36
+ } catch (error) {
37
+ if (!(error instanceof DeletionRefusal)) throw error;
38
+ console.warn(`@softure-ai/privacy: ${error.message}; nothing was deleted`);
39
+ return err("privacy.deletion_refused");
40
+ }
41
+ return ok();
42
+ }
@@ -0,0 +1,12 @@
1
+ // The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the consents table
2
+ // exists and answers, i.e. `softure migrate` ran. It reads no rows.
3
+ import { ok, type HealthCheck } from "@softure-ai/core";
4
+ import type { Queryable } from "@softure-ai/db";
5
+ import { sql } from "drizzle-orm";
6
+
7
+ export const checkConsentsTable: HealthCheck = async (context) => {
8
+ // Core types `db` as unknown; the health route passes the @softure-ai/db handle.
9
+ const db = context.db as Queryable;
10
+ await db.execute(sql`select 1 from privacy.consents limit 0`);
11
+ return ok();
12
+ };
@@ -0,0 +1,41 @@
1
+ // Server-only API of @softure-ai/privacy. Every function receives the module context
2
+ // (`{ db, clock, config }`) and never reads request scope; the user id comes in as a value.
3
+ export {
4
+ getConsent,
5
+ getEmailKey,
6
+ hasConsent,
7
+ listConsents,
8
+ recordConsent,
9
+ type ConsentQuery,
10
+ type RecordConsentInput,
11
+ type RecordConsentResult,
12
+ } from "./consents.js";
13
+ export {
14
+ deleteConsentUserData,
15
+ exportConsentUserData,
16
+ privacyConsentsContributor,
17
+ type PrivacyUserData,
18
+ } from "./consents-contributor.js";
19
+ export { collectUserData, type CollectedUserData, type CollectUserDataResult } from "./collect.js";
20
+ export type { PrivacyContext } from "./context.js";
21
+ export {
22
+ getDeletingContributors,
23
+ getExportingContributors,
24
+ getPrivacyContributors,
25
+ type DeletingContributor,
26
+ type ExportingContributor,
27
+ type RegisteredContributor,
28
+ } from "./contributors.js";
29
+ export { eraseUserData, type EraseUserDataResult } from "./erase.js";
30
+ export { findLegalDocument, getLegalDocument, getLegalDocuments } from "./legal-documents.js";
31
+ export { getPrivacyOptions, getPrivacyRoutes, type PrivacyRoutes } from "./options.js";
32
+ export { recordRegistrationConsent, REGISTRATION_SOURCE, type RegistrationConsentOptions } from "./registration-consent.js";
33
+ export {
34
+ deleteOwnAccount,
35
+ exportOwnData,
36
+ type DeleteOwnAccountErrorCode,
37
+ type DeleteOwnAccountInput,
38
+ type DeleteOwnAccountResult,
39
+ type ExportOwnDataInput,
40
+ type ExportOwnDataResult,
41
+ } from "./self-service.js";
@@ -0,0 +1,24 @@
1
+ // The app's legal documents and their current versions, declared once in `privacy({ documents })`:
2
+ // consents are stamped with these versions, and legal pages show the same ones.
3
+ import type { SoftureConfig } from "@softure-ai/core";
4
+ import type { LegalDocumentDeclaration } from "../options.js";
5
+ import { getPrivacyOptions } from "./options.js";
6
+
7
+ /** Every declared document, in the order the config lists them. */
8
+ export function getLegalDocuments(config: SoftureConfig): readonly LegalDocumentDeclaration[] {
9
+ return getPrivacyOptions(config).documents;
10
+ }
11
+
12
+ /** The declared document with this id, or undefined. */
13
+ export function findLegalDocument(config: SoftureConfig, id: string): LegalDocumentDeclaration | undefined {
14
+ return getLegalDocuments(config).find((document) => document.id === id);
15
+ }
16
+
17
+ /** The declared document with this id. Throws for an id the config does not declare: a page naming one is a bug. */
18
+ export function getLegalDocument(config: SoftureConfig, id: string): LegalDocumentDeclaration {
19
+ const document = findLegalDocument(config, id);
20
+ if (document === undefined) {
21
+ throw new Error(`@softure-ai/privacy: no legal document "${id}"; declare it in privacy({ documents: [{ id: "${id}", version }] })`);
22
+ }
23
+ return document;
24
+ }
@@ -0,0 +1,33 @@
1
+ // The privacy options and routes of the running app, read from the configuration in the module context.
2
+ import { getModule, type SoftureConfig } from "@softure-ai/core";
3
+ import type { PrivacyOptions } from "../options.js";
4
+
5
+ const MODULE_ID = "privacy";
6
+
7
+ export interface PrivacyRoutes {
8
+ /** The page with the export link and the delete form. */
9
+ readonly account: string;
10
+ /** The export route handler. */
11
+ readonly export: string;
12
+ /** Where a deleted account lands, signed out. */
13
+ readonly afterDelete: string;
14
+ }
15
+
16
+ /** The enabled module. Throws when the app did not enable it: calling its functions then is a bug. */
17
+ export function getPrivacyModule(config: SoftureConfig) {
18
+ const module = getModule(config, MODULE_ID);
19
+ if (module === undefined) {
20
+ throw new Error("@softure-ai/privacy: the module is not enabled; add privacy({ ... }) to modules in softure.config.ts");
21
+ }
22
+ return module;
23
+ }
24
+
25
+ export function getPrivacyOptions(config: SoftureConfig): PrivacyOptions {
26
+ // The module factory parsed these options with privacyOptionsSchema.
27
+ return getPrivacyModule(config).options as PrivacyOptions;
28
+ }
29
+
30
+ export function getPrivacyRoutes(config: SoftureConfig): PrivacyRoutes {
31
+ // The manifest declares exactly these routes; the factory merged the app's overrides.
32
+ return getPrivacyModule(config).routes as unknown as PrivacyRoutes;
33
+ }
@@ -0,0 +1,32 @@
1
+ // The rate limit buckets privacy consumes (PRIVACY_RATE_LIMIT_BUCKETS), both counted per user.
2
+ import { getModule, type SoftureConfig } from "@softure-ai/core";
3
+ import { subjectKey } from "@softure-ai/security/server";
4
+
5
+ export const BUCKETS = {
6
+ export: "privacy-export",
7
+ delete: "privacy-delete",
8
+ } as const;
9
+
10
+ /** The subject key of a user in the privacy buckets. */
11
+ export function userSubjectKey(userId: string): string {
12
+ return subjectKey(`user:${userId}`);
13
+ }
14
+
15
+ const checkedConfigs = new WeakSet<SoftureConfig>();
16
+
17
+ /**
18
+ * Throws, naming every missing bucket, when the app's `security({ buckets })` lacks one privacy
19
+ * counts in. Checked once per config, before the first attempt is counted.
20
+ */
21
+ export function assertPrivacyBuckets(config: SoftureConfig): void {
22
+ if (checkedConfigs.has(config)) return;
23
+ const options = getModule(config, "security")?.options as { buckets?: Readonly<Record<string, unknown>> } | undefined;
24
+ const configured = options?.buckets ?? {};
25
+ const missing = Object.values(BUCKETS).filter((name) => !Object.hasOwn(configured, name));
26
+ if (missing.length > 0) {
27
+ throw new Error(
28
+ `@softure-ai/privacy: security({ buckets }) lacks ${missing.map((name) => `"${name}"`).join(", ")}; spread PRIVACY_RATE_LIMIT_BUCKETS into it`,
29
+ );
30
+ }
31
+ checkedConfigs.add(config);
32
+ }
@@ -0,0 +1,43 @@
1
+ // The consent ticked at registration, recorded through auth's `onRegistered` hook: inside the
2
+ // account's transaction, so an account never exists without the evidence of its consent.
3
+ import type { OnRegisteredHook } from "@softure-ai/auth";
4
+ import { getLegalDocuments } from "./legal-documents.js";
5
+ import { insertConsent } from "./consents.js";
6
+
7
+ export interface RegistrationConsentOptions {
8
+ /**
9
+ * The documents the registration checkbox accepts, by id. Defaults to every document declared
10
+ * in `privacy({ documents })`.
11
+ */
12
+ readonly documents?: readonly string[];
13
+ }
14
+
15
+ /** The source of the rows this hook records. */
16
+ export const REGISTRATION_SOURCE = "registration";
17
+
18
+ /**
19
+ * An `onRegistered` hook for `auth({ onRegistered: recordRegistrationConsent() })`: one consent row
20
+ * per accepted document (purpose = document id, its configured version, source `registration`),
21
+ * at the account's creation time. Records nothing when auth's `requireConsent` is off. Throws
22
+ * (rolling the registration back) when no document is declared or one is unknown: a
23
+ * misconfiguration must not create accounts without evidence.
24
+ */
25
+ export function recordRegistrationConsent(options: RegistrationConsentOptions = {}): OnRegisteredHook {
26
+ return async (event, ctx) => {
27
+ if (event.consent === null) return;
28
+ const documents = options.documents ?? getLegalDocuments(ctx.config).map((document) => document.id);
29
+ if (documents.length === 0) {
30
+ throw new Error("@softure-ai/privacy: recordRegistrationConsent() has no document to record; declare them in privacy({ documents })");
31
+ }
32
+ for (const document of documents) {
33
+ const recorded = await insertConsent(
34
+ ctx,
35
+ { subject: { userId: event.user.id }, purpose: document, granted: true, document, source: REGISTRATION_SOURCE },
36
+ event.consent.acceptedAt,
37
+ );
38
+ if (!recorded.ok) {
39
+ throw new Error(`@softure-ai/privacy: recording the registration consent to "${document}" failed: ${recorded.error}`);
40
+ }
41
+ }
42
+ };
43
+ }