From d1877a4d5e6b3ffbb981d95e4b988f494e0e8cf6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:52:01 +0900 Subject: [PATCH 1/7] feat: add managed Tokyo recording storage --- .../__tests__/unit/content-transfer.test.ts | 18 ++ .../regional-organization-cleanup.test.ts | 152 ++++++++++++++ .../unit/s3-bucket-connections.test.ts | 187 +++++++++++++++++- .../__tests__/unit/video-cloudfront.test.ts | 24 +++ apps/web/actions/admin/replace-video.ts | 10 +- apps/web/lib/content-transfer.ts | 4 +- apps/web/lib/desktop-reupload.ts | 11 +- apps/web/lib/video-cloudfront.ts | 9 + apps/web/workflows/admin-reprocess-video.ts | 5 +- apps/web/workflows/edit-video.ts | 8 +- packages/env/server.ts | 4 + .../web-backend/src/Organisations/index.ts | 20 +- packages/web-backend/src/S3Buckets/index.ts | 148 ++++++++++---- packages/web-domain/src/S3Bucket.ts | 6 + 14 files changed, 543 insertions(+), 63 deletions(-) create mode 100644 apps/web/__tests__/unit/regional-organization-cleanup.test.ts create mode 100644 apps/web/__tests__/unit/video-cloudfront.test.ts create mode 100644 apps/web/lib/video-cloudfront.ts diff --git a/apps/web/__tests__/unit/content-transfer.test.ts b/apps/web/__tests__/unit/content-transfer.test.ts index 126f9a7f907..43e0958b95d 100644 --- a/apps/web/__tests__/unit/content-transfer.test.ts +++ b/apps/web/__tests__/unit/content-transfer.test.ts @@ -175,6 +175,24 @@ describe("content transfer planning", () => { }; expect(getContentTransferStorageBlockReason(input)).toBeNull(); + expect( + getContentTransferStorageBlockReason({ + ...input, + bucketId: "cap-tokyo", + bucketOwnerId: null, + bucketOrganizationId: null, + }), + ).toBeNull(); + expect( + getContentTransferStorageBlockReason({ ...input, bucketOwnerId: null }), + ).toBe("The storage bucket is missing"); + expect( + getContentTransferStorageBlockReason({ + ...input, + bucketId: "cap-tokyo", + storageIntegrationId: "drive", + }), + ).toBe("The Cap has conflicting storage assignments"); expect( getContentTransferStorageBlockReason({ ...input, diff --git a/apps/web/__tests__/unit/regional-organization-cleanup.test.ts b/apps/web/__tests__/unit/regional-organization-cleanup.test.ts new file mode 100644 index 00000000000..99b0d276bcc --- /dev/null +++ b/apps/web/__tests__/unit/regional-organization-cleanup.test.ts @@ -0,0 +1,152 @@ +import { CurrentUser, Organisation, S3Bucket, User } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + database: vi.fn(), + bucket: vi.fn(), + deleted: vi.fn(), +})); +vi.mock("@cap/web-backend/src/Database", async () => { + const { Effect } = await import("effect"); + class Database extends Effect.Service()("Database", { + sync: () => ({ use: mocks.database }), + }) {} + return { Database }; +}); +vi.mock("@cap/web-backend/src/S3Buckets", async () => { + const { Effect } = await import("effect"); + class S3Buckets extends Effect.Service()("S3Buckets", { + sync: () => ({ getBucketAccess: mocks.bucket }), + }) {} + return { S3Buckets }; +}); +vi.mock("@cap/web-backend/src/ImageUploads", async () => { + const { Effect } = await import("effect"); + class ImageUploads extends Effect.Service()("ImageUploads", { + sync: () => ({}), + }) {} + return { ImageUploads }; +}); +vi.mock("@cap/web-backend/src/Tinybird", async () => { + const { Effect } = await import("effect"); + class Tinybird extends Effect.Service()("Tinybird", { + sync: () => ({ deleteData: () => Effect.void }), + }) {} + return { Tinybird }; +}); +vi.mock("@cap/web-backend/src/Organisations/OrganisationsPolicy", async () => { + const { Effect } = await import("effect"); + const { Policy } = await import("@cap/web-domain"); + class OrganisationsPolicy extends Effect.Service()( + "OrganisationsPolicy", + { + sync: () => ({ + isOwner: () => Policy.policy(() => Effect.succeed(true)), + }), + }, + ) {} + return { OrganisationsPolicy }; +}); + +import { Organisations } from "@cap/web-backend/src/Organisations"; + +const cleanup = () => + Effect.runPromise( + Effect.flatMap(Organisations, (organizations) => + organizations.softDelete(Organisation.OrganisationId.make("org")), + ).pipe( + Effect.provide(Organisations.Default), + Effect.provideService(CurrentUser, { + id: User.UserId.make("owner"), + email: "owner@cap.test", + activeOrganizationId: Organisation.OrganisationId.make("org"), + iconUrlOrKey: Option.none(), + }), + ), + ); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.database + .mockReturnValueOnce(Effect.succeed([{ id: "org", ownerId: "owner" }])) + .mockReturnValueOnce( + Effect.succeed([ + { + id: "virginia", + ownerId: "owner", + bucket: null, + storageIntegrationId: null, + }, + { + id: "tokyo", + ownerId: "owner", + bucket: S3Bucket.TokyoBucketId, + storageIntegrationId: null, + }, + { + id: "custom", + ownerId: "owner", + bucket: "custom-bucket", + storageIntegrationId: null, + }, + { + id: "drive", + ownerId: "owner", + bucket: null, + storageIntegrationId: "drive-id", + }, + ]), + ) + .mockReturnValue(Effect.void); + mocks.bucket.mockImplementation((bucket: Option.Option) => + Effect.succeed([ + { + listObjects: ({ + prefix, + continuationToken, + }: { + prefix: string; + continuationToken?: string; + }) => + Effect.succeed({ + Contents: [{ Key: `${prefix}${continuationToken ?? "first"}` }], + IsTruncated: !continuationToken, + NextContinuationToken: "second", + }), + deleteObjects: (objects: Array<{ Key: string }>) => + Effect.sync(() => mocks.deleted(Option.getOrNull(bucket), objects)), + }, + Option.none(), + ]), + ); +}); + +describe("organization regional media cleanup", () => { + it("deletes both pages in each managed bucket and leaves customer storage alone", async () => { + await cleanup(); + expect(mocks.deleted.mock.calls).toEqual( + expect.arrayContaining([ + [null, [{ Key: "owner/virginia/first" }]], + [null, [{ Key: "owner/virginia/second" }]], + ["cap-tokyo", [{ Key: "owner/tokyo/first" }]], + ["cap-tokyo", [{ Key: "owner/tokyo/second" }]], + [null, [{ Key: "organizations/org/first" }]], + [null, [{ Key: "organizations/org/second" }]], + ]), + ); + expect(mocks.deleted).toHaveBeenCalledTimes(6); + expect(mocks.database).toHaveBeenCalledTimes(3); + }); + + it("keeps the database records when the regional bucket cannot be opened", async () => { + const original = mocks.bucket.getMockImplementation(); + mocks.bucket.mockImplementation((bucket: Option.Option) => + Option.getOrNull(bucket) === S3Bucket.TokyoBucketId + ? Effect.fail(new Error("Tokyo unavailable")) + : original?.(bucket), + ); + await expect(cleanup()).rejects.toThrow("Tokyo unavailable"); + expect(mocks.database).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/s3-bucket-connections.test.ts b/apps/web/__tests__/unit/s3-bucket-connections.test.ts index 07df81095f0..2be85b4b0d2 100644 --- a/apps/web/__tests__/unit/s3-bucket-connections.test.ts +++ b/apps/web/__tests__/unit/s3-bucket-connections.test.ts @@ -1,5 +1,7 @@ +import { generateKeyPairSync } from "node:crypto"; import { createServer, request as httpRequest } from "node:http"; import type { Socket } from "node:net"; +import * as S3 from "@aws-sdk/client-s3"; import { S3Bucket } from "@cap/web-domain"; import { ConfigProvider, Effect, Layer, ManagedRuntime, Option } from "effect"; import { describe, expect, it, vi } from "vitest"; @@ -42,7 +44,7 @@ vi.mock("@cap/web-backend/src/S3Buckets/S3BucketsRepo.ts", async () => { import { S3Buckets } from "@cap/web-backend/src/S3Buckets"; import { s3ConnectionPool } from "@cap/web-backend/src/S3Buckets/S3ConnectionPool"; -async function storageFixture() { +async function storageFixture(config: Record = {}) { let connections = 0; const sockets = new Set(); const authorizations: string[] = []; @@ -73,6 +75,7 @@ async function storageFixture() { ["CAP_AWS_BUCKET", "capso"], ["S3_INTERNAL_ENDPOINT", endpoint], ["S3_PUBLIC_ENDPOINT", endpoint], + ...Object.entries(config), ]), ), ), @@ -228,3 +231,185 @@ describe("S3 connection reuse", () => { } }); }); + +const regionalConfig = { + CAP_TOKYO_UPLOADS_ENABLED: "true", + CAP_TOKYO_BUCKET: "cap-test-tokyo", + CAP_TOKYO_BUCKET_URL: "https://tokyo-cdn.cap.test", + CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "ETOKYO", + CAP_CLOUDFRONT_DISTRIBUTION_ID: "EVIRGINIA", + CAP_AWS_BUCKET_URL: "https://cdn.cap.test", + CLOUDFRONT_KEYPAIR_ID: "KTEST", + CLOUDFRONT_KEYPAIR_PRIVATE_KEY: generateKeyPairSync("rsa", { + modulusLength: 2048, + }) + .privateKey.export({ type: "pkcs8", format: "pem" }) + .toString(), +}; + +describe("regional storage", () => { + it("selects per upload without I/O and keeps regional reads after routing is disabled", async () => { + const fixture = await storageFixture(regionalConfig); + const repoCalls = mocks.getById.mock.calls.length; + const send = vi.spyOn(S3.S3Client.prototype, "send"); + try { + for (const [country, expected] of [ + ["JP", "cap-tokyo"], + ["US", null], + ["JP", "cap-tokyo"], + [undefined, null], + ["ZZ", null], + ["jp", null], + ] as const) { + expect( + Option.getOrNull(fixture.service.getRegionalUploadBucketId(country)), + ).toBe(expected); + } + const [regional] = await fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + ); + const key = "owner/video/segments/segment_000001.m4s"; + for (const effect of [ + regional.getPresignedPutUrl(key), + regional.getInternalSignedObjectUrl(key), + regional.getInternalPresignedPutUrl(key), + ]) { + const url = new URL(await fixture.runtime.runPromise(effect)); + expect(url.hostname).toBe( + "cap-test-tokyo.s3.ap-northeast-1.amazonaws.com", + ); + expect(url.pathname).toBe(`/${key}`); + expect(url.searchParams.get("X-Amz-Credential")).toContain( + "/ap-northeast-1/s3/", + ); + } + const playback = new URL( + await fixture.runtime.runPromise(regional.getSignedObjectUrl(key)), + ); + expect(playback.origin).toBe(regionalConfig.CAP_TOKYO_BUCKET_URL); + expect(playback.searchParams.get("Key-Pair-Id")).toBe("KTEST"); + const [original] = await fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.none()), + ); + expect( + new URL( + await fixture.runtime.runPromise(original.getSignedObjectUrl(key)), + ).origin, + ).toBe(regionalConfig.CAP_AWS_BUCKET_URL); + expect(send).not.toHaveBeenCalled(); + expect(mocks.getById.mock.calls).toHaveLength(repoCalls); + } finally { + send.mockRestore(); + await fixture.close(); + } + const disabled = await storageFixture({ + ...regionalConfig, + CAP_TOKYO_UPLOADS_ENABLED: "false", + }); + try { + expect( + Option.isNone(disabled.service.getRegionalUploadBucketId("JP")), + ).toBe(true); + const [regional] = await disabled.runtime.runPromise( + disabled.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + ); + expect(regional.bucketName).toBe("cap-test-tokyo"); + } finally { + await disabled.close(); + } + }); + + it.each([ + {}, + { CAP_TOKYO_UPLOADS_ENABLED: "true" }, + { ...regionalConfig, CAP_TOKYO_UPLOADS_ENABLED: "invalid" }, + { ...regionalConfig, CAP_TOKYO_BUCKET: "INVALID" }, + { ...regionalConfig, CAP_TOKYO_BUCKET_URL: "http://tokyo-cdn.cap.test" }, + { + ...regionalConfig, + CAP_TOKYO_BUCKET_URL: "https://tokyo-cdn.cap.test/path", + }, + { ...regionalConfig, CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "" }, + Object.fromEntries( + Object.entries(regionalConfig).filter( + ([key]) => key !== "CLOUDFRONT_KEYPAIR_ID", + ), + ), + ])( + "keeps the original upload endpoint when routing is unavailable (%#)", + async (config) => { + const fixture = await storageFixture({ + ...config, + S3_PUBLIC_ENDPOINT: "https://s3-accelerate.amazonaws.com", + S3_PATH_STYLE: "false", + }); + try { + expect( + Option.isNone(fixture.service.getRegionalUploadBucketId("JP")), + ).toBe(true); + const [original] = await fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.none()), + ); + const url = new URL( + await fixture.runtime.runPromise( + original.getPresignedPutUrl("owner/video/result.mp4"), + ), + ); + expect(url.hostname).toBe("capso.s3-accelerate.amazonaws.com"); + expect(url.searchParams.get("X-Amz-Credential")).toContain( + "/us-east-1/s3/", + ); + } finally { + await fixture.close(); + } + }, + ); + + it("fails a persisted regional recording safely instead of writing to Virginia when config is lost", async () => { + const fixture = await storageFixture(); + const repoCalls = mocks.getById.mock.calls.length; + try { + await expect( + fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + ), + ).rejects.toThrow("Tokyo storage configuration"); + expect(mocks.getById.mock.calls).toHaveLength(repoCalls); + } finally { + await fixture.close(); + } + }); + + it("keeps processing, copying, and cleanup commands in the persisted bucket", async () => { + const fixture = await storageFixture(regionalConfig); + const send = vi + .spyOn(S3.S3Client.prototype, "send") + .mockImplementation(async () => ({})); + try { + const [regional] = await fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + ); + await fixture.runtime.runPromise( + regional.headObject("owner/video/result.mp4"), + ); + await fixture.runtime.runPromise( + regional.copyObject( + "cap-test-tokyo/owner/video/result.mp4", + "new-owner/video/result.mp4", + ), + ); + await fixture.runtime.runPromise( + regional.listObjects({ prefix: "owner/video/" }), + ); + await fixture.runtime.runPromise( + regional.deleteObjects([{ Key: "owner/video/result.mp4" }]), + ); + expect(send).toHaveBeenCalledTimes(4); + for (const [command] of send.mock.calls) + expect(command.input).toHaveProperty("Bucket", "cap-test-tokyo"); + } finally { + send.mockRestore(); + await fixture.close(); + } + }); +}); diff --git a/apps/web/__tests__/unit/video-cloudfront.test.ts b/apps/web/__tests__/unit/video-cloudfront.test.ts new file mode 100644 index 00000000000..8ab5b966b30 --- /dev/null +++ b/apps/web/__tests__/unit/video-cloudfront.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + CAP_CLOUDFRONT_DISTRIBUTION_ID: "EVIRGINIA", + CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "ETOKYO", + CAP_TOKYO_UPLOADS_ENABLED: false, + }), +})); + +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; + +describe("video cache invalidation destination", () => { + it.each([ + [null, "EVIRGINIA"], + ["cap-tokyo", "ETOKYO"], + ["custom-bucket", undefined], + ])( + "uses the persisted bucket even with routing disabled: %s", + (bucket, expected) => { + expect(getVideoCloudFrontDistributionId(bucket)).toBe(expected); + }, + ); +}); diff --git a/apps/web/actions/admin/replace-video.ts b/apps/web/actions/admin/replace-video.ts index 0a6879c9ca9..279d5e0910a 100644 --- a/apps/web/actions/admin/replace-video.ts +++ b/apps/web/actions/admin/replace-video.ts @@ -1,4 +1,6 @@ -"use server"; +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; + +("use server"); import { CloudFrontClient, @@ -101,11 +103,7 @@ export async function invalidateVideoCache(videoId: string) { await tx.delete(videoUploads).where(eq(videoUploads.videoId, video.id)); }); - if (video.bucket) { - return; - } - - const distributionId = serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; + const distributionId = getVideoCloudFrontDistributionId(video.bucket); if (!distributionId) { return; } diff --git a/apps/web/lib/content-transfer.ts b/apps/web/lib/content-transfer.ts index 42e2588b575..17b682d9590 100644 --- a/apps/web/lib/content-transfer.ts +++ b/apps/web/lib/content-transfer.ts @@ -1,3 +1,5 @@ +import { S3Bucket } from "@cap/web-domain"; + export const CONTENT_TRANSFER_KIND = "transfer_org_content" as const; export const MAX_CONTENT_TRANSFER_VIDEOS = 10_000; export const MAX_CONTENT_TRANSFER_FOLDERS = 2_000; @@ -290,7 +292,7 @@ export function getContentTransferStorageBlockReason({ } return "The Cap uses a personal storage integration owned by another user"; } - if (bucketId) { + if (!S3Bucket.isCapManagedBucket(bucketId)) { if (!bucketOwnerId) return "The storage bucket is missing"; if ( bucketOrganizationId === organizationId || diff --git a/apps/web/lib/desktop-reupload.ts b/apps/web/lib/desktop-reupload.ts index 7ce70d31452..f328548d9e5 100644 --- a/apps/web/lib/desktop-reupload.ts +++ b/apps/web/lib/desktop-reupload.ts @@ -19,6 +19,7 @@ import { type DesktopReuploadToken, } from "@/lib/desktop-reupload-token"; import { runPromise } from "@/lib/server"; +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; type ReuploadedVideo = Pick< Video.Video, @@ -90,12 +91,10 @@ export async function prepareDesktopReupload( } export async function invalidateReuploadedVideo(video: ReuploadedVideo) { - if ( - Option.isSome(video.bucketId) || - Option.isSome(video.storageIntegrationId) - ) - return; - const distributionId = serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; + if (Option.isSome(video.storageIntegrationId)) return; + const distributionId = getVideoCloudFrontDistributionId( + Option.getOrNull(video.bucketId), + ); if (!distributionId) return; const client = new CloudFrontClient({ region: serverEnv().CAP_AWS_REGION || "us-east-1", diff --git a/apps/web/lib/video-cloudfront.ts b/apps/web/lib/video-cloudfront.ts new file mode 100644 index 00000000000..4db52be5847 --- /dev/null +++ b/apps/web/lib/video-cloudfront.ts @@ -0,0 +1,9 @@ +import { serverEnv } from "@cap/env"; +import { S3Bucket } from "@cap/web-domain"; + +export function getVideoCloudFrontDistributionId(bucketId: string | null) { + if (bucketId === S3Bucket.TokyoBucketId) + return serverEnv().CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID; + if (bucketId) return undefined; + return serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; +} diff --git a/apps/web/workflows/admin-reprocess-video.ts b/apps/web/workflows/admin-reprocess-video.ts index 8baa0d6d0fc..7da8cebaad9 100644 --- a/apps/web/workflows/admin-reprocess-video.ts +++ b/apps/web/workflows/admin-reprocess-video.ts @@ -16,6 +16,7 @@ import { createMediaServerCapacityError, isMediaServerCapacityError, } from "@/lib/media-server-backpressure"; +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; import { decodeStorageVideo } from "@/lib/video-storage"; import { runWorkflowPromise } from "@/lib/workflow-runtime"; @@ -443,9 +444,7 @@ async function invalidateResultCache( ): Promise { "use step"; - if (bucketId) return; - - const distributionId = serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; + const distributionId = getVideoCloudFrontDistributionId(bucketId); if (!distributionId) return; const basePath = `/${ownerId}/${videoId}`; diff --git a/apps/web/workflows/edit-video.ts b/apps/web/workflows/edit-video.ts index 1f182eb5059..7ace4a35c1e 100644 --- a/apps/web/workflows/edit-video.ts +++ b/apps/web/workflows/edit-video.ts @@ -33,6 +33,7 @@ import { import { decryptEditTranscriptObject } from "@/lib/edit-transcript-storage"; import { startAiGeneration } from "@/lib/generate-ai"; import { transcribeVideo } from "@/lib/transcribe"; +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; import { clearFailedEdit, type EditOperation, @@ -723,9 +724,6 @@ async function invalidateEditedVideoCache( ): Promise { "use step"; - const distributionId = serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; - if (!distributionId) return; - const [video] = await db() .select({ ownerId: videos.ownerId, @@ -734,7 +732,9 @@ async function invalidateEditedVideoCache( .from(videos) .where(eq(videos.id, videoId as Video.VideoId)); - if (!video || video.bucket) return; + if (!video) return; + const distributionId = getVideoCloudFrontDistributionId(video.bucket); + if (!distributionId) return; const basePath = `/${video.ownerId}/${videoId}`; const paths = [ diff --git a/packages/env/server.ts b/packages/env/server.ts index 78d649a442f..747d1581307 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -59,6 +59,10 @@ function createServerEnv() { .optional() .describe("Public URL of the S3 bucket"), CAP_CLOUDFRONT_DISTRIBUTION_ID: z.string().optional(), + CAP_TOKYO_UPLOADS_ENABLED: boolString(), + CAP_TOKYO_BUCKET: z.string().optional(), + CAP_TOKYO_BUCKET_URL: z.string().optional(), + CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: z.string().optional(), CLOUDFRONT_KEYPAIR_ID: z.string().optional(), CLOUDFRONT_KEYPAIR_PRIVATE_KEY: z.string().optional(), diff --git a/packages/web-backend/src/Organisations/index.ts b/packages/web-backend/src/Organisations/index.ts index 55bf96d6665..044d1a67cb0 100644 --- a/packages/web-backend/src/Organisations/index.ts +++ b/packages/web-backend/src/Organisations/index.ts @@ -1,5 +1,5 @@ import * as Db from "@cap/database/schema"; -import { CurrentUser, Organisation, Policy } from "@cap/web-domain"; +import { CurrentUser, Organisation, Policy, S3Bucket } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Array as EffectArray, Option } from "effect"; import { Database } from "../Database"; @@ -103,16 +103,18 @@ export class Organisations extends Effect.Service()( .where(Dz.eq(Db.videos.orgId, id)), ); const capManagedVideos = videos.filter( - (video) => !video.bucket && !video.storageIntegrationId, + (video) => + S3Bucket.isCapManagedBucket(video.bucket) && + !video.storageIntegrationId, ); const [defaultBucket] = yield* s3Buckets.getBucketAccess(Option.none()); - const deleteS3Prefix = (prefix: string) => + const deleteS3Prefix = (prefix: string, bucket = defaultBucket) => Effect.gen(function* () { let continuationToken: string | undefined; do { - const listedObjects = yield* defaultBucket.listObjects({ + const listedObjects = yield* bucket.listObjects({ prefix, continuationToken, }); @@ -126,7 +128,7 @@ export class Organisations extends Effect.Service()( index < objects.length; index += s3DeleteBatchSize ) { - yield* defaultBucket.deleteObjects( + yield* bucket.deleteObjects( objects.slice(index, index + s3DeleteBatchSize), ); } @@ -139,7 +141,13 @@ export class Organisations extends Effect.Service()( yield* Effect.forEach( capManagedVideos, - (video) => deleteS3Prefix(`${video.ownerId}/${video.id}/`), + (video) => + Effect.gen(function* () { + const [bucket] = yield* s3Buckets.getBucketAccess( + Option.fromNullable(video.bucket), + ); + yield* deleteS3Prefix(`${video.ownerId}/${video.id}/`, bucket); + }), { concurrency: 3 }, ); yield* deleteS3Prefix(`organizations/${id}/`); diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 49ecb912ecc..51f99c5f106 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -1,7 +1,7 @@ import * as S3 from "@aws-sdk/client-s3"; import * as CloudFrontPresigner from "@aws-sdk/cloudfront-signer"; import { decrypt } from "@cap/database/crypto"; -import type { Organisation, S3Bucket, User } from "@cap/web-domain"; +import { type Organisation, S3Bucket, type User } from "@cap/web-domain"; import type { RequestPresigningArguments } from "@smithy/types"; import { Config, Effect, Layer, Option } from "effect"; @@ -100,47 +100,99 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { Effect.map(Option.fromNullable), ); - const cloudfrontBucketAccess = cloudfrontEnvs.pipe( - Option.map((cloudfrontEnvs) => - Effect.flatMap(createS3BucketAccess, (s3) => { - const getCloudFrontSignedUrl = ( - key: string, - signingArgs?: RequestPresigningArguments, - ) => { - const url = `${cloudfrontEnvs.bucketUrl}/${key}`; - const expiresIn = signingArgs?.expiresIn ?? 3600; - const expires = Math.floor((Date.now() + expiresIn * 1000) / 1000); - - const policy = { - Statement: [ - { - Resource: url, - Condition: { - DateLessThan: { - "AWS:EpochTime": Math.floor(expires), + const cloudfrontBucketAccess = (bucketUrl?: string) => + cloudfrontEnvs.pipe( + Option.map((cloudfrontEnvs) => + Effect.flatMap(createS3BucketAccess, (s3) => { + const getCloudFrontSignedUrl = ( + key: string, + signingArgs?: RequestPresigningArguments, + ) => { + const url = `${bucketUrl ?? cloudfrontEnvs.bucketUrl}/${key}`; + const expiresIn = signingArgs?.expiresIn ?? 3600; + const expires = Math.floor( + (Date.now() + expiresIn * 1000) / 1000, + ); + + const policy = { + Statement: [ + { + Resource: url, + Condition: { + DateLessThan: { + "AWS:EpochTime": Math.floor(expires), + }, }, }, - }, - ], + ], + }; + + return Effect.succeed( + CloudFrontPresigner.getSignedUrl({ + url, + keyPairId: cloudfrontEnvs.keypairId, + privateKey: cloudfrontEnvs.privateKey, + policy: JSON.stringify(policy), + }), + ); }; - return Effect.succeed( - CloudFrontPresigner.getSignedUrl({ - url, - keyPairId: cloudfrontEnvs.keypairId, - privateKey: cloudfrontEnvs.privateKey, - policy: JSON.stringify(policy), - }), - ); - }; + return Effect.succeed({ + ...s3, + getSignedObjectUrl: getCloudFrontSignedUrl, + }); + }), + ), + ); - return Effect.succeed({ - ...s3, - getSignedObjectUrl: getCloudFrontSignedUrl, - }); + const tokyoConfig = yield* Config.all({ + bucket: Config.string("CAP_TOKYO_BUCKET").pipe( + Config.validate({ + message: "Invalid Tokyo bucket name", + validation: (value) => + /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(value), }), ), - ); + bucketUrl: Config.string("CAP_TOKYO_BUCKET_URL").pipe( + Config.validate({ + message: "Tokyo CDN must be an HTTPS origin", + validation: (value) => { + try { + const url = new URL(value); + return url.protocol === "https:" && url.origin === value; + } catch { + return false; + } + }, + }), + ), + distributionId: Config.nonEmptyString( + "CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID", + ), + }).pipe(Effect.option); + const tokyoUploadsEnabled = yield* Config.boolean( + "CAP_TOKYO_UPLOADS_ENABLED", + ).pipe(Effect.orElseSucceed(() => false)); + const tokyoBucketAccess = Option.flatMap(tokyoConfig, (config) => { + const client = new S3.S3Client({ + region: "ap-northeast-1", + credentials, + forcePathStyle: false, + requestHandler, + }); + return Option.map(cloudfrontBucketAccess(config.bucketUrl), (access) => + access.pipe( + Effect.provide( + Layer.succeed(S3BucketClientProvider, { + getInternal: Effect.succeed(client), + getPublic: Effect.succeed(client), + bucket: config.bucket, + isPathStyle: false, + }), + ), + ), + ); + }); const getBucketAccess = Effect.fn("S3Buckets.getProviderLayer")(function* ( customBucket: Option.Option, @@ -154,7 +206,7 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { isPathStyle: defaultConfigs.forcePathStyle, }); - return Option.match(cloudfrontBucketAccess, { + return Option.match(cloudfrontBucketAccess(), { onSome: (access) => access, onNone: () => createS3BucketAccess, }).pipe(Effect.provide(provider)); @@ -183,9 +235,31 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { }); return { + getRegionalUploadBucketId: (country: string | undefined) => + tokyoUploadsEnabled && + country === "JP" && + Option.isSome(tokyoBucketAccess) + ? Option.some(S3Bucket.TokyoBucketId) + : Option.none(), getBucketAccess: Effect.fn("S3Buckets.getBucketAccess")(function* ( bucketId?: Option.Option, ) { + if ( + Option.getOrNull(bucketId ?? Option.none()) === S3Bucket.TokyoBucketId + ) { + const access = yield* Option.match(tokyoBucketAccess, { + onSome: (access) => access, + onNone: () => + Effect.fail( + new S3Bucket.S3Error({ + cause: new Error( + "Tokyo storage configuration is missing or invalid", + ), + }), + ), + }); + return [access, Option.none()] as const; + } const customBucket = yield* (bucketId ?? Option.none()).pipe( Option.map(repo.getById), Effect.transposeOption, @@ -225,6 +299,8 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { AwsCredentials.Default, ], }) { + static getRegionalUploadBucketId = (country: string | undefined) => + Effect.map(S3Buckets, (b) => b.getRegionalUploadBucketId(country)); static getBucketAccess = (bucketId: Option.Option) => Effect.flatMap(S3Buckets, (b) => b.getBucketAccess(Option.fromNullable(bucketId).pipe(Option.flatten)), diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index 60f264f3ce2..40511d5fae2 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -4,6 +4,12 @@ import { UserId } from "./User.ts"; export const S3BucketId = Schema.String.pipe(Schema.brand("S3BucketId")); export type S3BucketId = typeof S3BucketId.Type; +// This ID cannot collide with generated customer bucket IDs, which have no hyphens. +export const TokyoBucketId = S3BucketId.make("cap-tokyo"); + +export const isCapManagedBucket = (id: string | null | undefined) => + !id || id === TokyoBucketId; + export class S3Bucket extends Schema.Class("S3Bucket")({ id: S3BucketId, ownerId: UserId, From 087b8c37b3c705b8340873ffbfb9d68ba304c0fc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:52:01 +0900 Subject: [PATCH 2/7] feat: select Instant upload storage from request location --- .../unit/desktop-video-create.test.ts | 146 +++++++++++++++++- apps/web/app/api/desktop/[...route]/video.ts | 21 ++- packages/web-backend/src/S3Buckets/README.md | 36 +++++ 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 packages/web-backend/src/S3Buckets/README.md diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index b3ae9980899..af5fbf5d5b7 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -6,7 +6,9 @@ import { Video, } from "@cap/web-domain"; import { Effect, Option } from "effect"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const regionalStorage = vi.hoisted(() => ({ select: vi.fn(), s3: vi.fn() })); const deletion = vi.hoisted(() => ({ deleteVideo: vi.fn(), @@ -89,9 +91,10 @@ vi.mock("@cap/web-backend", async () => { return { makeCurrentUserLayer, Videos, + S3Buckets: { getRegionalUploadBucketId: regionalStorage.select }, Storage: { getOrganizationWritableAccess: vi.fn(), - getS3WritableAccessForUser: vi.fn(), + getS3WritableAccessForUser: regionalStorage.s3, }, }; }); @@ -126,6 +129,8 @@ vi.mock("@/lib/google-drive-storage-quota", () => ({ // The live-transcription stack drags in the whole workflow graph // (server-only modules included); these tests only care that create works. +vi.mock("next/server", () => ({ after: vi.fn() })); + vi.mock("@/lib/live-transcribe", () => ({ maybeStartLiveTranscription: vi.fn(async () => "skipped"), })); @@ -138,7 +143,7 @@ vi.mock("drizzle-orm", () => ({ })); const mockGetCurrentUser = getCurrentUser as ReturnType; -const { Storage } = await import("@cap/web-backend"); +const { Storage, S3Buckets } = await import("@cap/web-backend"); const { invalidateGoogleDriveStorageQuotaCache } = await import( "@/lib/google-drive-storage-quota" ); @@ -667,3 +672,138 @@ describe("GET /create", () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); }); + +describe("new Instant recording regions", () => { + let app: typeof import("@/app/api/desktop/[...route]/video")["app"]; + beforeEach(async () => { + vi.clearAllMocks(); + vi.stubEnv("VERCEL", "1"); + resetMockDb(); + stubStorage(); + regionalStorage.s3.mockReturnValue( + Effect.succeed({ + bucketId: Option.none(), + storageIntegrationId: Option.none(), + }), + ); + regionalStorage.select.mockImplementation((country: string | undefined) => + Effect.succeed( + country === "JP" ? Option.some("cap-tokyo") : Option.none(), + ), + ); + defaultSharing.getNewVideoPublic.mockResolvedValue(true); + mockGetCurrentUser.mockResolvedValue({ + id: "user-1", + defaultOrgId: "org-1", + activeOrganizationId: "org-1", + }); + mockDb.where + .mockResolvedValueOnce([ + { id: "org-1", name: "Org", createdAt: new Date() }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: 5 }]); + app = (await import("@/app/api/desktop/[...route]/video")).app; + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each(["desktopMP4", "desktopSegments"])( + "persists the selected bucket for %s", + async (mode) => { + const response = await app.request( + `https://cap.test/create?recordingMode=${mode}`, + { headers: { "x-vercel-ip-country": "JP" } }, + ); + expect(response.status).toBe(200); + expect(insertedValues(schema.videos)?.bucket).toBe("cap-tokyo"); + expect(S3Buckets.getRegionalUploadBucketId).toHaveBeenCalledWith("JP"); + }, + ); + + it.each(["US", "", "ZZ"])( + "keeps Virginia for country %s", + async (country) => { + const response = await app.request( + "https://cap.test/create?recordingMode=desktopMP4", + { headers: country ? { "x-vercel-ip-country": country } : {} }, + ); + expect(response.status).toBe(200); + expect(insertedValues(schema.videos)?.bucket).toBeNull(); + }, + ); + + it.each([ + { + bucket: "custom-bucket", + integration: null, + vercel: "1", + query: "recordingMode=desktopMP4", + }, + { + bucket: null, + integration: "drive-id", + vercel: "1", + query: "recordingMode=desktopMP4", + }, + { + bucket: null, + integration: null, + vercel: "", + query: "recordingMode=desktopMP4", + }, + { + bucket: null, + integration: null, + vercel: "1", + query: "isScreenshot=true&recordingMode=desktopMP4", + }, + { + bucket: null, + integration: null, + vercel: "1", + query: "recordingMode=hls", + }, + ])( + "preserves storage outside eligible new Instant recordings (%#)", + async ({ bucket, integration, vercel, query }) => { + vi.stubEnv("VERCEL", vercel); + regionalStorage.s3.mockReturnValue( + Effect.succeed({ + bucketId: Option.fromNullable(bucket), + storageIntegrationId: Option.fromNullable(integration), + }), + ); + const response = await app.request(`https://cap.test/create?${query}`, { + headers: { "x-vercel-ip-country": "JP" }, + }); + expect(response.status).toBe(200); + expect(insertedValues(schema.videos)).toMatchObject({ + bucket, + storageIntegrationId: integration, + }); + expect(S3Buckets.getRegionalUploadBucketId).not.toHaveBeenCalled(); + }, + ); + + it.each([null, "cap-tokyo", "custom-bucket"])( + "does not reroute an existing recording after travel: %s", + async (bucket) => { + mockDb.where.mockReset().mockResolvedValue([ + { + id: "existing", + ownerId: "user-1", + bucket, + source: { type: "desktopMP4" }, + }, + ]); + const response = await app.request( + "https://cap.test/create?videoId=existing&recordingMode=desktopMP4", + { headers: { "x-vercel-ip-country": "JP" } }, + ); + expect(response.status).toBe(200); + expect(mockDb.insert).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(S3Buckets.getRegionalUploadBucketId).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 173c1a933b3..a139d8f27c9 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -13,7 +13,12 @@ import type { VideoMetadata } from "@cap/database/types"; import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; -import { makeCurrentUserLayer, Storage, Videos } from "@cap/web-backend"; +import { + makeCurrentUserLayer, + S3Buckets, + Storage, + Videos, +} from "@cap/web-backend"; import { Organisation, Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, count, eq, lte } from "drizzle-orm"; @@ -304,6 +309,18 @@ app.get( : Storage.getS3WritableAccessForUser(user.id, videoOrgId) ).pipe(runPromise); + const bucketId = + Option.isNone(writable.bucketId) && + Option.isNone(writable.storageIntegrationId) && + !isScreenshot && + (recordingMode === "desktopSegments" || + recordingMode === "desktopMP4") && + process.env.VERCEL === "1" + ? await S3Buckets.getRegionalUploadBucketId( + c.req.header("x-vercel-ip-country"), + ).pipe(runPromise) + : writable.bucketId; + await db() .insert(videos) .values({ @@ -320,7 +337,7 @@ app.get( ? { type: "desktopSegments" as const } : undefined, isScreenshot, - bucket: Option.getOrNull(writable.bucketId), + bucket: Option.getOrNull(bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), public: await getNewVideoPublic(videoOrgId), duration: durationInSecs, diff --git a/packages/web-backend/src/S3Buckets/README.md b/packages/web-backend/src/S3Buckets/README.md new file mode 100644 index 00000000000..05ec69a4cf7 --- /dev/null +++ b/packages/web-backend/src/S3Buckets/README.md @@ -0,0 +1,36 @@ +# Regional Instant uploads + +Routing is disabled by default. On Vercel, new desktop Instant recordings use the +request's `x-vercel-ip-country` header. Only `JP` selects Tokyo; missing/unknown +location, disabled routing, or incomplete/invalid configuration keeps the existing +Virginia path. No geolocation service, extra database query, or client change is +needed. Custom storage and Google Drive take precedence. + +The chosen bucket is stored on the recording, not the user. Each new recording +uses the current request location; retries, resume, processing, playback, edits, +transfers, and deletion keep the recording's original destination. `cap-tokyo` is +a reserved bucket ID (generated customer IDs cannot contain hyphens); no schema +migration or customer storage row is needed. + +Before enabling, provision a private S3 bucket in `ap-northeast-1` and a CloudFront +distribution pointing to it. Use the existing CloudFront signing key group, permit +the server/worker AWS identity to access the bucket and invalidate the distribution, +and configure the same upload CORS rules as Virginia. Set these on every web and +workflow deployment: + +- `CAP_TOKYO_BUCKET`: bucket name. +- `CAP_TOKYO_BUCKET_URL`: HTTPS CDN origin, without a trailing slash or path. +- `CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID`: that distribution's ID. +- `CAP_TOKYO_UPLOADS_ENABLED=true`: enable selection for new uploads from Japan. + +Keep the existing default AWS and CloudFront configuration. To roll back routing, +set `CAP_TOKYO_UPLOADS_ENABLED=false`; retain the Tokyo bucket and configuration +while recordings reference it. Never repoint its bucket name. Losing configuration +for a stored Tokyo recording fails explicitly instead of writing its remaining +objects into Virginia. + +The Tokyo benchmark improved upload completion but used direct S3 playback and +was slower to first playback/final MP4 than accelerated Virginia with CDN. Before +enabling, repeat the GPUI streaming benchmark with this CDN configuration and verify +source preparation, first playback, finalization, replacement, and deletion. This +PR does not enable routing or provision infrastructure. From b0f297179b8260a6d673980c022e8b21d2c62a7a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:56:57 +0900 Subject: [PATCH 3/7] fix: preserve the replacement server action boundary --- apps/web/actions/admin/replace-video.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/admin/replace-video.ts b/apps/web/actions/admin/replace-video.ts index 279d5e0910a..883d766cdff 100644 --- a/apps/web/actions/admin/replace-video.ts +++ b/apps/web/actions/admin/replace-video.ts @@ -1,6 +1,4 @@ -import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; - -("use server"); +"use server"; import { CloudFrontClient, @@ -18,6 +16,7 @@ import { Effect } from "effect"; import { retireDesktopRecordingJobForOutputReplacement } from "@/lib/desktop-recording-jobs"; import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants"; import { runPromise } from "@/lib/server"; +import { getVideoCloudFrontDistributionId } from "@/lib/video-cloudfront"; import { decodeStorageVideo } from "@/lib/video-storage"; async function requireAdmin() { From 84f52e15eb87c155b228b74373b700098a8f2055 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:01:04 +0900 Subject: [PATCH 4/7] refactor: select upload region within the storage effect --- .../unit/desktop-video-create.test.ts | 27 +++++++------ apps/web/app/api/desktop/[...route]/video.ts | 38 +++++++++++-------- packages/web-backend/src/S3Buckets/index.ts | 2 - 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index af5fbf5d5b7..4fe346947e9 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -5,7 +5,7 @@ import { Storage as StorageDomain, Video, } from "@cap/web-domain"; -import { Effect, Option } from "effect"; +import { Effect, Layer, Option } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const regionalStorage = vi.hoisted(() => ({ select: vi.fn(), s3: vi.fn() })); @@ -88,10 +88,13 @@ vi.mock("@cap/web-backend", async () => { class Videos extends Effect.Service()("Videos", { sync: () => ({ delete: deletion.deleteVideo }), }) {} + class S3Buckets extends Effect.Service()("S3Buckets", { + sync: () => ({ getRegionalUploadBucketId: regionalStorage.select }), + }) {} return { makeCurrentUserLayer, Videos, - S3Buckets: { getRegionalUploadBucketId: regionalStorage.select }, + S3Buckets, Storage: { getOrganizationWritableAccess: vi.fn(), getS3WritableAccessForUser: regionalStorage.s3, @@ -101,7 +104,7 @@ vi.mock("@cap/web-backend", async () => { vi.mock("@/lib/server", async () => { const { Effect } = await import("effect"); - const { Videos } = await import("@cap/web-backend"); + const { Videos, S3Buckets } = await import("@cap/web-backend"); return { runPromise: vi.fn(async (value: unknown) => Effect.isEffect(value) @@ -110,9 +113,11 @@ vi.mock("@/lib/server", async () => { value as Effect.Effect< unknown, unknown, - InstanceType + InstanceType | InstanceType > - ).pipe(Effect.provide(Videos.Default)), + ).pipe( + Effect.provide(Layer.mergeAll(Videos.Default, S3Buckets.Default)), + ), ) : value, ), @@ -143,7 +148,7 @@ vi.mock("drizzle-orm", () => ({ })); const mockGetCurrentUser = getCurrentUser as ReturnType; -const { Storage, S3Buckets } = await import("@cap/web-backend"); +const { Storage } = await import("@cap/web-backend"); const { invalidateGoogleDriveStorageQuotaCache } = await import( "@/lib/google-drive-storage-quota" ); @@ -687,9 +692,7 @@ describe("new Instant recording regions", () => { }), ); regionalStorage.select.mockImplementation((country: string | undefined) => - Effect.succeed( - country === "JP" ? Option.some("cap-tokyo") : Option.none(), - ), + country === "JP" ? Option.some("cap-tokyo") : Option.none(), ); defaultSharing.getNewVideoPublic.mockResolvedValue(true); mockGetCurrentUser.mockResolvedValue({ @@ -716,7 +719,7 @@ describe("new Instant recording regions", () => { ); expect(response.status).toBe(200); expect(insertedValues(schema.videos)?.bucket).toBe("cap-tokyo"); - expect(S3Buckets.getRegionalUploadBucketId).toHaveBeenCalledWith("JP"); + expect(regionalStorage.select).toHaveBeenCalledWith("JP"); }, ); @@ -781,7 +784,7 @@ describe("new Instant recording regions", () => { bucket, storageIntegrationId: integration, }); - expect(S3Buckets.getRegionalUploadBucketId).not.toHaveBeenCalled(); + expect(regionalStorage.select).not.toHaveBeenCalled(); }, ); @@ -803,7 +806,7 @@ describe("new Instant recording regions", () => { expect(response.status).toBe(200); expect(mockDb.insert).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); - expect(S3Buckets.getRegionalUploadBucketId).not.toHaveBeenCalled(); + expect(regionalStorage.select).not.toHaveBeenCalled(); }, ); }); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index a139d8f27c9..813d3c47011 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -304,22 +304,28 @@ app.get( ); } - const writable = await (clientSupportsGoogleDriveUpload - ? Storage.getWritableAccessForUser(user.id, videoOrgId) - : Storage.getS3WritableAccessForUser(user.id, videoOrgId) - ).pipe(runPromise); - - const bucketId = - Option.isNone(writable.bucketId) && - Option.isNone(writable.storageIntegrationId) && - !isScreenshot && - (recordingMode === "desktopSegments" || - recordingMode === "desktopMP4") && - process.env.VERCEL === "1" - ? await S3Buckets.getRegionalUploadBucketId( + const writable = await Effect.gen(function* () { + const writable = yield* clientSupportsGoogleDriveUpload + ? Storage.getWritableAccessForUser(user.id, videoOrgId) + : Storage.getS3WritableAccessForUser(user.id, videoOrgId); + if ( + Option.isNone(writable.bucketId) && + Option.isNone(writable.storageIntegrationId) && + !isScreenshot && + (recordingMode === "desktopSegments" || + recordingMode === "desktopMP4") && + process.env.VERCEL === "1" + ) { + const buckets = yield* S3Buckets; + return { + ...writable, + bucketId: buckets.getRegionalUploadBucketId( c.req.header("x-vercel-ip-country"), - ).pipe(runPromise) - : writable.bucketId; + ), + }; + } + return writable; + }).pipe(runPromise); await db() .insert(videos) @@ -337,7 +343,7 @@ app.get( ? { type: "desktopSegments" as const } : undefined, isScreenshot, - bucket: Option.getOrNull(bucketId), + bucket: Option.getOrNull(writable.bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), public: await getNewVideoPublic(videoOrgId), duration: durationInSecs, diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 51f99c5f106..7c0d323b8ef 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -299,8 +299,6 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { AwsCredentials.Default, ], }) { - static getRegionalUploadBucketId = (country: string | undefined) => - Effect.map(S3Buckets, (b) => b.getRegionalUploadBucketId(country)); static getBucketAccess = (bucketId: Option.Option) => Effect.flatMap(S3Buckets, (b) => b.getBucketAccess(Option.fromNullable(bucketId).pipe(Option.flatten)), From 3aa16c309ecdc19acb34972895b12119e1a89a52 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:10:55 +0900 Subject: [PATCH 5/7] feat: support worldwide Instant upload regions --- .../unit/desktop-video-create.test.ts | 40 +++- .../regional-organization-cleanup.test.ts | 4 +- .../unit/regional-upload-selection.test.ts | 120 ++++++++++++ .../unit/s3-bucket-connections.test.ts | 178 ++++++++++++------ .../__tests__/unit/video-cloudfront.test.ts | 17 +- apps/web/app/api/desktop/[...route]/video.ts | 3 +- apps/web/lib/video-cloudfront.ts | 10 +- packages/env/server.ts | 6 +- packages/web-backend/src/S3Buckets/README.md | 76 +++++--- .../src/S3Buckets/RegionalBuckets.ts | 69 +++++++ packages/web-backend/src/S3Buckets/index.ts | 126 ++++++------- packages/web-domain/src/S3Bucket.ts | 64 ++++++- 12 files changed, 530 insertions(+), 183 deletions(-) create mode 100644 apps/web/__tests__/unit/regional-upload-selection.test.ts create mode 100644 packages/web-backend/src/S3Buckets/RegionalBuckets.ts diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index 4fe346947e9..603ca7c94ce 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -691,8 +691,11 @@ describe("new Instant recording regions", () => { storageIntegrationId: Option.none(), }), ); - regionalStorage.select.mockImplementation((country: string | undefined) => - country === "JP" ? Option.some("cap-tokyo") : Option.none(), + regionalStorage.select.mockImplementation( + (latitude: string | undefined, longitude: string | undefined) => + latitude === "35.68" && longitude === "139.69" + ? Option.some("cap-tokyo") + : Option.none(), ); defaultSharing.getNewVideoPublic.mockResolvedValue(true); mockGetCurrentUser.mockResolvedValue({ @@ -715,20 +718,29 @@ describe("new Instant recording regions", () => { async (mode) => { const response = await app.request( `https://cap.test/create?recordingMode=${mode}`, - { headers: { "x-vercel-ip-country": "JP" } }, + { + headers: { + "x-vercel-ip-latitude": "35.68", + "x-vercel-ip-longitude": "139.69", + }, + }, ); expect(response.status).toBe(200); expect(insertedValues(schema.videos)?.bucket).toBe("cap-tokyo"); - expect(regionalStorage.select).toHaveBeenCalledWith("JP"); + expect(regionalStorage.select).toHaveBeenCalledWith("35.68", "139.69"); }, ); - it.each(["US", "", "ZZ"])( - "keeps Virginia for country %s", - async (country) => { + it.each>([ + { "x-vercel-ip-latitude": "40.71", "x-vercel-ip-longitude": "-74.01" }, + {}, + { "x-vercel-ip-latitude": "bad", "x-vercel-ip-longitude": "139.69" }, + ])( + "keeps Virginia when no regional bucket is selected (%#)", + async (headers) => { const response = await app.request( "https://cap.test/create?recordingMode=desktopMP4", - { headers: country ? { "x-vercel-ip-country": country } : {} }, + { headers }, ); expect(response.status).toBe(200); expect(insertedValues(schema.videos)?.bucket).toBeNull(); @@ -777,7 +789,10 @@ describe("new Instant recording regions", () => { }), ); const response = await app.request(`https://cap.test/create?${query}`, { - headers: { "x-vercel-ip-country": "JP" }, + headers: { + "x-vercel-ip-latitude": "35.68", + "x-vercel-ip-longitude": "139.69", + }, }); expect(response.status).toBe(200); expect(insertedValues(schema.videos)).toMatchObject({ @@ -801,7 +816,12 @@ describe("new Instant recording regions", () => { ]); const response = await app.request( "https://cap.test/create?videoId=existing&recordingMode=desktopMP4", - { headers: { "x-vercel-ip-country": "JP" } }, + { + headers: { + "x-vercel-ip-latitude": "35.68", + "x-vercel-ip-longitude": "139.69", + }, + }, ); expect(response.status).toBe(200); expect(mockDb.insert).not.toHaveBeenCalled(); diff --git a/apps/web/__tests__/unit/regional-organization-cleanup.test.ts b/apps/web/__tests__/unit/regional-organization-cleanup.test.ts index 99b0d276bcc..0c395603f8f 100644 --- a/apps/web/__tests__/unit/regional-organization-cleanup.test.ts +++ b/apps/web/__tests__/unit/regional-organization-cleanup.test.ts @@ -81,7 +81,7 @@ beforeEach(() => { { id: "tokyo", ownerId: "owner", - bucket: S3Bucket.TokyoBucketId, + bucket: S3Bucket.S3BucketId.make("cap-tokyo"), storageIntegrationId: null, }, { @@ -142,7 +142,7 @@ describe("organization regional media cleanup", () => { it("keeps the database records when the regional bucket cannot be opened", async () => { const original = mocks.bucket.getMockImplementation(); mocks.bucket.mockImplementation((bucket: Option.Option) => - Option.getOrNull(bucket) === S3Bucket.TokyoBucketId + Option.getOrNull(bucket) === S3Bucket.S3BucketId.make("cap-tokyo") ? Effect.fail(new Error("Tokyo unavailable")) : original?.(bucket), ); diff --git a/apps/web/__tests__/unit/regional-upload-selection.test.ts b/apps/web/__tests__/unit/regional-upload-selection.test.ts new file mode 100644 index 00000000000..d4d97f21415 --- /dev/null +++ b/apps/web/__tests__/unit/regional-upload-selection.test.ts @@ -0,0 +1,120 @@ +import { + getNearestRegionalBucket, + parseRegionalBuckets, +} from "@cap/web-backend/src/S3Buckets/RegionalBuckets"; +import { S3Bucket } from "@cap/web-domain"; +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; + +const regions = S3Bucket.RegionalBuckets; + +describe("upload region selection", () => { + it.each([ + ["New York", "40.71", "-74.01", null], + ["San Francisco", "37.77", "-122.42", "cap-oregon"], + ["London", "51.51", "-0.13", "cap-ireland"], + ["Berlin", "52.52", "13.41", "cap-frankfurt"], + ["Buenos Aires", "-34.60", "-58.38", "cap-sao-paulo"], + ["Johannesburg", "-26.20", "28.04", "cap-cape-town"], + ["Delhi", "28.61", "77.21", "cap-mumbai"], + ["Bangkok", "13.76", "100.50", "cap-singapore"], + ["Tokyo", "35.68", "139.69", "cap-tokyo"], + ["Melbourne", "-37.81", "144.96", "cap-sydney"], + ["Fiji", "-18.14", "178.44", "cap-sydney"], + ["Samoa", "-13.85", "-171.75", "cap-sydney"], + ])( + "selects the nearest configured region for %s", + (_, latitude, longitude, expected) => { + expect( + Option.getOrNull( + getNearestRegionalBucket(latitude, longitude, regions), + ), + ).toBe(expected); + }, + ); + + it.each([ + [undefined, undefined], + ["35.68", undefined], + [undefined, "139.69"], + ["", ""], + [" ", "139.69"], + ["35.68", " "], + ["NaN", "139.69"], + ["Infinity", "139.69"], + ["91", "0"], + ["-91", "0"], + ["0", "181"], + ["0", "-181"], + ["35.68,1", "139.69"], + ])( + "defaults to Virginia for missing or invalid coordinates (%#)", + (latitude, longitude) => { + expect( + Option.isNone(getNearestRegionalBucket(latitude, longitude, regions)), + ).toBe(true); + }, + ); + + it("uses only configured destinations and always considers Virginia", () => { + const tokyo = regions.filter( + (region) => region.region === "ap-northeast-1", + ); + expect(Option.isNone(getNearestRegionalBucket("35.68", "139.69", []))).toBe( + true, + ); + expect( + Option.isNone(getNearestRegionalBucket("51.51", "-0.13", tokyo)), + ).toBe(true); + expect( + Option.getOrNull(getNearestRegionalBucket("35.68", "139.69", tokyo)), + ).toBe("cap-tokyo"); + }); + + it("keeps managed IDs distinct from customer IDs and within the database limit", () => { + for (const region of regions) { + expect(region.id).toContain("-"); + expect(region.id.length).toBeLessThanOrEqual(15); + expect(S3Bucket.isCapManagedBucket(region.id)).toBe(true); + } + expect(new Set(regions.map((region) => region.id)).size).toBe( + regions.length, + ); + expect(S3Bucket.isCapManagedBucket(null)).toBe(true); + expect(S3Bucket.isCapManagedBucket("customer1234567")).toBe(false); + }); + + it("ignores unsupported regions and rejects malformed configuration", () => { + const bucket = { + bucket: "cap-test-tokyo", + bucketUrl: "https://tokyo.cap.test", + distributionId: "ETOKYO", + }; + expect( + parseRegionalBuckets(JSON.stringify({ "not-a-region": bucket })), + ).toEqual([]); + expect( + parseRegionalBuckets(JSON.stringify({ "ap-northeast-1": bucket })), + ).toMatchObject([{ id: "cap-tokyo", ...bucket }]); + expect( + parseRegionalBuckets( + JSON.stringify({ + "ap-northeast-1": { ...bucket, distributionId: " " }, + }), + ), + ).toEqual([]); + }); + it("preserves valid regions when another region is misconfigured", () => { + const config = parseRegionalBuckets( + JSON.stringify({ + "ap-northeast-1": { + bucket: "cap-test-tokyo", + bucketUrl: "https://tokyo.cap.test", + distributionId: "ETOKYO", + }, + "eu-central-1": { bucket: "cap-test-frankfurt" }, + }), + ); + expect(config.map((bucket) => bucket.id)).toEqual(["cap-tokyo"]); + }); +}); diff --git a/apps/web/__tests__/unit/s3-bucket-connections.test.ts b/apps/web/__tests__/unit/s3-bucket-connections.test.ts index 2be85b4b0d2..80e7a442cc3 100644 --- a/apps/web/__tests__/unit/s3-bucket-connections.test.ts +++ b/apps/web/__tests__/unit/s3-bucket-connections.test.ts @@ -232,11 +232,16 @@ describe("S3 connection reuse", () => { }); }); +const tokyoConfig = { + bucket: "cap-test-tokyo", + bucketUrl: "https://tokyo-cdn.cap.test", + distributionId: "ETOKYO", +}; const regionalConfig = { - CAP_TOKYO_UPLOADS_ENABLED: "true", - CAP_TOKYO_BUCKET: "cap-test-tokyo", - CAP_TOKYO_BUCKET_URL: "https://tokyo-cdn.cap.test", - CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "ETOKYO", + CAP_REGIONAL_UPLOADS_ENABLED: "true", + CAP_REGIONAL_UPLOAD_BUCKETS: JSON.stringify({ + "ap-northeast-1": tokyoConfig, + }), CAP_CLOUDFRONT_DISTRIBUTION_ID: "EVIRGINIA", CAP_AWS_BUCKET_URL: "https://cdn.cap.test", CLOUDFRONT_KEYPAIR_ID: "KTEST", @@ -253,20 +258,23 @@ describe("regional storage", () => { const repoCalls = mocks.getById.mock.calls.length; const send = vi.spyOn(S3.S3Client.prototype, "send"); try { - for (const [country, expected] of [ - ["JP", "cap-tokyo"], - ["US", null], - ["JP", "cap-tokyo"], - [undefined, null], - ["ZZ", null], - ["jp", null], + for (const [latitude, longitude, expected] of [ + ["35.68", "139.69", "cap-tokyo"], + ["40.71", "-74.01", null], + ["35.68", "139.69", "cap-tokyo"], + [undefined, undefined, null], + ["bad", "139.69", null], ] as const) { expect( - Option.getOrNull(fixture.service.getRegionalUploadBucketId(country)), + Option.getOrNull( + fixture.service.getRegionalUploadBucketId(latitude, longitude), + ), ).toBe(expected); } const [regional] = await fixture.runtime.runPromise( - fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + fixture.service.getBucketAccess( + Option.some(S3Bucket.S3BucketId.make("cap-tokyo")), + ), ); const key = "owner/video/segments/segment_000001.m4s"; for (const effect of [ @@ -286,7 +294,7 @@ describe("regional storage", () => { const playback = new URL( await fixture.runtime.runPromise(regional.getSignedObjectUrl(key)), ); - expect(playback.origin).toBe(regionalConfig.CAP_TOKYO_BUCKET_URL); + expect(playback.origin).toBe(tokyoConfig.bucketUrl); expect(playback.searchParams.get("Key-Pair-Id")).toBe("KTEST"); const [original] = await fixture.runtime.runPromise( fixture.service.getBucketAccess(Option.none()), @@ -304,14 +312,18 @@ describe("regional storage", () => { } const disabled = await storageFixture({ ...regionalConfig, - CAP_TOKYO_UPLOADS_ENABLED: "false", + CAP_REGIONAL_UPLOADS_ENABLED: "false", }); try { expect( - Option.isNone(disabled.service.getRegionalUploadBucketId("JP")), + Option.isNone( + disabled.service.getRegionalUploadBucketId("35.68", "139.69"), + ), ).toBe(true); const [regional] = await disabled.runtime.runPromise( - disabled.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + disabled.service.getBucketAccess( + Option.some(S3Bucket.S3BucketId.make("cap-tokyo")), + ), ); expect(regional.bucketName).toBe("cap-test-tokyo"); } finally { @@ -321,15 +333,35 @@ describe("regional storage", () => { it.each([ {}, - { CAP_TOKYO_UPLOADS_ENABLED: "true" }, - { ...regionalConfig, CAP_TOKYO_UPLOADS_ENABLED: "invalid" }, - { ...regionalConfig, CAP_TOKYO_BUCKET: "INVALID" }, - { ...regionalConfig, CAP_TOKYO_BUCKET_URL: "http://tokyo-cdn.cap.test" }, - { + { CAP_REGIONAL_UPLOADS_ENABLED: "true" }, + { ...regionalConfig, CAP_REGIONAL_UPLOADS_ENABLED: "invalid" }, + { ...regionalConfig, CAP_AWS_REGION: "eu-west-1" }, + ...[ + "not json", + "null", + "[]", + JSON.stringify({ + "ap-northeast-1": { ...tokyoConfig, bucket: "INVALID" }, + }), + JSON.stringify({ + "ap-northeast-1": { + ...tokyoConfig, + bucketUrl: "http://tokyo-cdn.cap.test", + }, + }), + JSON.stringify({ + "ap-northeast-1": { + ...tokyoConfig, + bucketUrl: "https://tokyo-cdn.cap.test/path", + }, + }), + JSON.stringify({ + "ap-northeast-1": { ...tokyoConfig, distributionId: "" }, + }), + ].map((value) => ({ ...regionalConfig, - CAP_TOKYO_BUCKET_URL: "https://tokyo-cdn.cap.test/path", - }, - { ...regionalConfig, CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "" }, + CAP_REGIONAL_UPLOAD_BUCKETS: value, + })), Object.fromEntries( Object.entries(regionalConfig).filter( ([key]) => key !== "CLOUDFRONT_KEYPAIR_ID", @@ -345,7 +377,9 @@ describe("regional storage", () => { }); try { expect( - Option.isNone(fixture.service.getRegionalUploadBucketId("JP")), + Option.isNone( + fixture.service.getRegionalUploadBucketId("35.68", "139.69"), + ), ).toBe(true); const [original] = await fixture.runtime.runPromise( fixture.service.getBucketAccess(Option.none()), @@ -357,7 +391,7 @@ describe("regional storage", () => { ); expect(url.hostname).toBe("capso.s3-accelerate.amazonaws.com"); expect(url.searchParams.get("X-Amz-Credential")).toContain( - "/us-east-1/s3/", + `/${"CAP_AWS_REGION" in config ? config.CAP_AWS_REGION : "us-east-1"}/s3/`, ); } finally { await fixture.close(); @@ -371,45 +405,67 @@ describe("regional storage", () => { try { await expect( fixture.runtime.runPromise( - fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), + fixture.service.getBucketAccess( + Option.some(S3Bucket.S3BucketId.make("cap-tokyo")), + ), ), - ).rejects.toThrow("Tokyo storage configuration"); + ).rejects.toThrow("Regional storage configuration"); expect(mocks.getById.mock.calls).toHaveLength(repoCalls); } finally { await fixture.close(); } }); - it("keeps processing, copying, and cleanup commands in the persisted bucket", async () => { - const fixture = await storageFixture(regionalConfig); - const send = vi - .spyOn(S3.S3Client.prototype, "send") - .mockImplementation(async () => ({})); - try { - const [regional] = await fixture.runtime.runPromise( - fixture.service.getBucketAccess(Option.some(S3Bucket.TokyoBucketId)), - ); - await fixture.runtime.runPromise( - regional.headObject("owner/video/result.mp4"), - ); - await fixture.runtime.runPromise( - regional.copyObject( - "cap-test-tokyo/owner/video/result.mp4", - "new-owner/video/result.mp4", - ), - ); - await fixture.runtime.runPromise( - regional.listObjects({ prefix: "owner/video/" }), - ); - await fixture.runtime.runPromise( - regional.deleteObjects([{ Key: "owner/video/result.mp4" }]), - ); - expect(send).toHaveBeenCalledTimes(4); - for (const [command] of send.mock.calls) - expect(command.input).toHaveProperty("Bucket", "cap-test-tokyo"); - } finally { - send.mockRestore(); - await fixture.close(); - } - }); + it.each(S3Bucket.RegionalBuckets)( + "keeps signing, processing, copying, and cleanup in $region", + async (region) => { + const bucket = `${region.id}-test`; + const fixture = await storageFixture({ + ...regionalConfig, + CAP_REGIONAL_UPLOAD_BUCKETS: JSON.stringify({ + [region.region]: { ...tokyoConfig, bucket }, + }), + }); + const send = vi + .spyOn(S3.S3Client.prototype, "send") + .mockImplementation(async () => ({})); + try { + const [regional] = await fixture.runtime.runPromise( + fixture.service.getBucketAccess(Option.some(region.id)), + ); + const upload = new URL( + await fixture.runtime.runPromise( + regional.getPresignedPutUrl("owner/video/result.mp4"), + ), + ); + expect(upload.hostname).toBe( + `${bucket}.s3.${region.region}.amazonaws.com`, + ); + expect(upload.searchParams.get("X-Amz-Credential")).toContain( + `/${region.region}/s3/`, + ); + await fixture.runtime.runPromise( + regional.headObject("owner/video/result.mp4"), + ); + await fixture.runtime.runPromise( + regional.copyObject( + `${bucket}/owner/video/result.mp4`, + "new-owner/video/result.mp4", + ), + ); + await fixture.runtime.runPromise( + regional.listObjects({ prefix: "owner/video/" }), + ); + await fixture.runtime.runPromise( + regional.deleteObjects([{ Key: "owner/video/result.mp4" }]), + ); + expect(send).toHaveBeenCalledTimes(4); + for (const [command] of send.mock.calls) + expect(command.input).toHaveProperty("Bucket", bucket); + } finally { + send.mockRestore(); + await fixture.close(); + } + }, + ); }); diff --git a/apps/web/__tests__/unit/video-cloudfront.test.ts b/apps/web/__tests__/unit/video-cloudfront.test.ts index 8ab5b966b30..c74255c8f54 100644 --- a/apps/web/__tests__/unit/video-cloudfront.test.ts +++ b/apps/web/__tests__/unit/video-cloudfront.test.ts @@ -3,8 +3,19 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@cap/env", () => ({ serverEnv: () => ({ CAP_CLOUDFRONT_DISTRIBUTION_ID: "EVIRGINIA", - CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: "ETOKYO", - CAP_TOKYO_UPLOADS_ENABLED: false, + CAP_REGIONAL_UPLOAD_BUCKETS: JSON.stringify({ + "ap-northeast-1": { + bucket: "cap-test-tokyo", + bucketUrl: "https://tokyo.cap.test", + distributionId: "ETOKYO", + }, + "eu-central-1": { + bucket: "cap-test-frankfurt", + bucketUrl: "https://frankfurt.cap.test", + distributionId: "EFRANKFURT", + }, + }), + CAP_REGIONAL_UPLOADS_ENABLED: false, }), })); @@ -14,6 +25,8 @@ describe("video cache invalidation destination", () => { it.each([ [null, "EVIRGINIA"], ["cap-tokyo", "ETOKYO"], + ["cap-frankfurt", "EFRANKFURT"], + ["cap-sydney", undefined], ["custom-bucket", undefined], ])( "uses the persisted bucket even with routing disabled: %s", diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 813d3c47011..cc4790fcf0d 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -320,7 +320,8 @@ app.get( return { ...writable, bucketId: buckets.getRegionalUploadBucketId( - c.req.header("x-vercel-ip-country"), + c.req.header("x-vercel-ip-latitude"), + c.req.header("x-vercel-ip-longitude"), ), }; } diff --git a/apps/web/lib/video-cloudfront.ts b/apps/web/lib/video-cloudfront.ts index 4db52be5847..42d8f332492 100644 --- a/apps/web/lib/video-cloudfront.ts +++ b/apps/web/lib/video-cloudfront.ts @@ -1,9 +1,9 @@ import { serverEnv } from "@cap/env"; -import { S3Bucket } from "@cap/web-domain"; +import { parseRegionalBuckets } from "@cap/web-backend/src/S3Buckets/RegionalBuckets"; export function getVideoCloudFrontDistributionId(bucketId: string | null) { - if (bucketId === S3Bucket.TokyoBucketId) - return serverEnv().CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID; - if (bucketId) return undefined; - return serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; + if (!bucketId) return serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID; + return parseRegionalBuckets(serverEnv().CAP_REGIONAL_UPLOAD_BUCKETS).find( + (bucket) => bucket.id === bucketId, + )?.distributionId; } diff --git a/packages/env/server.ts b/packages/env/server.ts index 747d1581307..ace6e634db0 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -59,10 +59,8 @@ function createServerEnv() { .optional() .describe("Public URL of the S3 bucket"), CAP_CLOUDFRONT_DISTRIBUTION_ID: z.string().optional(), - CAP_TOKYO_UPLOADS_ENABLED: boolString(), - CAP_TOKYO_BUCKET: z.string().optional(), - CAP_TOKYO_BUCKET_URL: z.string().optional(), - CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID: z.string().optional(), + CAP_REGIONAL_UPLOADS_ENABLED: boolString(), + CAP_REGIONAL_UPLOAD_BUCKETS: z.string().optional(), CLOUDFRONT_KEYPAIR_ID: z.string().optional(), CLOUDFRONT_KEYPAIR_PRIVATE_KEY: z.string().optional(), diff --git a/packages/web-backend/src/S3Buckets/README.md b/packages/web-backend/src/S3Buckets/README.md index 05ec69a4cf7..2d06e230306 100644 --- a/packages/web-backend/src/S3Buckets/README.md +++ b/packages/web-backend/src/S3Buckets/README.md @@ -1,36 +1,54 @@ # Regional Instant uploads -Routing is disabled by default. On Vercel, new desktop Instant recordings use the -request's `x-vercel-ip-country` header. Only `JP` selects Tokyo; missing/unknown -location, disabled routing, or incomplete/invalid configuration keeps the existing -Virginia path. No geolocation service, extra database query, or client change is -needed. Custom storage and Google Drive take precedence. +Disabled by default. On Vercel, new desktop Instant recordings use the request's +`x-vercel-ip-latitude` and `x-vercel-ip-longitude` headers to choose the geographically +nearest configured region, including the existing Virginia destination. Missing or +invalid location, disabled routing, or no usable regional configuration keeps the +original path. Selection is local arithmetic: no geolocation request or extra database +query. Custom storage and Google Drive retain priority. -The chosen bucket is stored on the recording, not the user. Each new recording -uses the current request location; retries, resume, processing, playback, edits, -transfers, and deletion keep the recording's original destination. `cap-tokyo` is -a reserved bucket ID (generated customer IDs cannot contain hyphens); no schema -migration or customer storage row is needed. +Supported destinations are Virginia (existing default), Oregon, Ireland, Frankfurt, +São Paulo, Cape Town, Mumbai, Singapore, Tokyo, and Sydney. Only provisioned and +configured regions participate. Geographic proximity does not guarantee the fastest +network route; validate each region before adding it to the configuration. -Before enabling, provision a private S3 bucket in `ap-northeast-1` and a CloudFront -distribution pointing to it. Use the existing CloudFront signing key group, permit -the server/worker AWS identity to access the bucket and invalidate the distribution, -and configure the same upload CORS rules as Virginia. Set these on every web and -workflow deployment: +The destination is stored on the recording, not the user. Travel affects the next new +recording; retries, processing, playback, edits, transfers, and deletion keep the +original destination. Reserved `cap-*` IDs fit `videos.bucket` and cannot collide with +generated customer IDs. No schema migration or desktop change is needed. -- `CAP_TOKYO_BUCKET`: bucket name. -- `CAP_TOKYO_BUCKET_URL`: HTTPS CDN origin, without a trailing slash or path. -- `CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID`: that distribution's ID. -- `CAP_TOKYO_UPLOADS_ENABLED=true`: enable selection for new uploads from Japan. +Provision a private S3 bucket and CloudFront distribution for each enabled region. +Use the existing CloudFront signing key group, match Virginia's upload CORS rules, +and grant the server/worker AWS identity bucket access and distribution invalidation. +Enable opt-in AWS regions (such as Cape Town) in the account first. Configure every +web and workflow deployment with `CAP_REGIONAL_UPLOAD_BUCKETS`, a JSON object keyed +by supported AWS region. For example: -Keep the existing default AWS and CloudFront configuration. To roll back routing, -set `CAP_TOKYO_UPLOADS_ENABLED=false`; retain the Tokyo bucket and configuration -while recordings reference it. Never repoint its bucket name. Losing configuration -for a stored Tokyo recording fails explicitly instead of writing its remaining -objects into Virginia. +```json +{ + "ap-northeast-1": { + "bucket": "your-tokyo-bucket", + "bucketUrl": "https://your-tokyo-cdn.example.com", + "distributionId": "YOUR_TOKYO_DISTRIBUTION_ID" + }, + "eu-central-1": { + "bucket": "your-frankfurt-bucket", + "bucketUrl": "https://your-frankfurt-cdn.example.com", + "distributionId": "YOUR_FRANKFURT_DISTRIBUTION_ID" + } +} +``` -The Tokyo benchmark improved upload completion but used direct S3 playback and -was slower to first playback/final MP4 than accelerated Virginia with CDN. Before -enabling, repeat the GPUI streaming benchmark with this CDN configuration and verify -source preparation, first playback, finalization, replacement, and deletion. This -PR does not enable routing or provision infrastructure. +Use HTTPS CDN origins without a trailing slash or path. Retain the existing default +AWS/CloudFront configuration (`CAP_AWS_REGION=us-east-1`). After validating the +configured destinations, set `CAP_REGIONAL_UPLOADS_ENABLED=true`. Roll back selection +by setting it to `false`; retain regional bucket configuration while recordings refer +to it, and never repoint a region's bucket name. Missing configuration for an existing +regional recording fails explicitly rather than splitting its objects across regions. + +The Japan benchmark improved upload completion but used direct S3 playback and was +slower to first playback/final MP4 than accelerated Virginia with CDN. Before enabling, +repeat the GPUI benchmark with the configured CDN path and verify preparation, +playback, finalization, replacement, and deletion. Additional buckets do not replicate +recordings, but regional storage rates and cross-region processing transfers affect +cost. This change does not provision infrastructure or enable production routing. diff --git a/packages/web-backend/src/S3Buckets/RegionalBuckets.ts b/packages/web-backend/src/S3Buckets/RegionalBuckets.ts new file mode 100644 index 00000000000..1a498827638 --- /dev/null +++ b/packages/web-backend/src/S3Buckets/RegionalBuckets.ts @@ -0,0 +1,69 @@ +import { S3Bucket } from "@cap/web-domain"; +import { Option, Schema } from "effect"; + +const BucketConfig = Schema.Struct({ + bucket: Schema.String.pipe( + Schema.pattern(/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/), + ), + bucketUrl: Schema.String.pipe( + Schema.filter((value) => { + try { + const url = new URL(value); + return url.protocol === "https:" && url.origin === value; + } catch { + return false; + } + }), + ), + distributionId: Schema.NonEmptyTrimmedString, +}); +const decodeConfig = Schema.decodeUnknownOption( + Schema.parseJson( + Schema.Record({ key: Schema.String, value: Schema.Unknown }), + ), +); + +const decodeBucket = Schema.decodeUnknownOption(BucketConfig); + +export function parseRegionalBuckets(value: string | undefined) { + const config = Option.getOrUndefined(decodeConfig(value ?? "")); + return S3Bucket.RegionalBuckets.flatMap((region) => { + const bucket = Option.getOrUndefined(decodeBucket(config?.[region.region])); + return bucket ? [{ ...region, ...bucket }] : []; + }); +} + +export function getNearestRegionalBucket( + latitude: string | undefined, + longitude: string | undefined, + buckets: ReadonlyArray<(typeof S3Bucket.RegionalBuckets)[number]>, +) { + const lat = Number(latitude); + const lon = Number(longitude); + if ( + !latitude?.trim() || + !longitude?.trim() || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + return Option.none(); + + const radians = Math.PI / 180; + const distance = (targetLat: number, targetLon: number) => + Math.sin(((targetLat - lat) * radians) / 2) ** 2 + + Math.cos(lat * radians) * + Math.cos(targetLat * radians) * + Math.sin(((targetLon - lon) * radians) / 2) ** 2; + let nearest = Option.none(); + let nearestDistance = distance(38.13, -78.45); + for (const bucket of buckets) { + const candidate = distance(bucket.latitude, bucket.longitude); + if (candidate < nearestDistance) { + nearest = Option.some(bucket.id); + nearestDistance = candidate; + } + } + return nearest; +} diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 7c0d323b8ef..d14c45de5e9 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -7,6 +7,10 @@ import { Config, Effect, Layer, Option } from "effect"; import { AwsCredentials } from "../Aws.ts"; import { Database } from "../Database.ts"; +import { + getNearestRegionalBucket, + parseRegionalBuckets, +} from "./RegionalBuckets.ts"; import { createS3BucketAccess } from "./S3BucketAccess.ts"; import { S3BucketClientProvider } from "./S3BucketClientProvider.ts"; import { S3BucketsRepo } from "./S3BucketsRepo.ts"; @@ -145,54 +149,44 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { ), ); - const tokyoConfig = yield* Config.all({ - bucket: Config.string("CAP_TOKYO_BUCKET").pipe( - Config.validate({ - message: "Invalid Tokyo bucket name", - validation: (value) => - /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(value), - }), + const regionalConfigs = parseRegionalBuckets( + Option.getOrUndefined( + yield* Config.string("CAP_REGIONAL_UPLOAD_BUCKETS").pipe(Config.option), ), - bucketUrl: Config.string("CAP_TOKYO_BUCKET_URL").pipe( - Config.validate({ - message: "Tokyo CDN must be an HTTPS origin", - validation: (value) => { - try { - const url = new URL(value); - return url.protocol === "https:" && url.origin === value; - } catch { - return false; - } - }, - }), - ), - distributionId: Config.nonEmptyString( - "CAP_TOKYO_CLOUDFRONT_DISTRIBUTION_ID", - ), - }).pipe(Effect.option); - const tokyoUploadsEnabled = yield* Config.boolean( - "CAP_TOKYO_UPLOADS_ENABLED", + ); + const regionalUploadsEnabled = yield* Config.boolean( + "CAP_REGIONAL_UPLOADS_ENABLED", ).pipe(Effect.orElseSucceed(() => false)); - const tokyoBucketAccess = Option.flatMap(tokyoConfig, (config) => { - const client = new S3.S3Client({ - region: "ap-northeast-1", - credentials, - forcePathStyle: false, - requestHandler, - }); - return Option.map(cloudfrontBucketAccess(config.bucketUrl), (access) => - access.pipe( - Effect.provide( - Layer.succeed(S3BucketClientProvider, { - getInternal: Effect.succeed(client), - getPublic: Effect.succeed(client), - bucket: config.bucket, - isPathStyle: false, - }), - ), - ), - ); - }); + const regionalBucketAccess = new Map( + regionalConfigs.flatMap((config) => { + const access = cloudfrontBucketAccess(config.bucketUrl); + if (Option.isNone(access)) return []; + const client = new S3.S3Client({ + region: config.region, + credentials, + forcePathStyle: false, + requestHandler, + }); + return [ + [ + config.id, + access.value.pipe( + Effect.provide( + Layer.succeed(S3BucketClientProvider, { + getInternal: Effect.succeed(client), + getPublic: Effect.succeed(client), + bucket: config.bucket, + isPathStyle: false, + }), + ), + ), + ] as const, + ]; + }), + ); + const uploadRegions = regionalConfigs.filter((config) => + regionalBucketAccess.has(config.id), + ); const getBucketAccess = Effect.fn("S3Buckets.getProviderLayer")(function* ( customBucket: Option.Option, @@ -235,30 +229,30 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { }); return { - getRegionalUploadBucketId: (country: string | undefined) => - tokyoUploadsEnabled && - country === "JP" && - Option.isSome(tokyoBucketAccess) - ? Option.some(S3Bucket.TokyoBucketId) + getRegionalUploadBucketId: ( + latitude: string | undefined, + longitude: string | undefined, + ) => + regionalUploadsEnabled && defaultConfigs.region === "us-east-1" + ? getNearestRegionalBucket(latitude, longitude, uploadRegions) : Option.none(), getBucketAccess: Effect.fn("S3Buckets.getBucketAccess")(function* ( bucketId?: Option.Option, ) { - if ( - Option.getOrNull(bucketId ?? Option.none()) === S3Bucket.TokyoBucketId - ) { - const access = yield* Option.match(tokyoBucketAccess, { - onSome: (access) => access, - onNone: () => - Effect.fail( - new S3Bucket.S3Error({ - cause: new Error( - "Tokyo storage configuration is missing or invalid", - ), - }), - ), - }); - return [access, Option.none()] as const; + const regionalBucket = S3Bucket.getRegionalBucket( + Option.getOrNull(bucketId ?? Option.none()), + ); + if (regionalBucket) { + const access = regionalBucketAccess.get(regionalBucket.id); + if (!access) + return yield* Effect.fail( + new S3Bucket.S3Error({ + cause: new Error( + `Regional storage configuration is missing or invalid: ${regionalBucket.region}`, + ), + }), + ); + return [yield* access, Option.none()] as const; } const customBucket = yield* (bucketId ?? Option.none()).pipe( Option.map(repo.getById), diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index 40511d5fae2..a855d6707c1 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -4,11 +4,69 @@ import { UserId } from "./User.ts"; export const S3BucketId = Schema.String.pipe(Schema.brand("S3BucketId")); export type S3BucketId = typeof S3BucketId.Type; -// This ID cannot collide with generated customer bucket IDs, which have no hyphens. -export const TokyoBucketId = S3BucketId.make("cap-tokyo"); +// Reserved IDs fit videos.bucket and cannot collide with customer IDs (no hyphens). +export const RegionalBuckets = [ + { + region: "us-west-2", + id: S3BucketId.make("cap-oregon"), + latitude: 45.84, + longitude: -119.7, + }, + { + region: "eu-west-1", + id: S3BucketId.make("cap-ireland"), + latitude: 53.35, + longitude: -6.26, + }, + { + region: "eu-central-1", + id: S3BucketId.make("cap-frankfurt"), + latitude: 50.11, + longitude: 8.68, + }, + { + region: "sa-east-1", + id: S3BucketId.make("cap-sao-paulo"), + latitude: -23.55, + longitude: -46.63, + }, + { + region: "af-south-1", + id: S3BucketId.make("cap-cape-town"), + latitude: -33.92, + longitude: 18.42, + }, + { + region: "ap-south-1", + id: S3BucketId.make("cap-mumbai"), + latitude: 19.08, + longitude: 72.88, + }, + { + region: "ap-southeast-1", + id: S3BucketId.make("cap-singapore"), + latitude: 1.35, + longitude: 103.82, + }, + { + region: "ap-northeast-1", + id: S3BucketId.make("cap-tokyo"), + latitude: 35.68, + longitude: 139.69, + }, + { + region: "ap-southeast-2", + id: S3BucketId.make("cap-sydney"), + latitude: -33.87, + longitude: 151.21, + }, +] as const; + +export const getRegionalBucket = (id: string | null | undefined) => + RegionalBuckets.find((bucket) => bucket.id === id); export const isCapManagedBucket = (id: string | null | undefined) => - !id || id === TokyoBucketId; + !id || getRegionalBucket(id) !== undefined; export class S3Bucket extends Schema.Class("S3Bucket")({ id: S3BucketId, From 5a31384e0a84cfa066038058f96554a1ba4554d6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:44:48 +0900 Subject: [PATCH 6/7] feat: expand lower-cost upload regions --- .../unit/regional-upload-selection.test.ts | 37 ++++++-- packages/web-backend/src/S3Buckets/README.md | 12 +-- packages/web-domain/src/S3Bucket.ts | 88 +++++++++++++++++-- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/apps/web/__tests__/unit/regional-upload-selection.test.ts b/apps/web/__tests__/unit/regional-upload-selection.test.ts index d4d97f21415..e0239954bf4 100644 --- a/apps/web/__tests__/unit/regional-upload-selection.test.ts +++ b/apps/web/__tests__/unit/regional-upload-selection.test.ts @@ -12,14 +12,28 @@ describe("upload region selection", () => { it.each([ ["New York", "40.71", "-74.01", null], ["San Francisco", "37.77", "-122.42", "cap-oregon"], - ["London", "51.51", "-0.13", "cap-ireland"], + ["London", "51.51", "-0.13", "cap-london"], ["Berlin", "52.52", "13.41", "cap-frankfurt"], - ["Buenos Aires", "-34.60", "-58.38", "cap-sao-paulo"], - ["Johannesburg", "-26.20", "28.04", "cap-cape-town"], + ["Buenos Aires", "-34.60", "-58.38", null], + ["Johannesburg", "-26.20", "28.04", "cap-uae"], ["Delhi", "28.61", "77.21", "cap-mumbai"], - ["Bangkok", "13.76", "100.50", "cap-singapore"], + ["Bangkok", "13.76", "100.50", "cap-malaysia"], ["Tokyo", "35.68", "139.69", "cap-tokyo"], - ["Melbourne", "-37.81", "144.96", "cap-sydney"], + ["Melbourne", "-37.81", "144.96", "cap-melbourne"], + ["Columbus", "40.10", "-83.00", "cap-ohio"], + ["Montreal", "45.50", "-73.57", "cap-montreal"], + ["Calgary", "51.04", "-114.07", "cap-calgary"], + ["Paris", "48.86", "2.35", "cap-paris"], + ["Stockholm", "59.33", "18.07", "cap-stockholm"], + ["Milan", "45.46", "9.19", "cap-milan"], + ["Madrid", "40.42", "-3.70", "cap-spain"], + ["Manama", "26.22", "50.59", "cap-bahrain"], + ["Abu Dhabi", "24.45", "54.38", "cap-uae"], + ["Hyderabad", "17.39", "78.49", "cap-hyderabad"], + ["Singapore", "1.35", "103.82", "cap-singapore"], + ["Kuala Lumpur", "3.14", "101.69", "cap-malaysia"], + ["Osaka", "34.69", "135.50", "cap-osaka"], + ["Sydney", "-33.87", "151.21", "cap-sydney"], ["Fiji", "-18.14", "178.44", "cap-sydney"], ["Samoa", "-13.85", "-171.75", "cap-sydney"], ])( @@ -104,6 +118,19 @@ describe("upload region selection", () => { ), ).toEqual([]); }); + it("does not admit the excluded high-cost regions", () => { + const bucket = { + bucket: "unused-bucket", + bucketUrl: "https://unused.cap.test", + distributionId: "EUNUSED", + }; + expect( + parseRegionalBuckets( + JSON.stringify({ "sa-east-1": bucket, "af-south-1": bucket }), + ), + ).toEqual([]); + }); + it("preserves valid regions when another region is misconfigured", () => { const config = parseRegionalBuckets( JSON.stringify({ diff --git a/packages/web-backend/src/S3Buckets/README.md b/packages/web-backend/src/S3Buckets/README.md index 2d06e230306..bb71f7d8a48 100644 --- a/packages/web-backend/src/S3Buckets/README.md +++ b/packages/web-backend/src/S3Buckets/README.md @@ -7,10 +7,12 @@ invalid location, disabled routing, or no usable regional configuration keeps th original path. Selection is local arithmetic: no geolocation request or extra database query. Custom storage and Google Drive retain priority. -Supported destinations are Virginia (existing default), Oregon, Ireland, Frankfurt, -São Paulo, Cape Town, Mumbai, Singapore, Tokyo, and Sydney. Only provisioned and -configured regions participate. Geographic proximity does not guarantee the fastest -network route; validate each region before adding it to the configuration. +Supported destinations are Virginia (existing default), Ohio, Oregon, Montreal, +Calgary, Ireland, London, Paris, Frankfurt, Stockholm, Milan, Spain, Bahrain, UAE, +Mumbai, Hyderabad, Singapore, Malaysia, Tokyo, Osaka, Sydney, and Melbourne. São Paulo +and Cape Town are excluded for cost. Only provisioned and configured regions +participate. Geographic proximity does not guarantee the fastest network route; +validate each region before adding it to the configuration. The destination is stored on the recording, not the user. Travel affects the next new recording; retries, processing, playback, edits, transfers, and deletion keep the @@ -20,7 +22,7 @@ generated customer IDs. No schema migration or desktop change is needed. Provision a private S3 bucket and CloudFront distribution for each enabled region. Use the existing CloudFront signing key group, match Virginia's upload CORS rules, and grant the server/worker AWS identity bucket access and distribution invalidation. -Enable opt-in AWS regions (such as Cape Town) in the account first. Configure every +Enable opt-in AWS regions (such as UAE and Malaysia) in the account first. Configure every web and workflow deployment with `CAP_REGIONAL_UPLOAD_BUCKETS`, a JSON object keyed by supported AWS region. For example: diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index a855d6707c1..78f137c68d0 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -6,18 +6,48 @@ export type S3BucketId = typeof S3BucketId.Type; // Reserved IDs fit videos.bucket and cannot collide with customer IDs (no hyphens). export const RegionalBuckets = [ + { + region: "us-east-2", + id: S3BucketId.make("cap-ohio"), + latitude: 40.1, + longitude: -83.0, + }, { region: "us-west-2", id: S3BucketId.make("cap-oregon"), latitude: 45.84, longitude: -119.7, }, + { + region: "ca-central-1", + id: S3BucketId.make("cap-montreal"), + latitude: 45.5, + longitude: -73.57, + }, + { + region: "ca-west-1", + id: S3BucketId.make("cap-calgary"), + latitude: 51.04, + longitude: -114.07, + }, { region: "eu-west-1", id: S3BucketId.make("cap-ireland"), latitude: 53.35, longitude: -6.26, }, + { + region: "eu-west-2", + id: S3BucketId.make("cap-london"), + latitude: 51.51, + longitude: -0.13, + }, + { + region: "eu-west-3", + id: S3BucketId.make("cap-paris"), + latitude: 48.86, + longitude: 2.35, + }, { region: "eu-central-1", id: S3BucketId.make("cap-frankfurt"), @@ -25,16 +55,34 @@ export const RegionalBuckets = [ longitude: 8.68, }, { - region: "sa-east-1", - id: S3BucketId.make("cap-sao-paulo"), - latitude: -23.55, - longitude: -46.63, + region: "eu-north-1", + id: S3BucketId.make("cap-stockholm"), + latitude: 59.33, + longitude: 18.07, + }, + { + region: "eu-south-1", + id: S3BucketId.make("cap-milan"), + latitude: 45.46, + longitude: 9.19, }, { - region: "af-south-1", - id: S3BucketId.make("cap-cape-town"), - latitude: -33.92, - longitude: 18.42, + region: "eu-south-2", + id: S3BucketId.make("cap-spain"), + latitude: 41.65, + longitude: -0.89, + }, + { + region: "me-south-1", + id: S3BucketId.make("cap-bahrain"), + latitude: 26.22, + longitude: 50.59, + }, + { + region: "me-central-1", + id: S3BucketId.make("cap-uae"), + latitude: 24.45, + longitude: 54.38, }, { region: "ap-south-1", @@ -42,24 +90,48 @@ export const RegionalBuckets = [ latitude: 19.08, longitude: 72.88, }, + { + region: "ap-south-2", + id: S3BucketId.make("cap-hyderabad"), + latitude: 17.39, + longitude: 78.49, + }, { region: "ap-southeast-1", id: S3BucketId.make("cap-singapore"), latitude: 1.35, longitude: 103.82, }, + { + region: "ap-southeast-5", + id: S3BucketId.make("cap-malaysia"), + latitude: 3.14, + longitude: 101.69, + }, { region: "ap-northeast-1", id: S3BucketId.make("cap-tokyo"), latitude: 35.68, longitude: 139.69, }, + { + region: "ap-northeast-3", + id: S3BucketId.make("cap-osaka"), + latitude: 34.69, + longitude: 135.5, + }, { region: "ap-southeast-2", id: S3BucketId.make("cap-sydney"), latitude: -33.87, longitude: 151.21, }, + { + region: "ap-southeast-4", + id: S3BucketId.make("cap-melbourne"), + latitude: -37.81, + longitude: 144.96, + }, ] as const; export const getRegionalBucket = (id: string | null | undefined) => From 7e0dfbf605a306fc14b80d31c5b941600830d391 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:55:43 +0900 Subject: [PATCH 7/7] feat: limit regional uploads to twelve new locations --- .../unit/regional-upload-selection.test.ts | 16 +++--- packages/web-backend/src/S3Buckets/README.md | 17 +++--- packages/web-domain/src/S3Bucket.ts | 54 ------------------- 3 files changed, 17 insertions(+), 70 deletions(-) diff --git a/apps/web/__tests__/unit/regional-upload-selection.test.ts b/apps/web/__tests__/unit/regional-upload-selection.test.ts index e0239954bf4..86a9e9e8dd5 100644 --- a/apps/web/__tests__/unit/regional-upload-selection.test.ts +++ b/apps/web/__tests__/unit/regional-upload-selection.test.ts @@ -19,20 +19,20 @@ describe("upload region selection", () => { ["Delhi", "28.61", "77.21", "cap-mumbai"], ["Bangkok", "13.76", "100.50", "cap-malaysia"], ["Tokyo", "35.68", "139.69", "cap-tokyo"], - ["Melbourne", "-37.81", "144.96", "cap-melbourne"], - ["Columbus", "40.10", "-83.00", "cap-ohio"], + ["Melbourne", "-37.81", "144.96", "cap-sydney"], + ["Columbus", "40.10", "-83.00", null], ["Montreal", "45.50", "-73.57", "cap-montreal"], - ["Calgary", "51.04", "-114.07", "cap-calgary"], + ["Calgary", "51.04", "-114.07", "cap-oregon"], ["Paris", "48.86", "2.35", "cap-paris"], ["Stockholm", "59.33", "18.07", "cap-stockholm"], - ["Milan", "45.46", "9.19", "cap-milan"], - ["Madrid", "40.42", "-3.70", "cap-spain"], - ["Manama", "26.22", "50.59", "cap-bahrain"], + ["Milan", "45.46", "9.19", "cap-frankfurt"], + ["Madrid", "40.42", "-3.70", "cap-paris"], + ["Manama", "26.22", "50.59", "cap-uae"], ["Abu Dhabi", "24.45", "54.38", "cap-uae"], - ["Hyderabad", "17.39", "78.49", "cap-hyderabad"], + ["Hyderabad", "17.39", "78.49", "cap-mumbai"], ["Singapore", "1.35", "103.82", "cap-singapore"], ["Kuala Lumpur", "3.14", "101.69", "cap-malaysia"], - ["Osaka", "34.69", "135.50", "cap-osaka"], + ["Osaka", "34.69", "135.50", "cap-tokyo"], ["Sydney", "-33.87", "151.21", "cap-sydney"], ["Fiji", "-18.14", "178.44", "cap-sydney"], ["Samoa", "-13.85", "-171.75", "cap-sydney"], diff --git a/packages/web-backend/src/S3Buckets/README.md b/packages/web-backend/src/S3Buckets/README.md index bb71f7d8a48..15c265d6e8f 100644 --- a/packages/web-backend/src/S3Buckets/README.md +++ b/packages/web-backend/src/S3Buckets/README.md @@ -7,12 +7,11 @@ invalid location, disabled routing, or no usable regional configuration keeps th original path. Selection is local arithmetic: no geolocation request or extra database query. Custom storage and Google Drive retain priority. -Supported destinations are Virginia (existing default), Ohio, Oregon, Montreal, -Calgary, Ireland, London, Paris, Frankfurt, Stockholm, Milan, Spain, Bahrain, UAE, -Mumbai, Hyderabad, Singapore, Malaysia, Tokyo, Osaka, Sydney, and Melbourne. São Paulo -and Cape Town are excluded for cost. Only provisioned and configured regions -participate. Geographic proximity does not guarantee the fastest network route; -validate each region before adding it to the configuration. +Supported destinations are Virginia (existing default) plus 12 new regions: +Oregon, Montreal, London, Paris, Frankfurt, Stockholm, UAE, Mumbai, Malaysia, +Singapore, Tokyo, and Sydney. São Paulo and Cape Town are excluded for cost. +Only provisioned and configured regions participate. Geographic proximity does not +guarantee the fastest network route; validate each region before enabling it. The destination is stored on the recording, not the user. Travel affects the next new recording; retries, processing, playback, edits, transfers, and deletion keep the @@ -20,8 +19,10 @@ original destination. Reserved `cap-*` IDs fit `videos.bucket` and cannot collid generated customer IDs. No schema migration or desktop change is needed. Provision a private S3 bucket and CloudFront distribution for each enabled region. -Use the existing CloudFront signing key group, match Virginia's upload CORS rules, -and grant the server/worker AWS identity bucket access and distribution invalidation. +Match Virginia's CloudFront cache, CORS, and signed ZIP-download behavior; +use the existing signing key group and S3 origin access control, and grant the +server/worker AWS identity bucket access and distribution invalidation. Keep direct S3 access private. Playback URLs +and relative HLS segments must retain the existing viewer-access behavior. Enable opt-in AWS regions (such as UAE and Malaysia) in the account first. Configure every web and workflow deployment with `CAP_REGIONAL_UPLOAD_BUCKETS`, a JSON object keyed by supported AWS region. For example: diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index 78f137c68d0..f13a241ca45 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -6,12 +6,6 @@ export type S3BucketId = typeof S3BucketId.Type; // Reserved IDs fit videos.bucket and cannot collide with customer IDs (no hyphens). export const RegionalBuckets = [ - { - region: "us-east-2", - id: S3BucketId.make("cap-ohio"), - latitude: 40.1, - longitude: -83.0, - }, { region: "us-west-2", id: S3BucketId.make("cap-oregon"), @@ -24,18 +18,6 @@ export const RegionalBuckets = [ latitude: 45.5, longitude: -73.57, }, - { - region: "ca-west-1", - id: S3BucketId.make("cap-calgary"), - latitude: 51.04, - longitude: -114.07, - }, - { - region: "eu-west-1", - id: S3BucketId.make("cap-ireland"), - latitude: 53.35, - longitude: -6.26, - }, { region: "eu-west-2", id: S3BucketId.make("cap-london"), @@ -60,24 +42,6 @@ export const RegionalBuckets = [ latitude: 59.33, longitude: 18.07, }, - { - region: "eu-south-1", - id: S3BucketId.make("cap-milan"), - latitude: 45.46, - longitude: 9.19, - }, - { - region: "eu-south-2", - id: S3BucketId.make("cap-spain"), - latitude: 41.65, - longitude: -0.89, - }, - { - region: "me-south-1", - id: S3BucketId.make("cap-bahrain"), - latitude: 26.22, - longitude: 50.59, - }, { region: "me-central-1", id: S3BucketId.make("cap-uae"), @@ -90,12 +54,6 @@ export const RegionalBuckets = [ latitude: 19.08, longitude: 72.88, }, - { - region: "ap-south-2", - id: S3BucketId.make("cap-hyderabad"), - latitude: 17.39, - longitude: 78.49, - }, { region: "ap-southeast-1", id: S3BucketId.make("cap-singapore"), @@ -114,24 +72,12 @@ export const RegionalBuckets = [ latitude: 35.68, longitude: 139.69, }, - { - region: "ap-northeast-3", - id: S3BucketId.make("cap-osaka"), - latitude: 34.69, - longitude: 135.5, - }, { region: "ap-southeast-2", id: S3BucketId.make("cap-sydney"), latitude: -33.87, longitude: 151.21, }, - { - region: "ap-southeast-4", - id: S3BucketId.make("cap-melbourne"), - latitude: -37.81, - longitude: 144.96, - }, ] as const; export const getRegionalBucket = (id: string | null | undefined) =>