diff --git a/projects/hutch/src/e2e/e2e-server.main.ts b/projects/hutch/src/e2e/e2e-server.main.ts index 711db2574..e04770d1e 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/hutch-storage.ts b/projects/hutch/src/infra/hutch-storage.ts index c97551808..44b96f07e 100644 --- a/projects/hutch/src/infra/hutch-storage.ts +++ b/projects/hutch/src/infra/hutch-storage.ts @@ -191,7 +191,19 @@ export class HutchStorage extends pulumi.ComponentResource { name: args.tableNames.passwordResetTokens, billingMode: "PAY_PER_REQUEST", hashKey: "token", - attributes: [{ name: "token", type: "S" }] + attributes: [{ name: "token", type: "S" }], + /* Expire reset tokens on the same `expiresAt` (epoch seconds) the row + * already carries, like every sibling token table. Beyond token hygiene + * this is the compliance backstop for account deletion: the delete-account + * scrub matches rows by the *normalized* email, so a token written before + * the write-path normalization (a raw `John@Example.com`) can't be matched + * and — without this TTL — would outlive the deleted account forever. TTL + * erases those pre-normalization stragglers within ~48h with no one-off + * backfill; the synchronous scrub still erases every new row immediately. */ + ttl: { + attributeName: "expiresAt", + enabled: true, + }, }, { parent: this, aliases: [{ parent: pulumi.rootStackResource }] }); this.pendingSignupsTable = new aws.dynamodb.Table(`hutch-pending-signups`, { diff --git a/projects/hutch/src/infra/index.ts b/projects/hutch/src/infra/index.ts index 7184cda71..9fbe88334 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,129 @@ 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 }, + { arn: storage.verificationTokensTable.arn, includeIndexes: false }, + { arn: storage.pendingSignupsTable.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, + // The scrub converges on partial state by design, and its Apple-revocation + // step fails closed on an Apple outage — 12 receives ≈ 3 hours of retries + // so a losable deletion only falls to the DLQ (email-alarmed) after riding + // out a real outage, not after the default 3 attempts. + dlqMaxReceiveCount: 12, +}); + +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, + DYNAMODB_VERIFICATION_TOKENS_TABLE: storage.verificationTokensTable.name, + DYNAMODB_PENDING_SIGNUPS_TABLE: storage.pendingSignupsTable.name, + RAW_EMAIL_BUCKET_NAME: rawEmailBucketName, + CONTENT_BUCKET_NAME: contentBucketName, + USER_EXPORT_BUCKET_NAME: userExportBucketName, + STRIPE_SECRET_KEY: requireEnv("STRIPE_SECRET_KEY"), + APPLE_LOGIN_CLIENT_ID: requireEnv("APPLE_LOGIN_CLIENT_ID"), + APPLE_LOGIN_TEAM_ID: requireEnv("APPLE_LOGIN_TEAM_ID"), + APPLE_LOGIN_KEY_ID: requireEnv("APPLE_LOGIN_KEY_ID"), + APPLE_LOGIN_PRIVATE_KEY_BASE64: requireEnv("APPLE_LOGIN_PRIVATE_KEY_BASE64"), + 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 9573478d2..878d4f023 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, @@ -404,11 +410,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(); @@ -559,6 +567,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(); @@ -628,6 +637,7 @@ function initProviders() { googleAuth, appleAuth, oauthModel, + revokeAllUserOAuthTokens, validateAccessToken: createValidateAccessToken(oauthModel), findOAuthClient: oauthClientLookup.findClient, validateOAuthRedirectUri: oauthClientLookup.validateRedirectUri, @@ -640,6 +650,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..f50646ced --- /dev/null +++ b/projects/hutch/src/runtime/delete-account.main.ts @@ -0,0 +1,181 @@ +/* c8 ignore start -- composition root, no logic to test */ +import assert from "node:assert"; +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 { initCreateAppleClientSecret } from "./providers/apple-auth/apple-client-secret"; +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 { initDynamoDbEmailVerification } from "./providers/email-verification/dynamodb-email-verification"; +import { initDynamoDbPendingSignup } from "./providers/pending-signup/dynamodb-pending-signup"; +import { initRevokeExternalIdpTokens } 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 emailVerification = initDynamoDbEmailVerification({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_VERIFICATION_TOKENS_TABLE"), +}); + +const pendingSignup = initDynamoDbPendingSignup({ + client: dynamoClient, + tableName: requireEnv("DYNAMODB_PENDING_SIGNUPS_TABLE"), + logger, +}); + +const appleClientId = requireEnv("APPLE_LOGIN_CLIENT_ID"); +const applePrivateKeyPem = Buffer.from(requireEnv("APPLE_LOGIN_PRIVATE_KEY_BASE64"), "base64").toString("utf8"); +assert(applePrivateKeyPem.includes("BEGIN PRIVATE KEY"), "APPLE_LOGIN_PRIVATE_KEY_BASE64 must decode to a PKCS#8 PEM"); + +const revokeExternalIdpTokens = initRevokeExternalIdpTokens({ + findAppleRefreshTokenByUserId: auth.findAppleRefreshTokenByUserId, + appleClientId, + createAppleClientSecret: initCreateAppleClientSecret({ + teamId: requireEnv("APPLE_LOGIN_TEAM_ID"), + clientId: appleClientId, + keyId: requireEnv("APPLE_LOGIN_KEY_ID"), + privateKeyPem: applePrivateKeyPem, + now, + }), + fetch: globalThis.fetch, + logger, +}); + +export const handler = initDeleteAccountHandler({ + findEmailByUserId: auth.findEmailByUserId, + findSubscriptionByUserId: subscriptionProviders.findByUserId, + deleteStripeCustomer: stripeSubscriptions.deleteCustomer, + deleteSubscription: subscriptionProviders.deleteSubscription, + deleteTrialEndSchedule: trialScheduler.deleteTrialEndSchedule, + deleteDeferredCancellationSchedule: trialScheduler.deleteDeferredCancellationSchedule, + deleteTrialFeedbackEmailSchedule: trialScheduler.deleteTrialFeedbackEmailSchedule, + deleteTrialReminderSchedule: trialScheduler.deleteTrialReminderSchedule, + listInboxDeletionReferences: inboxEmail.listDeletionReferencesByUserId, + 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, + deleteVerificationTokensByUserId: emailVerification.deleteTokensByUserId, + deletePendingSignupsByUser: pendingSignup.deleteByUser, + 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..60c52a7e3 --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/delete-account-handler.test.ts @@ -0,0 +1,659 @@ +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 { UserIdSchema, 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 { initRevokeExternalIdpTokens } 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 deleteCustomerCalls: Array<{ customerId: string }> = []; + const deleteSubscriptionCalls: UserId[] = []; + const trialEndCalls: UserId[] = []; + const deferredCancelCalls: UserId[] = []; + const trialFeedbackCalls: UserId[] = []; + const trialReminderCalls: UserId[] = []; + const rawEmailDeleteArgs: string[][] = []; + const bodyEmailDeleteArgs: string[][] = []; + const deleteExportsCalls: UserId[] = []; + const passwordResetCalls: string[] = []; + const verificationTokenDeleteCalls: UserId[] = []; + const pendingSignupDeleteCalls: Array<{ userId: UserId; email: string | null }> = []; + const revokeIdpCalls: UserId[] = []; + + // The real revoker runs against the in-memory auth store and a fake Apple + // endpoint, so the scrub tests prove revocation happens while the auth row + // (and its stored refresh token) still exists. + const appleRevokeCalls: string[] = []; + const revokeIdpTokens = initRevokeExternalIdpTokens({ + findAppleRefreshTokenByUserId: auth.findAppleRefreshTokenByUserId, + appleClientId: "com.readplace.web", + createAppleClientSecret: () => "minted-client-secret-jwt", + fetch: async (_input, init) => { + const body = init?.body; + assert(typeof body === "string", "Apple revocation must send a form-encoded body"); + appleRevokeCalls.push(new URLSearchParams(body).get("token") ?? ""); + return new Response(null, { status: 200 }); + }, + 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(); + + // Test-only one-shot failures: each flag makes its step throw exactly once + // (self-clearing), so the record fails on the first delivery and its redrive + // then runs clean — the interleavings that exercise idempotency on retry. + const injectedFailures = { deleteSubscriptionOnce: false, deleteRawEmailOnce: false }; + + const handler = initDeleteAccountHandler({ + findEmailByUserId: auth.findEmailByUserId, + findSubscriptionByUserId: subs.findByUserId, + deleteStripeCustomer: async ({ customerId }: { customerId: string }) => { + deleteCustomerCalls.push({ customerId }); + }, + deleteSubscription: async ({ userId }: { userId: UserId }) => { + if (injectedFailures.deleteSubscriptionOnce) { + injectedFailures.deleteSubscriptionOnce = false; + throw new Error("simulated deleteSubscription failure"); + } + 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); + }, + deleteTrialReminderSchedule: async ({ userId }: { userId: UserId }) => { + trialReminderCalls.push(userId); + }, + listInboxDeletionReferences: inboxEmail.listDeletionReferencesByUserId, + deleteAllInboxEmails: inboxEmail.deleteAllEmailsByUserId, + deleteAllInboxLinks: inboxLink.deleteAllLinksByUserId, + tombstoneInboxAddresses: inboxAddress.tombstoneUserAddresses, + deleteRawEmailObjects: async (keys: string[]) => { + if (injectedFailures.deleteRawEmailOnce) { + injectedFailures.deleteRawEmailOnce = false; + throw new Error("simulated deleteRawEmailObjects failure"); + } + 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); + }, + deleteVerificationTokensByUserId: async (userId: UserId) => { + verificationTokenDeleteCalls.push(userId); + }, + deletePendingSignupsByUser: async ({ userId, email }: { userId: UserId; email: string | null }) => { + pendingSignupDeleteCalls.push({ userId, email }); + }, + revokeExternalIdpTokens: async (userId: UserId) => { + revokeIdpCalls.push(userId); + await revokeIdpTokens(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, + deleteCustomerCalls, + deleteSubscriptionCalls, + trialEndCalls, + deferredCancelCalls, + trialFeedbackCalls, + trialReminderCalls, + rawEmailDeleteArgs, + bodyEmailDeleteArgs, + deleteExportsCalls, + passwordResetCalls, + verificationTokenDeleteCalls, + pendingSignupDeleteCalls, + revokeIdpCalls, + appleRevokeCalls, + failArticleDeleteFor: (userId: UserId): void => { + articleDeleteThrowIds.add(userId); + }, + failDeleteSubscriptionOnce: (): void => { + injectedFailures.deleteSubscriptionOnce = true; + }, + failRawEmailDeleteOnce: (): void => { + injectedFailures.deleteRawEmailOnce = true; + }, + }; +} + +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]); + + // Verification tokens and abandoned-checkout pending-signup rows scrubbed — + // the remnant stores this PR closes. Pending-signups receive the captured + // email too, so legacy pre-userId rows are reachable by email. + assert.deepEqual(s.verificationTokenDeleteCalls, [victim.userId]); + assert.deepEqual(s.pendingSignupDeleteCalls, [ + { userId: victim.userId, email: victim.email }, + ]); + + // Billing side effects fired for the active subscription: deleting the + // Stripe customer cancels the subscription and detaches the cards. + 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 — deletes the Stripe customer (which cancels the sub) 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.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 four 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.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]); + assert.deepEqual(s.trialReminderCalls, [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.deleteCustomerCalls, []); + assert.deepEqual(s.deleteSubscriptionCalls, []); + assert.deepEqual(s.trialEndCalls, [account.userId]); + assert.deepEqual(s.deferredCancelCalls, [account.userId]); + assert.deepEqual(s.trialFeedbackCalls, [account.userId]); + assert.deepEqual(s.trialReminderCalls, [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.deleteCustomerCalls, [{ customerId: "cus_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, without an Apple call for a password account", 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]); + assert.deepEqual(s.appleRevokeCalls, []); + }); + + it("revokes the Sign in with Apple grant using the refresh token stored on the auth row", async () => { + const s = buildSubject(); + const created = await s.auth.createAppleUser({ + email: "siwa@example.com", + userId: UserIdSchema.parse("siwa-user-1"), + appleRefreshToken: "stored-apple-refresh-token", + }); + assert(created.ok, "expected the Apple user to be created"); + + const result = await run(s, [{ messageId: "msg-siwa", body: bodyFor(created.userId) }]); + + assert.deepEqual(result.batchItemFailures, []); + assert.deepEqual(s.appleRevokeCalls, ["stored-apple-refresh-token"]); + assert.equal(await s.auth.findUserByEmail("siwa@example.com"), null); + }); + + it("redrive after a failed local subscription delete re-runs the Stripe customer delete idempotently and converges", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "redrive", + email: "redrive@example.com", + subscription: "active", + }); + + // First delivery: the Stripe customer delete succeeds, but dropping the + // local subscription row throws — so the record fails and redrives with the + // row still present, re-running the billing branch on the retry. + s.failDeleteSubscriptionOnce(); + const first = await run(s, [{ messageId: "msg-1", body: bodyFor(account.userId) }]); + assert.deepEqual(first.batchItemFailures, [{ itemIdentifier: "msg-1" }]); + + const second = await run(s, [{ messageId: "msg-2", body: bodyFor(account.userId) }]); + assert.deepEqual(second.batchItemFailures, []); + + // The customer delete ran on both deliveries; deleting an already-deleted + // Stripe customer is a 404-idempotent no-op, and there is no separate + // re-cancel of the already-cancelled subscription — the interleaving that + // used to throw a non-404 and poison the queue into the DLQ. + assert.deepEqual(s.deleteCustomerCalls, [ + { customerId: "cus_redrive" }, + { customerId: "cus_redrive" }, + ]); + assert.equal(await s.subs.findByUserId(account.userId), undefined); + assert.equal(await s.auth.findEmailByUserId(account.userId), null); + }); + + it("redrive after a failed raw-email S3 delete re-derives the keys from the surviving rows instead of orphaning them", async () => { + const s = buildSubject(); + const account = await seedAccount(s, { + label: "orphan", + email: "orphan@example.com", + subscription: "none", + }); + + // First delivery: the raw-email S3 delete throws. The email rows are read + // (not deleted) before the S3 delete, so they survive the failed record — + // the keys can be re-derived on the redrive rather than being lost. + s.failRawEmailDeleteOnce(); + const first = await run(s, [{ messageId: "msg-1", body: bodyFor(account.userId) }]); + assert.deepEqual(first.batchItemFailures, [{ itemIdentifier: "msg-1" }]); + assert.deepEqual(s.rawEmailDeleteArgs, []); + + const second = await run(s, [{ messageId: "msg-2", body: bodyFor(account.userId) }]); + assert.deepEqual(second.batchItemFailures, []); + + // The redrive re-read the surviving rows and re-derived the raw keys, so the + // .eml objects are deleted rather than left orphaned in S3, and only then + // were the rows removed. + assert.equal(s.rawEmailDeleteArgs.length, 1); + assert.deepEqual(sorted(s.rawEmailDeleteArgs[0]), sorted(account.rawKeys)); + assert.equal((await s.inboxEmail.listEmailsByUserId(account.userId)).length, 0); + + // The per-user schedule deletes run before the injected S3 failure, so the + // pre-expiry trial-reminder schedule delete fired on both the failed first + // delivery and the redrive. Being ResourceNotFound-idempotent, the repeat is + // a no-op — a trialing account that deletes mid-trial leaves no orphaned + // reminder schedule live at AWS after a redrive. + assert.deepEqual(s.trialReminderCalls, [account.userId, 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..7e1fb866c --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/delete-account-handler.ts @@ -0,0 +1,182 @@ +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 { DeleteVerificationTokensByUserId } from "@packages/provider-contracts/email-verification"; +import type { DeletePendingSignupsByUser } from "@packages/provider-contracts/pending-signup"; +import type { + DeleteSubscription, + FindSubscriptionByUserId, +} from "@packages/provider-contracts/subscription-providers"; +import type { DeleteCustomer } from "@packages/provider-contracts/stripe-subscriptions"; +import type { + DeleteDeferredCancellationSchedule, + DeleteTrialEndSchedule, + DeleteTrialFeedbackEmailSchedule, + DeleteTrialReminderSchedule, +} 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; + deleteStripeCustomer: DeleteCustomer; + deleteSubscription: DeleteSubscription; + deleteTrialEndSchedule: DeleteTrialEndSchedule; + deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; + deleteTrialFeedbackEmailSchedule: DeleteTrialFeedbackEmailSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; + listInboxDeletionReferences: InboxEmailStore["listDeletionReferencesByUserId"]; + 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; + deleteVerificationTokensByUserId: DeleteVerificationTokensByUserId; + deletePendingSignupsByUser: DeletePendingSignupsByUser; + 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: delete the Stripe customer — which immediately cancels any + // live subscription and detaches every card — then drop the local row. + // Deleting the customer is the ONLY Stripe write: Stripe cascades the + // cancellation and then blocks further operations on the customer, so a + // separate immediate-cancel would be redundant and, on an at-least-once + // redrive, a non-idempotent re-cancel of an already-cancelled subscription + // that throws and poisons the queue into the DLQ. deleteCustomer instead + // treats an already-gone customer as success, so a redrive converges. + // Founding members have no row; trialing users have a row but no customerId + // (a local trial with no Stripe object). + const subscription = await deps.findSubscriptionByUserId(userId); + if (subscription) { + 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 four are ResourceNotFound-idempotent. + await deps.deleteTrialEndSchedule({ userId }); + await deps.deleteDeferredCancellationSchedule({ userId }); + await deps.deleteTrialFeedbackEmailSchedule({ userId }); + await deps.deleteTrialReminderSchedule({ userId }); + + // Inbox: read the pointers the email rows hold (S3 keys + link message-ids) + // while the rows still exist, then delete the S3 objects and link rows, and + // only then the email rows themselves. The rows are the sole index for those + // S3 objects (whose keys carry no userId) and link rows (no userId index), so + // deleting the rows last lets an at-least-once redrive re-derive the pointers + // from the still-present rows instead of orphaning the raw `.eml`/body objects + // in S3. Finally 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.listInboxDeletionReferences(userId); + await deps.deleteRawEmailObjects(rawEmailS3Keys); + await deps.deleteEmailContentObjects(bodyS3Keys); + await deps.deleteAllInboxLinks(userId, receivedAtMessageIds); + await deps.deleteAllInboxEmails(userId); + 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); + } + + // Signup/verification remnants (all scanned, all no-op when absent): the + // email-verification token (a `{userId, email}` row the TTL would otherwise + // keep for the verification window, scrubbed by userId) and any abandoned- + // checkout pending-signup rows (that table has no TTL, so they'd keep + // `{email, userId}` forever — scrubbed by userId OR the captured email, since + // legacy pre-userId rows carry only the email). Import sessions are + // deliberately NOT scrubbed: their `{userId, urls}` rows self-expire via that + // table's 24h TTL, so no scan is spent on them. + await deps.deleteVerificationTokensByUserId(userId); + await deps.deletePendingSignupsByUser({ userId, 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..466d73da1 --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { UserIdSchema } from "@packages/domain/user"; +import { initRevokeExternalIdpTokens } from "./revoke-external-idp-tokens"; + +const USER_ID = UserIdSchema.parse("user-1"); + +function buildRevoker(opts: { + storedToken: string | null; + responseStatus?: number; +}) { + const fetchCalls: { url: string; init: RequestInit | undefined }[] = []; + const fakeFetch: typeof globalThis.fetch = async (input, init) => { + fetchCalls.push({ url: typeof input === "string" ? input : input.toString(), init }); + return new Response(null, { status: opts.responseStatus ?? 200 }); + }; + const revokeExternalIdpTokens = initRevokeExternalIdpTokens({ + findAppleRefreshTokenByUserId: async () => opts.storedToken, + appleClientId: "com.readplace.web", + createAppleClientSecret: () => "minted-client-secret-jwt", + fetch: fakeFetch, + logger: HutchLogger.from(noopLogger), + }); + return { revokeExternalIdpTokens, fetchCalls }; +} + +describe("initRevokeExternalIdpTokens", () => { + it("resolves without calling Apple when the user has no stored refresh token (password/Google accounts)", async () => { + const s = buildRevoker({ storedToken: null }); + + await assert.doesNotReject(s.revokeExternalIdpTokens(USER_ID)); + + assert.equal(s.fetchCalls.length, 0); + }); + + it("POSTs the stored refresh token to Apple's revocation endpoint with a freshly minted client secret", async () => { + const s = buildRevoker({ storedToken: "stored-apple-refresh-token" }); + + await s.revokeExternalIdpTokens(USER_ID); + + assert.equal(s.fetchCalls.length, 1); + const call = s.fetchCalls[0]; + assert(call, "revocation must issue exactly one request"); + assert.equal(call.url, "https://appleid.apple.com/auth/revoke"); + assert.equal(call.init?.method, "POST"); + const headers = new Headers(call.init?.headers); + assert.equal(headers.get("Content-Type"), "application/x-www-form-urlencoded"); + const body = call.init?.body; + assert(typeof body === "string"); + const params = new URLSearchParams(body); + assert.equal(params.get("client_id"), "com.readplace.web"); + assert.equal(params.get("client_secret"), "minted-client-secret-jwt"); + assert.equal(params.get("token"), "stored-apple-refresh-token"); + assert.equal(params.get("token_type_hint"), "refresh_token"); + }); + + it("rejects on a non-2xx Apple response so the SQS record redrives and revocation is retried", async () => { + const s = buildRevoker({ storedToken: "stored-apple-refresh-token", responseStatus: 400 }); + + await assert.rejects(s.revokeExternalIdpTokens(USER_ID), /Apple token revocation failed with status 400/); + }); +}); 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..4a8594717 --- /dev/null +++ b/projects/hutch/src/runtime/delete-account/revoke-external-idp-tokens.ts @@ -0,0 +1,47 @@ +import assert from "node:assert"; +import type { UserId } from "@packages/domain/user"; +import type { FindAppleRefreshTokenByUserId } from "@packages/provider-contracts/auth"; +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. Apple requires this for App Store + * 5.1.1(v): without it a deleted account lingers in the user's "Sign in with + * Apple" list. The only IdPs today are Google (which persists no token) and + * Sign in with Apple. A user without a stored token is either a password or + * Google account (nothing to revoke) or an Apple account from before token + * persistence — indistinguishable, because the provider `sub` is never stored. + * That legacy grant cannot be revoked here; each Apple login stores the newest + * token, shrinking the cohort until every deletion can revoke. */ +export type RevokeExternalIdpTokens = (userId: UserId) => Promise; + +export function initRevokeExternalIdpTokens(deps: { + findAppleRefreshTokenByUserId: FindAppleRefreshTokenByUserId; + appleClientId: string; + createAppleClientSecret: () => string; + fetch: typeof globalThis.fetch; + logger: HutchLogger; +}): RevokeExternalIdpTokens { + return async (userId) => { + const appleRefreshToken = await deps.findAppleRefreshTokenByUserId(userId); + if (appleRefreshToken === null) { + deps.logger.info("[delete-account] no external IdP token to revoke", { userId }); + return; + } + + const response = await deps.fetch("https://appleid.apple.com/auth/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: deps.appleClientId, + client_secret: deps.createAppleClientSecret(), + token: appleRefreshToken, + token_type_hint: "refresh_token", + }).toString(), + }); + /* Throwing redrives the SQS record: the user row (and its token) is only + * deleted later in the scrub, so the retry can revoke again. Apple returns + * 200 for already-revoked tokens (RFC 7009), so redrives converge. */ + assert(response.ok, `Apple token revocation failed with status ${response.status}`); + deps.logger.info("[delete-account] revoked Sign in with Apple grant", { 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/apple-auth/apple-token.test.ts b/projects/hutch/src/runtime/providers/apple-auth/apple-token.test.ts index b2f5fb748..49de70404 100644 --- a/projects/hutch/src/runtime/providers/apple-auth/apple-token.test.ts +++ b/projects/hutch/src/runtime/providers/apple-auth/apple-token.test.ts @@ -23,6 +23,7 @@ describe("initExchangeAppleCode", () => { receivedInit = init; return jsonResponse({ id_token: idToken({ sub: "apple-sub-1", email: "user@example.com", email_verified: true }), + refresh_token: "apple-refresh-1", }); }; @@ -53,6 +54,7 @@ describe("initExchangeAppleCode", () => { const fakeFetch: typeof globalThis.fetch = async () => jsonResponse({ id_token: idToken({ sub: "apple-sub-42", email: "person@example.com", email_verified: true }), + refresh_token: "apple-refresh-42", }); const exchange = initExchangeAppleCode({ @@ -69,10 +71,46 @@ describe("initExchangeAppleCode", () => { assert.equal(result.emailVerified, true); }); + it("returns Apple's refresh_token so the login flow can persist it for deletion-time revocation", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse({ + id_token: idToken({ sub: "apple-sub-42", email: "person@example.com", email_verified: true }), + refresh_token: "apple-refresh-42", + }); + + const exchange = initExchangeAppleCode({ + clientId: "id", + createClientSecret: () => "secret", + redirectUri: "https://app.test/cb", + fetch: fakeFetch, + }); + + const result = await exchange("code"); + + assert.equal(result.appleRefreshToken, "apple-refresh-42"); + }); + + it("rejects a token response that is missing the refresh_token — the grant would be unrevokable at account deletion", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse({ + id_token: idToken({ sub: "apple-sub", email: "e@example.com", email_verified: true }), + }); + + const exchange = initExchangeAppleCode({ + clientId: "id", + createClientSecret: () => "secret", + redirectUri: "https://app.test/cb", + fetch: fakeFetch, + }); + + await assert.rejects(exchange("code")); + }); + it("coerces the documented string email_verified 'true' to boolean true", async () => { const fakeFetch: typeof globalThis.fetch = async () => jsonResponse({ id_token: idToken({ sub: "apple-sub", email: "e@example.com", email_verified: "true" }), + refresh_token: "apple-refresh", }); const exchange = initExchangeAppleCode({ @@ -91,6 +129,7 @@ describe("initExchangeAppleCode", () => { const fakeFetch: typeof globalThis.fetch = async () => jsonResponse({ id_token: idToken({ sub: "apple-sub", email: "e@example.com", email_verified: "false" }), + refresh_token: "apple-refresh", }); const exchange = initExchangeAppleCode({ @@ -109,6 +148,7 @@ describe("initExchangeAppleCode", () => { const fakeFetch: typeof globalThis.fetch = async () => jsonResponse({ id_token: idToken({ sub: "apple-sub", email: "unverified@example.com", email_verified: false }), + refresh_token: "apple-refresh", }); const exchange = initExchangeAppleCode({ @@ -138,7 +178,10 @@ describe("initExchangeAppleCode", () => { it("rejects an id_token whose claims are missing email_verified", async () => { const fakeFetch: typeof globalThis.fetch = async () => - jsonResponse({ id_token: idToken({ sub: "apple-sub", email: "x@example.com" }) }); + jsonResponse({ + id_token: idToken({ sub: "apple-sub", email: "x@example.com" }), + refresh_token: "apple-refresh", + }); const exchange = initExchangeAppleCode({ clientId: "id", diff --git a/projects/hutch/src/runtime/providers/apple-auth/apple-token.ts b/projects/hutch/src/runtime/providers/apple-auth/apple-token.ts index 5f9ee6ea1..f056b8a55 100644 --- a/projects/hutch/src/runtime/providers/apple-auth/apple-token.ts +++ b/projects/hutch/src/runtime/providers/apple-auth/apple-token.ts @@ -2,8 +2,14 @@ import { z } from "zod"; import { AppleIdSchema } from "@packages/provider-contracts/apple-auth"; import type { ExchangeAppleCode } from "@packages/provider-contracts/apple-auth"; -const AppleTokenResponse = z.object({ +/** Apple's token-exchange response. `refresh_token` is required, not optional: + * account deletion revokes the Sign in with Apple grant through it (App Store + * 5.1.1(v)), so an exchange that fails to return one must fail the login rather + * than mint an unrevokable account. Exported so the deletion-compliance guard in + * auth.route.test.ts can detect if this schema ever stops persisting the token. */ +export const AppleTokenResponse = z.object({ id_token: z.string(), + refresh_token: z.string(), }); const AppleIdTokenClaims = z.object({ @@ -45,6 +51,7 @@ export function initExchangeAppleCode(deps: { appleId: claims.sub, email: claims.email, emailVerified: claims.email_verified, + appleRefreshToken: tokenData.refresh_token, }; }; } 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.test.ts b/projects/hutch/src/runtime/providers/auth/dynamodb-auth.test.ts index 5a394859c..228eb3e6d 100644 --- a/projects/hutch/src/runtime/providers/auth/dynamodb-auth.test.ts +++ b/projects/hutch/src/runtime/providers/auth/dynamodb-auth.test.ts @@ -299,6 +299,90 @@ describe("initDynamoDbAuth", () => { }); }); + describe("createAppleUser", () => { + it("stores the Apple refresh token on the verified user row", async () => { + const { client, commands } = createWriteFakeClient(); + + const result = await initAuth(client).createAppleUser({ + email: "User@Example.com", + userId: USER, + appleRefreshToken: "apple-refresh-1", + }); + + assert(result.ok, "create should succeed"); + const put = commands.find((c) => c.name === "PutCommand"); + expect(put?.input).toMatchObject({ + Item: { + email: "user@example.com", + userId: USER, + emailVerified: true, + appleRefreshToken: "apple-refresh-1", + }, + }); + }); + + it("keeps Google rows free of the appleRefreshToken attribute", async () => { + const { client, commands } = createWriteFakeClient(); + + const result = await initAuth(client).createGoogleUser({ + email: "user@example.com", + userId: USER, + }); + + assert(result.ok, "create should succeed"); + const put = commands.find((c) => c.name === "PutCommand"); + assert(put, "expected a PutCommand"); + expect(put.input.Item).not.toHaveProperty("appleRefreshToken"); + }); + }); + + describe("saveAppleRefreshToken", () => { + it("updates the normalized row conditionally so a deleted account cannot be resurrected", async () => { + const { client, commands } = createWriteFakeClient(); + + await initAuth(client).saveAppleRefreshToken({ + email: "User@Example.com", + appleRefreshToken: "apple-refresh-2", + }); + + const update = commands.find((c) => c.name === "UpdateCommand"); + expect(update?.input).toMatchObject({ + Key: { email: "user@example.com" }, + UpdateExpression: "SET appleRefreshToken = :token", + ConditionExpression: "attribute_exists(email)", + ExpressionAttributeValues: { ":token": "apple-refresh-2" }, + }); + }); + }); + + describe("findAppleRefreshTokenByUserId", () => { + it("returns the stored token via the userId-index", async () => { + const { client, commands } = createQueryFakeClient({ + row: { email: "u@example.com", userId: "abc123", appleRefreshToken: "apple-refresh-3" }, + }); + + const token = await initAuth(client).findAppleRefreshTokenByUserId(USER); + + expect(token).toBe("apple-refresh-3"); + const query = commands.find((c) => c.name === "QueryCommand"); + expect(query?.input).toMatchObject({ IndexName: "userId-index" }); + }); + + it("returns null when the user row has no stored token", async () => { + const { client } = createQueryFakeClient({ + row: { email: "u@example.com", userId: "abc123" }, + }); + + expect(await initAuth(client).findAppleRefreshTokenByUserId(USER)).toBe(null); + }); + + it("returns null when no row exists for the id", async () => { + const { client } = createQueryFakeClient({}); + + expect(await initAuth(client).findAppleRefreshTokenByUserId(USER)).toBe(null); + }); + }); + describe("destroyUserSessions", () => { it("queries the sessions userId-index and deletes each session by id", async () => { const { client, commands } = createQueryFakeClient({ diff --git a/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts b/projects/hutch/src/runtime/providers/auth/dynamodb-auth.ts index bb92f9631..d8e461a39 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, @@ -33,6 +34,7 @@ import type { DestroySession, DestroyUserSessions, ExistsUserByIdPrefix, + FindAppleRefreshTokenByUserId, FindEmailByUserId, FindUserById, FindUserByEmail, @@ -40,7 +42,9 @@ import type { GetSessionUserId, MarkEmailVerified, MarkSessionEmailVerified, + SaveAppleRefreshToken, UpdatePassword, + UserAcquisitionAttribution, UserExistsByEmail, VerifyCredentials, } from "@packages/provider-contracts/auth"; @@ -49,6 +53,9 @@ const UserRow = z.object({ email: z.string(), userId: UserIdSchema, passwordHash: dynamoField(z.string()), + /* Only on rows that signed in with Apple; revoked and deleted with the + * account (App Store 5.1.1(v)). */ + appleRefreshToken: dynamoField(z.string()), emailVerified: dynamoField(z.boolean()), /* Optional in the schema so reads of pre-backfill rows don't throw; new writes always set it. */ registeredAt: dynamoField(z.string()), @@ -83,12 +90,15 @@ export function initDynamoDbAuth(deps: { createUserWithPasswordHash: CreateUserWithPasswordHash; createGoogleUser: CreateGoogleUser; createAppleUser: CreateAppleUser; + saveAppleRefreshToken: SaveAppleRefreshToken; + findAppleRefreshTokenByUserId: FindAppleRefreshTokenByUserId; findUserByEmail: FindUserByEmail; verifyCredentials: VerifyCredentials; createSession: CreateSession; getSessionUserId: GetSessionUserId; destroySession: DestroySession; destroyUserSessions: DestroyUserSessions; + closeUserAccount: CloseUserAccount; countUsers: CountUsers; markEmailVerified: MarkEmailVerified; markSessionEmailVerified: MarkSessionEmailVerified; @@ -222,11 +232,22 @@ export function initDynamoDbAuth(deps: { }; /** A user created from a federated identity provider (Google/Apple): verified - * email, no password hash. Both providers share this body because they persist - * an identical row — the provider's `sub` is deliberately never stored, users - * are keyed by normalized email. The Gmail canonical claim still applies, so an - * Apple ID backed by a Gmail address contends for the same uniqueness claim. */ - const createFederatedUser: CreateGoogleUser = async ({ email, userId, attribution }) => { + * email, no password hash. The provider's `sub` is deliberately never stored, + * users are keyed by normalized email. The Gmail canonical claim still applies, + * so an Apple ID backed by a Gmail address contends for the same uniqueness + * claim. Apple rows additionally carry the refresh token that account deletion + * revokes; Google persists no token. */ + const createFederatedUser = async ({ + email, + userId, + attribution, + appleRefreshToken, + }: { + email: string; + userId: UserId; + attribution?: UserAcquisitionAttribution; + appleRefreshToken?: string; + }) => { const normalizedEmail = normalizeEmail(email); assert(!normalizedEmail.startsWith(CLAIM_PK_PREFIX), `Email collides with the claim namespace: ${email}`); @@ -238,16 +259,38 @@ export function initDynamoDbAuth(deps: { registeredAt: new Date().toISOString(), userIdPrefix: userIdPrefixFrom(userId), canonicalEmail: canonicalizeEmail(email), + ...(appleRefreshToken === undefined ? {} : { appleRefreshToken }), ...(attribution ?? {}), }, userId, gmailClaimKey: gmailIdentityKey(email), }); - return result.ok ? { ok: true, userId } : result; + return result.ok ? { ok: true as const, userId } : result; }; const createGoogleUser: CreateGoogleUser = createFederatedUser; const createAppleUser: CreateAppleUser = createFederatedUser; + const saveAppleRefreshToken: SaveAppleRefreshToken = async ({ email, appleRefreshToken }) => { + const normalizedEmail = normalizeEmail(email); + await users.update({ + Key: { email: normalizedEmail }, + UpdateExpression: "SET appleRefreshToken = :token", + ConditionExpression: "attribute_exists(email)", + ExpressionAttributeValues: { ":token": appleRefreshToken }, + }); + }; + + const findAppleRefreshTokenByUserId: FindAppleRefreshTokenByUserId = async (userId) => { + const { items } = await users.query({ + IndexName: "userId-index", + KeyConditionExpression: "userId = :userId", + ExpressionAttributeValues: { ":userId": userId }, + Limit: 1, + }); + const row = items[0]; + return row?.appleRefreshToken ?? null; + }; + const findUserByEmail: FindUserByEmail = async (email) => { const normalizedEmail = normalizeEmail(email); const row = await users.get( @@ -363,6 +406,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", @@ -419,12 +485,15 @@ export function initDynamoDbAuth(deps: { createUserWithPasswordHash, createGoogleUser, createAppleUser, + saveAppleRefreshToken, + findAppleRefreshTokenByUserId, findUserByEmail, verifyCredentials, createSession, 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/email-verification/dynamodb-email-verification.test.ts b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.test.ts index f191cbcc5..f20fa241c 100644 --- a/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.test.ts +++ b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.test.ts @@ -121,4 +121,41 @@ describe("initDynamoDbEmailVerification", () => { await expect(initVerification(client).verifyEmailToken(TOKEN)).rejects.toThrow(failure); }); }); + + describe("deleteTokensByUserId", () => { + it("scans by userId across pages and deletes each token by its primary key", async () => { + const pages = [ + { Items: [{ token: "t1" }, { token: "t2" }], LastEvaluatedKey: { token: "t2" } }, + { Items: [{ token: "t3" }] }, + ]; + let call = 0; + const { client, commands } = createFakeClient({ ScanCommand: () => pages[call++] }); + + await initVerification(client).deleteTokensByUserId(USER); + + const scans = commands.filter((c) => c.name === "ScanCommand"); + expect(scans).toHaveLength(2); + expect(scans[0]?.input.FilterExpression).toBe("userId = :u"); + expect(scans[0]?.input.ExpressionAttributeValues).toEqual({ ":u": USER }); + expect(scans[0]?.input.ProjectionExpression).toBe("#tk"); + expect(scans[0]?.input.ExpressionAttributeNames).toEqual({ "#tk": "token" }); + expect(scans[1]?.input.ExclusiveStartKey).toEqual({ token: "t2" }); + + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((d) => d.input.Key)).toEqual([ + { token: "t1" }, + { token: "t2" }, + { token: "t3" }, + ]); + }); + + it("issues no deletes when the user has no verification tokens", async () => { + const { client, commands } = createFakeClient({ ScanCommand: { Items: [] } }); + + await initVerification(client).deleteTokensByUserId(USER); + + 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/email-verification/dynamodb-email-verification.ts b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts index e789a2a4b..6ca1fc693 100644 --- a/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts +++ b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { UserIdSchema } from "@packages/domain/user"; import type { CreateVerificationToken, + DeleteVerificationTokensByUserId, VerifyEmailToken, } from "@packages/provider-contracts/email-verification"; import { VerificationTokenSchema } from "@packages/provider-contracts/email-verification"; @@ -24,12 +25,17 @@ const VerificationRow = z.object({ expiresAt: z.number(), }); +const TokenKeyRow = z.object({ + token: z.string(), +}); + export function initDynamoDbEmailVerification(deps: { client: DynamoDBDocumentClient; tableName: string; }): { createVerificationToken: CreateVerificationToken; verifyEmailToken: VerifyEmailToken; + deleteTokensByUserId: DeleteVerificationTokensByUserId; } { const table = defineDynamoTable({ client: deps.client, @@ -37,6 +43,12 @@ export function initDynamoDbEmailVerification(deps: { schema: VerificationRow, }); + const tokenKeyTable = defineDynamoTable({ + client: deps.client, + tableName: deps.tableName, + schema: TokenKeyRow, + }); + const createVerificationToken: CreateVerificationToken = async ({ userId, email }) => { const token = VerificationTokenSchema.parse(randomBytes(32).toString("hex")); const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; @@ -76,5 +88,23 @@ export function initDynamoDbEmailVerification(deps: { } }; - return { createVerificationToken, verifyEmailToken }; + const deleteTokensByUserId: DeleteVerificationTokensByUserId = async (userId) => { + // Scan-and-delete by userId. The table's TTL eventually evicts an abandoned + // token, but deletion must erase the {userId, email} remnant now rather than + // leave it readable for the remainder of the verification window. + let ExclusiveStartKey: Record | undefined; + do { + const { items, lastEvaluatedKey } = await tokenKeyTable.scan({ + FilterExpression: "userId = :u", + ExpressionAttributeValues: { ":u": userId }, + ProjectionExpression: "#tk", + ExpressionAttributeNames: { "#tk": "token" }, + ExclusiveStartKey, + }); + for (const { token } of items) await table.delete({ Key: { token } }); + ExclusiveStartKey = lastEvaluatedKey; + } while (ExclusiveStartKey); + }; + + return { createVerificationToken, verifyEmailToken, deleteTokensByUserId }; } 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..e9667f0aa 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,146 @@ describe("initDynamoDbInboxEmail", () => { ).toBeUndefined(); }); }); + + describe("listDeletionReferencesByUserId", () => { + it("returns raw and body keys and message ids for the user without deleting any 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 refs = await store.listDeletionReferencesByUserId(USER); + + expect(refs.receivedAtMessageIds).toEqual([ + "2026-06-23T09:00:00.000Z#", + "2026-06-23T08:00:00.000Z#", + ]); + expect(refs.rawEmailS3Keys).toEqual(["inbound/a", "inbound/b"]); + expect(refs.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 }); + // Read-only: the rows survive so a redrive can re-derive the keys. + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); + + 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 refs = await store.listDeletionReferencesByUserId(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(refs.rawEmailS3Keys).toEqual(["inbound/1", "inbound/2"]); + expect(refs.bodyS3Keys).toEqual([]); + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); + + it("returns empty 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.listDeletionReferencesByUserId(USER)).toEqual({ + receivedAtMessageIds: [], + rawEmailS3Keys: [], + bodyS3Keys: [], + }); + expect(commands.some((c) => c.name === "DeleteCommand")).toBe(false); + }); + }); + + describe("deleteAllEmailsByUserId", () => { + it("deletes every row the user owns", async () => { + const { client, commands } = createPaginatedClient([ + { + rows: [ + emailRow({ receivedAtMessageId: "2026-06-23T09:00:00.000Z#" }), + emailRow({ receivedAtMessageId: "2026-06-23T08:00:00.000Z#" }), + ], + }, + ]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + await store.deleteAllEmailsByUserId(USER); + + 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, deleting each page's rows and feeding its key back as ExclusiveStartKey", async () => { + const { client, commands } = createPaginatedClient([ + { + rows: [emailRow({ receivedAtMessageId: "r1" })], + lastEvaluatedKey: { userId: "user-1", receivedAtMessageId: "r1" }, + }, + { + rows: [emailRow({ receivedAtMessageId: "r2" })], + }, + ]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + await store.deleteAllEmailsByUserId(USER); + + const queries = commands.filter((c) => c.name === "QueryCommand"); + expect(queries).toHaveLength(2); + expect(queries[1]?.input.ExclusiveStartKey).toEqual({ + userId: "user-1", + receivedAtMessageId: "r1", + }); + const deletes = commands.filter((c) => c.name === "DeleteCommand"); + expect(deletes.map((c) => c.input.Key)).toEqual([ + { userId: USER, receivedAtMessageId: "r1" }, + { userId: USER, receivedAtMessageId: "r2" }, + ]); + }); + + it("issues no delete when the user has no emails", async () => { + const { client, commands } = createPaginatedClient([{ rows: [] }]); + const store = initDynamoDbInboxEmail({ client, tableName: TABLE }); + + await store.deleteAllEmailsByUserId(USER); + + 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..a321c9851 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,43 @@ export function initDynamoDbInboxEmail(deps: { }, getEmail: async ({ userId, receivedAtMessageId }) => table.get({ userId, receivedAtMessageId }), + listDeletionReferencesByUserId: 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); + } + }, + ); + return { receivedAtMessageIds, rawEmailS3Keys, bodyS3Keys }; + }, + deleteAllEmailsByUserId: async (userId) => { + await forEachQueryPage( + table, + { + KeyConditionExpression: "userId = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }, + async (rows) => { + await Promise.all( + rows.map((row) => + table.delete({ + Key: { userId, receivedAtMessageId: row.receivedAtMessageId }, + }), + ), + ); + }, + ); + }, }; } 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..4f9da4e57 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) { @@ -56,6 +80,16 @@ describe("initDynamoDbPasswordReset", () => { expect(item.expiresAt).toBeGreaterThanOrEqual(before + 60 * 60); expect(item.expiresAt).toBeLessThanOrEqual(after + 60 * 60); }); + + it("normalizes the email on write so a mixed-case reset stays scrubbable at deletion", async () => { + const { client, commands } = createFakeClient({}); + + await initStore(client).createPasswordResetToken({ email: "John@Example.com" }); + + const put = commands.find((c) => c.name === "PutCommand"); + const item = put?.input.Item as { email: string }; + expect(item.email).toBe("john@example.com"); + }); }); describe("verifyPasswordResetToken", () => { @@ -117,4 +151,54 @@ 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); + }); + + it("normalizes a mixed-case filter email so it matches the normalized stored rows", async () => { + const { client, commands } = createScanClient([{ Items: [{ token: "aaa" }] }]); + + await initStore(client).deleteTokensByEmail("John@Example.com"); + + const scan = commands.find((c) => c.name === "ScanCommand"); + expect(scan?.input.ExpressionAttributeValues).toEqual({ ":e": "john@example.com" }); + expect(commands.filter((c) => c.name === "DeleteCommand")).toHaveLength(1); + }); + }); }); 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..83a94b912 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 @@ -6,8 +6,10 @@ import { defineDynamoTable, } from "@packages/hutch-storage-client"; import { z } from "zod"; +import { normalizeEmail } from "@packages/domain/user"; import type { CreatePasswordResetToken, + DeletePasswordResetTokensByEmail, VerifyPasswordResetToken, } from "@packages/provider-contracts/password-reset"; import { PasswordResetTokenSchema } from "@packages/provider-contracts/password-reset"; @@ -20,12 +22,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,11 +40,21 @@ 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; - await table.put({ Item: { token, email, expiresAt } }); + // Store the normalized (lowercased) email so a mixed-case reset request + // (`John@Example.com`) is matched — and erased immediately — by the deletion + // scrub, which filters on the normalized users-table PK. An un-normalized row + // escapes the synchronous scrub and lingers until the `expiresAt` TTL reaps it. + await table.put({ Item: { token, email: normalizeEmail(email), expiresAt } }); return token; }; @@ -66,5 +83,22 @@ export function initDynamoDbPasswordReset(deps: { } }; - return { createPasswordResetToken, verifyPasswordResetToken }; + const deleteTokensByEmail: DeletePasswordResetTokensByEmail = async (email) => { + // Purge every row for this email now so deletion erases the token immediately; + // the `expiresAt` TTL only reaps an unexpired token later, after it lapses. + let ExclusiveStartKey: Record | undefined; + do { + const { items, lastEvaluatedKey } = await tokenKeyTable.scan({ + FilterExpression: "email = :e", + ExpressionAttributeValues: { ":e": normalizeEmail(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/pending-signup/dynamodb-pending-signup.test.ts b/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.test.ts index 168060421..54026d502 100644 --- a/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.test.ts +++ b/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.test.ts @@ -12,6 +12,7 @@ type CommandInput = { ReturnValues?: string; UpdateExpression?: string; ProjectionExpression?: string; + FilterExpression?: string; ExclusiveStartKey?: Record; ExpressionAttributeValues?: Record; }; @@ -268,4 +269,92 @@ describe("initDynamoDbPendingSignup", () => { expect(inputs[0]?.ExpressionAttributeValues?.[":sentAt"]).toBe(999); }); }); + + describe("deleteByUser", () => { + const SCRUB_PROJECTION = "checkoutSessionId, email, userId"; + + it("scans every page and deletes rows matching the userId or the normalized email", async () => { + const pages = [ + { + Items: [ + { checkoutSessionId: "cs_by_user", email: "someone@b.com", userId: USER_ID }, + { checkoutSessionId: "cs_other", email: "keep@b.com", userId: "user-other" }, + ], + LastEvaluatedKey: { checkoutSessionId: "cs_other" }, + }, + { + // Legacy pre-userId row: no userId, raw mixed-case email that + // normalizes to the target — reachable only by the email match. + Items: [{ checkoutSessionId: "cs_legacy", email: "Target@B.com" }], + }, + ]; + let scanCall = 0; + // The impl is invoked for every command; a DeleteCommand carries a Key, + // a ScanCommand does not — so return {} for deletes and the next page otherwise. + const { client, inputs } = createClient((input) => + input.Key ? {} : pages[scanCall++], + ); + const { deleteByUser } = initDynamoDbPendingSignup({ + client, + tableName: TABLE, + logger: noopLogger, + }); + + await deleteByUser({ userId: USER_ID, email: "target@b.com" }); + + const scans = inputs.filter((i) => i.ProjectionExpression === SCRUB_PROJECTION); + expect(scans).toHaveLength(2); + // No server-side FilterExpression: the full table is scanned and matched + // client-side, so legacy rows and raw-cased emails are both reached. + expect(scans[0]?.FilterExpression).toBeUndefined(); + expect(scans[1]?.ExclusiveStartKey).toEqual({ checkoutSessionId: "cs_other" }); + + const deletes = inputs.filter((i) => i.Key); + expect(deletes.map((d) => d.Key)).toEqual([ + { checkoutSessionId: "cs_by_user" }, + { checkoutSessionId: "cs_legacy" }, + ]); + }); + + it("matches only by userId when the email is already gone (null)", async () => { + const pages = [ + { + Items: [ + { checkoutSessionId: "cs_by_user", email: "a@b.com", userId: USER_ID }, + // Legacy row with the same email but no userId is NOT deleted when + // there is no email to match on. + { checkoutSessionId: "cs_legacy", email: "a@b.com" }, + ], + }, + ]; + let scanCall = 0; + const { client, inputs } = createClient((input) => + input.Key ? {} : pages[scanCall++], + ); + const { deleteByUser } = initDynamoDbPendingSignup({ + client, + tableName: TABLE, + logger: noopLogger, + }); + + await deleteByUser({ userId: USER_ID, email: null }); + + const deletes = inputs.filter((i) => i.Key); + expect(deletes.map((d) => d.Key)).toEqual([{ checkoutSessionId: "cs_by_user" }]); + }); + + it("issues no deletes when nothing matches the user", async () => { + const { client, inputs } = createClient(() => ({ Items: [] })); + const { deleteByUser } = initDynamoDbPendingSignup({ + client, + tableName: TABLE, + logger: noopLogger, + }); + + await deleteByUser({ userId: USER_ID, email: "nobody@b.com" }); + + expect(inputs.filter((i) => i.ProjectionExpression === SCRUB_PROJECTION)).toHaveLength(1); + expect(inputs.filter((i) => i.Key)).toHaveLength(0); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.ts b/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.ts index 437b19487..783a3f69a 100644 --- a/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.ts +++ b/projects/hutch/src/runtime/providers/pending-signup/dynamodb-pending-signup.ts @@ -5,10 +5,11 @@ import { } from "@packages/hutch-storage-client"; import type { HutchLogger } from "@packages/hutch-logger"; import { z } from "zod"; -import { UserIdSchema } from "@packages/domain/user"; +import { UserIdSchema, normalizeEmail } from "@packages/domain/user"; import { CheckoutSessionIdSchema } from "@packages/provider-contracts/stripe-checkout"; import type { ConsumePendingSignup, + DeletePendingSignupsByUser, ListAllPendingSignups, MarkCheckoutRecoveryEmailSent, PendingSignup, @@ -37,6 +38,15 @@ const PendingSignupSummaryRow = z.object({ checkoutRecoveryEmailSentAt: dynamoField(z.number()), }); +/** Projection for the deletion scrub: the key to delete by, plus the two handles + * a deleted user can be matched on. `userId` is optional because legacy + * pre-userId rows carry only `{checkoutSessionId, method, email}`. */ +const ScrubScanRow = z.object({ + checkoutSessionId: CheckoutSessionIdSchema, + email: z.string(), + userId: dynamoField(UserIdSchema), +}); + export function initDynamoDbPendingSignup(deps: { client: DynamoDBDocumentClient; tableName: string; @@ -46,6 +56,7 @@ export function initDynamoDbPendingSignup(deps: { consumePendingSignup: ConsumePendingSignup; listAllPendingSignups: ListAllPendingSignups; markCheckoutRecoveryEmailSent: MarkCheckoutRecoveryEmailSent; + deleteByUser: DeletePendingSignupsByUser; } { const table = defineDynamoTable({ client: deps.client, @@ -59,6 +70,12 @@ export function initDynamoDbPendingSignup(deps: { schema: PendingSignupSummaryRow, }); + const scrubTable = defineDynamoTable({ + client: deps.client, + tableName: deps.tableName, + schema: ScrubScanRow, + }); + const storePendingSignup: StorePendingSignup = async ({ checkoutSessionId, signup, createdAt }) => { await table.put({ Item: { @@ -133,10 +150,36 @@ export function initDynamoDbPendingSignup(deps: { }); }; + const deleteByUser: DeletePendingSignupsByUser = async ({ userId, email }) => { + // No TTL on this table, so an abandoned-checkout row keeps the deleted user's + // email + userId forever unless purged. Match on userId OR the normalized + // email — a server-side FilterExpression can't reach legacy pre-userId rows + // (no userId) nor normalize the raw-cased email they stored, so scan the + // full table and compare client-side, deleting each match by its PK. + const normalizedEmail = email === null ? null : normalizeEmail(email); + let ExclusiveStartKey: Record | undefined; + do { + const { items, lastEvaluatedKey } = await scrubTable.scan({ + ProjectionExpression: "checkoutSessionId, email, userId", + ExclusiveStartKey, + }); + for (const row of items) { + const matchesUser = row.userId === userId; + const matchesEmail = + normalizedEmail !== null && normalizeEmail(row.email) === normalizedEmail; + if (matchesUser || matchesEmail) { + await table.delete({ Key: { checkoutSessionId: row.checkoutSessionId } }); + } + } + ExclusiveStartKey = lastEvaluatedKey; + } while (ExclusiveStartKey); + }; + return { storePendingSignup, consumePendingSignup, listAllPendingSignups, markCheckoutRecoveryEmailSent, + deleteByUser, }; } 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 7d53d0552..2fc8da230 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 @@ -444,6 +444,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 f0288cc5f..94c8635cd 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, @@ -13,7 +14,7 @@ import type { } from "@packages/provider-contracts/subscription-providers"; import { SubscriptionProviderRow } from "@packages/subscription-access"; -/** The write half of the subscription table — the seven mutations, wired +/** The write half of the subscription table — the eight mutations, wired * independently from the read half. The save gate composes write access from * the read half alone, so no save path ever depends on a mutation. */ export function initDynamoDbSubscriptionWrites(deps: { @@ -28,6 +29,7 @@ export function initDynamoDbSubscriptionWrites(deps: { markActive: MarkSubscriptionActive; markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent; markTrialReminderEmailSent: MarkTrialReminderEmailSent; + deleteSubscription: DeleteSubscription; } { const table = defineDynamoTable({ client: deps.client, @@ -140,6 +142,10 @@ export function initDynamoDbSubscriptionWrites(deps: { }); }; + const deleteSubscription: DeleteSubscription = async ({ userId }) => { + await table.delete({ Key: { userId } }); + }; + return { upsertTrialing, upsertActive, @@ -148,5 +154,6 @@ export function initDynamoDbSubscriptionWrites(deps: { markActive, markTrialFeedbackEmailSent, markTrialReminderEmailSent, + 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 e90ec9e69..1c395c5f7 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -23,6 +23,7 @@ import type { GetSessionUserId, MarkEmailVerified, MarkSessionEmailVerified, + SaveAppleRefreshToken, UpdatePassword, UserExistsByEmail, VerifyCredentials, @@ -51,6 +52,7 @@ import type { } from "@packages/provider-contracts/trial-scheduler"; import type { PublishCancelSubscriptionCommand, + PublishDeleteAccountCommand, PublishSubscriptionReactivated, } from "@packages/provider-contracts/events"; import type { @@ -118,6 +120,7 @@ import type { FindOAuthClient, OAuthModel, RegisterOAuthClient, + RevokeAllUserOAuthTokens, ValidateAccessToken, ValidateOAuthRedirectUri, } from "@packages/provider-contracts/oauth"; @@ -210,6 +213,7 @@ interface AppDependencies { createUserWithPasswordHash: CreateUserWithPasswordHash; createGoogleUser: CreateGoogleUser; createAppleUser: CreateAppleUser; + saveAppleRefreshToken: SaveAppleRefreshToken; findUserByEmail: FindUserByEmail; verifyCredentials: VerifyCredentials; createSession: CreateSession; @@ -253,6 +257,7 @@ interface AppDependencies { baseUrl: string; logError: (message: string, error?: Error) => void; oauthModel: OAuthModel; + revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens; validateAccessToken: ValidateAccessToken; findOAuthClient: FindOAuthClient; validateOAuthRedirectUri: ValidateOAuthRedirectUri; @@ -264,6 +269,7 @@ interface AppDependencies { publishSaveLinkRawHtmlCommand: PublishSaveLinkRawHtmlCommand; publishSaveLinkRawPdfCommand: PublishSaveLinkRawPdfCommand; publishExportUserDataCommand: PublishExportUserDataCommand; + publishDeleteAccountCommand: PublishDeleteAccountCommand; findEmailByUserId: FindEmailByUserId; putPendingHtml: PutPendingHtml; putPendingPdf: PutPendingPdf; @@ -932,6 +938,7 @@ export function createApp(dependencies: AppDependencies): Express { secureCookies, createSession: deps.createSession, createAppleUser, + saveAppleRefreshToken: deps.saveAppleRefreshToken, findUserByEmail: deps.findUserByEmail, countUsers, markEmailVerified: deps.markEmailVerified, @@ -1121,6 +1128,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 5519243e8..1c5921166 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -103,6 +103,7 @@ function flattenFixtureToAppDependencies( createUserWithPasswordHash: fixture.auth.createUserWithPasswordHash, createGoogleUser: fixture.auth.createGoogleUser, createAppleUser: fixture.auth.createAppleUser, + saveAppleRefreshToken: fixture.auth.saveAppleRefreshToken, findUserByEmail: fixture.auth.findUserByEmail, verifyCredentials: fixture.auth.verifyCredentials, createSession: fixture.auth.createSession, @@ -141,6 +142,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, @@ -149,6 +151,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/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/auth/apple-auth.page.ts b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts index 54e8013ab..563bb5153 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts @@ -10,6 +10,7 @@ import type { CreateSession, FindUserByEmail, MarkEmailVerified, + SaveAppleRefreshToken, } from "@packages/provider-contracts/auth"; import type { SendEmail } from "@packages/provider-contracts/email"; import type { ExchangeAppleCode } from "@packages/provider-contracts/apple-auth"; @@ -72,6 +73,7 @@ interface AppleAuthDependencies { secureCookies: boolean; createSession: CreateSession; createAppleUser: CreateAppleUser; + saveAppleRefreshToken: SaveAppleRefreshToken; findUserByEmail: FindUserByEmail; countUsers: CountUsers; markEmailVerified: MarkEmailVerified; @@ -203,6 +205,13 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const existing = await deps.findUserByEmail(tokenResult.email); if (existing) { + /* Every code exchange mints a fresh grant, and a returning user may + * predate token persistence — store it on login too, or account + * deletion could not revoke their Sign in with Apple grant. */ + await deps.saveAppleRefreshToken({ + email: tokenResult.email, + appleRefreshToken: tokenResult.appleRefreshToken, + }); if (!existing.emailVerified) { await deps.markEmailVerified(tokenResult.email); } @@ -234,11 +243,16 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const created = await deps.createAppleUser({ email: tokenResult.email, userId: newUserId, + appleRefreshToken: tokenResult.appleRefreshToken, attribution, }); if (!created.ok) { const lookup = await deps.findUserByEmail(tokenResult.email); if (lookup) { + await deps.saveAppleRefreshToken({ + email: tokenResult.email, + appleRefreshToken: tokenResult.appleRefreshToken, + }); if (!lookup.emailVerified) { await deps.markEmailVerified(tokenResult.email); } @@ -272,11 +286,16 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const created = await deps.createAppleUser({ email: tokenResult.email, userId: newUserId, + appleRefreshToken: tokenResult.appleRefreshToken, attribution, }); if (!created.ok) { const lookup = await deps.findUserByEmail(tokenResult.email); if (lookup) { + await deps.saveAppleRefreshToken({ + email: tokenResult.email, + appleRefreshToken: tokenResult.appleRefreshToken, + }); if (!lookup.emailVerified) { await deps.markEmailVerified(tokenResult.email); } diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts index 8d29954c6..92533e814 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts @@ -45,6 +45,7 @@ function stubExchange(overrides?: Partial> appleId: AppleIdSchema.parse("apple-sub-123"), email: "apple@example.com", emailVerified: true, + appleRefreshToken: "apple-refresh-123", ...overrides, }); } @@ -468,6 +469,42 @@ describe("Apple auth routes", () => { expect(passwordCheck.ok).toBe(true); }); + it("persists Apple's refresh token at signup so account deletion can revoke the grant", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "revocable@example.com" })) }); + const { auth } = harness; + const state = signState(freshState()); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + const lookup = await auth.findUserByEmail("revocable@example.com"); + assert(lookup, "Apple signup must create the user"); + expect(await auth.findAppleRefreshTokenByUserId(lookup.userId)).toBe("apple-refresh-123"); + }); + + it("stores the fresh refresh token when an existing account signs in with Apple", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "existing@example.com" })) }); + const { auth } = harness; + const createResult = await auth.createUser({ email: "existing@example.com", password: "password123" }); + assert(createResult.ok, "setup failed"); + await auth.markEmailVerified("existing@example.com"); + expect(await auth.findAppleRefreshTokenByUserId(createResult.userId)).toBe(null); + const state = signState(freshState()); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(await auth.findAppleRefreshTokenByUserId(createResult.userId)).toBe("apple-refresh-123"); + }); + it("should upgrade an unverified email/password account to verified", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "unverified@example.com" })) }); diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 7ba7de0f1..1bc05a448 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -11,6 +11,7 @@ import { import { initInMemoryRateLimit } from "@packages/test-fixtures/providers/rate-limit"; import { completeStripeSignup } from "./test-helpers/complete-stripe-signup"; import { createAccessToken, saveAccessTokenForUser } from "../test-helpers/oauth-token"; +import { AppleTokenResponse } from "../../providers/apple-auth/apple-token"; import { DISPOSABLE_EMAIL_MESSAGE } from "./disposable-email"; import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from "@packages/web-session"; @@ -1286,6 +1287,35 @@ describe("Auth routes", () => { expect(link.querySelector(".auth-apple-button__label")?.textContent).toBe("Sign up with Apple"); assert(link.querySelector("svg.auth-apple-button__logo"), "apple logo must be rendered"); }); + + // Fail-closed guard for App Store 5.1.1(v): Sign in with Apple and the + // delete-account worker's Apple-token revocation MUST ship together. While + // SIWA is reachable, in-app deletion must also revoke the Apple grant + // (persist refresh_token + POST https://appleid.apple.com/auth/revoke), or + // Apple leaves the app in the user's "Sign in with Apple" list and rejects + // under the exact guideline this feature targets. This test couples the two + // facts as an equality — they must move together — so the only accepted + // states are {reachable:true, persisted:true} (today: SIWA live + revocation + // wired) and {reachable:false, persisted:false} (a re-dark-launch that also + // retires the token). Dropping refresh_token from the exchange schema while + // SIWA stays reachable (the dangerous {true, false}) — or, symmetrically, + // drifting either alone — turns CI red and points here. + it("locks Sign in with Apple to Apple account-deletion revocation (App Store 5.1.1(v))", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const loginNoFlag = (await request(harness.server).get("/login")).text; + const signupNoFlag = (await request(harness.server).get("/signup")).text; + const siwaReachableByDefault = [loginNoFlag, signupNoFlag].some( + (html) => appleSection(html) !== null, + ); + + // Proxy for "the code exchange persists Apple's refresh_token": the schema + // keeps a `refresh_token` field only once someone wires revocation. + const appleExchange = AppleTokenResponse.safeParse({ id_token: "x", refresh_token: "y" }); + assert(appleExchange.success, "a well-formed Apple token response must parse"); + const appleRefreshTokenPersisted = "refresh_token" in appleExchange.data; + + expect(siwaReachableByDefault).toBe(appleRefreshTokenPersisted); + }); }); describe("Founding members progress", () => { 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 d8bdb5847..8c13152b9 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 { @@ -64,6 +67,9 @@ interface AccountDependencies { upsertTrialingSubscription: UpsertTrialingSubscription; markActiveSubscription: MarkSubscriptionActive; findEmailByUserId: FindEmailByUserId; + destroyUserSessions: DestroyUserSessions; + revokeAllUserOAuthTokens: RevokeAllUserOAuthTokens; + publishDeleteAccountCommand: PublishDeleteAccountCommand; publishCancelSubscriptionCommand: PublishCancelSubscriptionCommand; publishSubscriptionReactivated: PublishSubscriptionReactivated; createCheckoutSession: CreateCheckoutSession; @@ -317,6 +323,30 @@ export function initAccountRoutes(deps: AccountDependencies): Router { res.redirect(303, buildAccountUrl({ cancelling: true })); }); + /** Irreversible account deletion. The synchronous work here revokes every + * *existing* credential the instant the user confirms — all sessions and bearer + * tokens die at once — after which the durable, at-least-once scrub of every + * user-owned store runs asynchronously via DeleteAccountCommand. So existing + * access is cut synchronously, but full erasure is eventual: until the worker + * removes the identity row a brief window remains where the raw credentials + * could still mint a fresh session (self-healing once the row is gone). 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; @@ -404,13 +434,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; @@ -424,7 +455,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"); @@ -437,7 +468,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 { @@ -462,7 +493,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 7c782f474..98a864696 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 @@ -1274,3 +1274,62 @@ describe("POST /account/cards/confirm — post-attach cap reconciliation", () => expect(response.headers.location).toBe("/login"); }); }); + +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 0a65a6a54..9227c2376 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