Privacy: export and delete (privacy)

Two rights most privacy laws give your users (GDPR articles 17 and 20, CCPA, LGPD and others): getting a copy of their data, and having it erased. This block adds both for the signed-in user, and for admins handling support requests. You decide per resource what an account deletion does to the records the user owns: delete them, keep them without the owner (anonymize), or keep them as they are. calm doctor shows the plan and warns about data that stays.

Shell
npx calm add privacy
Adds
Filesapp/blocks/privacy/index.js (routes, guard, sweep, doctor), export.js (streamed export), erase.js (account deletion)
Environmentnone (settings live in calm.config.js under blocks.privacy)
User fielddeletion, hidden: set while an account is being deleted
RouteWhoWhat
GET /api/auth/me/exportsigned inA JSON download of everything you own
DELETE /api/auth/mesigned inDelete your account, confirmed with your password
POST /api/admin/users/:id/exportadminExport a user's data
DELETE /api/admin/users/:idadminDelete a user's account and data
POST /api/admin/users/:id/cancel-deletionadminCancel a deletion during the grace period

Export your data#

Shell
curl -OJ http://localhost:5001/api/auth/me/export -H "Authorization: Bearer $TOKEN"
# saves my-api-export-2026-10-12.json

The file holds your profile, every record you own in every resource with an owner field (f.ref('user').owner()), your sessions and your push devices, then a manifest:

JSON
{
  "format": "calmapi-export/1",
  "app": "my-api",
  "userId": "6acd1c970d5d7607a3466c9c",
  "generatedAt": "2026-10-12T17:44:56.205Z",
  "profile": { "id": "6acd1c970d5d7607a3466c9c", "name": "Alice", "email": "alice@example.com", "roles": ["user"] },
  "resources": {
    "note": [{ "id": "6acd1c980d5d7607a3466c9e", "text": "Buy milk", "author": "6acd1c970d5d7607a3466c9c" }],
    "post": []
  },
  "sessions": [{ "id": "6acd1c980d5d7607a3466c9d", "createdAt": "...", "ip": "127.0.0.1", "userAgent": "..." }],
  "devices": [{ "id": "6acd1c98a9b5969f7217541e", "platform": "android", "token": "fcm-Al…abcdef" }],
  "manifest": { "generatedAt": "...", "resources": ["note", "post"], "counts": { "profile": 1, "note": 1, "post": 0, "sessions": 1, "devices": 1 } }
}

What the export guarantees:

  • Records are serialized exactly as the API shows them to their owner: .hidden() and .writeOnly() fields and password hashes are never written, visibleTo() rules apply (an admin-only field stays out of a regular user's export), and .encrypted() fields are decrypted only where the owner may see them.
  • Sessions come without their token hashes, push devices with masked tokens.
  • Soft-deleted records (soft-delete block) are included: they are still stored data.
  • One cursor per resource, and the response is streamed, so a large account never sits in memory. If something fails halfway, the connection is cut, so a partial file never looks complete.
  • 5 exports per user per hour (exportLimit, while config.rateLimit.enabled is on).
  • privacy.exported is emitted with { userId, by, counts } (by is 'self' or 'admin', plus adminId).

The export follows ownership through owner fields only. Records that mention a user in another field (an assignee, a mention) are not that user's records; calm doctor lists those fields.

Delete your account#

Shell
curl -X DELETE http://localhost:5001/api/auth/me \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "password": "correct-horse-battery" }'
# 204: deleted
# 202 { "data": { "status": "scheduled", "scheduledFor": "..." } } with a grace period

The current password is the confirmation, so a stolen session token alone cannot delete an account. A wrong password answers 422 with details.password and emits security.account_delete_failed. The route is rate limited like login (config.rateLimit.auth).

What happens, in this order, so an interrupted deletion can always be finished:

  1. The user is marked status: 'deleting'. From then on they cannot sign in (403) and no credential works (401), API keys included.
  2. Sessions, password reset links, push devices, 2FA challenges and lockout counters are removed.
  3. Each resource the user owns records in is deleted, anonymized or kept (below).
  4. Collections in alsoDelete are cleaned (by default the idempotency block's stored responses).
  5. The user record is deleted last, then privacy.account_deleted is emitted with { userId, by, resources: { note: { deleted: 3 }, comment: { anonymized: 1 } } }. No other personal data is in the payload.

If the process stops midway, the sweep (every sweepEveryMinutes) finishes the job: every step is safe to run twice. Deleting a user with the user CRUD route (DELETE /api/users/:id) goes through the same erasure (turn that off with cascadeCrudDelete: false).

Records are deleted through your service hooks: a beforeDelete or afterDelete in <name>.service.js runs for each record (that is how the storage blocks remove the stored files). Services without delete hooks are cleaned with one deleteMany. Per-record <name>.deleted events are not emitted, because the audit-log block would copy the erased data into the audit trail. Listen to privacy.account_deleted instead.

Delete, anonymize or keep, per resource#

By default, records are deleted with their owner. Choose per resource:

JavaScript
// app/resources/comment/comment.resource.js
export default resource('comment', {
  onOwnerDelete: 'anonymize', // 'delete' (default) | 'anonymize' | 'keep'
  personalFields: ['authorName'], // also removed when anonymizing
  fields: {
    body: f.text().required(),
    authorName: f.string(),
    author: f.ref('user').owner(),
  },
});
  • delete: the records are removed.
  • anonymize: the records stay, and the owner field and the personal fields are removed. Fields marked .pii() count as personal too.
  • keep: the records stay untouched and still hold the deleted account's id. Use it for what the law makes you keep (invoices, tax records) and say so in your privacy policy. calm doctor warns about every resource set to keep.

Or decide centrally, which wins over the resource option:

JavaScript
// calm.config.js
export default defineConfig({
  blocks: {
    privacy: {
      resources: { invoice: 'keep', comment: 'anonymize' },
      personalFields: { comment: ['authorName', 'authorEmail'] },
    },
  },
});

Unknown behaviors and personal fields that do not exist stop the boot with an error.

Grace period#

JavaScript
blocks: { privacy: { graceDays: 14 } }

With a grace period, the account is locked at once (steps 1 and 2) and erased when the period ends. DELETE /api/auth/me answers 202 with scheduledFor, and privacy.deletion_requested is emitted (send the "your account will be deleted on..." email from there). Support can undo it:

Shell
curl -X POST http://localhost:5001/api/admin/users/6acd1c970d5d7607a3466c9c/cancel-deletion \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# 204: the account is back with its previous status and the user can sign in again

Sessions and devices removed meanwhile stay removed. The sweep runs every sweepEveryMinutes (default 60; its timer does not keep the process alive). Several instances can sweep at the same time: each account is claimed with a lease. Run it yourself from a job or a script with app.ext.privacy.sweep().

Admin routes#

Shell
# Export a user's data (as that user would see it, not as the admin sees it)
curl -X POST -OJ http://localhost:5001/api/admin/users/6acd1c970d5d7607a3466c9c/export \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Delete an account (follows graceDays)
curl -X DELETE http://localhost:5001/api/admin/users/6acd1c970d5d7607a3466c9c \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Delete right away, skipping the grace period
curl -X DELETE http://localhost:5001/api/admin/users/6acd1c970d5d7607a3466c9c \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{ "immediate": true }'

Doctor#

calm doctor (and the devtools Doctor panel) shows what deleting an account does:

info  What deleting an account does
      Owned records: comment kept without owner (author, authorName removed); invoice kept as is;
      note deleted; post deleted. Always deleted: the profile, sessions, push devices, password
      reset links, calm_idempotency (by user). Grace period: none, data is erased right away.
warn  invoice: records stay when their owner deletes their account
info  User references that are not owner fields
      comment.mentions keep pointing at deleted accounts

Settings#

OptionDefault
graceDays0Days between the request and the erasure; the account is locked meanwhile
sweepEveryMinutes60How often due and interrupted deletions are finished; 0 turns the timer off
resources{}{ resource: 'delete' | 'anonymize' | 'keep' }, wins over onOwnerDelete
personalFields{}{ resource: ['field'] }, removed when anonymizing
alsoDelete{ calm_idempotency: 'user' }Other collections to clean: { collection: 'fieldHoldingTheUserId' }, for example calm_audit_log: 'user' if your retention policy allows
exportLimit{ windowMs: 3600000, max: 5 }Exports per user; false for no limit
batchSize200Cursor batch size for exports and per-record deletes
cascadeCrudDeletetrueDELETE /api/users/:id erases the same way

Events#

EventPayload
privacy.exported{ userId, by, adminId?, counts }
privacy.deletion_requested{ userId, by, adminId, scheduledFor }
privacy.deletion_cancelled{ userId }
privacy.account_deleted{ userId, by, resources }
security.account_delete_failed{ userId, ip } (wrong password)

From code: app.ext.privacy.request(userId, { by, immediate }), .purge(userId), .cancel(userId), .sweep() and .plan().

Testing#

JavaScript
test('deleting an account removes what the user owns', async () => {
  const alice = await t.signup();
  await t.post('/notes', { text: 'Buy milk' }, alice);

  const exported = await t.get('/auth/me/export', alice);
  assert.equal(exported.body.resources.note.length, 1);

  const deleted = await t.delete('/auth/me', alice); // no password: refused
  assert.equal(deleted.status, 422);
  assert.equal((await t.request('DELETE', '/auth/me', { password: alice.password }, alice)).status, 204);
  assert.equal(await t.app.model('note').countDocuments(), 0);
  assert.equal((await t.get('/auth/me', alice)).status, 401);
});

t.delete() sends no body, so use t.request('DELETE', path, body, auth) to send the password.