@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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legal-footer.d.ts","sourceRoot":"","sources":["../../src/ui/legal-footer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAAyB,MAAM,gBAAgB,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAM5D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;CAC3B;AAED,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,SAAS,eAAe,EAAE,CAAC;IAC3C,oEAAoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC,eAAe,CAAC,CAAC;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AASD,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,gBAAgB,+BAkB5F"}
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createSlotClassGetter } from "@softure-ai/ui";
3
+ const DEFAULT_CLASSES = {
4
+ root: "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:items-center sft:gap-2 sft:border-t sft:border-border sft:px-4 sft:py-4 sft:font-sans sft:text-sm sft:text-muted",
5
+ list: "sft:m-0 sft:flex sft:items-center sft:justify-center sft:gap-4 sft:p-0 sft:list-none",
6
+ link: "sft:text-muted sft:hover:text-foreground sft:focus-visible:outline-2 sft:focus-visible:outline-focus sft:focus-visible:outline-offset-2",
7
+ note: "sft:m-0 sft:text-xs sft:text-center",
8
+ };
9
+ export function LegalFooter({ links, note, messages, classNames, unstyled }) {
10
+ const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
11
+ return (_jsxs("footer", { className: slot("root"), children: [_jsx("nav", { "aria-label": messages.legal.footer, children: _jsx("ul", { className: slot("list"), children: links.map((link) => (_jsx("li", { children: _jsx("a", { href: link.href, className: slot("link"), children: link.label }) }, link.href))) }) }), note === undefined ? null : _jsx("p", { className: slot("note"), children: note })] }));
12
+ }
13
+ //# sourceMappingURL=legal-footer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legal-footer.js","sourceRoot":"","sources":["../../src/ui/legal-footer.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAmB,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAwBxE,MAAM,eAAe,GAA8C;IACjE,IAAI,EAAE,kLAAkL;IACxL,IAAI,EAAE,sFAAsF;IAC5F,IAAI,EAAE,yIAAyI;IAC/I,IAAI,EAAE,qCAAqC;CAC5C,CAAC;AAEF,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAoB;IAC3F,MAAM,IAAI,GAAG,qBAAqB,CAAC,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC;IACxF,OAAO,CACL,kBAAQ,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,aAC7B,4BAAiB,QAAQ,CAAC,KAAK,CAAC,MAAM,YACpC,aAAI,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,YACxB,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CACnB,uBACE,YAAG,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,YACxC,IAAI,CAAC,KAAK,GACT,IAHG,IAAI,CAAC,IAAI,CAIb,CACN,CAAC,GACC,GACD,EACL,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,YAAG,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,YAAG,IAAI,GAAK,IAC5D,CACV,CAAC;AACJ,CAAC"}
@@ -0,0 +1,37 @@
1
+ -- The consent ledger: who agreed to what, when, from where and against which version of which
2
+ -- legal document. Append-only: a withdrawal is a new row with granted = false, and the trigger
3
+ -- below refuses any UPDATE, so the evidence of an earlier consent cannot be rewritten. Rows are
4
+ -- deleted only with the person's data (the privacy contributor, on account deletion).
5
+ -- A subject is either an account (user_id) or an email address without one (email_key: the
6
+ -- base64url SHA-256 of the trimmed, lowercased address; the address itself is never stored).
7
+ -- Rollback: DROP TABLE privacy.consents; DROP FUNCTION privacy.refuse_consent_update(); then
8
+ -- DELETE FROM softure.migrations WHERE module = 'privacy' AND version = 1;
9
+ CREATE TABLE consents (
10
+ -- Orders rows recorded in the same instant: the latest row of a purpose is its current state.
11
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
12
+ user_id uuid REFERENCES auth.users (id) ON DELETE CASCADE,
13
+ email_key text CHECK (email_key ~ '^[A-Za-z0-9_-]{43}$'),
14
+ -- What the consent is for, e.g. terms, privacy-policy, newsletter.
15
+ purpose text NOT NULL CHECK (char_length(purpose) <= 64 AND purpose ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'),
16
+ granted boolean NOT NULL,
17
+ -- The legal document the person saw, with the version the app declared at that moment.
18
+ document_id text CHECK (char_length(document_id) <= 64 AND document_id ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'),
19
+ document_version text CHECK (document_version ~ '^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$'),
20
+ -- Where it was given, e.g. registration, waitlist, account.
21
+ source text NOT NULL CHECK (char_length(source) <= 64 AND source ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'),
22
+ recorded_at timestamptz NOT NULL,
23
+ CONSTRAINT consents_one_subject CHECK (num_nonnulls(user_id, email_key) = 1),
24
+ CONSTRAINT consents_document_with_version CHECK ((document_id IS NULL) = (document_version IS NULL))
25
+ );
26
+
27
+ -- The current state and the history of a subject, per purpose, newest first.
28
+ CREATE INDEX consents_user_id_idx ON consents (user_id, purpose, recorded_at DESC, id DESC) WHERE user_id IS NOT NULL;
29
+ CREATE INDEX consents_email_key_idx ON consents (email_key, purpose, recorded_at DESC, id DESC) WHERE email_key IS NOT NULL;
30
+
31
+ CREATE FUNCTION refuse_consent_update() RETURNS trigger LANGUAGE plpgsql AS $$
32
+ BEGIN
33
+ RAISE EXCEPTION 'privacy.consents is append-only: record a new row instead of updating row %', OLD.id;
34
+ END
35
+ $$;
36
+
37
+ CREATE TRIGGER consents_append_only BEFORE UPDATE ON consents FOR EACH ROW EXECUTE FUNCTION refuse_consent_update();
package/module.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "id": "privacy",
3
+ "version": "0.1.5",
4
+ "dependsOn": { "auth": "^0.1.0", "security": "^0.1.0" },
5
+ "dbSchema": "privacy",
6
+ "tables": ["consents"],
7
+ "env": [],
8
+ "switches": [],
9
+ "routes": { "account": "/account/privacy", "export": "/api/privacy/export", "afterDelete": "/" },
10
+ "mount": [
11
+ { "kind": "page", "path": "app/account/privacy/page.tsx", "export": "PrivacyPage" },
12
+ { "kind": "route-handler", "path": "app/api/privacy/export/route.ts", "export": "exportRoute" }
13
+ ],
14
+ "privacy": { "exports": true, "deletes": true }
15
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@softure-ai/privacy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.5",
4
+ "description": "SOFTURE AI privacy module: modules and the app register GDPR export and deletion contributors; a JSON export endpoint and self-service account deletion for Next.js.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/SOFTURE/AI.git",
14
+ "directory": "modules/privacy"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/**/*.test.tsx",
21
+ "migrations",
22
+ "module.json"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "@softure-ai/source": "./src/index.ts",
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./server": {
35
+ "@softure-ai/source": "./src/server/index.ts",
36
+ "types": "./dist/server/index.d.ts",
37
+ "default": "./dist/server/index.js"
38
+ },
39
+ "./next": {
40
+ "@softure-ai/source": "./src/next/index.ts",
41
+ "types": "./dist/next/index.d.ts",
42
+ "default": "./dist/next/index.js"
43
+ },
44
+ "./ui": {
45
+ "@softure-ai/source": "./src/ui/index.ts",
46
+ "types": "./dist/ui/index.d.ts",
47
+ "default": "./dist/ui/index.js"
48
+ }
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.build.json"
52
+ },
53
+ "dependencies": {
54
+ "@softure-ai/auth": "^0.1.0",
55
+ "@softure-ai/core": "^0.1.0",
56
+ "@softure-ai/db": "^0.1.0",
57
+ "@softure-ai/security": "^0.1.0",
58
+ "@softure-ai/ui": "^0.1.0",
59
+ "zod": "^4.6.5"
60
+ },
61
+ "peerDependencies": {
62
+ "drizzle-orm": "^0.45.2",
63
+ "next": "^16.0.0",
64
+ "react": "^19.0.0"
65
+ }
66
+ }
@@ -0,0 +1,70 @@
1
+ // Result types and error codes of the privacy module. No user-facing copy here: the UI translates
2
+ // codes through `messages` (docs/02-module-standard.md §6).
3
+ import type { CoreErrorCode } from "@softure-ai/core";
4
+
5
+ export type PrivacyErrorCode =
6
+ | "privacy.export_failed"
7
+ | "privacy.export_too_large"
8
+ | "privacy.deletion_refused"
9
+ | "privacy.password_invalid"
10
+ | "privacy.confirmation_required"
11
+ | "privacy.consent_invalid"
12
+ | "privacy.document_unknown";
13
+
14
+ /**
15
+ * Whose consent it is: an account, or an email address that has none (a waitlist sign-up). An
16
+ * account's consents and its email's consents are both its own in the export and the deletion.
17
+ * `{ emailKey }` names the same email subject by its stored key (`getEmailKey`), for a caller that
18
+ * only holds the key, such as mailing's unsubscribe link.
19
+ */
20
+ export type ConsentSubject = { readonly userId: string } | { readonly email: string } | { readonly emailKey: string };
21
+
22
+ /** One row of the ledger: a consent given (`granted`) or withdrawn. */
23
+ export interface ConsentRecord {
24
+ readonly purpose: string;
25
+ readonly granted: boolean;
26
+ /** The legal document and its version at the moment of consent; null for a purpose without one. */
27
+ readonly document: { readonly id: string; readonly version: string } | null;
28
+ /** Where it was given, e.g. `registration`, `waitlist`. */
29
+ readonly source: string;
30
+ readonly recordedAt: Date;
31
+ }
32
+
33
+ /** The current state of one purpose: its latest record, checked against the configured version. */
34
+ export interface ConsentState extends ConsentRecord {
35
+ /**
36
+ * False when the record names a document version that is no longer the configured one (or a
37
+ * document the app no longer declares): the person agreed to an earlier text.
38
+ */
39
+ readonly isCurrentVersion: boolean;
40
+ }
41
+
42
+ /** The file a user downloads: every contributor's part under its id. */
43
+ export interface PrivacyExport {
44
+ readonly format: "softure.privacy-export";
45
+ readonly version: 1;
46
+ readonly userId: string;
47
+ /** ISO 8601, UTC. */
48
+ readonly exportedAt: string;
49
+ /** Each contributor's data under its id (a module id or an app contributor id), in export order. */
50
+ readonly data: Readonly<Record<string, unknown>>;
51
+ }
52
+
53
+ /** Every code the delete form can show: its own, the session and rate limit refusals, the generic ones. */
54
+ export type DeleteAccountErrorCode =
55
+ | Extract<PrivacyErrorCode, "privacy.deletion_refused" | "privacy.password_invalid" | "privacy.confirmation_required">
56
+ | "auth.unauthenticated"
57
+ | "security.rate_limited"
58
+ | CoreErrorCode;
59
+
60
+ export type DeleteAccountField = "password" | "confirm";
61
+
62
+ /** What the delete action returns to its form (`useActionState`); success redirects instead. */
63
+ export interface DeleteAccountFormState {
64
+ readonly status: "idle" | "error";
65
+ readonly error?: DeleteAccountErrorCode;
66
+ /** The field the error belongs to; none for a form-level error. */
67
+ readonly field?: DeleteAccountField;
68
+ }
69
+
70
+ export const INITIAL_DELETE_ACCOUNT_STATE: DeleteAccountFormState = { status: "idle" };
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Public API of @softure-ai/privacy: the module factory for softure.config.ts, its types, messages,
2
+ // rate limit buckets and the consents table. The registry, export, deletion and the consent ledger
3
+ // are in `@softure-ai/privacy/server`, the Next.js adapter (page, export route, delete action) in
4
+ // `/next`, the delete form and the legal document shell in `/ui`.
5
+ import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
6
+ import { privacyMessages } from "./messages/index.js";
7
+ import { privacyOptionsSchema } from "./options.js";
8
+ import { privacyConsentsContributor } from "./server/consents-contributor.js";
9
+ import { checkConsentsTable } from "./server/health.js";
10
+
11
+ export const MODULE_ID = "privacy";
12
+
13
+ /**
14
+ * Rate limit buckets privacy consumes, with defaults to spread into `security({ buckets })`, both
15
+ * per user: `privacy-export` (downloads) and `privacy-delete` (deletion attempts, each one a
16
+ * password check).
17
+ */
18
+ export const PRIVACY_RATE_LIMIT_BUCKETS = {
19
+ "privacy-export": { limit: 5, windowMinutes: 60 },
20
+ "privacy-delete": { limit: 5, windowMinutes: 15 },
21
+ } as const;
22
+
23
+ /**
24
+ * Enables the GDPR export, self-service account deletion and the consent ledger in
25
+ * `softure.config.ts` (after `auth({ ... })`):
26
+ * `privacy({ documents: [{ id: "terms", version: "2026-10-01" }], contributors: [{ id: "profile", exportUserData, deleteUserData }] })`.
27
+ * Every enabled module with user data contributes its own part; `contributors` adds the app's.
28
+ */
29
+ export const privacy = defineModule({
30
+ manifest: {
31
+ id: MODULE_ID,
32
+ version: "0.1.5",
33
+ dependsOn: { auth: "^0.1.0", security: "^0.1.0" },
34
+ dbSchema: "privacy",
35
+ tables: ["consents"],
36
+ env: [],
37
+ switches: [],
38
+ routes: { account: "/account/privacy", export: "/api/privacy/export", afterDelete: "/" },
39
+ mount: [
40
+ { kind: "page", path: "app/account/privacy/page.tsx", export: "PrivacyPage" },
41
+ { kind: "route-handler", path: "app/api/privacy/export/route.ts", export: "exportRoute" },
42
+ ],
43
+ privacy: { exports: true, deletes: true },
44
+ },
45
+ messages: privacyMessages,
46
+ options: privacyOptionsSchema,
47
+ migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
48
+ privacy: privacyConsentsContributor,
49
+ health: checkConsentsTable,
50
+ });
51
+
52
+ export {
53
+ INITIAL_DELETE_ACCOUNT_STATE,
54
+ type ConsentRecord,
55
+ type ConsentState,
56
+ type ConsentSubject,
57
+ type DeleteAccountErrorCode,
58
+ type DeleteAccountField,
59
+ type DeleteAccountFormState,
60
+ type PrivacyErrorCode,
61
+ type PrivacyExport,
62
+ } from "./contract.js";
63
+ export { getPrivacyErrorMessage, privacyMessages, type PrivacyMessages } from "./messages/index.js";
64
+ export {
65
+ CONTRIBUTOR_ID_PATTERN,
66
+ DEFAULT_EXPORT_MAX_BYTES,
67
+ DOCUMENT_VERSION_PATTERN,
68
+ type AppPrivacyContributor,
69
+ type LegalDocumentDeclaration,
70
+ type PrivacyOptions,
71
+ type PrivacyOptionsInput,
72
+ } from "./options.js";
73
+ export { consents, privacySchema } from "./schema.js";
@@ -0,0 +1,45 @@
1
+ export const en = {
2
+ page: {
3
+ title: "Your data",
4
+ lead: "Download a copy of what this app stores about you, or delete your account.",
5
+ },
6
+ export: {
7
+ title: "Download your data",
8
+ description: "A JSON file with everything this app stores about your account.",
9
+ button: "Download my data",
10
+ },
11
+ delete: {
12
+ title: "Delete your account",
13
+ description: "This deletes your account and your data and signs you out on every device. It cannot be undone.",
14
+ password: "Current password",
15
+ confirm: "I understand that my account and my data will be deleted for good.",
16
+ submit: "Delete my account",
17
+ pending: "Deleting…",
18
+ },
19
+ legal: {
20
+ contents: "Contents",
21
+ version: "Version",
22
+ effectiveFrom: "Effective from",
23
+ changes: "Change history",
24
+ footer: "Legal information",
25
+ },
26
+ errors: {
27
+ privacy: {
28
+ export_failed: "Your data could not be collected. Try again in a moment.",
29
+ export_too_large: "Your data is too large to download here. Contact us and we will send it to you.",
30
+ deletion_refused: "Your account cannot be deleted right now, because some of its data must be kept for a while. Contact us for details.",
31
+ password_invalid: "This is not your current password.",
32
+ confirmation_required: "Confirm that you want to delete your account.",
33
+ },
34
+ auth: {
35
+ unauthenticated: "Your session has ended. Sign in again.",
36
+ },
37
+ security: {
38
+ rate_limited: "Too many attempts. Wait a moment and try again.",
39
+ },
40
+ core: {
41
+ database_failed: "Something went wrong on our side. Try again in a moment.",
42
+ unexpected: "Something went wrong on our side. Try again in a moment.",
43
+ },
44
+ },
45
+ };
@@ -0,0 +1,18 @@
1
+ import type { DeleteAccountErrorCode } from "../contract.js";
2
+ import { en } from "./en.js";
3
+ import { pl } from "./pl.js";
4
+
5
+ /** Complete default dictionaries; apps pass partial overrides per locale. */
6
+ export const privacyMessages = { en, pl };
7
+
8
+ export type PrivacyMessages = typeof en;
9
+
10
+ /** The copy for an error code; an unknown code gets the generic failure. */
11
+ export function getPrivacyErrorMessage(messages: PrivacyMessages, code: DeleteAccountErrorCode): string {
12
+ const separator = code.lastIndexOf(".");
13
+ const namespace = code.slice(0, separator);
14
+ const name = code.slice(separator + 1);
15
+ const groups = messages.errors as Readonly<Record<string, Readonly<Record<string, string>>>>;
16
+ const group = Object.hasOwn(groups, namespace) ? groups[namespace] : undefined;
17
+ return (group !== undefined && Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.core.unexpected;
18
+ }
@@ -0,0 +1,47 @@
1
+ import type { en } from "./en.js";
2
+
3
+ export const pl: typeof en = {
4
+ page: {
5
+ title: "Twoje dane",
6
+ lead: "Pobierz kopię danych, które ta aplikacja o Tobie przechowuje, albo usuń konto.",
7
+ },
8
+ export: {
9
+ title: "Pobierz swoje dane",
10
+ description: "Plik JSON ze wszystkim, co ta aplikacja przechowuje o Twoim koncie.",
11
+ button: "Pobierz moje dane",
12
+ },
13
+ delete: {
14
+ title: "Usuń konto",
15
+ description: "To usunie Twoje konto i dane oraz wyloguje Cię na wszystkich urządzeniach. Tego nie da się cofnąć.",
16
+ password: "Obecne hasło",
17
+ confirm: "Rozumiem, że moje konto i moje dane zostaną usunięte na zawsze.",
18
+ submit: "Usuń moje konto",
19
+ pending: "Usuwanie…",
20
+ },
21
+ legal: {
22
+ contents: "Spis treści",
23
+ version: "Wersja",
24
+ effectiveFrom: "Obowiązuje od",
25
+ changes: "Historia zmian",
26
+ footer: "Informacje prawne",
27
+ },
28
+ errors: {
29
+ privacy: {
30
+ export_failed: "Nie udało się zebrać Twoich danych. Spróbuj ponownie za chwilę.",
31
+ export_too_large: "Twoich danych jest za dużo, by pobrać je tutaj. Napisz do nas, a prześlemy je.",
32
+ deletion_refused: "Nie można teraz usunąć konta, bo część jego danych musimy jeszcze przechowywać. Napisz do nas po szczegóły.",
33
+ password_invalid: "To nie jest Twoje obecne hasło.",
34
+ confirmation_required: "Potwierdź, że chcesz usunąć konto.",
35
+ },
36
+ auth: {
37
+ unauthenticated: "Twoja sesja wygasła. Zaloguj się ponownie.",
38
+ },
39
+ security: {
40
+ rate_limited: "Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie.",
41
+ },
42
+ core: {
43
+ database_failed: "Coś poszło nie tak po naszej stronie. Spróbuj ponownie za chwilę.",
44
+ unexpected: "Coś poszło nie tak po naszej stronie. Spróbuj ponownie za chwilę.",
45
+ },
46
+ },
47
+ };
@@ -0,0 +1,61 @@
1
+ "use server";
2
+
3
+ // The account deletion action. The user comes from the session (never from a bound argument or
4
+ // the form, which the client controls, docs/02 §8) before the form is read; unexpected failures
5
+ // become `safeError` codes. Next refuses an action whose Origin does not match the host.
6
+ import { getCurrentUser } from "@softure-ai/auth/next";
7
+ import { errorLogLabel, safeError } from "@softure-ai/core";
8
+ import { getSoftureConfig } from "@softure-ai/core/next";
9
+ import { redirect } from "next/navigation";
10
+ import { z } from "zod";
11
+ import type { DeleteAccountErrorCode, DeleteAccountField, DeleteAccountFormState } from "../contract.js";
12
+ import { getPrivacyRoutes } from "../server/options.js";
13
+ import { deleteOwnAccount } from "../server/self-service.js";
14
+ import { getPrivacyContext } from "./context.js";
15
+ import { clearSessionCookie } from "./session-cookie.js";
16
+
17
+ /** Longer values are cut: auth refuses them anyway, and nothing huge is hashed. */
18
+ const MAX_FIELD_LENGTH = 4096;
19
+
20
+ const deleteAccountInput = z.object({
21
+ password: z
22
+ .string()
23
+ .catch("")
24
+ .transform((value) => value.slice(0, MAX_FIELD_LENGTH)),
25
+ // A checked HTML checkbox sends "on" (or its value); an unchecked one sends nothing.
26
+ confirm: z.string().nullable().catch(null),
27
+ });
28
+
29
+ const FIELD_OF: Partial<Record<DeleteAccountErrorCode, DeleteAccountField>> = {
30
+ "privacy.password_invalid": "password",
31
+ "privacy.confirmation_required": "confirm",
32
+ };
33
+
34
+ function failure(error: DeleteAccountErrorCode): DeleteAccountFormState {
35
+ const field = FIELD_OF[error];
36
+ return field === undefined ? { status: "error", error } : { status: "error", error, field };
37
+ }
38
+
39
+ /** Deletes the signed-in user's account, ends the browser's session and goes to `afterDelete`. */
40
+ export async function deleteAccountAction(_previous: DeleteAccountFormState, formData: FormData): Promise<DeleteAccountFormState> {
41
+ const config = getSoftureConfig();
42
+ let result;
43
+ try {
44
+ const user = await getCurrentUser();
45
+ if (user === null) return failure("auth.unauthenticated");
46
+ // Every field has a `catch`, so parsing cannot fail.
47
+ const input = deleteAccountInput.parse({ password: formData.get("password"), confirm: formData.get("confirm") });
48
+ result = await deleteOwnAccount(await getPrivacyContext(config), {
49
+ userId: user.id,
50
+ password: input.password,
51
+ isConfirmed: input.confirm !== null,
52
+ });
53
+ } catch (error) {
54
+ console.error(`@softure-ai/privacy: account deletion failed: ${errorLogLabel(error)}`);
55
+ return failure(safeError(error).error);
56
+ }
57
+ if (!result.ok) return failure(result.error);
58
+
59
+ await clearSessionCookie(config);
60
+ redirect(getPrivacyRoutes(config).afterDelete);
61
+ }
@@ -0,0 +1,14 @@
1
+ // What the adapter hands the server functions: the registered config, the process-wide database
2
+ // handle and the wall clock. Request scope (cookies, headers) stays in this folder.
3
+ import { systemClock, type SoftureConfig } from "@softure-ai/core";
4
+ import { getSoftureConfig } from "@softure-ai/core/next";
5
+ import { getSharedDatabase } from "@softure-ai/db";
6
+ import type { PrivacyContext } from "../server/context.js";
7
+
8
+ export async function getPrivacyContext(config: SoftureConfig = getSoftureConfig()): Promise<PrivacyContext> {
9
+ if (config.database === null) {
10
+ throw new Error("@softure-ai/privacy: softure.config.ts has no database; privacy needs one");
11
+ }
12
+ const { db } = await getSharedDatabase(config.database.url);
13
+ return { db, clock: systemClock, config };
14
+ }
@@ -0,0 +1,6 @@
1
+ // The Next.js adapter of @softure-ai/privacy: the page, the delete action and the export route
2
+ // (docs/02-module-standard.md §8).
3
+ export { deleteAccountAction } from "./actions.js";
4
+ export { getPrivacyMessages } from "./messages.js";
5
+ export { PrivacyPage } from "./pages.js";
6
+ export { exportRoute } from "./route.js";
@@ -0,0 +1,9 @@
1
+ import type { SoftureConfig } from "@softure-ai/core";
2
+ import type { PrivacyMessages } from "../messages/index.js";
3
+ import { getPrivacyModule } from "../server/options.js";
4
+
5
+ /** The module's copy in the app's locale, with the app's overrides applied. */
6
+ export function getPrivacyMessages(config: SoftureConfig): PrivacyMessages {
7
+ // The module factory merged the dictionaries; their shape is the module's own.
8
+ return getPrivacyModule(config).messages[config.locale] as PrivacyMessages;
9
+ }
@@ -0,0 +1,12 @@
1
+ // Next.js has no `exports` map, so NodeNext resolution (tsconfig.base.json) only finds
2
+ // `next/navigation.js` and `next/headers.js`. The code must still import the bare specifiers:
3
+ // Next's bundler aliases them per runtime, and the `.js` form bypasses the alias (measured:
4
+ // `next build` failed to collect a route handler importing `next/navigation.js`, MODULE_UNPARSABLE
5
+ // on the vendored app-router context). These declarations give the bare specifiers their types.
6
+ declare module "next/navigation" {
7
+ export * from "next/navigation.js";
8
+ }
9
+
10
+ declare module "next/headers" {
11
+ export * from "next/headers.js";
12
+ }
@@ -0,0 +1,36 @@
1
+ // The privacy page, ready to mount with one line:
2
+ // `export { PrivacyPage as default } from "@softure-ai/privacy/next"` in app/account/privacy/page.tsx.
3
+ // A server component: without a session it redirects to the login page and back.
4
+ import { requireUser } from "@softure-ai/auth/next";
5
+ import { getSoftureConfig } from "@softure-ai/core/next";
6
+ import { ButtonLink, Card } from "@softure-ai/ui";
7
+ import { getPrivacyRoutes } from "../server/options.js";
8
+ import { DeleteAccountForm } from "../ui/delete-account-form.js";
9
+ import { deleteAccountAction } from "./actions.js";
10
+ import { getPrivacyMessages } from "./messages.js";
11
+
12
+ const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:gap-4 sft:sm:max-w-md sft:px-4 sft:py-4";
13
+ const DESCRIPTION_CLASS = "sft:m-0 sft:text-sm sft:text-muted";
14
+ const SECTION_CLASS = "sft:flex sft:flex-col sft:gap-3";
15
+
16
+ export async function PrivacyPage() {
17
+ const config = getSoftureConfig();
18
+ const routes = getPrivacyRoutes(config);
19
+ await requireUser({ next: routes.account });
20
+ const messages = getPrivacyMessages(config);
21
+ return (
22
+ <main className={LAYOUT_CLASS}>
23
+ <Card title={messages.page.title} subtitle={messages.page.lead} variant="lead">
24
+ <div className={SECTION_CLASS}>
25
+ <p className={DESCRIPTION_CLASS}>{messages.export.description}</p>
26
+ <ButtonLink href={routes.export} download variant="secondary">
27
+ {messages.export.button}
28
+ </ButtonLink>
29
+ </div>
30
+ </Card>
31
+ <Card title={messages.delete.title}>
32
+ <DeleteAccountForm action={deleteAccountAction} messages={messages} locale={config.locale} />
33
+ </Card>
34
+ </main>
35
+ );
36
+ }
@@ -0,0 +1,50 @@
1
+ // The export route handler. Mount with a rename:
2
+ // `export { exportRoute as GET } from "@softure-ai/privacy/next"` in app/api/privacy/export/route.ts.
3
+ // The user comes from the session cookie only; the answer is a JSON attachment that is never cached.
4
+ import { getCurrentUser } from "@softure-ai/auth/next";
5
+ import { errorLogLabel, safeError, type SoftureConfig } from "@softure-ai/core";
6
+ import { getSoftureConfig } from "@softure-ai/core/next";
7
+ import { getPrivacyOptions } from "../server/options.js";
8
+ import { exportOwnData } from "../server/self-service.js";
9
+ import { getPrivacyContext } from "./context.js";
10
+
11
+ const NO_STORE = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
12
+
13
+ function fail(error: string, status: number, headers: Record<string, string> = {}): Response {
14
+ return Response.json({ error }, { status, headers: { ...NO_STORE, ...headers } });
15
+ }
16
+
17
+ /** `account-data-2026-10-03.json`: the date of the download in the app's time zone. */
18
+ function getFileName(config: SoftureConfig, now: Date): string {
19
+ const date = new Intl.DateTimeFormat("en-CA", { timeZone: config.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(now);
20
+ return `${getPrivacyOptions(config).export.fileName}-${date}.json`;
21
+ }
22
+
23
+ /**
24
+ * The signed-in user's data as a JSON download. 401 without a session, 429 with `retry-after` over
25
+ * the `privacy-export` limit, 413 over `export.maxBytes`, 500 when a contributor fails.
26
+ */
27
+ export async function exportRoute(): Promise<Response> {
28
+ try {
29
+ const config = getSoftureConfig();
30
+ const user = await getCurrentUser();
31
+ if (user === null) return fail("auth.unauthenticated", 401);
32
+
33
+ const ctx = await getPrivacyContext(config);
34
+ const result = await exportOwnData(ctx, { userId: user.id });
35
+ if (!result.ok) {
36
+ if ("retryAfterSeconds" in result) return fail(result.error, 429, { "retry-after": String(result.retryAfterSeconds) });
37
+ return fail(result.error, result.error === "privacy.export_too_large" ? 413 : 500);
38
+ }
39
+ return new Response(result.value.json, {
40
+ headers: {
41
+ ...NO_STORE,
42
+ "content-type": "application/json; charset=utf-8",
43
+ "content-disposition": `attachment; filename="${getFileName(config, ctx.clock.now())}"`,
44
+ },
45
+ });
46
+ } catch (error) {
47
+ console.error(`@softure-ai/privacy: export failed: ${errorLogLabel(error)}`);
48
+ return fail(safeError(error).error, 500);
49
+ }
50
+ }
@@ -0,0 +1,18 @@
1
+ // Ending the browser's session cookie after the account is gone. The database sessions are
2
+ // deleted with the account; this removes the cookie with the attributes auth set it with.
3
+ import { getSessionCookie } from "@softure-ai/auth";
4
+ import type { SoftureConfig } from "@softure-ai/core";
5
+ import { cookies } from "next/headers";
6
+
7
+ export async function clearSessionCookie(config: SoftureConfig): Promise<void> {
8
+ const cookie = getSessionCookie(config);
9
+ const store = await cookies();
10
+ store.set(cookie.name, "", {
11
+ httpOnly: cookie.httpOnly,
12
+ secure: cookie.secure,
13
+ sameSite: cookie.sameSite,
14
+ path: cookie.path,
15
+ domain: cookie.domain,
16
+ maxAge: 0,
17
+ });
18
+ }