From b7771b75abe6c7ec39511f4bfe4ed32392f7a4b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 15:41:45 +0000 Subject: [PATCH 1/9] feat(hutch): self-serve account deletion (App Store 5.1.1(v)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS app supports in-app account creation, so Apple requires users to be able to initiate account deletion from within the app. Neither the app nor readplace.com offered self-serve deletion; the privacy policy told users to email, which Apple rejects. Add end-to-end deletion. Server pipeline (mirrors the export/cancel-subscription workers): - DeleteAccountCommand + publisher + EventBridge/in-memory wrappers. - delete-account worker: an idempotent, DLQ-backed scrub of every user-owned store — auth row + Gmail claim, sessions, OAuth grants, saved articles, digest queue, reader-ready state, iOS onboarding, inbox emails/links (+ their S3 objects) and forwarding addresses, S3 exports, password-reset tokens, and a live Stripe subscription + customer. Founding-member and trialing accounts are first-class branches with no Stripe call. - Synchronous POST /account/delete teardown (destroy sessions -> revoke every OAuth token -> clear the session cookie -> publish the command -> redirect to the logged-out home) so the account is unusable the instant the user confirms. - Per-store bulk delete/revoke primitives next to each store, mirrored in the in-memory fixtures, unit-tested. Clients: - Siren: a `class` field on SirenAction, a new account resource with a bare destructive `delete-account` action, and an `account` discovery link. - Web: an /account danger zone behind an hx-confirm, and a privacy-policy rewrite pointing at the self-serve path. - iOS: a `delete-account` presentation case, the row destructive-confirm generalized to any destructive collection affordance (keyed on isDestructive, not the name), and forceLogout after a successful account-ending invoke. Also: an injectable no-op Apple-revocation seam (Phase 6 slots in when SIWA is extended to persist a refresh token) and a webhook-race fix so a late SubscriptionCancelled event on a just-deleted account is an idempotent no-op rather than a DLQ poison. Product decisions applied (plan defaults; flag for review): D1 inbox addresses tombstoned (PII stripped, hash kept reserved so it can never be re-minted and leak mail); D2 Stripe customer deleted; D3 raw .eml deleted; D4 password-reset tokens scan-deleted by email (that table has no TTL); D5 native/hx destructive confirm, no forced re-auth; D6 PITR/deletion-protection retention documented out of band. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RkzGzig54ovfhxFHM9FyG3 --- projects/hutch/src/e2e/e2e-server.main.ts | 1 + projects/hutch/src/infra/index.ts | 111 ++++ projects/hutch/src/runtime/app.ts | 13 +- .../hutch/src/runtime/delete-account.main.ts | 147 +++++ .../delete-account-handler.test.ts | 527 ++++++++++++++++++ .../delete-account/delete-account-handler.ts | 158 ++++++ .../revoke-external-idp-tokens.test.ts | 14 + .../revoke-external-idp-tokens.ts | 25 + ...dle-subscription-cancelled-handler.test.ts | 22 + .../handle-subscription-cancelled-handler.ts | 15 +- .../dynamodb-article-store.test.ts | 36 ++ .../article-store/dynamodb-article-store.ts | 20 + .../runtime/providers/auth/dynamodb-auth.ts | 26 + .../dynamodb-digest-queue.test.ts | 42 ++ .../digest-queue/dynamodb-digest-queue.ts | 20 +- .../eventbridge-delete-account-command.ts | 14 + .../dynamodb-inbox-address.test.ts | 75 +++ .../inbox-address/dynamodb-inbox-address.ts | 23 + .../dynamodb-inbox-email-link.test.ts | 110 ++++ .../inbox-email/dynamodb-inbox-email-link.ts | 28 + .../inbox-email/dynamodb-inbox-email.test.ts | 128 +++++ .../inbox-email/dynamodb-inbox-email.ts | 28 + .../inbox-email/s3-delete-objects.test.ts | 61 ++ .../inbox-email/s3-delete-objects.ts | 23 + .../dynamodb-ios-onboarding-signal.test.ts | 11 + .../dynamodb-ios-onboarding-signal.ts | 8 +- .../providers/oauth/dynamodb-oauth-model.ts | 51 +- .../dynamodb-password-reset.test.ts | 64 +++ .../password-reset/dynamodb-password-reset.ts | 31 +- .../dynamodb-reader-ready-state.test.ts | 11 + .../dynamodb-reader-ready-state.ts | 8 +- .../stripe-subscriptions.test.ts | 72 +++ .../stripe-subscriptions.ts | 25 + .../dynamodb-subscription-providers.test.ts | 26 + .../dynamodb-subscription-writes.ts | 7 + .../user-data-export/s3-user-data-export.ts | 31 +- .../user-data-export.types.ts | 2 + projects/hutch/src/runtime/server.ts | 7 + projects/hutch/src/runtime/test-app.ts | 2 + .../src/runtime/web/api/account-siren.test.ts | 19 + .../src/runtime/web/api/account-siren.ts | 23 + .../src/runtime/web/api/api.routes.test.ts | 1 + .../runtime/web/api/collection-siren.test.ts | 5 +- .../src/runtime/web/api/collection-siren.ts | 1 + projects/hutch/src/runtime/web/api/siren.ts | 4 + .../runtime/web/pages/account/account.page.ts | 57 +- .../web/pages/account/account.route.test.ts | 80 +++ .../web/pages/account/account.styles.css | 26 + .../web/pages/account/account.template.html | 10 + .../runtime/web/pages/account/account.url.ts | 1 + .../pages/account/account.view-model.test.ts | 15 + .../web/pages/account/account.view-model.ts | 16 +- .../web/pages/admin/recrawl.route.test.ts | 1 + .../web/pages/privacy/privacy.template.html | 2 +- .../web/pages/queue/queue.card.route.test.ts | 6 + .../pages/queue/queue.freshness.route.test.ts | 1 + .../queue/queue.listing.filters.route.test.ts | 1 + .../pages/queue/queue.listing.route.test.ts | 1 + .../pages/queue/queue.mutations.route.test.ts | 1 + .../queue/queue.reader-presence.route.test.ts | 1 + .../queue/queue.reader.advanced.route.test.ts | 14 + .../pages/queue/queue.reader.route.test.ts | 18 + .../queue/queue.save-content.route.test.ts | 2 + .../pages/queue/queue.save-html.route.test.ts | 3 + .../queue/queue.share-balloon.route.test.ts | 1 + .../pages/view/view.metadata.route.test.ts | 14 + .../runtime/web/pages/view/view.route.test.ts | 27 + .../App/AffordancePresentation.swift | 17 + .../ios-readplace/App/ReadingListView.swift | 51 +- .../Tests/AffordancePresentationTests.swift | 22 + .../domain/src/inbox/inbox-address.schema.ts | 8 + .../domain/src/inbox/inbox-address.types.ts | 10 + .../src/inbox/inbox-email-link.types.ts | 11 + .../domain/src/inbox/inbox-email.types.ts | 11 + src/packages/domain/src/inbox/index.ts | 7 +- .../hutch-infra-components/src/events.ts | 10 + .../hutch-infra-components/src/index.ts | 2 + .../provider-contracts/src/article-store.ts | 5 + src/packages/provider-contracts/src/auth.ts | 2 + .../provider-contracts/src/digest-queue.ts | 3 + src/packages/provider-contracts/src/events.ts | 4 + .../src/ios-onboarding-signal.ts | 3 + src/packages/provider-contracts/src/oauth.ts | 4 +- .../provider-contracts/src/password-reset.ts | 2 + .../src/reader-ready-state.ts | 3 + .../src/stripe-subscriptions.ts | 2 + .../src/subscription-providers.ts | 2 + src/packages/test-fixtures/src/fixture.ts | 12 +- .../in-memory-article-store.test.ts | 28 + .../article-store/in-memory-article-store.ts | 9 + .../src/providers/auth/in-memory-auth.test.ts | 37 ++ .../src/providers/auth/in-memory-auth.ts | 14 + .../digest-queue/digest-queue.types.ts | 1 + .../in-memory-digest-queue.test.ts | 12 + .../digest-queue/in-memory-digest-queue.ts | 10 +- .../in-memory-delete-account-command.ts | 16 + .../src/providers/events/index.ts | 2 + .../publish-delete-account-command.types.ts | 1 + .../in-memory-inbox-address.test.ts | 33 ++ .../inbox-address/in-memory-inbox-address.ts | 15 + .../in-memory-inbox-email-link.test.ts | 49 ++ .../inbox-email/in-memory-inbox-email-link.ts | 17 + .../inbox-email/in-memory-inbox-email.test.ts | 54 ++ .../inbox-email/in-memory-inbox-email.ts | 13 + .../in-memory-ios-onboarding-signal.test.ts | 26 + .../in-memory-ios-onboarding-signal.ts | 9 +- .../src/providers/oauth/oauth-model.test.ts | 89 +++ .../src/providers/oauth/oauth-model.ts | 18 + .../in-memory-password-reset.test.ts | 23 + .../in-memory-password-reset.ts | 10 +- .../password-reset/password-reset.types.ts | 1 + .../in-memory-reader-ready-state.test.ts | 26 + .../in-memory-reader-ready-state.ts | 8 +- .../in-memory-subscription-providers.test.ts | 18 + .../in-memory-subscription-providers.ts | 7 + .../subscription-providers.types.ts | 1 + .../web-test-harness/src/bundle.types.ts | 6 + 117 files changed, 3074 insertions(+), 44 deletions(-) create mode 100644 projects/hutch/src/runtime/delete-account.main.ts create mode 100644 projects/hutch/src/runtime/delete-account/delete-account-handler.test.ts create mode 100644 projects/hutch/src/runtime/delete-account/delete-account-handler.ts create mode 100644 projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.test.ts create mode 100644 projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.ts create mode 100644 projects/hutch/src/runtime/providers/events/eventbridge-delete-account-command.ts create mode 100644 projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.test.ts create mode 100644 projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.ts create mode 100644 projects/hutch/src/runtime/web/api/account-siren.test.ts create mode 100644 projects/hutch/src/runtime/web/api/account-siren.ts create mode 100644 src/packages/test-fixtures/src/providers/events/in-memory-delete-account-command.ts create mode 100644 src/packages/test-fixtures/src/providers/events/publish-delete-account-command.types.ts diff --git a/projects/hutch/src/e2e/e2e-server.main.ts b/projects/hutch/src/e2e/e2e-server.main.ts index 68b021fba..9bb9e67c7 100644 --- a/projects/hutch/src/e2e/e2e-server.main.ts +++ b/projects/hutch/src/e2e/e2e-server.main.ts @@ -129,6 +129,7 @@ const { app: hutchApp, auth, email } = createTestApp({ publishStaleCheckRequested: fixture.events.publishStaleCheckRequested, publishUpdateFetchTimestamp, publishExportUserDataCommand: fixture.events.publishExportUserDataCommand, + publishDeleteAccountCommand: fixture.events.publishDeleteAccountCommand, publishCancelSubscriptionCommand: fixture.events.publishCancelSubscriptionCommand, publishSubscriptionReactivated: fixture.events.publishSubscriptionReactivated, }, diff --git a/projects/hutch/src/infra/index.ts b/projects/hutch/src/infra/index.ts index 08187621f..3e183b36a 100644 --- a/projects/hutch/src/infra/index.ts +++ b/projects/hutch/src/infra/index.ts @@ -6,6 +6,7 @@ import { HutchLambda, HutchAPIGateway, HutchDynamoDBAccess, HutchEventBus, Hutch import { CancelSubscriptionCommand, CrawlEmailLinkPreview, + DeleteAccountCommand, EmailReceivedEvent, ExportUserDataCommand, SendTrialFeedbackEmailCommand, @@ -524,6 +525,116 @@ const exportUserDataLambdaWithSQS = new HutchSQSBackedLambda("export-user-data", eventBus.subscribe(ExportUserDataCommand, exportUserDataLambdaWithSQS); +// --- DeleteAccount worker Lambda --- +// Durable, DLQ-backed scrub of every user-owned store when an account is +// deleted. Runs behind SQS at-least-once with an email alert on the DLQ so a +// stuck erasure is never silent. + +const deleteAccountDynamodb = new HutchDynamoDBAccess("delete-account-dynamodb", { + tables: [ + { arn: storage.usersTable.arn, includeIndexes: true }, + { arn: storage.sessionsTable.arn, includeIndexes: true }, + { arn: storage.oauthTable.arn, includeIndexes: true }, + { arn: storage.userArticlesTable.arn, includeIndexes: true }, + { arn: storage.digestQueueTable.arn, includeIndexes: false }, + { arn: storage.readerReadyNotificationsTable.arn, includeIndexes: false }, + { arn: storage.onboardingTable.arn, includeIndexes: false }, + { arn: storage.subscriptionProvidersTable.arn, includeIndexes: false }, + { arn: storage.inboxEmailsTable.arn, includeIndexes: false }, + { arn: storage.inboxEmailLinksTable.arn, includeIndexes: false }, + { arn: storage.inboxAddressesTable.arn, includeIndexes: true }, + { arn: storage.passwordResetTokensTable.arn, includeIndexes: false }, + ], + actions: [ + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem", + "dynamodb:TransactWriteItems", + ], +}); + +// The write-policy helpers grant only PutObject, so a delete worker needs an +// explicit s3:DeleteObject grant (+ ListBucket for the export-prefix listing). +const deleteAccountS3Policy = { + name: "delete-account-s3-delete", + policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:DeleteObject"], + Resource: [ + `arn:aws:s3:::${rawEmailBucketName}/*`, + `arn:aws:s3:::${contentBucketName}/*`, + `arn:aws:s3:::${userExportBucketName}/*`, + ], + }, + { + Effect: "Allow", + Action: ["s3:ListBucket"], + Resource: [ + `arn:aws:s3:::${rawEmailBucketName}`, + `arn:aws:s3:::${contentBucketName}`, + `arn:aws:s3:::${userExportBucketName}`, + ], + }, + ], + }), +}; + +const deleteAccountQueue = new HutchSQS("delete-account", { + // Matches the worker Lambda timeout so a single in-flight scrub cannot be + // redelivered while still running. + visibilityTimeoutSeconds: 900, +}); + +const deleteAccountLambda = new HutchLambda("delete-account", { + entryPoint: "./src/runtime/delete-account.main.ts", + outputDir: ".lib/delete-account", + assetDir: "./src/runtime", + memorySize: 1024, + timeout: 900, + environment: { + PERSISTENCE: "prod", + DYNAMODB_USERS_TABLE: storage.usersTable.name, + DYNAMODB_SESSIONS_TABLE: storage.sessionsTable.name, + DYNAMODB_OAUTH_TABLE: storage.oauthTable.name, + DYNAMODB_ARTICLES_TABLE: storage.articlesTable.name, + DYNAMODB_USER_ARTICLES_TABLE: storage.userArticlesTable.name, + DYNAMODB_DIGEST_QUEUE_TABLE: storage.digestQueueTable.name, + DYNAMODB_READER_READY_NOTIFICATIONS_TABLE: storage.readerReadyNotificationsTable.name, + DYNAMODB_ONBOARDING_TABLE: storage.onboardingTable.name, + DYNAMODB_SUBSCRIPTION_PROVIDERS_TABLE: storage.subscriptionProvidersTable.name, + DYNAMODB_INBOX_EMAILS_TABLE: storage.inboxEmailsTable.name, + DYNAMODB_INBOX_EMAIL_LINKS_TABLE: storage.inboxEmailLinksTable.name, + DYNAMODB_INBOX_ADDRESSES_TABLE: storage.inboxAddressesTable.name, + DYNAMODB_PASSWORD_RESET_TOKENS_TABLE: storage.passwordResetTokensTable.name, + RAW_EMAIL_BUCKET_NAME: rawEmailBucketName, + CONTENT_BUCKET_NAME: contentBucketName, + USER_EXPORT_BUCKET_NAME: userExportBucketName, + STRIPE_SECRET_KEY: requireEnv("STRIPE_SECRET_KEY"), + EVENT_BUS_ARN: eventBus.eventBusArn, + TRIAL_SCHEDULER_GROUP_NAME: trialSchedulerGroupName, + TRIAL_SCHEDULER_ROLE_ARN: trialSchedulerRole.arn, + }, + policies: [ + ...deleteAccountDynamodb.policies, + deleteAccountS3Policy, + cancelSubscriptionSchedulerManagePolicy, + ], +}); + +const deleteAccountLambdaWithSQS = new HutchSQSBackedLambda("delete-account", { + lambda: deleteAccountLambda, + queue: deleteAccountQueue, + alertEmailDLQEntry: alertEmail, + batchSize: 1, +}); + +eventBus.subscribe(DeleteAccountCommand, deleteAccountLambdaWithSQS); + // --- Inbound email receive worker Lambda --- // Drains the SES→SNS receipt notifications: fetches the raw .eml from the raw // bucket, resolves each recipient, parses + sanitizes the body into the content diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index a5b07b3e2..4bbf7f85f 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -25,6 +25,7 @@ import { initReadabilityParser, linkedinSiteRules, mediumSiteRules, theInformati import { initRefreshArticleIfStale } from "@packages/finalize-article"; import { createOAuthModel, + createRevokeAllUserOAuthTokens, initInMemoryOAuthClients, initInMemoryOAuthModel, } from "@packages/test-fixtures/providers/oauth"; @@ -67,10 +68,12 @@ import { initEventBridgeSaveLinkRawPdfCommand } from "./providers/events/eventbr import { initEventBridgeRefreshArticleContent, initPutRefreshHtml } from "@packages/refresh-article-content"; import { initEventBridgeUpdateFetchTimestamp } from "./providers/events/eventbridge-update-fetch-timestamp"; import { initEventBridgeExportUserDataCommand } from "./providers/events/eventbridge-export-user-data-command"; +import { initEventBridgeDeleteAccountCommand } from "./providers/events/eventbridge-delete-account-command"; import { initEventBridgeCancelSubscriptionCommand } from "./providers/events/eventbridge-cancel-subscription-command"; import { initEventBridgeSubscriptionReactivated } from "./providers/events/eventbridge-subscription-reactivated"; import { initInMemoryCancelSubscriptionCommand, + initInMemoryDeleteAccountCommand, initInMemoryExportUserDataCommand, initInMemorySubscriptionReactivated, } from "@packages/test-fixtures/providers/events"; @@ -221,6 +224,7 @@ function initProviders() { const { publishRefreshArticleContent } = initEventBridgeRefreshArticleContent({ publishEvent, putRefreshHtml }); const { publishUpdateFetchTimestamp } = initEventBridgeUpdateFetchTimestamp({ publishEvent }); const { publishExportUserDataCommand } = initEventBridgeExportUserDataCommand({ publishEvent }); + const { publishDeleteAccountCommand } = initEventBridgeDeleteAccountCommand({ publishEvent }); const { publishCancelSubscriptionCommand } = initEventBridgeCancelSubscriptionCommand({ publishEvent }); const { publishSubscriptionReactivated } = initEventBridgeSubscriptionReactivated({ publishEvent }); const { putPendingHtml } = initPutPendingHtml({ client: new S3Client({}), bucketName: pendingHtmlBucketName }); @@ -368,6 +372,7 @@ function initProviders() { googleAuth, appleAuth, oauthModel, + revokeAllUserOAuthTokens: oauthModel.revokeAllUserOAuthTokens, validateAccessToken: createValidateAccessToken(oauthModel), findOAuthClient: oauthClientLookup.findClient, validateOAuthRedirectUri: oauthClientLookup.validateRedirectUri, @@ -380,6 +385,7 @@ function initProviders() { publishSaveLinkRawPdfCommand, publishUpdateFetchTimestamp, publishExportUserDataCommand, + publishDeleteAccountCommand, publishCancelSubscriptionCommand, publishSubscriptionReactivated, putPendingHtml, @@ -403,11 +409,13 @@ function initProviders() { const articleStore = initInMemoryArticleStore(); const oauthClients = initInMemoryOAuthClients({ now: () => new Date() }); const oauthClientLookup = initOAuthClientLookup({ dynamic: oauthClients }); - const oauthModel = createOAuthModel(initInMemoryOAuthModel(), { + const oauthModelDeps = initInMemoryOAuthModel(); + const oauthModel = createOAuthModel(oauthModelDeps, { findUserById: auth.findUserById, findClient: oauthClientLookup.findClient, markClientActive: oauthClientLookup.markClientActive, }); + const revokeAllUserOAuthTokens = createRevokeAllUserOAuthTokens(oauthModelDeps); const devStripe = initInMemoryStripeCheckout({ checkoutBaseUrl: "https://checkout.stripe.test", now: () => new Date() }); const devStripeSubscriptions = initInMemoryStripeSubscriptions(); const devPendingSignup = initInMemoryPendingSignup(); @@ -545,6 +553,7 @@ function initProviders() { const { publishSaveLinkRawHtmlCommand } = initInMemorySaveLinkRawHtmlCommand({ logger: consoleLogger }); const { publishSaveLinkRawPdfCommand } = initInMemorySaveLinkRawPdfCommand({ logger: consoleLogger }); const { publishExportUserDataCommand } = initInMemoryExportUserDataCommand({ logger: consoleLogger }); + const { publishDeleteAccountCommand } = initInMemoryDeleteAccountCommand({ logger: consoleLogger }); const { publishCancelSubscriptionCommand } = initInMemoryCancelSubscriptionCommand({ logger: consoleLogger }); const { publishSubscriptionReactivated } = initInMemorySubscriptionReactivated({ logger: consoleLogger }); const { putPendingHtml } = initInMemoryPendingHtml(); @@ -614,6 +623,7 @@ function initProviders() { googleAuth, appleAuth, oauthModel, + revokeAllUserOAuthTokens, validateAccessToken: createValidateAccessToken(oauthModel), findOAuthClient: oauthClientLookup.findClient, validateOAuthRedirectUri: oauthClientLookup.validateRedirectUri, @@ -626,6 +636,7 @@ function initProviders() { publishSaveLinkRawPdfCommand, publishUpdateFetchTimestamp, publishExportUserDataCommand, + publishDeleteAccountCommand, publishCancelSubscriptionCommand, publishSubscriptionReactivated, putPendingHtml, diff --git a/projects/hutch/src/runtime/delete-account.main.ts b/projects/hutch/src/runtime/delete-account.main.ts new file mode 100644 index 000000000..e3659eaf5 --- /dev/null +++ b/projects/hutch/src/runtime/delete-account.main.ts @@ -0,0 +1,147 @@ +/* c8 ignore start -- composition root, no logic to test */ +import { S3Client } from "@aws-sdk/client-s3"; +import { SchedulerClient } from "@aws-sdk/client-scheduler"; +import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; +import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; +import { requireEnv } from "@packages/require-env"; +import { initDynamoDbAuth } from "./providers/auth/dynamodb-auth"; +import { initRevokeAllUserOAuthTokens } from "./providers/oauth/dynamodb-oauth-model"; +import { initDynamoDbArticleStore } from "./providers/article-store/dynamodb-article-store"; +import { initDynamoDbDigestQueue } from "./providers/digest-queue/dynamodb-digest-queue"; +import { initDynamoDbReaderReadyState } from "./providers/reader-ready-state/dynamodb-reader-ready-state"; +import { initIosOnboardingSignal } from "./providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal"; +import { initDynamoDbSubscriptionProviders } from "./providers/subscription-providers/dynamodb-subscription-providers"; +import { initStripeSubscriptions } from "./providers/stripe-subscriptions/stripe-subscriptions"; +import { initAwsTrialScheduler } from "./providers/trial-scheduler/aws-trial-scheduler"; +import { initDynamoDbInboxEmail } from "./providers/inbox-email/dynamodb-inbox-email"; +import { initDynamoDbInboxEmailLink } from "./providers/inbox-email/dynamodb-inbox-email-link"; +import { initS3DeleteObjects } from "./providers/inbox-email/s3-delete-objects"; +import { initDynamoDbInboxAddress } from "./providers/inbox-address/dynamodb-inbox-address"; +import { initS3UserDataExport } from "./providers/user-data-export/s3-user-data-export"; +import { initDynamoDbPasswordReset } from "./providers/password-reset/dynamodb-password-reset"; +import { initNoopRevokeExternalIdpTokens } from "./delete-account/revoke-external-idp-tokens"; +import { initDeleteAccountHandler } from "./delete-account/delete-account-handler"; + +const logger = HutchLogger.from(consoleLogger); +const now = () => new Date(); +const dynamoClient = createDynamoDocumentClient(); +const s3Client = new S3Client({}); + +const auth = initDynamoDbAuth({ + client: dynamoClient, + usersTableName: requireEnv("DYNAMODB_USERS_TABLE"), + sessionsTableName: requireEnv("DYNAMODB_SESSIONS_TABLE"), +}); + +const revokeAllUserOAuthTokens = initRevokeAllUserOAuthTokens({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_OAUTH_TABLE"), +}); + +const articleStore = initDynamoDbArticleStore({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_ARTICLES_TABLE"), + userArticlesTableName: requireEnv("DYNAMODB_USER_ARTICLES_TABLE"), + logger, +}); + +const digestQueue = initDynamoDbDigestQueue({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_DIGEST_QUEUE_TABLE"), +}); + +const readerReadyState = initDynamoDbReaderReadyState({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_READER_READY_NOTIFICATIONS_TABLE"), +}); + +const onboarding = initIosOnboardingSignal({ + client: dynamoClient, + onboardingTableName: requireEnv("DYNAMODB_ONBOARDING_TABLE"), + now, +}); + +const subscriptionProviders = initDynamoDbSubscriptionProviders({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_SUBSCRIPTION_PROVIDERS_TABLE"), + now, +}); + +const stripeSubscriptions = initStripeSubscriptions({ + apiKey: requireEnv("STRIPE_SECRET_KEY"), + fetch: globalThis.fetch, +}); + +const trialScheduler = initAwsTrialScheduler({ + client: new SchedulerClient({}), + scheduleGroupName: requireEnv("TRIAL_SCHEDULER_GROUP_NAME"), + schedulerRoleArn: requireEnv("TRIAL_SCHEDULER_ROLE_ARN"), + eventBusArn: requireEnv("EVENT_BUS_ARN"), +}); + +const inboxEmail = initDynamoDbInboxEmail({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_INBOX_EMAILS_TABLE"), +}); + +const inboxEmailLink = initDynamoDbInboxEmailLink({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_INBOX_EMAIL_LINKS_TABLE"), +}); + +const inboxAddress = initDynamoDbInboxAddress({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_INBOX_ADDRESSES_TABLE"), + now, +}); + +const deleteRawEmailObjects = initS3DeleteObjects({ + client: s3Client, + bucketName: requireEnv("RAW_EMAIL_BUCKET_NAME"), +}); + +const deleteEmailContentObjects = initS3DeleteObjects({ + client: s3Client, + bucketName: requireEnv("CONTENT_BUCKET_NAME"), +}); + +const { deleteUserExports } = initS3UserDataExport({ + client: s3Client, + bucketName: requireEnv("USER_EXPORT_BUCKET_NAME"), + now, +}); + +const passwordReset = initDynamoDbPasswordReset({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_PASSWORD_RESET_TOKENS_TABLE"), +}); + +const revokeExternalIdpTokens = initNoopRevokeExternalIdpTokens({ logger }); + +export const handler = initDeleteAccountHandler({ + findEmailByUserId: auth.findEmailByUserId, + findSubscriptionByUserId: subscriptionProviders.findByUserId, + cancelStripeSubscription: stripeSubscriptions.cancelImmediately, + deleteStripeCustomer: stripeSubscriptions.deleteCustomer, + deleteSubscription: subscriptionProviders.deleteSubscription, + deleteTrialEndSchedule: trialScheduler.deleteTrialEndSchedule, + deleteDeferredCancellationSchedule: trialScheduler.deleteDeferredCancellationSchedule, + deleteTrialFeedbackEmailSchedule: trialScheduler.deleteTrialFeedbackEmailSchedule, + deleteAllInboxEmails: inboxEmail.deleteAllEmailsByUserId, + deleteAllInboxLinks: inboxEmailLink.deleteAllLinksByUserId, + tombstoneInboxAddresses: inboxAddress.tombstoneUserAddresses, + deleteRawEmailObjects, + deleteEmailContentObjects, + deleteAllUserArticles: articleStore.deleteAllUserArticles, + deleteDigestByUser: digestQueue.deleteDigestByUser, + deleteReaderReadyState: readerReadyState.deleteReaderReadyState, + deleteOnboarding: onboarding.deleteOnboarding, + deleteUserExports, + deletePasswordResetTokensByEmail: passwordReset.deleteTokensByEmail, + revokeExternalIdpTokens, + revokeAllUserOAuthTokens, + destroyUserSessions: auth.destroyUserSessions, + closeUserAccount: auth.closeUserAccount, + logger, +}); +/* c8 ignore stop */ diff --git a/projects/hutch/src/runtime/delete-account/delete-account-handler.test.ts b/projects/hutch/src/runtime/delete-account/delete-account-handler.test.ts new file mode 100644 index 000000000..61ce097cd --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/delete-account-handler.test.ts @@ -0,0 +1,527 @@ +import assert from "node:assert/strict"; +import { MinutesSchema } from "@packages/domain/article"; +import { + AliasNameSchema, + DELETED_ACCOUNT_INBOX_OWNER, + EmailLinkOrdinalSchema, + InboxAddressSchema, + MessageIdSchema, +} from "@packages/domain/inbox"; +import type { UserId } from "@packages/domain/user"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { initInMemoryArticleStore } from "@packages/test-fixtures/providers/article-store"; +import { initInMemoryAuth } from "@packages/test-fixtures/providers/auth"; +import { initInMemoryDigestQueue } from "@packages/test-fixtures/providers/digest-queue"; +import { + initInMemoryInboxAddress, +} from "@packages/test-fixtures/providers/inbox-address"; +import { + initInMemoryInboxEmail, + initInMemoryInboxEmailLink, +} from "@packages/test-fixtures/providers/inbox-email"; +import { initInMemoryIosOnboardingSignal } from "@packages/test-fixtures/providers/ios-onboarding-signal"; +import { + createRevokeAllUserOAuthTokens, + initInMemoryOAuthModel, +} from "@packages/test-fixtures/providers/oauth"; +import { initInMemoryReaderReadyState } from "@packages/test-fixtures/providers/reader-ready-state"; +import { initInMemorySubscriptionProviders } from "@packages/test-fixtures/providers/subscription-providers"; +import { buildLambdaContext } from "@packages/test-fixtures/lambda-context"; +import { buildSqsEvent } from "@packages/test-fixtures/sqs"; +import { initDeleteAccountHandler } from "./delete-account-handler"; +import { initNoopRevokeExternalIdpTokens } from "./revoke-external-idp-tokens"; + +const SEED_NOW = new Date("2026-07-05T00:00:00.000Z"); +const COOLDOWN_MS = 1000 * 60 * 60 * 24 * 365; + +function bodyFor(userId: string): string { + return JSON.stringify({ detail: { userId } }); +} + +function articleMetadata(title: string) { + return { title, siteName: "example.com", excerpt: "An excerpt", wordCount: 100 }; +} + +function sorted(values: string[]): string[] { + return [...values].sort(); +} + +function buildSubject() { + const auth = initInMemoryAuth({ + hashPassword: async (password) => `hashed:${password}`, + verifyPassword: async (password, stored) => stored === `hashed:${password}`, + }); + const oauthDeps = initInMemoryOAuthModel(); + const articleStore = initInMemoryArticleStore(); + const digest = initInMemoryDigestQueue(); + const readerReady = initInMemoryReaderReadyState(); + const onboarding = initInMemoryIosOnboardingSignal(); + const subs = initInMemorySubscriptionProviders({ now: () => SEED_NOW }); + const inboxEmail = initInMemoryInboxEmail(); + const inboxLink = initInMemoryInboxEmailLink(); + const inboxAddress = initInMemoryInboxAddress({ now: () => SEED_NOW }); + + const cancelStripeCalls: Array<{ subscriptionId: string }> = []; + const deleteCustomerCalls: Array<{ customerId: string }> = []; + const deleteSubscriptionCalls: UserId[] = []; + const trialEndCalls: UserId[] = []; + const deferredCancelCalls: UserId[] = []; + const trialFeedbackCalls: UserId[] = []; + const rawEmailDeleteArgs: string[][] = []; + const bodyEmailDeleteArgs: string[][] = []; + const deleteExportsCalls: UserId[] = []; + const passwordResetCalls: string[] = []; + const revokeIdpCalls: UserId[] = []; + + // The noop is exercised through this wrapper so its own logging line stays + // covered while the spy still records each invocation. + const noopRevoke = initNoopRevokeExternalIdpTokens({ logger: HutchLogger.from(noopLogger) }); + + // Test-only failure injection: the article-store fake throws for any user id + // added here, so a batch can carry one poisoned record beside a healthy one. + const articleDeleteThrowIds = new Set(); + + const handler = initDeleteAccountHandler({ + findEmailByUserId: auth.findEmailByUserId, + findSubscriptionByUserId: subs.findByUserId, + cancelStripeSubscription: async ({ subscriptionId }: { subscriptionId: string }) => { + cancelStripeCalls.push({ subscriptionId }); + }, + deleteStripeCustomer: async ({ customerId }: { customerId: string }) => { + deleteCustomerCalls.push({ customerId }); + }, + deleteSubscription: async ({ userId }: { userId: UserId }) => { + deleteSubscriptionCalls.push(userId); + await subs.deleteSubscription({ userId }); + }, + deleteTrialEndSchedule: async ({ userId }: { userId: UserId }) => { + trialEndCalls.push(userId); + }, + deleteDeferredCancellationSchedule: async ({ userId }: { userId: UserId }) => { + deferredCancelCalls.push(userId); + }, + deleteTrialFeedbackEmailSchedule: async ({ userId }: { userId: UserId }) => { + trialFeedbackCalls.push(userId); + }, + deleteAllInboxEmails: inboxEmail.deleteAllEmailsByUserId, + deleteAllInboxLinks: inboxLink.deleteAllLinksByUserId, + tombstoneInboxAddresses: inboxAddress.tombstoneUserAddresses, + deleteRawEmailObjects: async (keys: string[]) => { + rawEmailDeleteArgs.push(keys); + }, + deleteEmailContentObjects: async (keys: string[]) => { + bodyEmailDeleteArgs.push(keys); + }, + deleteAllUserArticles: async (userId: UserId) => { + if (articleDeleteThrowIds.has(userId)) { + throw new Error("simulated deleteAllUserArticles failure"); + } + await articleStore.deleteAllUserArticles(userId); + }, + deleteDigestByUser: digest.deleteDigestByUser, + deleteReaderReadyState: readerReady.deleteReaderReadyState, + deleteOnboarding: onboarding.deleteOnboarding, + deleteUserExports: async (userId: UserId) => { + deleteExportsCalls.push(userId); + }, + deletePasswordResetTokensByEmail: async (email: string) => { + passwordResetCalls.push(email); + }, + revokeExternalIdpTokens: async (userId: UserId) => { + revokeIdpCalls.push(userId); + await noopRevoke(userId); + }, + revokeAllUserOAuthTokens: createRevokeAllUserOAuthTokens(oauthDeps), + destroyUserSessions: auth.destroyUserSessions, + closeUserAccount: auth.closeUserAccount, + logger: HutchLogger.from(noopLogger), + }); + + return { + handler, + auth, + oauthDeps, + articleStore, + digest, + readerReady, + onboarding, + subs, + inboxEmail, + inboxLink, + inboxAddress, + cancelStripeCalls, + deleteCustomerCalls, + deleteSubscriptionCalls, + trialEndCalls, + deferredCancelCalls, + trialFeedbackCalls, + rawEmailDeleteArgs, + bodyEmailDeleteArgs, + deleteExportsCalls, + passwordResetCalls, + revokeIdpCalls, + failArticleDeleteFor: (userId: UserId): void => { + articleDeleteThrowIds.add(userId); + }, + }; +} + +type Subject = ReturnType; + +interface SeededAccount { + userId: UserId; + email: string; + address: string; + ramA: string; + ramB: string; + rawKeys: string[]; + bodyKeys: string[]; + sessionId: string; +} + +async function seedAccount( + s: Subject, + opts: { + label: string; + email: string; + subscription: "active" | "trialing" | "none"; + }, +): Promise { + const { label, email, subscription } = opts; + const subscriptionId = `sub_${label}`; + const customerId = `cus_${label}`; + + const created = await s.auth.createUser({ email, password: "password-123" }); + assert(created.ok, "expected the seeded user to be created"); + const userId = created.userId; + const storedEmail = await s.auth.findEmailByUserId(userId); + assert(storedEmail !== null, "expected the seeded user to resolve an email"); + const sessionId = await s.auth.createSession({ userId, emailVerified: true }); + + await s.articleStore.saveArticle({ + userId, + url: `https://example.com/${label}/article`, + metadata: articleMetadata(`${label} article`), + estimatedReadTime: MinutesSchema.parse(4), + }); + + await s.digest.enqueueDigestItem({ + userId, + url: `https://example.com/${label}/digest`, + enqueuedAt: SEED_NOW.toISOString(), + retentionMs: COOLDOWN_MS, + }); + + const claimed = await s.readerReady.claimReaderReadyEmailSlot({ + userId, + now: SEED_NOW, + cooldownMs: COOLDOWN_MS, + }); + assert(claimed, "expected the reader-ready slot to seed as claimed"); + + await s.onboarding.recordIosSavedArticle({ userId }); + + if (subscription === "active") { + await s.subs.upsertActive({ userId, subscriptionId, customerId }); + } else if (subscription === "trialing") { + await s.subs.upsertTrialing({ userId, trialEndsAt: "2026-08-01T00:00:00.000Z" }); + } + + const ramA = `2026-07-05T00:00:00.000Z#<${label}-a@x>`; + const ramB = `2026-07-04T00:00:00.000Z#<${label}-b@x>`; + const rawKeyA = `inbound/${label}-a`; + const rawKeyB = `inbound/${label}-b`; + const bodyKeyA = `content/${label}-a.html`; + const recipientAddress = InboxAddressSchema.parse("in-3f9a2c@read.place"); + + await s.inboxEmail.putEmail({ + userId, + receivedAtMessageId: ramA, + messageId: MessageIdSchema.parse(`<${label}-a@x>`), + recipientAddress, + senderEmail: "news@example.com", + subject: "Received newsletter", + status: "received", + receivedAt: "2026-07-05T00:00:00.000Z", + rawEmailS3Key: rawKeyA, + bodyS3Key: bodyKeyA, + }); + await s.inboxEmail.putEmail({ + userId, + receivedAtMessageId: ramB, + messageId: MessageIdSchema.parse(`<${label}-b@x>`), + recipientAddress, + senderEmail: "spam@example.com", + subject: "Rejected message", + status: "rejected", + receivedAt: "2026-07-04T00:00:00.000Z", + rawEmailS3Key: rawKeyB, + bodyS3Key: undefined, + }); + + await s.inboxLink.putLink({ + userId, + receivedAtMessageId: ramA, + ordinal: EmailLinkOrdinalSchema.parse("0000"), + url: `https://example.com/${label}/link`, + status: "pending", + title: undefined, + excerpt: undefined, + siteName: undefined, + imageUrl: undefined, + failureReason: undefined, + }); + await s.inboxLink.putLinksMeta({ + userId, + receivedAtMessageId: ramA, + meta: { truncated: true }, + }); + + const addressEntry = await s.inboxAddress.createAddress({ + userId, + domain: "read.place", + name: AliasNameSchema.parse("news"), + }); + + s.oauthDeps.userIdIndex.set(userId, new Set([`at-${label}`])); + + return { + userId, + email: storedEmail, + address: addressEntry.address, + ramA, + ramB, + rawKeys: [rawKeyA, rawKeyB], + bodyKeys: [bodyKeyA], + sessionId, + }; +} + +/** A present reader-ready slot blocks a same-instant claim (returns false); an + * absent slot allows it (returns true). This is the only readable signal the + * fixture exposes, so deletion is inferred from a now-allowed claim. */ +async function readerReadySlotPresent(s: Subject, userId: UserId): Promise { + const claimed = await s.readerReady.claimReaderReadyEmailSlot({ + userId, + now: SEED_NOW, + cooldownMs: COOLDOWN_MS, + }); + return !claimed; +} + +async function run(s: Subject, records: Array<{ messageId: string; body: string }>) { + const result = await s.handler(buildSqsEvent(records), buildLambdaContext(), () => {}); + assert(result, "handler must return an SQSBatchResponse"); + return result; +} + +describe("delete-account handler", () => { + it("scrubs every user-owned store for the deleted account and leaves a second account untouched", async () => { + const s = buildSubject(); + const victim = await seedAccount(s, { + label: "u1", + email: "u1@example.com", + subscription: "active", + }); + const bystander = await seedAccount(s, { + label: "u2", + email: "u2@example.com", + subscription: "active", + }); + + const result = await run(s, [{ messageId: "msg-u1", body: bodyFor(victim.userId) }]); + + assert.deepEqual(result.batchItemFailures, []); + + // Victim: every store now returns empty / none. + assert.equal((await s.articleStore.findArticlesByUser({ userId: victim.userId })).total, 0); + assert.equal((await s.digest.listDigestItemsByUser(victim.userId)).length, 0); + assert.equal(await readerReadySlotPresent(s, victim.userId), false); + assert.deepEqual(await s.onboarding.getIosAppSignals({ userId: victim.userId }), { + installed: false, + savedArticle: false, + }); + assert.equal(await s.subs.findByUserId(victim.userId), undefined); + assert.equal((await s.inboxEmail.listEmailsByUserId(victim.userId)).length, 0); + const victimLinks = await s.inboxLink.listLinksByEmail({ + userId: victim.userId, + receivedAtMessageId: victim.ramA, + }); + assert.equal(victimLinks.links.length, 0); + assert.equal(victimLinks.meta, undefined); + + // Inbox address is tombstoned, not deleted: the row survives under the + // reserved sentinel owner and no longer resolves for the victim. + const tombstoned = await s.inboxAddress.findByAddress(InboxAddressSchema.parse(victim.address)); + assert(tombstoned, "expected the tombstoned address row to survive"); + assert.equal(tombstoned.userId, DELETED_ACCOUNT_INBOX_OWNER); + assert.equal((await s.inboxAddress.listAddressesByUserId(victim.userId)).length, 0); + + // OAuth grants, sessions, and the identity row are gone. + assert.equal(s.oauthDeps.userIdIndex.has(victim.userId), false); + assert.equal(await s.auth.getSessionUserId(victim.sessionId), null); + assert.equal(await s.auth.findEmailByUserId(victim.userId), null); + + // S3 object deletes received exactly the seeded raw + body keys. + assert.equal(s.rawEmailDeleteArgs.length, 1); + assert.deepEqual(sorted(s.rawEmailDeleteArgs[0]), sorted(victim.rawKeys)); + assert.equal(s.bodyEmailDeleteArgs.length, 1); + assert.deepEqual(sorted(s.bodyEmailDeleteArgs[0]), sorted(victim.bodyKeys)); + + // Password-reset tokens purged by the email captured before deletion. + assert.deepEqual(s.passwordResetCalls, [victim.email]); + + // Billing side effects fired for the active subscription. + assert.deepEqual(s.cancelStripeCalls, [{ subscriptionId: "sub_u1" }]); + assert.deepEqual(s.deleteCustomerCalls, [{ customerId: "cus_u1" }]); + assert.deepEqual(s.deleteSubscriptionCalls, [victim.userId]); + assert.deepEqual(s.deleteExportsCalls, [victim.userId]); + assert.deepEqual(s.revokeIdpCalls, [victim.userId]); + + // Bystander: everything intact. + assert.equal((await s.articleStore.findArticlesByUser({ userId: bystander.userId })).total, 1); + assert.equal((await s.digest.listDigestItemsByUser(bystander.userId)).length, 1); + assert.equal(await readerReadySlotPresent(s, bystander.userId), true); + assert.deepEqual(await s.onboarding.getIosAppSignals({ userId: bystander.userId }), { + installed: true, + savedArticle: true, + }); + const bystanderSub = await s.subs.findByUserId(bystander.userId); + assert(bystanderSub, "expected the bystander subscription to survive"); + assert.equal(bystanderSub.status, "active"); + assert.equal((await s.inboxEmail.listEmailsByUserId(bystander.userId)).length, 2); + assert.equal( + ( + await s.inboxLink.listLinksByEmail({ + userId: bystander.userId, + receivedAtMessageId: bystander.ramA, + }) + ).links.length, + 1, + ); + const bystanderAddress = await s.inboxAddress.findByAddress( + InboxAddressSchema.parse(bystander.address), + ); + assert(bystanderAddress, "expected the bystander address to survive"); + assert.equal(bystanderAddress.userId, bystander.userId); + assert.equal((await s.inboxAddress.listAddressesByUserId(bystander.userId)).length, 1); + assert.equal(s.oauthDeps.userIdIndex.has(bystander.userId), true); + assert(await s.auth.getSessionUserId(bystander.sessionId)); + assert.equal(await s.auth.findEmailByUserId(bystander.userId), bystander.email); + }); + + it("active subscription branch — cancels Stripe, deletes the customer, and drops the local row", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "active", + email: "active@example.com", + subscription: "active", + }); + + const result = await run(s, [{ messageId: "msg", body: bodyFor(account.userId) }]); + + assert.deepEqual(result.batchItemFailures, []); + assert.deepEqual(s.cancelStripeCalls, [{ subscriptionId: "sub_active" }]); + assert.deepEqual(s.deleteCustomerCalls, [{ customerId: "cus_active" }]); + assert.deepEqual(s.deleteSubscriptionCalls, [account.userId]); + assert.equal(await s.subs.findByUserId(account.userId), undefined); + }); + + it("trialing branch — no Stripe calls, but the row is dropped and all three schedules deleted", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "trial", + email: "trial@example.com", + subscription: "trialing", + }); + + const result = await run(s, [{ messageId: "msg", body: bodyFor(account.userId) }]); + + assert.deepEqual(result.batchItemFailures, []); + assert.deepEqual(s.cancelStripeCalls, []); + assert.deepEqual(s.deleteCustomerCalls, []); + assert.deepEqual(s.deleteSubscriptionCalls, [account.userId]); + assert.deepEqual(s.trialEndCalls, [account.userId]); + assert.deepEqual(s.deferredCancelCalls, [account.userId]); + assert.deepEqual(s.trialFeedbackCalls, [account.userId]); + }); + + it("founding-member branch — no subscription row, so no billing calls, but the schedules are still deleted", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "founder", + email: "founder@example.com", + subscription: "none", + }); + + const result = await run(s, [{ messageId: "msg", body: bodyFor(account.userId) }]); + + assert.deepEqual(result.batchItemFailures, []); + assert.deepEqual(s.cancelStripeCalls, []); + assert.deepEqual(s.deleteCustomerCalls, []); + assert.deepEqual(s.deleteSubscriptionCalls, []); + assert.deepEqual(s.trialEndCalls, [account.userId]); + assert.deepEqual(s.deferredCancelCalls, [account.userId]); + assert.deepEqual(s.trialFeedbackCalls, [account.userId]); + }); + + it("is idempotent — a second run against the now-empty account does not throw and reports no failures", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "again", + email: "again@example.com", + subscription: "active", + }); + + const first = await run(s, [{ messageId: "msg-1", body: bodyFor(account.userId) }]); + assert.deepEqual(first.batchItemFailures, []); + + // The identity row is gone, so the second run finds no email (email === null + // branch) and no subscription — every teardown step is a stable no-op. + const second = await run(s, [{ messageId: "msg-2", body: bodyFor(account.userId) }]); + assert.deepEqual(second.batchItemFailures, []); + + // Billing and password-reset only fired on the first, data-bearing run. + assert.deepEqual(s.cancelStripeCalls, [{ subscriptionId: "sub_again" }]); + assert.deepEqual(s.passwordResetCalls, [account.email]); + }); + + it("reports the failing record in batchItemFailures while a second valid record in the same batch still succeeds", async () => { + const s = buildSubject(); + const poisoned = await seedAccount(s, { + label: "boom", + email: "boom@example.com", + subscription: "active", + }); + const healthy = await seedAccount(s, { + label: "ok", + email: "ok@example.com", + subscription: "active", + }); + s.failArticleDeleteFor(poisoned.userId); + + const result = await run(s, [ + { messageId: "msg-boom", body: bodyFor(poisoned.userId) }, + { messageId: "msg-ok", body: bodyFor(healthy.userId) }, + ]); + + assert.deepEqual(result.batchItemFailures, [{ itemIdentifier: "msg-boom" }]); + // The healthy record processed to completion despite its sibling failing. + assert.equal(await s.subs.findByUserId(healthy.userId), undefined); + assert.equal((await s.articleStore.findArticlesByUser({ userId: healthy.userId })).total, 0); + assert.equal(await s.auth.findEmailByUserId(healthy.userId), null); + }); + + it("invokes revokeExternalIdpTokens exactly once per deletion", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "idp", + email: "idp@example.com", + subscription: "none", + }); + + await run(s, [{ messageId: "msg", body: bodyFor(account.userId) }]); + + assert.deepEqual(s.revokeIdpCalls, [account.userId]); + }); +}); diff --git a/projects/hutch/src/runtime/delete-account/delete-account-handler.ts b/projects/hutch/src/runtime/delete-account/delete-account-handler.ts new file mode 100644 index 000000000..9a8e7b71a --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/delete-account-handler.ts @@ -0,0 +1,158 @@ +import type { + Handler, + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from "aws-lambda"; +import { z } from "zod"; +import { type UserId, UserIdSchema } from "@packages/domain/user"; +import type { HutchLogger } from "@packages/hutch-logger"; +import { DeleteAccountCommand } from "@packages/hutch-infra-components"; +import type { + CloseUserAccount, + DestroyUserSessions, + FindEmailByUserId, +} from "@packages/provider-contracts/auth"; +import type { RevokeAllUserOAuthTokens } from "@packages/provider-contracts/oauth"; +import type { DeleteAllUserArticles } from "@packages/provider-contracts/article-store"; +import type { DeleteDigestByUser } from "@packages/provider-contracts/digest-queue"; +import type { DeleteReaderReadyState } from "@packages/provider-contracts/reader-ready-state"; +import type { DeleteOnboarding } from "@packages/provider-contracts/ios-onboarding-signal"; +import type { DeletePasswordResetTokensByEmail } from "@packages/provider-contracts/password-reset"; +import type { + DeleteSubscription, + FindSubscriptionByUserId, +} from "@packages/provider-contracts/subscription-providers"; +import type { + CancelSubscriptionImmediately, + DeleteCustomer, +} from "@packages/provider-contracts/stripe-subscriptions"; +import type { + DeleteDeferredCancellationSchedule, + DeleteTrialEndSchedule, + DeleteTrialFeedbackEmailSchedule, +} from "@packages/provider-contracts/trial-scheduler"; +import type { + InboxAddressStore, + InboxEmailLinkStore, + InboxEmailStore, +} from "@packages/domain/inbox"; +import type { DeleteUserExports } from "../providers/user-data-export/user-data-export.types"; +import type { RevokeExternalIdpTokens } from "./revoke-external-idp-tokens"; + +export interface DeleteAccountHandlerDependencies { + findEmailByUserId: FindEmailByUserId; + findSubscriptionByUserId: FindSubscriptionByUserId; + cancelStripeSubscription: CancelSubscriptionImmediately; + deleteStripeCustomer: DeleteCustomer; + deleteSubscription: DeleteSubscription; + deleteTrialEndSchedule: DeleteTrialEndSchedule; + deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; + deleteTrialFeedbackEmailSchedule: DeleteTrialFeedbackEmailSchedule; + deleteAllInboxEmails: InboxEmailStore["deleteAllEmailsByUserId"]; + deleteAllInboxLinks: InboxEmailLinkStore["deleteAllLinksByUserId"]; + tombstoneInboxAddresses: InboxAddressStore["tombstoneUserAddresses"]; + deleteRawEmailObjects: (keys: string[]) => Promise; + deleteEmailContentObjects: (keys: string[]) => Promise; + deleteAllUserArticles: DeleteAllUserArticles; + deleteDigestByUser: DeleteDigestByUser; + deleteReaderReadyState: DeleteReaderReadyState; + deleteOnboarding: DeleteOnboarding; + deleteUserExports: DeleteUserExports; + deletePasswordResetTokensByEmail: DeletePasswordResetTokensByEmail; + revokeExternalIdpTokens: RevokeExternalIdpTokens; + revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens; + destroyUserSessions: DestroyUserSessions; + closeUserAccount: CloseUserAccount; + logger: HutchLogger; +} + +/** Erase every user-owned store for a deleted account. Each step is idempotent + * because the queue is at-least-once (a double-confirm enqueues twice) and a + * step that throws redrives the whole record — so re-running against a partially + * scrubbed account must converge, never throw on already-absent data. */ +async function processCommand( + userId: UserId, + deps: DeleteAccountHandlerDependencies, +): Promise { + // Capture the delivery email before the user row is deleted — password-reset + // tokens are keyed by email, and closeUserAccount removes the row that + // findEmailByUserId reads. + const email = await deps.findEmailByUserId(userId); + + // Billing first: cancel any live subscription and delete the Stripe customer + // (which detaches every card) before dropping the local row. Founding members + // have no row; trialing users have a row but no subscriptionId/customerId. + const subscription = await deps.findSubscriptionByUserId(userId); + if (subscription) { + if (subscription.subscriptionId) { + await deps.cancelStripeSubscription({ subscriptionId: subscription.subscriptionId }); + } + if (subscription.customerId) { + await deps.deleteStripeCustomer({ customerId: subscription.customerId }); + } + await deps.deleteSubscription({ userId }); + } + + // Delete every per-user schedule so a later fire can't dispatch a command at + // a deleted account. All three are ResourceNotFound-idempotent. + await deps.deleteTrialEndSchedule({ userId }); + await deps.deleteDeferredCancellationSchedule({ userId }); + await deps.deleteTrialFeedbackEmailSchedule({ userId }); + + // Inbox: delete the email rows (returns their ids + S3 keys), then the link + // rows keyed off those ids (that table has no userId index), then the S3 + // objects, then tombstone the forwarding addresses (kept reserved, PII + // stripped, so a freed hash can never be re-minted to leak another user's + // mail). + const { receivedAtMessageIds, rawEmailS3Keys, bodyS3Keys } = + await deps.deleteAllInboxEmails(userId); + await deps.deleteAllInboxLinks(userId, receivedAtMessageIds); + await deps.deleteRawEmailObjects(rawEmailS3Keys); + await deps.deleteEmailContentObjects(bodyS3Keys); + await deps.tombstoneInboxAddresses(userId); + + // Saved articles and the remaining per-user stores. + await deps.deleteAllUserArticles(userId); + await deps.deleteDigestByUser(userId); + await deps.deleteReaderReadyState(userId); + await deps.deleteOnboarding({ userId }); + await deps.deleteUserExports(userId); + if (email !== null) { + await deps.deletePasswordResetTokensByEmail(email); + } + + // Credentials last: revoke external IdP tokens, kill OAuth grants and every + // session, then delete the identity row (and its Gmail uniqueness claim). + await deps.revokeExternalIdpTokens(userId); + await deps.revokeAllUserOAuthTokens(userId); + await deps.destroyUserSessions(userId); + await deps.closeUserAccount(userId); + + deps.logger.info("[delete-account] completed", { userId }); +} + +export function initDeleteAccountHandler( + deps: DeleteAccountHandlerDependencies, +): Handler { + return async (event) => { + const batchItemFailures: SQSBatchItemFailure[] = []; + + for (const record of event.Records) { + try { + const envelope = z.object({ detail: z.unknown() }).parse(JSON.parse(record.body)); + const detail = DeleteAccountCommand.detailSchema.parse(envelope.detail); + const userId = UserIdSchema.parse(detail.userId); + await processCommand(userId, deps); + } catch (error) { + deps.logger.error("[delete-account] record failed", { + messageId: record.messageId, + error, + }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + return { batchItemFailures }; + }; +} diff --git a/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.test.ts b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.test.ts new file mode 100644 index 000000000..de375e7d0 --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { UserIdSchema } from "@packages/domain/user"; +import { initNoopRevokeExternalIdpTokens } from "./revoke-external-idp-tokens"; + +describe("initNoopRevokeExternalIdpTokens", () => { + it("resolves without throwing — no external IdP refresh token is persisted yet", async () => { + const revokeExternalIdpTokens = initNoopRevokeExternalIdpTokens({ + logger: HutchLogger.from(noopLogger), + }); + + await assert.doesNotReject(revokeExternalIdpTokens(UserIdSchema.parse("user-1"))); + }); +}); diff --git a/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.ts b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.ts new file mode 100644 index 000000000..84a1ce9ce --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.ts @@ -0,0 +1,25 @@ +import type { UserId } from "@packages/domain/user"; +import type { HutchLogger } from "@packages/hutch-logger"; + +/** Revoke a user's tokens at any external identity provider they signed in + * through, as part of account deletion. Injected into the delete worker so the + * real Apple `/auth/revoke` call can be added without touching the teardown. */ +export type RevokeExternalIdpTokens = (userId: UserId) => Promise; + +/** The only IdPs today are Google (which persists no token) and Sign in with + * Apple — whose merged sign-in flow discards Apple's `refresh_token`, so there + * is nothing to revoke yet. This no-op is the injectable seam: when SIWA is + * extended to persist the refresh token, its runtime implementation replaces + * this at the composition root and the worker is unchanged. Apple *requires* + * revocation once both SIWA and account deletion ship, so this must be swapped + * for the real call in the same change that persists the token. */ +export function initNoopRevokeExternalIdpTokens(deps: { + logger: HutchLogger; +}): RevokeExternalIdpTokens { + return async (userId) => { + deps.logger.info( + "[delete-account] no external IdP token to revoke — Apple revocation pending SIWA refresh-token persistence", + { userId }, + ); + }; +} diff --git a/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.test.ts b/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.test.ts index 54019da89..f7b191092 100644 --- a/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.test.ts +++ b/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import type { SQSEvent } from "aws-lambda"; import { buildLambdaContext } from "@packages/test-fixtures/lambda-context"; +import { ConditionalCheckFailedException } from "@packages/hutch-storage-client"; import { UserIdSchema } from "@packages/domain/user"; import { HutchLogger, noopLogger } from "@packages/hutch-logger"; import { initHandleSubscriptionCancelledHandler } from "./handle-subscription-cancelled-handler"; @@ -137,6 +138,27 @@ describe("handle-subscription-cancelled-handler", () => { assert.equal(result.batchItemFailures[0].itemIdentifier, "msg-fail"); }); + it("treats a missing subscription row (account deleted) as an idempotent no-op, not a failure", async () => { + const { emit, captured } = makeEmit(); + const handler = initHandleSubscriptionCancelledHandler({ + markCancelledByUserId: async () => { + throw new ConditionalCheckFailedException({ $metadata: {}, message: "row gone" }); + }, + emit, + logger: HutchLogger.from(noopLogger), + }); + + const result = await handler( + buildSqsEvent([{ messageId: "msg-gone", body: buildEventBridgeBody({ userId: USER_ID }) }]), + buildLambdaContext(), + () => {}, + ); + + assert(result); + assert.equal(result.batchItemFailures.length, 0); + assert.deepStrictEqual(captured, []); + }); + it("reports a batch item failure for malformed JSON", async () => { const handler = initHandleSubscriptionCancelledHandler({ markCancelledByUserId: async () => {}, diff --git a/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.ts b/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.ts index 0829b8c9b..dc4a9a0ec 100644 --- a/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.ts +++ b/projects/hutch/src/runtime/handle-subscription-cancelled/handle-subscription-cancelled-handler.ts @@ -5,6 +5,7 @@ import type { SQSEvent, } from "aws-lambda"; import { z } from "zod"; +import { ConditionalCheckFailedException } from "@packages/hutch-storage-client"; import { UserIdSchema } from "@packages/domain/user"; import type { HutchLogger } from "@packages/hutch-logger"; import { SubscriptionCancelledEvent } from "@packages/hutch-infra-components"; @@ -24,7 +25,19 @@ export function initHandleSubscriptionCancelledHandler(deps: { const envelope = z.object({ detail: z.unknown() }).parse(JSON.parse(record.body)); const detail = SubscriptionCancelledEvent.detailSchema.parse(envelope.detail); const userId = UserIdSchema.parse(detail.userId); - await deps.markCancelledByUserId({ userId }); + try { + await deps.markCancelledByUserId({ userId }); + } catch (error) { + // The subscription row is gone — account deletion removed it before + // this (possibly duplicate) cancellation event was processed. The + // desired end state already holds, so treat the missing row as an + // idempotent no-op instead of failing the record into the DLQ. + if (!(error instanceof ConditionalCheckFailedException)) throw error; + deps.logger.info("[SubscriptionCancelled] no subscription row (account deleted) — no-op", { + userId, + }); + continue; + } deps.emit.cancelled({ userId, reason: detail.reason, diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts index a4caa5558..c1c962247 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts @@ -582,6 +582,42 @@ describe("initDynamoDbArticleStore deleteArticle", () => { }); }); +describe("initDynamoDbArticleStore deleteAllUserArticles", () => { + it("pages the userId-savedAt-index and deletes every userArticles row by its (userId, url) key across pages", async () => { + const { client, commands } = createFakeClient({ + QueryCommand: { + queue: [ + { Items: [userArticleItem({ url: "a" })], Count: 1, LastEvaluatedKey: { userId: USER, url: "a" } }, + { Items: [userArticleItem({ url: "b" })], Count: 1 }, + ], + }, + }); + + await initStore(client).deleteAllUserArticles(USER); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries).toHaveLength(2); + expect(queries[0]?.input.IndexName).toBe("userId-savedAt-index"); + expect(queries[0]?.input.KeyConditionExpression).toBe("userId = :userId"); + expect((queries[0]?.input.ExpressionAttributeValues as Record)[":userId"]).toBe(USER); + expect(queries[1]?.input.ExclusiveStartKey).toEqual({ userId: USER, url: "a" }); + + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((d) => d.input.Key)).toEqual([ + { userId: USER, url: "a" }, + { userId: USER, url: "b" }, + ]); + }); + + it("issues no deletes when the user has no saved rows", async () => { + const { client, commands } = createFakeClient({ QueryCommand: { default: { Items: [], Count: 0 } } }); + + await initStore(client).deleteAllUserArticles(USER); + + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); +}); + describe("initDynamoDbArticleStore updateArticleStatus", () => { it("stamps readAt when marking an article read", async () => { const { client, commands } = createFakeClient({ diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts index 04e2f153c..47baf0407 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts @@ -5,6 +5,7 @@ import { batchGetFromTable, defineDynamoTable, dynamoField, + forEachQueryPage, } from "@packages/hutch-storage-client"; import { z } from "zod"; import type { SavedArticle } from "@packages/domain/article"; @@ -17,6 +18,7 @@ import type { UserId } from "@packages/domain/user"; import type { BumpArticleSavedAt, CountArticlesByUser, + DeleteAllUserArticles, DeleteArticle, FindArticleById, FindArticleByUrl, @@ -129,6 +131,7 @@ export function initDynamoDbArticleStore(deps: { findArticlesByUser: FindArticlesByUser; countArticlesByUser: CountArticlesByUser; deleteArticle: DeleteArticle; + deleteAllUserArticles: DeleteAllUserArticles; updateArticleStatus: UpdateArticleStatus; findArticleFreshness: FindArticleFreshness; markArticleViewed: MarkArticleViewed; @@ -405,6 +408,22 @@ export function initDynamoDbArticleStore(deps: { return true; }; + const deleteAllUserArticles: DeleteAllUserArticles = async (userId) => { + await forEachQueryPage( + userArticles, + { + IndexName: "userId-savedAt-index", + KeyConditionExpression: "userId = :userId", + ExpressionAttributeValues: { ":userId": userId }, + }, + async (rows) => { + await Promise.all( + rows.map((row) => userArticles.delete({ Key: { userId: row.userId, url: row.url } })), + ); + }, + ); + }; + const updateArticleStatus: UpdateArticleStatus = async (routeId, userId, status) => { const article = await findArticleByRouteId(routeId); if (!article) return false; @@ -593,6 +612,7 @@ export function initDynamoDbArticleStore(deps: { findArticlesByUser, countArticlesByUser, deleteArticle, + deleteAllUserArticles, updateArticleStatus, findArticleFreshness, markArticleViewed, diff --git a/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts b/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts index bb92f9631..07c5b0a1b 100644 --- a/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts +++ b/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts @@ -24,6 +24,7 @@ import { } from "@packages/domain/user"; import { SESSION_TTL_SECONDS, SessionRow, initGetSessionUserId } from "@packages/web-session"; import type { + CloseUserAccount, CountUsers, CreateAppleUser, CreateGoogleUser, @@ -89,6 +90,7 @@ export function initDynamoDbAuth(deps: { getSessionUserId: GetSessionUserId; destroySession: DestroySession; destroyUserSessions: DestroyUserSessions; + closeUserAccount: CloseUserAccount; countUsers: CountUsers; markEmailVerified: MarkEmailVerified; markSessionEmailVerified: MarkSessionEmailVerified; @@ -363,6 +365,29 @@ export function initDynamoDbAuth(deps: { return row ? row.email : null; }; + const closeUserAccount: CloseUserAccount = async (userId) => { + const email = await findEmailByUserId(userId); + if (email === null) return; + const claimKey = gmailIdentityKey(email); + if (claimKey === null) { + await users.delete({ Key: { email } }); + return; + } + await deps.client.send( + new TransactWriteCommand({ + TransactItems: [ + { Delete: { TableName: deps.usersTableName, Key: { email } } }, + { + Delete: { + TableName: deps.usersTableName, + Key: { email: `${CLAIM_PK_PREFIX}${claimKey}` }, + }, + }, + ], + }), + ); + }; + const findUserContactByUserId: FindUserContactByUserId = async (userId) => { const { items } = await users.query({ IndexName: "userId-index", @@ -425,6 +450,7 @@ export function initDynamoDbAuth(deps: { getSessionUserId, destroySession, destroyUserSessions, + closeUserAccount, countUsers, markEmailVerified, markSessionEmailVerified, diff --git a/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.test.ts b/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.test.ts index 035b85ab9..b5597045a 100644 --- a/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.test.ts +++ b/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.test.ts @@ -107,6 +107,48 @@ describe("initDynamoDbDigestQueue", () => { }); }); + describe("deleteDigestByUser", () => { + it("pages the user partition and deletes every row by its (userId, url) key", async () => { + const { client, commands } = createFakeClient({ + queryPages: [ + { + Items: [ + { userId: USER, url: "example.com/a", originalUrl: "https://example.com/a", enqueuedAt: "t1" }, + ], + LastEvaluatedKey: { userId: USER, url: "example.com/a" }, + }, + { + Items: [ + { userId: USER, url: "example.com/b", originalUrl: "https://example.com/b", enqueuedAt: "t2" }, + ], + }, + ], + }); + + await initQueue(client).deleteDigestByUser(USER); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries).toHaveLength(2); + expect(queries[0]?.input.KeyConditionExpression).toBe("userId = :userId"); + expect((queries[0]?.input.ExpressionAttributeValues as Record)[":userId"]).toBe(USER); + expect(queries[1]?.input.ExclusiveStartKey).toEqual({ userId: USER, url: "example.com/a" }); + + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((d) => d.input.Key)).toEqual([ + { userId: USER, url: "example.com/a" }, + { userId: USER, url: "example.com/b" }, + ]); + }); + + it("issues no deletes when the user has no queued rows", async () => { + const { client, commands } = createFakeClient({}); + + await initQueue(client).deleteDigestByUser(USER); + + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); + }); + describe("scanPendingDigestUsers", () => { it("scans every page and returns each distinct userId once", async () => { const other = UserIdSchema.parse("user-2"); diff --git a/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.ts b/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.ts index 0d598fd87..b69aff37a 100644 --- a/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.ts +++ b/projects/hutch/src/runtime/providers/digest-queue/dynamodb-digest-queue.ts @@ -2,12 +2,14 @@ import { type DynamoDBDocumentClient, defineDynamoTable, dynamoField, + forEachQueryPage, } from "@packages/hutch-storage-client"; import { z } from "zod"; import { UserIdSchema } from "@packages/domain/user"; import type { UserId } from "@packages/domain/user"; import { ArticleResourceUniqueId } from "@packages/article-resource-unique-id"; import type { + DeleteDigestByUser, DeleteDigestItem, DigestQueueItem, EnqueueDigestItem, @@ -35,6 +37,7 @@ export function initDynamoDbDigestQueue(deps: { enqueueDigestItem: EnqueueDigestItem; listDigestItemsByUser: ListDigestItemsByUser; deleteDigestItem: DeleteDigestItem; + deleteDigestByUser: DeleteDigestByUser; scanPendingDigestUsers: ScanPendingDigestUsers; } { const table = defineDynamoTable({ @@ -84,6 +87,21 @@ export function initDynamoDbDigestQueue(deps: { await table.delete({ Key: { userId, url } }); }; + const deleteDigestByUser: DeleteDigestByUser = async (userId) => { + await forEachQueryPage( + table, + { + KeyConditionExpression: "userId = :userId", + ExpressionAttributeValues: { ":userId": userId }, + }, + async (rows) => { + await Promise.all( + rows.map((row) => table.delete({ Key: { userId: row.userId, url: row.url } })), + ); + }, + ); + }; + const scanPendingDigestUsers: ScanPendingDigestUsers = async () => { const users = new Set(); let exclusiveStartKey: Record | undefined; @@ -99,5 +117,5 @@ export function initDynamoDbDigestQueue(deps: { return [...users]; }; - return { enqueueDigestItem, listDigestItemsByUser, deleteDigestItem, scanPendingDigestUsers }; + return { enqueueDigestItem, listDigestItemsByUser, deleteDigestItem, deleteDigestByUser, scanPendingDigestUsers }; } diff --git a/projects/hutch/src/runtime/providers/events/eventbridge-delete-account-command.ts b/projects/hutch/src/runtime/providers/events/eventbridge-delete-account-command.ts new file mode 100644 index 000000000..e14f42703 --- /dev/null +++ b/projects/hutch/src/runtime/providers/events/eventbridge-delete-account-command.ts @@ -0,0 +1,14 @@ +/* c8 ignore start -- thin SDK wrapper, only used in prod path */ +import type { PublishEvent } from "@packages/hutch-infra-components/runtime"; +import { DeleteAccountCommand } from "@packages/hutch-infra-components"; +import type { PublishDeleteAccountCommand } from "@packages/provider-contracts/events"; + +export function initEventBridgeDeleteAccountCommand(deps: { + publishEvent: PublishEvent; +}): { publishDeleteAccountCommand: PublishDeleteAccountCommand } { + return { + publishDeleteAccountCommand: (params) => + deps.publishEvent(DeleteAccountCommand, params), + }; +} +/* c8 ignore stop */ diff --git a/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.test.ts b/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.test.ts index 3f020526f..54ab64604 100644 --- a/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.test.ts +++ b/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.test.ts @@ -5,6 +5,7 @@ import { } from "@packages/hutch-storage-client"; import { AliasNameSchema, + DELETED_ACCOUNT_INBOX_OWNER, INBOX_ADDRESS_MAX_PER_USER, InboxAddressLimitReachedError, InboxAddressSchema, @@ -31,6 +32,7 @@ interface CapturedCommand { ConditionExpression?: string; UpdateExpression?: string; ExpressionAttributeValues?: Record; + ExpressionAttributeNames?: Record; }; } @@ -299,4 +301,77 @@ describe("initDynamoDbInboxAddress", () => { expect(await store.findByAddress(address)).toBeUndefined(); }); }); + + describe("tombstoneUserAddresses", () => { + it("reassigns each owned address to the sentinel owner, stripping the alias and stamping disabledAt, keeping every row", async () => { + const commands: CapturedCommand[] = []; + const store = initDynamoDbInboxAddress({ + client: createFakeClient((cmd) => { + const command = cmd as CapturedCommand; + commands.push(command); + if (command.input.IndexName) { + return { + Items: [ + { + address: "news-aaaaaa@read.place", + userId: "user-1", + name: "news", + token: "aaaaaa", + createdAt: "2026-06-20T00:00:00.000Z", + disabledAt: null, + }, + { + address: "news-bbbbbb@read.place", + userId: "user-1", + name: "news", + token: "bbbbbb", + createdAt: "2026-06-20T00:00:00.000Z", + disabledAt: "2026-06-21T00:00:00.000Z", + }, + ], + Count: 2, + }; + } + return {}; + }) as DynamoDBDocumentClient, + tableName: TABLE, + now: () => NOW, + }); + + await store.tombstoneUserAddresses(USER); + + const updates = commands.filter((c) => c.input.UpdateExpression); + expect(updates).toHaveLength(2); + for (const update of updates) { + expect(update.input.UpdateExpression).toBe( + "SET userId = :tomb, disabledAt = if_not_exists(disabledAt, :now) REMOVE #name", + ); + expect(update.input.ConditionExpression).toBe("userId = :uid"); + expect(update.input.ExpressionAttributeNames).toEqual({ "#name": "name" }); + expect(update.input.ExpressionAttributeValues?.[":tomb"]).toBe(DELETED_ACCOUNT_INBOX_OWNER); + expect(update.input.ExpressionAttributeValues?.[":uid"]).toBe(USER); + expect(update.input.ExpressionAttributeValues?.[":now"]).toBe(NOW.toISOString()); + } + expect(updates.map((u) => u.input.Key)).toEqual([ + { address: "news-aaaaaa@read.place" }, + { address: "news-bbbbbb@read.place" }, + ]); + }); + + it("issues no update when the user owns no addresses", async () => { + const commands: CapturedCommand[] = []; + const store = initDynamoDbInboxAddress({ + client: createFakeClient((cmd) => { + commands.push(cmd as CapturedCommand); + return { Items: [], Count: 0 }; + }) as DynamoDBDocumentClient, + tableName: TABLE, + now: () => NOW, + }); + + await store.tombstoneUserAddresses(USER); + + expect(commands.some((c) => c.input.UpdateExpression)).toBe(false); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.ts b/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.ts index 9cf379740..caeebbf92 100644 --- a/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.ts +++ b/projects/hutch/src/runtime/providers/inbox-address/dynamodb-inbox-address.ts @@ -11,6 +11,7 @@ import { AliasNameSchema, buildInboxAddress, countLiveAddresses, + DELETED_ACCOUNT_INBOX_OWNER, generateInboxToken, INBOX_ADDRESS_MAX_CREATE_ATTEMPTS, INBOX_ADDRESS_MAX_PER_USER, @@ -19,6 +20,7 @@ import { InboxAddressSchema, type InboxAddressStore, InboxTokenSchema, + type TombstoneUserAddresses, } from "@packages/domain/inbox"; const InboxAddressRow = z.object({ @@ -75,6 +77,26 @@ export function initDynamoDbInboxAddress(deps: { return items.map(toEntry); }; + const tombstoneUserAddresses: TombstoneUserAddresses = async (userId) => { + const addresses = await listAddressesByUserId(userId); + await Promise.all( + addresses.map((entry) => + table.update({ + Key: { address: entry.address }, + UpdateExpression: + "SET userId = :tomb, disabledAt = if_not_exists(disabledAt, :now) REMOVE #name", + ConditionExpression: "userId = :uid", + ExpressionAttributeNames: { "#name": "name" }, + ExpressionAttributeValues: { + ":tomb": DELETED_ACCOUNT_INBOX_OWNER, + ":uid": userId, + ":now": deps.now().toISOString(), + }, + }), + ), + ); + }; + return { createAddress: async ({ userId, domain, name }) => { // Bound the live addresses a user can hold. disabledAt is not a key @@ -119,5 +141,6 @@ export function initDynamoDbInboxAddress(deps: { const row = await table.get({ address }); return row === undefined ? undefined : toEntry(row); }, + tombstoneUserAddresses, }; } diff --git a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.test.ts b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.test.ts index 80bb9765c..2104b5d5c 100644 --- a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.test.ts +++ b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.test.ts @@ -15,6 +15,46 @@ function createFakeClient(impl: (input: unknown) => unknown): Partial; +} + +/** Records commands by SDK class name and replays a sequence of query pages, + * each carrying its rows and the LastEvaluatedKey that drives the next page + * (omitted on the final page). Queries past the last page replay it. */ +function createPaginatedClient( + pages: { rows: Record[]; lastEvaluatedKey?: Record }[], +): { client: DynamoDBDocumentClient; commands: RecordedCommand[] } { + const commands: RecordedCommand[] = []; + let queryCount = 0; + const client = { + send: (async (command: { constructor: { name: string }; input: Record }) => { + const name = command.constructor.name; + commands.push({ name, input: command.input }); + if (name === "QueryCommand") { + const page = pages[Math.min(queryCount, pages.length - 1)]; + queryCount += 1; + return { Items: page.rows, Count: page.rows.length, LastEvaluatedKey: page.lastEvaluatedKey }; + } + return {}; + }) as SendFn, + }; + return { client: client as typeof client & DynamoDBDocumentClient, commands }; +} + +function linkRow(overrides: Record = {}): Record { + return { + userLinkGroup: GROUP, + ordinal: "0000", + userId: USER, + receivedAtMessageId: RAM, + url: "https://a.test", + status: "pending", + ...overrides, + }; +} + interface CapturedCommand { input: { Item?: Record; @@ -294,4 +334,74 @@ describe("initDynamoDbInboxEmailLink", () => { expect(found).toBeUndefined(); }); }); + + describe("deleteLinksByEmail", () => { + it("queries the email's partition and deletes every link row and the meta row", async () => { + const { client, commands } = createPaginatedClient([ + { + rows: [ + linkRow({ ordinal: "0000" }), + linkRow({ ordinal: "0001", url: "https://b.test" }), + { userLinkGroup: GROUP, ordinal: "meta", userId: USER, receivedAtMessageId: RAM, truncated: true }, + ], + }, + ]); + const store = initDynamoDbInboxEmailLink({ client, tableName: TABLE }); + + await store.deleteLinksByEmail({ userId: USER, receivedAtMessageId: RAM }); + + const query = commands.find((c) => c.name === "QueryCommand"); + expect(query?.input.KeyConditionExpression).toBe("userLinkGroup = :g"); + expect(query?.input.ExpressionAttributeValues).toEqual({ ":g": GROUP }); + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((c) => c.input.Key)).toEqual([ + { userLinkGroup: GROUP, ordinal: "0000" }, + { userLinkGroup: GROUP, ordinal: "0001" }, + { userLinkGroup: GROUP, ordinal: "meta" }, + ]); + }); + + it("paginates the partition, feeding each page's key back as ExclusiveStartKey", async () => { + const { client, commands } = createPaginatedClient([ + { rows: [linkRow({ ordinal: "0000" })], lastEvaluatedKey: { userLinkGroup: GROUP, ordinal: "0000" } }, + { rows: [linkRow({ ordinal: "0001" })] }, + ]); + const store = initDynamoDbInboxEmailLink({ client, tableName: TABLE }); + + await store.deleteLinksByEmail({ userId: USER, receivedAtMessageId: RAM }); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries).toHaveLength(2); + expect(queries[1]?.input.ExclusiveStartKey).toEqual({ userLinkGroup: GROUP, ordinal: "0000" }); + expect(commands.filter((c) => c.name === "DeleteCommand")).toHaveLength(2); + }); + }); + + describe("deleteAllLinksByUserId", () => { + it("loops deleteLinksByEmail across every provided email id", async () => { + const ramA = "2026-06-23T00:00:00.000Z#"; + const ramB = "2026-06-24T00:00:00.000Z#"; + const { client, commands } = createPaginatedClient([{ rows: [linkRow({ ordinal: "0000" })] }]); + const store = initDynamoDbInboxEmailLink({ client, tableName: TABLE }); + + await store.deleteAllLinksByUserId(USER, [ramA, ramB]); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries.map((q) => q.input.ExpressionAttributeValues)).toEqual([ + { ":g": `${USER}#${ramA}` }, + { ":g": `${USER}#${ramB}` }, + ]); + // One page (hence one delete) replayed per email id. + expect(commands.filter((c) => c.name === "DeleteCommand")).toHaveLength(2); + }); + + it("issues no query or delete when the id list is empty", async () => { + const { client, commands } = createPaginatedClient([{ rows: [] }]); + const store = initDynamoDbInboxEmailLink({ client, tableName: TABLE }); + + await store.deleteAllLinksByUserId(USER, []); + + expect(commands).toHaveLength(0); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.ts b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.ts index 5be4fd134..e34a270f2 100644 --- a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.ts +++ b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email-link.ts @@ -4,6 +4,7 @@ import { type DynamoDBDocumentClient, defineDynamoTable, dynamoField, + forEachQueryPage, } from "@packages/hutch-storage-client"; import { z } from "zod"; import { @@ -75,6 +76,27 @@ export function initDynamoDbInboxEmailLink(deps: { schema: InboxEmailLinkRow, }); + const deleteLinksByEmail: InboxEmailLinkStore["deleteLinksByEmail"] = async ({ + userId, + receivedAtMessageId, + }) => { + const group = groupKey({ userId, receivedAtMessageId }); + await forEachQueryPage( + table, + { + KeyConditionExpression: "userLinkGroup = :g", + ExpressionAttributeValues: { ":g": group }, + }, + async (rows) => { + await Promise.all( + rows.map((row) => + table.delete({ Key: { userLinkGroup: row.userLinkGroup, ordinal: row.ordinal } }), + ), + ); + }, + ); + }; + return { putLink: async (link) => { // A pending link carries no preview fields; the document client does not @@ -184,5 +206,11 @@ export function initDynamoDbInboxEmailLink(deps: { if (row === undefined) return undefined; return toEntry(row); }, + deleteLinksByEmail, + deleteAllLinksByUserId: async (userId, receivedAtMessageIds) => { + for (const receivedAtMessageId of receivedAtMessageIds) { + await deleteLinksByEmail({ userId, receivedAtMessageId }); + } + }, }; } diff --git a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.test.ts b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.test.ts index 88f1f3c86..8cc5cafbe 100644 --- a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.test.ts +++ b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.test.ts @@ -19,6 +19,50 @@ function createFakeClient(impl: (input: unknown) => unknown): Partial; +} + +/** Records commands by SDK class name and replays a sequence of query pages, + * each carrying its rows and the LastEvaluatedKey that drives the next page + * (omitted on the final page). Queries past the last page replay it. */ +function createPaginatedClient( + pages: { rows: Record[]; lastEvaluatedKey?: Record }[], +): { client: DynamoDBDocumentClient; commands: RecordedCommand[] } { + const commands: RecordedCommand[] = []; + let queryCount = 0; + const client = { + send: (async (command: { constructor: { name: string }; input: Record }) => { + const name = command.constructor.name; + commands.push({ name, input: command.input }); + if (name === "QueryCommand") { + const page = pages[Math.min(queryCount, pages.length - 1)]; + queryCount += 1; + return { Items: page.rows, Count: page.rows.length, LastEvaluatedKey: page.lastEvaluatedKey }; + } + return {}; + }) as SendFn, + }; + return { client: client as typeof client & DynamoDBDocumentClient, commands }; +} + +function emailRow(overrides: Record = {}): Record { + return { + userId: "user-1", + receivedAtMessageId: "2026-06-23T00:00:00.000Z#", + messageId: "", + recipientAddress: "in-3f9a2c@read.place", + senderEmail: "news@example.com", + subject: "Weekly digest", + status: "received", + receivedAt: "2026-06-23T00:00:00.000Z", + rawEmailS3Key: "inbound/m-1", + bodyS3Key: "content/m-1/content.html", + ...overrides, + }; +} + interface CapturedCommand { input: { Item?: Record; @@ -221,4 +265,88 @@ describe("initDynamoDbInboxEmail", () => { ).toBeUndefined(); }); }); + + describe("deleteAllEmailsByUserId", () => { + it("drains the user's emails, returning raw and body keys and deleting each row", async () => { + const { client, commands } = createPaginatedClient([ + { + rows: [ + emailRow({ + receivedAtMessageId: "2026-06-23T09:00:00.000Z#", + rawEmailS3Key: "inbound/a", + bodyS3Key: "content/a/content.html", + }), + emailRow({ + receivedAtMessageId: "2026-06-23T08:00:00.000Z#", + status: "rejected", + rawEmailS3Key: "inbound/b", + // A rejected row renders no body, so DynamoDB returns null here, + // which the row schema normalizes to undefined. + bodyS3Key: null, + }), + ], + }, + ]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + const result = await store.deleteAllEmailsByUserId(USER); + + expect(result.receivedAtMessageIds).toEqual([ + "2026-06-23T09:00:00.000Z#", + "2026-06-23T08:00:00.000Z#", + ]); + expect(result.rawEmailS3Keys).toEqual(["inbound/a", "inbound/b"]); + expect(result.bodyS3Keys).toEqual(["content/a/content.html"]); + const query = commands.find((c) => c.name === "QueryCommand"); + expect(query?.input.KeyConditionExpression).toBe("userId = :uid"); + expect(query?.input.ExpressionAttributeValues).toEqual({ ":uid": USER }); + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((c) => c.input.Key)).toEqual([ + { userId: USER, receivedAtMessageId: "2026-06-23T09:00:00.000Z#" }, + { userId: USER, receivedAtMessageId: "2026-06-23T08:00:00.000Z#" }, + ]); + }); + + it("paginates the query, feeding each page's key back as ExclusiveStartKey", async () => { + const { client, commands } = createPaginatedClient([ + { + rows: [ + emailRow({ receivedAtMessageId: "r1", rawEmailS3Key: "inbound/1", bodyS3Key: null }), + ], + lastEvaluatedKey: { userId: "user-1", receivedAtMessageId: "r1" }, + }, + { + rows: [ + emailRow({ receivedAtMessageId: "r2", rawEmailS3Key: "inbound/2", bodyS3Key: null }), + ], + }, + ]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + const result = await store.deleteAllEmailsByUserId(USER); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries).toHaveLength(2); + expect(queries[0]?.input.ExclusiveStartKey).toBeUndefined(); + expect(queries[1]?.input.ExclusiveStartKey).toEqual({ + userId: "user-1", + receivedAtMessageId: "r1", + }); + expect(result.rawEmailS3Keys).toEqual(["inbound/1", "inbound/2"]); + expect(result.bodyS3Keys).toEqual([]); + expect(commands.filter((c) => c.name === "DeleteCommand")).toHaveLength(2); + }); + + it("returns empty key lists and issues no delete when the user has no emails", async () => { + const { client, commands } = createPaginatedClient([{ rows: [] }]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + expect(await store.deleteAllEmailsByUserId(USER)).toEqual({ + receivedAtMessageIds: [], + rawEmailS3Keys: [], + bodyS3Keys: [], + }); + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.ts b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.ts index d1b2ec516..de8efb675 100644 --- a/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.ts +++ b/projects/hutch/src/runtime/providers/inbox-email/dynamodb-inbox-email.ts @@ -3,6 +3,7 @@ import { type DynamoDBDocumentClient, defineDynamoTable, dynamoField, + forEachQueryPage, } from "@packages/hutch-storage-client"; import { z } from "zod"; import { @@ -64,5 +65,32 @@ export function initDynamoDbInboxEmail(deps: { }, getEmail: async ({ userId, receivedAtMessageId }) => table.get({ userId, receivedAtMessageId }), + deleteAllEmailsByUserId: async (userId) => { + const receivedAtMessageIds: string[] = []; + const rawEmailS3Keys: string[] = []; + const bodyS3Keys: string[] = []; + await forEachQueryPage( + table, + { + KeyConditionExpression: "userId = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }, + async (rows) => { + for (const row of rows) { + receivedAtMessageIds.push(row.receivedAtMessageId); + rawEmailS3Keys.push(row.rawEmailS3Key); + if (row.bodyS3Key !== undefined) bodyS3Keys.push(row.bodyS3Key); + } + await Promise.all( + rows.map((row) => + table.delete({ + Key: { userId, receivedAtMessageId: row.receivedAtMessageId }, + }), + ), + ); + }, + ); + return { receivedAtMessageIds, rawEmailS3Keys, bodyS3Keys }; + }, }; } diff --git a/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.test.ts b/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.test.ts new file mode 100644 index 000000000..1ea6f5211 --- /dev/null +++ b/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.test.ts @@ -0,0 +1,61 @@ +import type { S3Client } from "@aws-sdk/client-s3"; +import { initS3DeleteObjects } from "./s3-delete-objects"; + +type SendFn = S3Client["send"]; + +interface CapturedCommand { + input: { Bucket?: string; Delete?: { Objects?: { Key?: string }[] } }; +} + +function recordingClient(): { + client: Pick; + commands: CapturedCommand[]; +} { + const commands: CapturedCommand[] = []; + return { + client: { + send: (async (cmd: unknown) => { + commands.push(cmd as CapturedCommand); + return {}; + }) as unknown as SendFn, + }, + commands, + }; +} + +describe("initS3DeleteObjects", () => { + it("issues no request when there are no keys", async () => { + const { client, commands } = recordingClient(); + const del = initS3DeleteObjects({ client, bucketName: "raw-bucket" }); + + await del([]); + + expect(commands).toHaveLength(0); + }); + + it("deletes a batch of keys in one request against the target bucket", async () => { + const { client, commands } = recordingClient(); + const del = initS3DeleteObjects({ client, bucketName: "raw-bucket" }); + + await del(["inbound/a", "inbound/b"]); + + expect(commands).toHaveLength(1); + expect(commands[0].input.Bucket).toBe("raw-bucket"); + expect(commands[0].input.Delete?.Objects).toEqual([ + { Key: "inbound/a" }, + { Key: "inbound/b" }, + ]); + }); + + it("splits into requests of at most 1000 keys", async () => { + const { client, commands } = recordingClient(); + const del = initS3DeleteObjects({ client, bucketName: "content-bucket" }); + const keys = Array.from({ length: 1001 }, (_, i) => `content/${i}`); + + await del(keys); + + expect(commands).toHaveLength(2); + expect(commands[0].input.Delete?.Objects).toHaveLength(1000); + expect(commands[1].input.Delete?.Objects).toEqual([{ Key: "content/1000" }]); + }); +}); diff --git a/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.ts b/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.ts new file mode 100644 index 000000000..ec45f6faf --- /dev/null +++ b/projects/hutch/src/runtime/providers/inbox-email/s3-delete-objects.ts @@ -0,0 +1,23 @@ +import { DeleteObjectsCommand, type S3Client } from "@aws-sdk/client-s3"; + +/** DeleteObjects accepts at most 1000 keys per request. */ +const DELETE_OBJECTS_MAX_KEYS = 1000; + +export function initS3DeleteObjects(deps: { + client: Pick; + bucketName: string; +}): (keys: string[]) => Promise { + return async (keys) => { + // An empty key list issues no request — an empty DeleteObjects call is a + // pointless round-trip and S3 rejects a zero-length Objects list. + for (let i = 0; i < keys.length; i += DELETE_OBJECTS_MAX_KEYS) { + const batch = keys.slice(i, i + DELETE_OBJECTS_MAX_KEYS); + await deps.client.send( + new DeleteObjectsCommand({ + Bucket: deps.bucketName, + Delete: { Objects: batch.map((Key) => ({ Key })) }, + }), + ); + } + }; +} diff --git a/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.test.ts b/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.test.ts index a0eab04fe..ea82fb974 100644 --- a/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.test.ts +++ b/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.test.ts @@ -103,4 +103,15 @@ describe("initIosOnboardingSignal", () => { expect(signals).toEqual({ installed: true, savedArticle: true }); }); }); + + describe("deleteOnboarding", () => { + it("deletes the single onboarding row by the userId PK", async () => { + const { client, commands } = createFakeClient({}); + + await initSignal(client).deleteOnboarding({ userId: USER }); + + const del = commands.find((c) => c.name === "DeleteCommand"); + expect(del?.input.Key).toEqual({ userId: "user-1" }); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.ts b/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.ts index 31b146cdb..5365c12a5 100644 --- a/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.ts +++ b/projects/hutch/src/runtime/providers/ios-onboarding-signal/dynamodb-ios-onboarding-signal.ts @@ -6,6 +6,7 @@ import { import { z } from "zod"; import { UserIdSchema } from "@packages/domain/user"; import type { + DeleteOnboarding, GetIosAppSignals, RecordIosAnyActivity, RecordIosSavedArticle, @@ -32,6 +33,7 @@ export function initIosOnboardingSignal(deps: { recordIosAnyActivity: RecordIosAnyActivity; recordIosSavedArticle: RecordIosSavedArticle; getIosAppSignals: GetIosAppSignals; + deleteOnboarding: DeleteOnboarding; } { const onboarding = defineDynamoTable({ client: deps.client, @@ -61,5 +63,9 @@ export function initIosOnboardingSignal(deps: { return { installed: !!row?.iosAppActivatedAt, savedArticle: !!row?.iosAppSavedAt }; }; - return { recordIosAnyActivity, recordIosSavedArticle, getIosAppSignals }; + const deleteOnboarding: DeleteOnboarding = async ({ userId }) => { + await onboarding.delete({ Key: { userId } }); + }; + + return { recordIosAnyActivity, recordIosSavedArticle, getIosAppSignals, deleteOnboarding }; } diff --git a/projects/hutch/src/runtime/providers/oauth/dynamodb-oauth-model.ts b/projects/hutch/src/runtime/providers/oauth/dynamodb-oauth-model.ts index 41155d4c5..9a55f58a0 100644 --- a/projects/hutch/src/runtime/providers/oauth/dynamodb-oauth-model.ts +++ b/projects/hutch/src/runtime/providers/oauth/dynamodb-oauth-model.ts @@ -3,6 +3,7 @@ import assert from "node:assert"; import { type DynamoDBDocumentClient, defineDynamoTable, + forEachQueryPage, } from "@packages/hutch-storage-client"; import { z } from "zod"; import type { @@ -18,6 +19,7 @@ import type { FindOAuthClient, MarkOAuthClientActive, OAuthModel, + RevokeAllUserOAuthTokens, } from "@packages/provider-contracts/oauth"; import type { FindUserById } from "@packages/provider-contracts/auth"; import { generateToken } from "@packages/domain/oauth"; @@ -55,17 +57,63 @@ const RefreshIndexRow = z.object({ accessToken: z.string(), }); +/** Bulk-revoke every OAuth grant a user holds (account deletion + logout-all). + * Needs only the table — the delete worker reuses it without wiring the full + * OAuth2 model. The userId-index projects both token# and code# rows, so query + * it through a permissive schema and branch on the pk prefix; refresh# rows + * carry no userId and are reached via each token row's refreshToken. */ +export function initRevokeAllUserOAuthTokens(deps: { + client: DynamoDBDocumentClient; + tableName: string; +}): RevokeAllUserOAuthTokens { + const { client, tableName } = deps; + const authCodes = defineDynamoTable({ client, tableName, schema: AuthCodeRow }); + const tokens = defineDynamoTable({ client, tableName, schema: TokenRow }); + const refreshIndex = defineDynamoTable({ client, tableName, schema: RefreshIndexRow }); + const byUser = defineDynamoTable({ + client, + tableName, + schema: z.object({ pk: z.string(), userId: z.string(), refreshToken: z.string().optional() }), + }); + + return async (userId) => { + await forEachQueryPage( + byUser, + { + IndexName: "userId-index", + KeyConditionExpression: "userId = :userId", + ExpressionAttributeValues: { ":userId": userId }, + }, + async (rows) => { + await Promise.all( + rows.map(async (row) => { + if (row.pk.startsWith("token#")) { + await tokens.delete({ Key: { pk: row.pk } }); + if (row.refreshToken) { + await refreshIndex.delete({ Key: { pk: `refresh#${row.refreshToken}` } }); + } + } else if (row.pk.startsWith("code#")) { + await authCodes.delete({ Key: { pk: row.pk } }); + } + }), + ); + }, + ); + }; +} + export function initDynamoDbOAuthModel(deps: { client: DynamoDBDocumentClient; tableName: string; findUserById: FindUserById; findClient: FindOAuthClient; markClientActive: MarkOAuthClientActive; -}): OAuthModel { +}): OAuthModel & { revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens } { const { client, tableName, findUserById, findClient, markClientActive } = deps; const authCodes = defineDynamoTable({ client, tableName, schema: AuthCodeRow }); const tokens = defineDynamoTable({ client, tableName, schema: TokenRow }); const refreshIndex = defineDynamoTable({ client, tableName, schema: RefreshIndexRow }); + const revokeAllUserOAuthTokens = initRevokeAllUserOAuthTokens({ client, tableName }); async function resolveClient(clientId: string): Promise { const found = await findClient(clientId); @@ -74,6 +122,7 @@ export function initDynamoDbOAuthModel(deps: { } return { + revokeAllUserOAuthTokens, async getClient(clientId: string, _clientSecret: string): Promise { return resolveClient(clientId); }, diff --git a/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.test.ts b/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.test.ts index ab364df05..1323e4dc2 100644 --- a/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.test.ts +++ b/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.test.ts @@ -30,6 +30,30 @@ function createFakeClient(opts: { return { client: client as typeof client & DynamoDBDocumentClient, commands }; } +interface ScanPage { + Items: Record[]; + LastEvaluatedKey?: Record; +} + +/** Replays a queue of Scan pages in order and records every command so the + * scan filter and the per-token deletes can be asserted. */ +function createScanClient(pages: ScanPage[]): { + client: DynamoDBDocumentClient; + commands: CapturedCommand[]; +} { + const commands: CapturedCommand[] = []; + let scanCall = 0; + const client = { + send: (async (command: { constructor: { name: string }; input: Record }) => { + const name = command.constructor.name; + commands.push({ name, input: command.input }); + if (name === "ScanCommand") return pages[scanCall++]; + return {}; + }) as DynamoDBDocumentClient["send"], + }; + return { client: client as typeof client & DynamoDBDocumentClient, commands }; +} + const TABLE = "password-reset-tokens"; function initStore(client: DynamoDBDocumentClient) { @@ -117,4 +141,44 @@ describe("initDynamoDbPasswordReset", () => { ); }); }); + + describe("deleteTokensByEmail", () => { + it("scans every page filtering by email and deletes each token by its primary key", async () => { + const { client, commands } = createScanClient([ + { + Items: [{ token: "aaa" }, { token: "bbb" }], + LastEvaluatedKey: { token: "bbb" }, + }, + { Items: [{ token: "ccc" }] }, + ]); + + await initStore(client).deleteTokensByEmail("user@example.com"); + + const scans = commands.filter((c) => c.name === "ScanCommand"); + expect(scans).toHaveLength(2); + expect(scans[0]?.input.TableName).toBe(TABLE); + expect(scans[0]?.input.FilterExpression).toBe("email = :e"); + expect(scans[0]?.input.ExpressionAttributeValues).toEqual({ ":e": "user@example.com" }); + expect(scans[0]?.input.ProjectionExpression).toBe("#tk"); + expect(scans[0]?.input.ExpressionAttributeNames).toEqual({ "#tk": "token" }); + expect(scans[0]?.input.ExclusiveStartKey).toBeUndefined(); + expect(scans[1]?.input.ExclusiveStartKey).toEqual({ token: "bbb" }); + + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((d) => d.input.Key)).toEqual([ + { token: "aaa" }, + { token: "bbb" }, + { token: "ccc" }, + ]); + }); + + it("issues no deletes when the email matches no tokens", async () => { + const { client, commands } = createScanClient([{ Items: [] }]); + + await initStore(client).deleteTokensByEmail("nobody@example.com"); + + expect(commands.filter((c) => c.name === "ScanCommand")).toHaveLength(1); + expect(commands.filter((c) => c.name === "DeleteCommand")).toHaveLength(0); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.ts b/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.ts index fa39cef1a..78c83b36f 100644 --- a/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.ts +++ b/projects/hutch/src/runtime/providers/password-reset/dynamodb-password-reset.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import type { CreatePasswordResetToken, + DeletePasswordResetTokensByEmail, VerifyPasswordResetToken, } from "@packages/provider-contracts/password-reset"; import { PasswordResetTokenSchema } from "@packages/provider-contracts/password-reset"; @@ -20,12 +21,17 @@ const PasswordResetRow = z.object({ expiresAt: z.number(), }); +const TokenKeyRow = z.object({ + token: z.string(), +}); + export function initDynamoDbPasswordReset(deps: { client: DynamoDBDocumentClient; tableName: string; }): { createPasswordResetToken: CreatePasswordResetToken; verifyPasswordResetToken: VerifyPasswordResetToken; + deleteTokensByEmail: DeletePasswordResetTokensByEmail; } { const table = defineDynamoTable({ client: deps.client, @@ -33,6 +39,12 @@ export function initDynamoDbPasswordReset(deps: { schema: PasswordResetRow, }); + const tokenKeyTable = defineDynamoTable({ + client: deps.client, + tableName: deps.tableName, + schema: TokenKeyRow, + }); + const createPasswordResetToken: CreatePasswordResetToken = async ({ email }) => { const token = PasswordResetTokenSchema.parse(randomBytes(32).toString("hex")); const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; @@ -66,5 +78,22 @@ export function initDynamoDbPasswordReset(deps: { } }; - return { createPasswordResetToken, verifyPasswordResetToken }; + const deleteTokensByEmail: DeletePasswordResetTokensByEmail = async (email) => { + // This table carries no TTL attribute, so an unexpired reset token would + // outlive the deleted account unless every row for the email is purged now. + let ExclusiveStartKey: Record | undefined; + do { + const { items, lastEvaluatedKey } = await tokenKeyTable.scan({ + FilterExpression: "email = :e", + ExpressionAttributeValues: { ":e": email }, + ProjectionExpression: "#tk", + ExpressionAttributeNames: { "#tk": "token" }, + ExclusiveStartKey, + }); + for (const { token } of items) await table.delete({ Key: { token } }); + ExclusiveStartKey = lastEvaluatedKey; + } while (ExclusiveStartKey); + }; + + return { createPasswordResetToken, verifyPasswordResetToken, deleteTokensByEmail }; } diff --git a/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.test.ts b/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.test.ts index 48ae5d88d..bc16682d0 100644 --- a/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.test.ts +++ b/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.test.ts @@ -133,4 +133,15 @@ describe("initDynamoDbReaderReadyState", () => { ).rejects.toThrow("throttled"); }); }); + + describe("deleteReaderReadyState", () => { + it("deletes the single cooldown row by the userId PK", async () => { + const { client, commands } = createFakeClient({}); + + await initStore(client).deleteReaderReadyState(USER); + + const del = commands.find((c) => c.name === "DeleteCommand"); + expect(del?.input.Key).toEqual({ userId: USER }); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.ts b/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.ts index 096d6c0cd..43003d54c 100644 --- a/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.ts +++ b/projects/hutch/src/runtime/providers/reader-ready-state/dynamodb-reader-ready-state.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { UserIdSchema } from "@packages/domain/user"; import type { ClaimReaderReadyEmailSlot, + DeleteReaderReadyState, ReleaseReaderReadyEmailSlot, } from "@packages/provider-contracts/reader-ready-state"; @@ -24,6 +25,7 @@ export function initDynamoDbReaderReadyState(deps: { }): { claimReaderReadyEmailSlot: ClaimReaderReadyEmailSlot; releaseReaderReadyEmailSlot: ReleaseReaderReadyEmailSlot; + deleteReaderReadyState: DeleteReaderReadyState; } { const table = defineDynamoTable({ client: deps.client, @@ -65,5 +67,9 @@ export function initDynamoDbReaderReadyState(deps: { } }; - return { claimReaderReadyEmailSlot, releaseReaderReadyEmailSlot }; + const deleteReaderReadyState: DeleteReaderReadyState = async (userId) => { + await table.delete({ Key: { userId } }); + }; + + return { claimReaderReadyEmailSlot, releaseReaderReadyEmailSlot, deleteReaderReadyState }; } diff --git a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts index cb71fcb67..aab5ef2ae 100644 --- a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts +++ b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts @@ -325,4 +325,76 @@ describe("initStripeSubscriptions", () => { ); }); }); + + describe("deleteCustomer", () => { + it("issues DELETE /v1/customers/ with the bearer token", async () => { + let receivedUrl: string | undefined; + let receivedInit: RequestInit | undefined; + const fakeFetch: typeof globalThis.fetch = async (input, init) => { + receivedUrl = typeof input === "string" ? input : input.toString(); + receivedInit = init; + return jsonResponse(200, { id: "cus_to_delete", deleted: true }); + }; + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await stripe.deleteCustomer({ customerId: "cus_to_delete" }); + + assert.equal(receivedUrl, "https://api.stripe.com/v1/customers/cus_to_delete"); + assert.equal(receivedInit?.method, "DELETE"); + const headers = new Headers(receivedInit?.headers); + assert.equal(headers.get("Authorization"), "Bearer sk_test_abc"); + assert.equal(headers.get("Stripe-Version"), "2026-04-22.dahlia"); + }); + + it("URL-encodes the customer id so unusual characters reach Stripe intact", async () => { + let receivedUrl: string | undefined; + const fakeFetch: typeof globalThis.fetch = async (input) => { + receivedUrl = typeof input === "string" ? input : input.toString(); + return jsonResponse(200, {}); + }; + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await stripe.deleteCustomer({ customerId: "cus with/slash" }); + + assert.equal( + receivedUrl, + "https://api.stripe.com/v1/customers/cus%20with%2Fslash", + ); + }); + + it("treats 404 as success — the customer is already gone, which is the goal state", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(404, { error: { code: "resource_missing", message: "No such customer" } }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await stripe.deleteCustomer({ customerId: "cus_gone" }); + }); + + it("throws with the Stripe error message when the API returns a non-2xx other than 404", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(500, { error: { code: "api_error", message: "Stripe is down" } }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await assert.rejects( + () => stripe.deleteCustomer({ customerId: "cus_kaboom" }), + /Stripe deleteCustomer failed \(500\): Stripe is down/, + ); + }); + + it("falls back to a generic error message when the Stripe error shape is unrecognised", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(503, { unexpected: "shape" }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await assert.rejects( + () => stripe.deleteCustomer({ customerId: "cus_x" }), + /Stripe deleteCustomer failed \(503\): Stripe error/, + ); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts index 53a102cea..90da48689 100644 --- a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts +++ b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { CancelSubscriptionImmediately, CreateSubscriptionOnExistingCustomer, + DeleteCustomer, ReverseScheduledCancellation, ScheduleCancellationAtPeriodEnd, } from "@packages/provider-contracts/stripe-subscriptions"; @@ -43,6 +44,7 @@ export function initStripeSubscriptions(deps: { createSubscriptionOnExistingCustomer: CreateSubscriptionOnExistingCustomer; scheduleCancellationAtPeriodEnd: ScheduleCancellationAtPeriodEnd; reverseScheduledCancellation: ReverseScheduledCancellation; + deleteCustomer: DeleteCustomer; } { const stripeHeaders = { Authorization: `Bearer ${deps.apiKey}`, @@ -176,10 +178,33 @@ export function initStripeSubscriptions(deps: { } }; + const deleteCustomer: DeleteCustomer = async ({ customerId }) => { + const response = await deps.fetch( + `${STRIPE_API}/customers/${encodeURIComponent(customerId)}`, + { + method: "DELETE", + headers: stripeHeaders, + }, + ); + + // 404 means the customer is already gone — the desired end state; succeed + // silently so SQS at-least-once retries after a successful delete don't + // poison the queue. + if (response.status === 404) { + return; + } + + if (!response.ok) { + const message = await readStripeErrorMessage(response); + throw new Error(`Stripe deleteCustomer failed (${response.status}): ${message}`); + } + }; + return { cancelImmediately, createSubscriptionOnExistingCustomer, scheduleCancellationAtPeriodEnd, reverseScheduledCancellation, + deleteCustomer, }; } diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts index f83537010..a00f6aaf8 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts @@ -403,6 +403,32 @@ describe("initDynamoDbSubscriptionProviders", () => { }); }); + describe("deleteSubscription", () => { + it("issues an unconditioned Delete keyed by userId so it is idempotent", async () => { + let received: unknown; + const client = createFakeClient((input) => { + received = input; + return {}; + }); + const subs = initDynamoDbSubscriptionProviders({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + now: NOW, + }); + + await subs.deleteSubscription({ userId: USER_ID }); + + const command = received as { + input: { + Key?: Record; + ConditionExpression?: string; + }; + }; + expect(command.input.Key).toEqual({ userId: USER_ID }); + expect(command.input.ConditionExpression).toBeUndefined(); + }); + }); + describe("findByUserId with trialFeedbackEmailSentAt", () => { it("parses a cancelled trial row that carries trialFeedbackEmailSentAt", async () => { const client = createFakeClient(() => ({ diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts index 675f65fe5..38e25f813 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts @@ -3,6 +3,7 @@ import { defineDynamoTable, } from "@packages/hutch-storage-client"; import type { + DeleteSubscription, MarkSubscriptionActive, MarkSubscriptionCancelledByUserId, MarkSubscriptionPendingCancellation, @@ -26,6 +27,7 @@ export function initDynamoDbSubscriptionWrites(deps: { markCancelledByUserId: MarkSubscriptionCancelledByUserId; markActive: MarkSubscriptionActive; markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent; + deleteSubscription: DeleteSubscription; } { const table = defineDynamoTable({ client: deps.client, @@ -126,6 +128,10 @@ export function initDynamoDbSubscriptionWrites(deps: { }); }; + const deleteSubscription: DeleteSubscription = async ({ userId }) => { + await table.delete({ Key: { userId } }); + }; + return { upsertTrialing, upsertActive, @@ -133,5 +139,6 @@ export function initDynamoDbSubscriptionWrites(deps: { markCancelledByUserId, markActive, markTrialFeedbackEmailSent, + deleteSubscription, }; } diff --git a/projects/hutch/src/runtime/providers/user-data-export/s3-user-data-export.ts b/projects/hutch/src/runtime/providers/user-data-export/s3-user-data-export.ts index 0e907087f..9672e837f 100644 --- a/projects/hutch/src/runtime/providers/user-data-export/s3-user-data-export.ts +++ b/projects/hutch/src/runtime/providers/user-data-export/s3-user-data-export.ts @@ -1,17 +1,23 @@ /* c8 ignore start -- thin AWS SDK wrapper, tested via integration */ -import { GetObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; +import { + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { EXPORT_DOWNLOAD_TTL_SECONDS, EXPORT_S3_KEY_PREFIX, } from "../../web/pages/export/export-ttl"; -import type { UploadUserDataExport } from "./user-data-export.types"; +import type { DeleteUserExports, UploadUserDataExport } from "./user-data-export.types"; export function initS3UserDataExport(deps: { client: S3Client; bucketName: string; now: () => Date; -}): { uploadUserDataExport: UploadUserDataExport } { +}): { uploadUserDataExport: UploadUserDataExport; deleteUserExports: DeleteUserExports } { const { client, bucketName, now } = deps; const uploadUserDataExport: UploadUserDataExport = async ({ userId, body }) => { @@ -38,6 +44,23 @@ export function initS3UserDataExport(deps: { return { s3Key, downloadUrl }; }; - return { uploadUserDataExport }; + const deleteUserExports: DeleteUserExports = async (userId) => { + const Prefix = `${EXPORT_S3_KEY_PREFIX}${userId}/`; + let ContinuationToken: string | undefined; + do { + const list = await client.send( + new ListObjectsV2Command({ Bucket: bucketName, Prefix, ContinuationToken }), + ); + const objects = (list.Contents ?? []).flatMap((o) => (o.Key ? [{ Key: o.Key }] : [])); + if (objects.length > 0) { + await client.send( + new DeleteObjectsCommand({ Bucket: bucketName, Delete: { Objects: objects } }), + ); + } + ContinuationToken = list.IsTruncated ? list.NextContinuationToken : undefined; + } while (ContinuationToken); + }; + + return { uploadUserDataExport, deleteUserExports }; } /* c8 ignore stop */ diff --git a/projects/hutch/src/runtime/providers/user-data-export/user-data-export.types.ts b/projects/hutch/src/runtime/providers/user-data-export/user-data-export.types.ts index 82657d43b..06af6c314 100644 --- a/projects/hutch/src/runtime/providers/user-data-export/user-data-export.types.ts +++ b/projects/hutch/src/runtime/providers/user-data-export/user-data-export.types.ts @@ -13,3 +13,5 @@ export interface UploadUserDataExportResult { export type UploadUserDataExport = ( params: UploadUserDataExportParams, ) => Promise; + +export type DeleteUserExports = (userId: UserId) => Promise; diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index 7b52472ce..79db431a2 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -49,6 +49,7 @@ import type { } from "@packages/provider-contracts/trial-scheduler"; import type { PublishCancelSubscriptionCommand, + PublishDeleteAccountCommand, PublishSubscriptionReactivated, } from "@packages/provider-contracts/events"; import type { @@ -115,6 +116,7 @@ import type { FindOAuthClient, OAuthModel, RegisterOAuthClient, + RevokeAllUserOAuthTokens, ValidateAccessToken, ValidateOAuthRedirectUri, } from "@packages/provider-contracts/oauth"; @@ -247,6 +249,7 @@ interface AppDependencies { baseUrl: string; logError: (message: string, error?: Error) => void; oauthModel: OAuthModel; + revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens; validateAccessToken: ValidateAccessToken; findOAuthClient: FindOAuthClient; validateOAuthRedirectUri: ValidateOAuthRedirectUri; @@ -258,6 +261,7 @@ interface AppDependencies { publishSaveLinkRawHtmlCommand: PublishSaveLinkRawHtmlCommand; publishSaveLinkRawPdfCommand: PublishSaveLinkRawPdfCommand; publishExportUserDataCommand: PublishExportUserDataCommand; + publishDeleteAccountCommand: PublishDeleteAccountCommand; findEmailByUserId: FindEmailByUserId; putPendingHtml: PutPendingHtml; putPendingPdf: PutPendingPdf; @@ -1092,6 +1096,9 @@ export function createApp(dependencies: AppDependencies): Express { upsertTrialingSubscription: deps.subscriptionProviders.upsertTrialing, markActiveSubscription: deps.subscriptionProviders.markActive, findEmailByUserId: deps.findEmailByUserId, + destroyUserSessions: deps.destroyUserSessions, + revokeAllUserOAuthTokens: deps.revokeAllUserOAuthTokens, + publishDeleteAccountCommand: deps.publishDeleteAccountCommand, publishCancelSubscriptionCommand: deps.publishCancelSubscriptionCommand, publishSubscriptionReactivated: deps.publishSubscriptionReactivated, createCheckoutSession: deps.createCheckoutSession, diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index eb06432fc..34a086205 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -140,6 +140,7 @@ function flattenFixtureToAppDependencies( publishSaveLinkRawPdfCommand: fixture.events.publishSaveLinkRawPdfCommand, publishUpdateFetchTimestamp: fixture.events.publishUpdateFetchTimestamp, publishExportUserDataCommand: fixture.events.publishExportUserDataCommand, + publishDeleteAccountCommand: fixture.events.publishDeleteAccountCommand, publishCancelSubscriptionCommand: fixture.events.publishCancelSubscriptionCommand, publishSubscriptionReactivated: fixture.events.publishSubscriptionReactivated, putPendingHtml: fixture.pendingHtml.putPendingHtml, @@ -148,6 +149,7 @@ function flattenFixtureToAppDependencies( markSummaryPending: fixture.summary.markSummaryPending, refreshArticleIfStale: fixture.freshness.refreshArticleIfStale, oauthModel: fixture.oauth.oauthModel, + revokeAllUserOAuthTokens: fixture.oauth.revokeAllUserOAuthTokens, validateAccessToken: fixture.oauth.validateAccessToken, findOAuthClient: fixture.oauth.findClient, validateOAuthRedirectUri: fixture.oauth.validateRedirectUri, diff --git a/projects/hutch/src/runtime/web/api/account-siren.test.ts b/projects/hutch/src/runtime/web/api/account-siren.test.ts new file mode 100644 index 000000000..cbecef292 --- /dev/null +++ b/projects/hutch/src/runtime/web/api/account-siren.test.ts @@ -0,0 +1,19 @@ +import { toAccountEntity } from "./account-siren"; + +describe("toAccountEntity", () => { + it("advertises a self link and a bare destructive delete-account action", () => { + expect(toAccountEntity()).toEqual({ + class: ["account"], + links: [{ rel: ["self"], href: "/account" }], + actions: [ + { + name: "delete-account", + title: "Delete account", + href: "/account/delete", + method: "POST", + class: ["destructive"], + }, + ], + }); + }); +}); diff --git a/projects/hutch/src/runtime/web/api/account-siren.ts b/projects/hutch/src/runtime/web/api/account-siren.ts new file mode 100644 index 000000000..09769f0ed --- /dev/null +++ b/projects/hutch/src/runtime/web/api/account-siren.ts @@ -0,0 +1,23 @@ +import type { SirenEntity, SirenLink } from "./siren"; + +/** The account resource as Siren. Carries the account-scoped affordances a + * client can act on — today just irreversible deletion, advertised as a bare + * no-body POST tagged with the reserved "destructive" role token so the client + * gates it behind its own confirmation. */ +export function toAccountEntity(): SirenEntity { + const links: SirenLink[] = [{ rel: ["self"], href: "/account" }]; + + return { + class: ["account"], + links, + actions: [ + { + name: "delete-account", + title: "Delete account", + href: "/account/delete", + method: "POST", + class: ["destructive"], + }, + ], + }; +} diff --git a/projects/hutch/src/runtime/web/api/api.routes.test.ts b/projects/hutch/src/runtime/web/api/api.routes.test.ts index 90a944f1d..27a3b5cad 100644 --- a/projects/hutch/src/runtime/web/api/api.routes.test.ts +++ b/projects/hutch/src/runtime/web/api/api.routes.test.ts @@ -319,6 +319,7 @@ describe("POST /queue (Siren save article)", () => { publishStaleCheckRequested: fixture.events.publishStaleCheckRequested, publishUpdateFetchTimestamp: fixture.events.publishUpdateFetchTimestamp, publishExportUserDataCommand: fixture.events.publishExportUserDataCommand, + publishDeleteAccountCommand: fixture.events.publishDeleteAccountCommand, publishCancelSubscriptionCommand: fixture.events.publishCancelSubscriptionCommand, publishSubscriptionReactivated: fixture.events.publishSubscriptionReactivated, }, diff --git a/projects/hutch/src/runtime/web/api/collection-siren.test.ts b/projects/hutch/src/runtime/web/api/collection-siren.test.ts index 1007086fd..dafdf3de3 100644 --- a/projects/hutch/src/runtime/web/api/collection-siren.test.ts +++ b/projects/hutch/src/runtime/web/api/collection-siren.test.ts @@ -110,6 +110,7 @@ describe("toArticleCollectionEntity", () => { expect(entity.links).toContainEqual({ rel: ["self"], href: "/queue" }); expect(entity.links).toContainEqual({ rel: ["root"], href: "/queue" }); + expect(entity.links).toContainEqual({ rel: ["account"], title: "Account", href: "/account" }); }); it("advertises a titled add-links-help link so already-installed clients resolve the help URL", () => { @@ -168,7 +169,7 @@ describe("toArticleCollectionEntity", () => { const entity = toArticleCollectionEntity(result, { page: 2, pageSize: 20 }); const linkRels = entity.links?.map((l) => l.rel[0]); - expect(linkRels).toEqual(["self", "root", "add-links-help", "prev"]); + expect(linkRels).toEqual(["self", "root", "account", "add-links-help", "prev"]); }); it("single page omits both pagination links", () => { @@ -182,7 +183,7 @@ describe("toArticleCollectionEntity", () => { const entity = toArticleCollectionEntity(result, {}); const linkRels = entity.links?.map((l) => l.rel[0]); - expect(linkRels).toEqual(["self", "root", "add-links-help"]); + expect(linkRels).toEqual(["self", "root", "account", "add-links-help"]); }); it("preserves query params in pagination links", () => { diff --git a/projects/hutch/src/runtime/web/api/collection-siren.ts b/projects/hutch/src/runtime/web/api/collection-siren.ts index c30dbdf4b..a8f5864fa 100644 --- a/projects/hutch/src/runtime/web/api/collection-siren.ts +++ b/projects/hutch/src/runtime/web/api/collection-siren.ts @@ -41,6 +41,7 @@ export function toArticleCollectionEntity( const links: SirenLink[] = [ { rel: ["self"], href: `/queue${buildQueryString(queryParams)}` }, { rel: ["root"], href: "/queue" }, + { rel: ["account"], title: "Account", href: "/account" }, ]; // Older iOS builds resolve the Share-help URL from this rel; the current client diff --git a/projects/hutch/src/runtime/web/api/siren.ts b/projects/hutch/src/runtime/web/api/siren.ts index 29309ee35..f6c05d0fe 100644 --- a/projects/hutch/src/runtime/web/api/siren.ts +++ b/projects/hutch/src/runtime/web/api/siren.ts @@ -11,6 +11,10 @@ export interface SirenAction { title?: string; type?: string; fields?: SirenField[]; + /* A reserved semantic-role token the client maps to its own presentation + * (e.g. ["destructive"] → a red, confirm-gated control). The server never + * sends a CSS class; presentation stays entirely client-side. */ + class?: string[]; } export interface SirenLink { diff --git a/projects/hutch/src/runtime/web/pages/account/account.page.ts b/projects/hutch/src/runtime/web/pages/account/account.page.ts index e238cb120..820e4b87d 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.page.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.page.ts @@ -2,7 +2,9 @@ import assert from "node:assert"; import type { Request, Response, Router } from "express"; import express from "express"; import type { HutchLogger } from "@packages/hutch-logger"; -import type { FindEmailByUserId } from "@packages/provider-contracts/auth"; +import { SESSION_COOKIE_NAME } from "@packages/web-session"; +import type { DestroyUserSessions, FindEmailByUserId } from "@packages/provider-contracts/auth"; +import type { RevokeAllUserOAuthTokens } from "@packages/provider-contracts/oauth"; import type { CreateCheckoutSession, CheckoutSessionId, @@ -15,6 +17,7 @@ import type { } from "@packages/provider-contracts/subscription-providers"; import type { PublishCancelSubscriptionCommand, + PublishDeleteAccountCommand, PublishSubscriptionReactivated, } from "@packages/provider-contracts/events"; import type { @@ -34,6 +37,9 @@ import type { } from "@packages/provider-contracts/trial-scheduler"; import type { StorePendingSignup } from "@packages/provider-contracts/pending-signup"; import { Base } from "../../base.component"; +import { wantsSiren } from "../../content-negotiation"; +import { SIREN_MEDIA_TYPE } from "../../api/siren"; +import { toAccountEntity } from "../../api/account-siren"; import type { BuildBannerState } from "../../banner-state"; import { HxRedirectPage } from "../../hx-redirect-page"; import { sendComponent } from "@packages/web-shell"; @@ -62,6 +68,9 @@ interface AccountDependencies { upsertTrialingSubscription: UpsertTrialingSubscription; markActiveSubscription: MarkSubscriptionActive; findEmailByUserId: FindEmailByUserId; + destroyUserSessions: DestroyUserSessions; + revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens; + publishDeleteAccountCommand: PublishDeleteAccountCommand; publishCancelSubscriptionCommand: PublishCancelSubscriptionCommand; publishSubscriptionReactivated: PublishSubscriptionReactivated; createCheckoutSession: CreateCheckoutSession; @@ -135,6 +144,10 @@ export function initAccountRoutes(deps: AccountDependencies): Router { router.get("/", async (req: Request, res: Response) => { assert(req.userId, "userId required - route must be protected by requireAuth"); + if (wantsSiren(req)) { + res.type(SIREN_MEDIA_TYPE).json(toAccountEntity()); + return; + } const access = await deps.getEffectiveAccess(req.userId); const row = await deps.findSubscriptionByUserId(req.userId); const cardSection = await loadCardSection({ @@ -314,6 +327,27 @@ export function initAccountRoutes(deps: AccountDependencies): Router { res.redirect(303, buildAccountUrl({ cancelling: true })); }); + /** Irreversible account deletion. The synchronous work here is the + * security-critical teardown that must take effect the instant the user + * confirms — every session and bearer token dies at once — after which the + * durable, at-least-once scrub of every user-owned store runs asynchronously + * via DeleteAccountCommand. Sessions are destroyed before tokens are revoked + * (mirroring /oauth/revoke) so a failed step leaves the account still usable + * for a safe retry rather than half-torn-down. */ + router.post("/delete", async (req: Request, res: Response) => { + assert(req.userId, "userId required - route must be protected by requireAuth"); + const userId = req.userId; + await deps.destroyUserSessions(userId); + await deps.revokeAllUserOAuthTokens(userId); + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + await deps.publishDeleteAccountCommand({ userId }); + // Deletion logs the user out, so the whole page (nav, banner) must reset to + // the guest view. A boosted form would only swap
and leave a stale + // signed-in chrome, so force a full navigation to the logged-out home — + // HX-Redirect for HTMX requests, a plain 303 otherwise. + redirectFullPage(req, res, "/"); + }); + router.post("/reactivate", async (req: Request, res: Response) => { assert(req.userId, "userId required - route must be protected by requireAuth"); const userId = req.userId; @@ -397,13 +431,14 @@ export function initAccountRoutes(deps: AccountDependencies): Router { return checkout; } - /** HTMX intercepts hx-boost forms via XHR. A 303 Location to an external - * origin (Stripe Checkout) makes HTMX issue a cross-origin XHR and then - * fail to swap the response into
, so the browser never leaves - * /account. HxRedirectPage carries HTMX's HX-Redirect header, which - * triggers `window.location.href = url`. Plain (non-HTMX) form posts - * still get the 303 Location, so progressive enhancement is preserved. */ - function redirectToCheckout(req: Request, res: Response, url: string): void { + /** Force the browser to fully navigate to `url` rather than swap
. + * HTMX intercepts hx-boost forms via XHR; a 303 Location to an external origin + * (Stripe Checkout) makes HTMX issue a cross-origin XHR and never leave the + * page, and a same-origin 303 only swaps
(leaving stale chrome — wrong + * after a logout-like action). HxRedirectPage carries HTMX's HX-Redirect + * header, which triggers `window.location.href = url`. Plain (non-HTMX) form + * posts still get the 303 Location, so progressive enhancement is preserved. */ + function redirectFullPage(req: Request, res: Response, url: string): void { if (req.get("HX-Request") === "true") { sendComponent(req, res, HxRedirectPage(url)); return; @@ -417,7 +452,7 @@ export function initAccountRoutes(deps: AccountDependencies): Router { > = { trialing: async (req, res) => { const checkout = await startCheckout(req); - redirectToCheckout(req, res, checkout.url); + redirectFullPage(req, res, checkout.url); }, cancelled: async (req, res) => { assert(req.userId, "userId required - route must be protected by requireAuth"); @@ -430,7 +465,7 @@ export function initAccountRoutes(deps: AccountDependencies): Router { { userId }, ); const checkout = await startCheckout(req); - redirectToCheckout(req, res, checkout.url); + redirectFullPage(req, res, checkout.url); return; } try { @@ -455,7 +490,7 @@ export function initAccountRoutes(deps: AccountDependencies): Router { { userId, error: err instanceof Error ? err.message : String(err) }, ); const checkout = await startCheckout(req); - redirectToCheckout(req, res, checkout.url); + redirectFullPage(req, res, checkout.url); } }, noop: async (_req, res) => { diff --git a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts index 80b277946..cc2323754 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts @@ -1239,3 +1239,83 @@ describe("POST /account/cards/confirm — post-attach cap reconciliation", () => expect(response.headers.location).toBe("/login"); }); }); + +describe("GET /account (Siren)", () => { + it("emits the account entity with a destructive delete-account action for a Siren client", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const { agent } = await loginUser(harness, "siren@example.com"); + + const response = await agent + .get("/account") + .set("Accept", "application/vnd.siren+json"); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toContain("application/vnd.siren+json"); + const deleteAction = response.body.actions.find( + (a: { name: string }) => a.name === "delete-account", + ); + assert(deleteAction, "delete-account action must be present"); + expect(deleteAction.href).toBe("/account/delete"); + expect(deleteAction.method).toBe("POST"); + expect(deleteAction.class).toEqual(["destructive"]); + }); +}); + +describe("POST /account/delete", () => { + it("destroys the session, clears the cookie, and redirects to the logged-out home", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const { agent } = await loginUser(harness, "delete-me@example.com"); + + const response = await agent.post("/account/delete"); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/"); + const rawSetCookie = response.headers["set-cookie"]; + const setCookie = Array.isArray(rawSetCookie) ? rawSetCookie : []; + assert( + setCookie.some((c) => c.startsWith("hutch_sid=")), + "the session cookie must be cleared", + ); + + // The session was destroyed, so the agent's cookie no longer authenticates. + const after = await agent.get("/account"); + expect(after.status).toBe(303); + expect(after.headers.location).toBe("/login"); + }); + + it("returns an HX-Redirect to the logged-out home for a boosted (HTMX) request", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const { agent } = await loginUser(harness, "delete-hx@example.com"); + + const response = await agent.post("/account/delete").set("HX-Request", "true"); + + expect(response.status).toBe(200); + expect(response.headers["hx-redirect"]).toBe("/"); + }); + + it("redirects unauthenticated callers to /login", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const response = await request(harness.server).post("/account/delete"); + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/login"); + }); +}); + +describe("GET /account (danger zone)", () => { + it("renders a destructive delete-account form pointing at /account/delete", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = await loginAgent(harness.server, harness.auth); + + const response = await agent.get("/account"); + const doc = new JSDOM(response.text).window.document; + + const danger = doc.querySelector("[data-test-account-danger]"); + assert(danger, "the danger zone must render"); + const deleteForm = danger.querySelector('[data-test-danger-action="delete-account"]'); + assert(deleteForm, "the delete-account form must render"); + expect(deleteForm.getAttribute("method")).toBe("POST"); + expect(deleteForm.getAttribute("action")).toBe( + "/account/delete?utm_source=account&utm_medium=internal&utm_content=delete-account", + ); + }); +}); diff --git a/projects/hutch/src/runtime/web/pages/account/account.styles.css b/projects/hutch/src/runtime/web/pages/account/account.styles.css index 5a573509c..915b8b8cd 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.styles.css +++ b/projects/hutch/src/runtime/web/pages/account/account.styles.css @@ -119,6 +119,32 @@ text-decoration: underline; } +.account-danger { + padding: 20px; + border-radius: var(--radius-sm); + background: var(--muted); + border-left: 3px solid var(--error); + margin-bottom: 16px; +} + +.account-danger__heading { + font-size: 1.25rem; + font-weight: 700; + margin: 0 0 12px; + color: var(--foreground); +} + +.account-danger__body { + font-size: 0.9375rem; + margin: 0 0 12px; + color: var(--foreground); + line-height: 1.45; +} + +.account-danger__action-form { + margin: 0; +} + /* purgecss-ignore-start: account-cards and its state modifiers are interpolated from CardSectionViewModel.stateClass / item.itemClass in account.template.html */ .account-cards { padding: 20px; diff --git a/projects/hutch/src/runtime/web/pages/account/account.template.html b/projects/hutch/src/runtime/web/pages/account/account.template.html index 8ccc10afe..47b3fec2e 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.template.html +++ b/projects/hutch/src/runtime/web/pages/account/account.template.html @@ -81,4 +81,14 @@ {{/if}} +
diff --git a/projects/hutch/src/runtime/web/pages/account/account.url.ts b/projects/hutch/src/runtime/web/pages/account/account.url.ts index bcbb1d909..8c34ea562 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.url.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.url.ts @@ -6,6 +6,7 @@ export function buildAccountUrl(params?: { cancelling?: boolean }): string { } export const ACCOUNT_CANCEL_URL = "/account/cancel"; +export const ACCOUNT_DELETE_URL = "/account/delete"; export const ACCOUNT_REACTIVATE_URL = "/account/reactivate"; export const ACCOUNT_SUBSCRIBE_URL = "/account/subscribe"; export const ACCOUNT_ERROR_PAYMENT_METHOD_URL = "/account?error=payment_method"; diff --git a/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts b/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts index 9532fd524..518739abe 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts @@ -73,6 +73,21 @@ describe("toAccountViewModel — actions", () => { assert.deepEqual(vm.actions, []); }); + it("exposes the destructive delete-account danger action in every state, separate from the state actions", () => { + const vm = toAccountViewModel( + { tier: "founding", access: "full", banner: "none" }, + baseQuery, + now, + ); + assert.equal(vm.dangerAction.key, "delete-account"); + assert.equal(vm.dangerAction.variant, "destructive"); + assert.equal(vm.dangerAction.method, "POST"); + assert.equal( + vm.dangerAction.href, + "/account/delete?utm_source=account&utm_medium=internal&utm_content=delete-account", + ); + }); + it("active paid users get a destructive cancel form (POST) — no GET confirmation step", () => { const vm = toAccountViewModel( { tier: "paid", access: "full", banner: "none" }, diff --git a/projects/hutch/src/runtime/web/pages/account/account.view-model.ts b/projects/hutch/src/runtime/web/pages/account/account.view-model.ts index d982f840d..5f42608af 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.view-model.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.view-model.ts @@ -5,6 +5,7 @@ import { type LocalTime, toAbsoluteDate, withInternalTracking } from "@packages/ import { ACCOUNT_CANCEL_URL, ACCOUNT_CARDS_NEW_URL, + ACCOUNT_DELETE_URL, ACCOUNT_REACTIVATE_URL, ACCOUNT_SUBSCRIBE_URL, buildCardPrimaryUrl, @@ -23,7 +24,7 @@ export type AccountCardState = | "inactive" | "error-payment-method"; -export type AccountActionKey = "subscribe" | "cancel-form" | "reactivate-form"; +export type AccountActionKey = "subscribe" | "cancel-form" | "reactivate-form" | "delete-account"; export type AccountActionVariant = "primary" | "secondary" | "destructive"; @@ -48,6 +49,9 @@ export interface AccountViewModel { showCancellingNotice: boolean; stateIsErrorPaymentMethod: boolean; actions: AccountAction[]; + /** The irreversible "delete account" control. Kept out of the state-dependent + * `actions` array so the danger zone renders in every subscription state. */ + dangerAction: AccountAction; } function formatTrialDaysLeft(trialEndsAt: string, now: Date): { daysLeft: number; daysLeftWord: "day" | "days" } { @@ -112,6 +116,14 @@ const REACTIVATE_FORM_ACTION = action({ href: ACCOUNT_REACTIVATE_URL, }); +const DELETE_ACCOUNT_ACTION = action({ + key: "delete-account", + name: "Delete account", + variant: "destructive", + method: "POST", + href: ACCOUNT_DELETE_URL, +}); + export type CardSectionState = "no-customer" | "loaded" | "provider-error"; export type CardActionKey = "promote" | "remove"; @@ -271,6 +283,7 @@ function baseFor(state: AccountCardState, actions: AccountAction[]): { showCancellingNotice: false; stateIsErrorPaymentMethod: false; actions: AccountAction[]; + dangerAction: AccountAction; } { return { state, @@ -279,6 +292,7 @@ function baseFor(state: AccountCardState, actions: AccountAction[]): { showCancellingNotice: false, stateIsErrorPaymentMethod: false, actions, + dangerAction: DELETE_ACCOUNT_ACTION, }; } diff --git a/projects/hutch/src/runtime/web/pages/admin/recrawl.route.test.ts b/projects/hutch/src/runtime/web/pages/admin/recrawl.route.test.ts index 96a6e1430..878bae7fe 100644 --- a/projects/hutch/src/runtime/web/pages/admin/recrawl.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/admin/recrawl.route.test.ts @@ -75,6 +75,7 @@ function buildHarness(options: { adminEmails: readonly string[] }): RecrawlHarne publishStaleCheckRequested: fixture.events.publishStaleCheckRequested, publishUpdateFetchTimestamp: fixture.events.publishUpdateFetchTimestamp, publishExportUserDataCommand: fixture.events.publishExportUserDataCommand, + publishDeleteAccountCommand: fixture.events.publishDeleteAccountCommand, publishCancelSubscriptionCommand: fixture.events.publishCancelSubscriptionCommand, publishSubscriptionReactivated: fixture.events.publishSubscriptionReactivated, }, diff --git a/projects/hutch/src/runtime/web/pages/privacy/privacy.template.html b/projects/hutch/src/runtime/web/pages/privacy/privacy.template.html index 805b8ff5e..080ffdf1a 100644 --- a/projects/hutch/src/runtime/web/pages/privacy/privacy.template.html +++ b/projects/hutch/src/runtime/web/pages/privacy/privacy.template.html @@ -48,7 +48,7 @@

Cookies