Docs / Secure
GDPR + Malaysia PDPA
Status: engineering controls map — not a certification, not legal advice.
EdgeCMS implements concrete privacy hooks (inventory, export/delete, retention, consent recording, residency notice). Counsel must still review the draft legal pack under apps/website/legal/ before any customer-facing claim.
Companion code: apps/api/src/privacy/. Machine inventory: GET /api/privacy/inventory. Notice: GET /api/privacy/notice.
Scope (honest)
| In this slice | Out of this slice |
|---|---|
| Data inventory of what the Worker stores | DPIA, RoPA as a legal filing, ISO/SOC attestation |
| HS-13 user export + deletion (already shipped; extended) | Field-level PII search/redaction inside entry JSON |
| Entry version retention hooks (keep-N / max-age) | Automated workspace wipe after 30 days (HS-09 Planned) |
| SU-04 legal-acceptance API hooks + D1 table | Signup checkbox UI / website wiring (SU-04 Planned) |
| Declarative data-residency notice (env / host policy) | Pinning Cloudflare D1/R2 to a single country |
| Audit retention ≥365 days (HS-12) | Erasing host SIEM copies, Cloudflare logs, backups |
| Link to draft Privacy Policy / DPA / subprocessors | Filling [COMPANY] placeholders or publishing the drafts |
Malaysia PDPA 2010 (as amended) and GDPR share notice, purpose limitation, access/correction, and deletion themes. Where PDPA requires a specific consent form, the draft Privacy Policy is the notice and legal_acceptances records the tick — once signup wires SU-04.
Roles
| Role | When | Document |
|---|---|---|
| Controller | Account, sessions, memberships, billing contact, website, audit of platform actions | Privacy Policy |
| Processor | Personal data customers put in entries, assets, AI context | Data Processing Addendum |
Customers remain the controller for content they store. EdgeCMS does not invent a legal basis for that content; the customer does.
Data inventory
Authoritative list lives in code (PRIVACY_DATA_INVENTORY) and is served at GET /api/privacy/inventory. Summary:
| Category | Role | Retention | Deletion coverage |
|---|---|---|---|
| Account profile | Controller | Lifecycle of account | HS-13 user deletion |
| Credentials / MFA | Controller | Lifecycle | HS-13 (export omits secrets) |
| Sessions (incl. IP / UA) | Controller | Until expiry or deletion | HS-13 |
| Memberships | Controller | Lifecycle | HS-13 (sole-owner blocked) |
| Legal acceptances | Controller | Lifecycle (proof of notice) | HS-13 |
| Authored-by metadata | Processor | Follows content row | Pseudonymized on HS-13 |
| Audit events | Controller | ≥365 days (AUDIT_RETENTION_DAYS) | Pseudonymized; pruned by retention |
| Entry versions | Processor | Max 50 / 90 days | Content retention; not user wipe |
| Assets / live entries | Processor | Lifecycle / HS-09 target | Tenant offboarding Planned |
| AI context to subprocessors | Processor | Provider terms; AI off until enabled | Out of HS-13 scope beyond created_by |
| Edge / SIEM / backups | Controller | Host / Cloudflare policy | Out of scope |
Limits that stay true
- Personal data typed into content fields is not searched or changed on user deletion.
- Rows in a dedicated tenant D1 are not pseudonymized by HS-13.
- Webhook payloads, operator logs and backups outside D1 are host-owned.
- Pseudonymized ≠ anonymized: someone holding the old user id can still match audit digests.
Rights → product hooks
| Right (GDPR Art. / PDPA theme) | Hook |
|---|---|
| Access / portability | GET /api/me/data-export (and admin twin); Settings → Account → Your data |
| Erasure | POST /api/me/data-deletion { "confirm": "DELETE" } — pseudonymize refs, delete account |
| Notice | Draft Privacy Policy; GET /api/privacy/notice; inventory endpoint |
| Consent / acceptance record | POST /api/me/legal-acceptance { document, version } (SU-04 hook) |
| Restrict / object | Manual / support process today — no product toggle |
| Breach notice | Promised in draft DPA; operational runbook is host-owned |
Details of export/delete behaviour: Operations and security — Personal data requests.
Retention
Central knobs: apps/api/src/privacy/retention-policy.ts (PRIVACY_RETENTION).
| Data | Enforced? | Value |
|---|---|---|
| Audit ledger | Yes | Min/default 365 days |
| Entry versions | Yes | Max 50 per entry; drop older than 90 days (never current) |
| Account / workspace after closure | No (documented target) | 30 days then delete — needs HS-09 |
| Billing / tax | Host counsel | Placeholder in Privacy Policy |
Content hook used by write paths: contentRetentionHooks.enforceEntryVersions (wraps retentionService).
Consent and legal documents
Drafts (not in force): apps/website/legal/.
| Document | Product hook |
|---|---|
| Terms of Service | legal_acceptances.document = terms_of_service |
| Privacy Policy | privacy_policy |
| DPA | dpa (usually B2B countersign; still recordable) |
POST /api/me/legal-acceptance is idempotent per (userId, document, version). Signup UI that requires the checkbox before account creation is SU-04 Planned — do not claim it is live on the website until that ships.
Locale and data residency
- Content locales (
localeCatalog, collectionsupportedLocales, public locale provenance headers) are editorial/i18n features, not a residency control. - ABAC
localeattribute gates who may edit a locale; it does not move bytes. - Cloudflare runs a global edge. D1/R2/KV locations follow the Cloudflare account configuration. This product does not offer a per-tenant “store only in MY” switch.
- Hosts set a customer-facing sentence via
host.privacy.dataResidencyNoticeorPRIVACY_DATA_RESIDENCY_NOTICE. Example: jurisdiction of the operating entity + SCC posture for EU transfers. Exposed atGET /api/privacy/notice. - Cross-border transfers for EU/UK customers need SCCs (or equivalent) with subprocessors — see draft Subprocessors. Malaysia PDPA transfer rules need counsel review for the operating entity.
Host composition
createApp({
host: {
privacy: {
dataResidencyNotice:
'Operating entity in Malaysia; primary Cloudflare account region APAC; EU transfers under SCCs.',
onPrivacyEvent: async (event) => {
// enqueue ticket / SecOps notify — never block the request on slow I/O
console.log(event.action, event.userId)
},
},
},
})onPrivacyEvent fires after a successful HS-13 export or deletion. Errors are logged and never fail the request.
Related requirements
| ID | Status | Note |
|---|---|---|
| HS-12 Audit retention | Shipped | ≥365 days |
| HS-13 Personal data requests | Shipped | Extended with legal acceptances + host event |
| HS-09 Tenant offboarding | Planned | Needed for post-closure content wipe |
| HS-14 AI switch | Shipped | No AI subprocessors until tenant enables |
| SU-04 Accept ToS/privacy at signup | Planned | Table + API ready; UI not |
| SP-07 Certifications | Not claimed | Keep docs free of fake cert language |
Tests
bun test --isolate \
apps/api/src/tests/privacy/privacy.service.test.ts \
apps/api/src/tests/privacy/privacy.controller.test.ts \
apps/api/src/tests/privacy/data-inventory.test.ts \
apps/api/src/tests/privacy/legal-acceptance.test.ts