Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions projects/hutch/src/e2e/e2e-server.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
14 changes: 13 additions & 1 deletion projects/hutch/src/infra/hutch-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, {
Expand Down
124 changes: 124 additions & 0 deletions projects/hutch/src/infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HutchLambda, HutchAPIGateway, HutchDynamoDBAccess, HutchEventBus, Hutch
import {
CancelSubscriptionCommand,
CrawlEmailLinkPreview,
DeleteAccountCommand,
EmailReceivedEvent,
ExportUserDataCommand,
SendTrialFeedbackEmailCommand,
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion projects/hutch/src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -368,6 +372,7 @@ function initProviders() {
googleAuth,
appleAuth,
oauthModel,
revokeAllUserOAuthTokens: oauthModel.revokeAllUserOAuthTokens,
validateAccessToken: createValidateAccessToken(oauthModel),
findOAuthClient: oauthClientLookup.findClient,
validateOAuthRedirectUri: oauthClientLookup.validateRedirectUri,
Expand All @@ -380,6 +385,7 @@ function initProviders() {
publishSaveLinkRawPdfCommand,
publishUpdateFetchTimestamp,
publishExportUserDataCommand,
publishDeleteAccountCommand,
publishCancelSubscriptionCommand,
publishSubscriptionReactivated,
putPendingHtml,
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -628,6 +637,7 @@ function initProviders() {
googleAuth,
appleAuth,
oauthModel,
revokeAllUserOAuthTokens,
validateAccessToken: createValidateAccessToken(oauthModel),
findOAuthClient: oauthClientLookup.findClient,
validateOAuthRedirectUri: oauthClientLookup.validateRedirectUri,
Expand All @@ -640,6 +650,7 @@ function initProviders() {
publishSaveLinkRawPdfCommand,
publishUpdateFetchTimestamp,
publishExportUserDataCommand,
publishDeleteAccountCommand,
publishCancelSubscriptionCommand,
publishSubscriptionReactivated,
putPendingHtml,
Expand Down
Loading
Loading