Feature: Download a copy of my personal data as a ZIP of JSON files As a verified RenewCMS user I want one click on Profile to download the personal data RenewCMS holds about me So that I can exercise my right of access and portability (GDPR art. 15 and 20) without asking an admin Background: Given the design of record is docs/design/screens/account.md "Your data", direct flow only, and the board "RenewCMS Screens Account" group "Your data" And the emailed-link variant, export-ready.html and /account/export/ are out of scope And GET /v1/user/me/export is added to NewUserRouter behind RequireSession, Verifier, AuditContext, RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, so an API key gets 403 "This action needs a real session." and no cookie gets 401 And the route allows one successful export per user per 15 minutes; a second attempt answers 429 "Too many requests. Please try again later." with a Retry-After header in seconds; an export that fails with a 500 does not consume the slot And the success response is 200, Content-Type application/zip, Cache-Control no-store and Content-Disposition attachment; filename="renewcms-export--.zip", with the username reduced to [a-z0-9-] and the date in UTC from the use case's clock And the ZIP holds exactly four UTF-8 JSON files, snake_case fields, pretty-printed: | file | content | | profile.json | id, username, display_name, email, pending_email, role {id, name}, is_verified, created_at, updated_at, author (the author profile or null), api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}] | | authored-content.json | articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}] and entries [{id, content_type, data, created_at, updated_at}] created by the user or credited to the user's author | | uploaded-media.json | images [{id, filename, mime, size, hash, uploaded_at}] uploaded by the user: references only, no bytes | | account-activity.json | events recorded with the user as actor: id, created_at, action, category, target, outcome, security, source, ip, user_agent, params | And the ZIP never contains the password hash, any verification, reset or email-change code, the token version, an API-key hash, or other people's data And soft-deleted articles and images are excluded And a use case (ExportUserData) reads through domain repositories (user, role, author, apikey, article, entry, image, audit) and writes the archive with the stdlib; the handler streams the result; the archive is built in memory first so a failure never sends a half file And one audit event user.export_data is recorded on success (actor and target the user, category users) And the layering rule holds and no comment is added anywhere Rule: The endpoint Background: Given the user "marta" (id 7, verified) with 2 articles, 1 entry, 3 uploaded images and 5 audit events as actor Scenario: A first export returns the archive When marta sends GET /v1/user/me/export on 2026-10-29 Then the response is 200 with Content-Disposition attachment; filename="renewcms-export-marta-2026-10-29.zip" And the ZIP has exactly the four files above And profile.json has username "marta", email, role name and no key named password, code or token_version And authored-content.json has 2 articles and 1 entry And uploaded-media.json has 3 references and no file bytes And account-activity.json has the 5 events plus nothing recorded for anonymous failed logins And one audit event user.export_data is recorded Scenario: A second export within 15 minutes is refused Given marta exported 3 minutes ago When she sends GET /v1/user/me/export Then the response is 429 "Too many requests. Please try again later." with a numeric Retry-After of about 720 And no archive is built and no audit event is recorded Scenario: The slot is per user and expires Given marta exported 16 minutes ago, and ines exported 1 minute ago Then marta's request succeeds and ines's request answers 429 Scenario: A failed export does not consume the slot Given a repository fails while marta's data is being read When she sends GET /v1/user/me/export Then the response is 500 with a plain text body, no partial archive is sent and no audit event is recorded When the failure is gone and she sends the request again Then the response is 200 Scenario Outline: Only a real verified session may export When sends GET /v1/user/me/export Then the response is with the body "" Examples: | caller | status | text | | nobody (no cookie) | 401 | You must be logged in to access this resource. | | an API key bearer | 403 | This action needs a real session. | | an unverified user | 403 | You must verify your account to access this resource. | Scenario: Someone else's data is never included Given ines has articles, entries, images and events When marta exports Then no record of ines appears in any file, including events where ines is the target of marta's action Scenario: A user with a linked author profile Given marta has an author profile credited on article 4 which marta did not create When she exports Then profile.json has the author profile and authored-content.json includes article 4 Rule: Your data card (SPA) Background: Given the Your data card follows the Account and Password cards, with the title "Your data", the text "Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP." and the four content rows Profile, Authored content, Uploaded media, Account activity with the design's one-line descriptions And the download is a fetch of GET /v1/user/me/export read as a blob and saved with the filename from Content-Disposition And every new SPA string of the card (title, text, content rows, button, states, toasts and errors) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in English and in French under the same keys, with the design's English wording verbatim And the French wording is an assumption: docs/design has no French copy, so the coder authors it and flags it for the user's review And the ZIP file names and JSON keys are not translated And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board Scenario Outline: States Given the user is on /profile When Then the state "" shows Examples: | state | trigger | copy | | Direct (recommended): default | the page loads | "Download my data", "Ready in a few seconds." | | Direct: preparing | "Download my data" is activated, GET unresolved | "Preparing download…", button disabled with a spinner | | Direct: started | the GET answers 200 with the attachment | the browser download starts, then a toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads."; the card returns to the default | | Direct: rate limited (429) | the GET answers 429 with Retry-After | "You can export once every 15 minutes", "Your last export was at 09:41. Try again in 12 minutes.", "Available in 12:00" | | Direct: error | the GET answers 500 or fails | "Couldn't prepare your data", "Nothing was changed. Try again in a moment.", button enabled to retry | Scenario: The rate limited state is clock-sensitive and is skipped Then web/visual/states.ts marks "Direct: rate limited (429)" with skip 'clock-sensitive', no visual check exists for it, and no Vitest spec freezes the system date; the Vitest spec asserts only the copy driven by a mocked Retry-After header Scenario: The async states are out of scope and carry exception entries Then the board states "Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress" and "Alternative: error" are not built in the SPA And web/visual/states.ts lists each of them with an exception entry so `task visual:check -- account` does not fail on a missing setup And each exception needs the user's explicit approval recorded as approvedBy Rule: Documentation and privacy Scenario: The docs describe the feature Then docs/api.md documents GET /v1/user/me/export with the headers, the four files and the 429 And docs/privacy.md replaces "there is no self-service export yet" with the Profile download, lists the four files, notes that anonymous failed-login events and soft-deleted rows are not included, and removes the export half of the "No self-service export or email change" gap