diff --git a/docs/admin/README.md b/docs/admin/README.md index 6fd4847fade..77b86183eb7 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -165,6 +165,10 @@ cd tools/playwright-verify && node verify-admin.mjs ``` - `http://localhost:8602/admin/?devlogin=` で stg ログインをバイパス(バイパス identity `dev-admin@example.com` の allowlist 登録が前提) +- **ポートは 8601〜8610 のどれでもよい**(`PORT=8603 npm start` など)。非 prod の API は + この範囲の `http://localhost:` を CORS で許可する(#1160)。devpod で worktree を + 並行起動すると 8601 から順に別ポートが割り当たるため、範囲で許可している。 + **prod はこの範囲を含まない**(含む設定で deploy しようとすると stack が throw する) - ユニットテスト: `cd packages/admin && npm test`(eslint + jest)/ `cd infra/smalruby-admin && npm test` ## スクリーンショット diff --git a/infra/smalruby-admin/lambda/tests/cors-origins.test.ts b/infra/smalruby-admin/lambda/tests/cors-origins.test.ts new file mode 100644 index 00000000000..d3de9de263d --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/cors-origins.test.ts @@ -0,0 +1,95 @@ +/** + * CORS 許可オリジンの決定ロジック (#1160)。 + * + * ローカルの dev server は 8601 から順に使うため非 prod では範囲で許可するが、 + * その緩和が prod に漏れないことが本質。ここが崩れると本番 API が localhost から + * 叩けてしまうので、範囲そのものより「prod に入らない」ことを厚めに固定する。 + */ +import { + LOCAL_DEV_PORT_FIRST, + LOCAL_DEV_PORT_LAST, + isLocalOrigin, + localDevOrigins, + resolveCorsOrigins, +} from '../../lib/cors-origins'; + +const PROD_ORIGINS = ['https://smalruby.app', 'https://smalruby.jp']; + +describe('localDevOrigins', () => { + test('8601 から 8610 までを両端込みで並べる', () => { + const origins = localDevOrigins(); + + expect(origins).toHaveLength(LOCAL_DEV_PORT_LAST - LOCAL_DEV_PORT_FIRST + 1); + expect(origins[0]).toBe('http://localhost:8601'); + expect(origins[origins.length - 1]).toBe('http://localhost:8610'); + }); + + test('範囲外のポートは含まない', () => { + const origins = localDevOrigins(); + + expect(origins).not.toContain('http://localhost:8600'); + expect(origins).not.toContain('http://localhost:8611'); + }); +}); + +describe('isLocalOrigin', () => { + test.each([ + 'http://localhost:8601', + 'http://localhost', + 'https://localhost:8602', + 'http://127.0.0.1:8601', + 'http://[::1]:8601', + ])('%s はローカル扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(true); + }); + + test.each([ + 'https://smalruby.app', + 'https://smalruby.jp', + // ホスト名の一部に localhost を含むだけの別ドメインを取り違えない。 + 'https://localhost.example.com', + 'https://notlocalhost', + ])('%s は本番オリジン扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(false); + }); +}); + +describe('resolveCorsOrigins', () => { + test('非 prod は本番オリジン + ローカル範囲', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS); + + expect(origins).toEqual([...PROD_ORIGINS, ...localDevOrigins()]); + }); + + test('prod はローカルを一切含まない', () => { + const origins = resolveCorsOrigins('prod', PROD_ORIGINS); + + expect(origins).toEqual(PROD_ORIGINS); + expect(origins.some(isLocalOrigin)).toBe(false); + }); + + test('env の指定が既定より優先される', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS, 'https://example.com, http://localhost:9999'); + + expect(origins).toEqual(['https://example.com', 'http://localhost:9999']); + }); + + test('prod に localhost を渡した env は deploy させない', () => { + expect(() => + resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app,http://localhost:8601'), + ).toThrow(/must not contain local origins in prod/); + }); + + test('prod でも本番オリジンだけの env は通る', () => { + expect(resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app')).toEqual([ + 'https://smalruby.app', + ]); + }); + + test('env の空要素・余分な空白を落とす', () => { + expect(resolveCorsOrigins('stg', PROD_ORIGINS, ' https://a.example , , https://b.example ')).toEqual([ + 'https://a.example', + 'https://b.example', + ]); + }); +}); diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 44a3a5c773b..de351cb63ea 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -13,6 +13,8 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as path from 'path'; import { Construct } from 'constructs'; +import { resolveCorsOrigins } from './cors-origins'; + /** * Smalruby Admin service (EPIC #1073): the operator-only management API. * @@ -34,11 +36,12 @@ export class SmalrubyAdminStack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; - const corsOriginsEnv = process.env.CORS_ALLOWED_ORIGINS || - (stage === 'prod' - ? 'https://smalruby.app' - : 'https://smalruby.app,http://localhost:8602'); - const corsAllowOrigins = corsOriginsEnv.split(',').map(o => o.trim()); + const corsAllowOrigins = resolveCorsOrigins( + stage, + ['https://smalruby.app'], + process.env.CORS_ALLOWED_ORIGINS, + ); + const corsOriginsEnv = corsAllowOrigins.join(','); // Admin-dedicated Google OAuth client (decision B). An empty client ID // would disable the audience check, so prod refuses to deploy without it diff --git a/infra/smalruby-admin/lib/cors-origins.ts b/infra/smalruby-admin/lib/cors-origins.ts new file mode 100644 index 00000000000..7c3cd2a7d90 --- /dev/null +++ b/infra/smalruby-admin/lib/cors-origins.ts @@ -0,0 +1,59 @@ +/** + * CORS 許可オリジンの組み立て (#1160)。 + * + * ローカルの dev server は 8601 から順に使う(scratch-gui = 8601 / Admin SPA = 8602)。 + * devpod で worktree を並行起動すると 8603 以降も割り当てられるため、非 prod では + * 範囲でまとめて許可する。HTTP API v2 の `allowOrigins` は**完全一致**で、ポートに + * ワイルドカードは使えないので列挙する。 + * + * 各 infra プロジェクトは独立した npm パッケージで共有モジュールを持てないため、 + * 同じ内容のファイルが他プロジェクトにもある。変更するときは揃えること。 + */ + +/** ローカル dev server に割り当てるポートの範囲(両端を含む)。 */ +export const LOCAL_DEV_PORT_FIRST = 8601; +export const LOCAL_DEV_PORT_LAST = 8610; + +/** ローカル開発で許可するオリジン(非 prod のみで使う)。 */ +export const localDevOrigins = (): string[] => { + const origins: string[] = []; + for (let port = LOCAL_DEV_PORT_FIRST; port <= LOCAL_DEV_PORT_LAST; port += 1) { + origins.push(`http://localhost:${port}`); + } + return origins; +}; + +/** そのオリジンがローカル開発用(本番に出してはいけないもの)か。 */ +export const isLocalOrigin = (origin: string): boolean => + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim()); + +/** + * ステージに応じた許可オリジンを決める。 + * + * env `CORS_ALLOWED_ORIGINS` が最優先。prod にローカルオリジンが混ざっていたら + * **deploy させない**(`DEV_BYPASS_TOKEN` の prod ガードと同じ考え方。CORS は + * 「開発中は緩めて戻し忘れる」が起きやすいので、戻し忘れを検知する側に倒す)。 + * @param stage - デプロイ先ステージ + * @param productionOrigins - どのステージでも許可する本番オリジン + * @param envValue - `CORS_ALLOWED_ORIGINS` の値(未設定なら undefined) + * @returns 許可オリジンの配列 + */ +export const resolveCorsOrigins = ( + stage: string, + productionOrigins: string[], + envValue?: string, +): string[] => { + const origins = envValue + ? envValue.split(',').map(o => o.trim()).filter(Boolean) + : [...productionOrigins, ...(stage === 'prod' ? [] : localDevOrigins())]; + + if (stage === 'prod') { + const local = origins.filter(isLocalOrigin); + if (local.length > 0) { + throw new Error( + `CORS_ALLOWED_ORIGINS must not contain local origins in prod: ${local.join(', ')}`, + ); + } + } + return origins; +}; diff --git a/infra/smalruby-bug-report/lambda/tests/cors-origins.test.ts b/infra/smalruby-bug-report/lambda/tests/cors-origins.test.ts new file mode 100644 index 00000000000..d3de9de263d --- /dev/null +++ b/infra/smalruby-bug-report/lambda/tests/cors-origins.test.ts @@ -0,0 +1,95 @@ +/** + * CORS 許可オリジンの決定ロジック (#1160)。 + * + * ローカルの dev server は 8601 から順に使うため非 prod では範囲で許可するが、 + * その緩和が prod に漏れないことが本質。ここが崩れると本番 API が localhost から + * 叩けてしまうので、範囲そのものより「prod に入らない」ことを厚めに固定する。 + */ +import { + LOCAL_DEV_PORT_FIRST, + LOCAL_DEV_PORT_LAST, + isLocalOrigin, + localDevOrigins, + resolveCorsOrigins, +} from '../../lib/cors-origins'; + +const PROD_ORIGINS = ['https://smalruby.app', 'https://smalruby.jp']; + +describe('localDevOrigins', () => { + test('8601 から 8610 までを両端込みで並べる', () => { + const origins = localDevOrigins(); + + expect(origins).toHaveLength(LOCAL_DEV_PORT_LAST - LOCAL_DEV_PORT_FIRST + 1); + expect(origins[0]).toBe('http://localhost:8601'); + expect(origins[origins.length - 1]).toBe('http://localhost:8610'); + }); + + test('範囲外のポートは含まない', () => { + const origins = localDevOrigins(); + + expect(origins).not.toContain('http://localhost:8600'); + expect(origins).not.toContain('http://localhost:8611'); + }); +}); + +describe('isLocalOrigin', () => { + test.each([ + 'http://localhost:8601', + 'http://localhost', + 'https://localhost:8602', + 'http://127.0.0.1:8601', + 'http://[::1]:8601', + ])('%s はローカル扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(true); + }); + + test.each([ + 'https://smalruby.app', + 'https://smalruby.jp', + // ホスト名の一部に localhost を含むだけの別ドメインを取り違えない。 + 'https://localhost.example.com', + 'https://notlocalhost', + ])('%s は本番オリジン扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(false); + }); +}); + +describe('resolveCorsOrigins', () => { + test('非 prod は本番オリジン + ローカル範囲', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS); + + expect(origins).toEqual([...PROD_ORIGINS, ...localDevOrigins()]); + }); + + test('prod はローカルを一切含まない', () => { + const origins = resolveCorsOrigins('prod', PROD_ORIGINS); + + expect(origins).toEqual(PROD_ORIGINS); + expect(origins.some(isLocalOrigin)).toBe(false); + }); + + test('env の指定が既定より優先される', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS, 'https://example.com, http://localhost:9999'); + + expect(origins).toEqual(['https://example.com', 'http://localhost:9999']); + }); + + test('prod に localhost を渡した env は deploy させない', () => { + expect(() => + resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app,http://localhost:8601'), + ).toThrow(/must not contain local origins in prod/); + }); + + test('prod でも本番オリジンだけの env は通る', () => { + expect(resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app')).toEqual([ + 'https://smalruby.app', + ]); + }); + + test('env の空要素・余分な空白を落とす', () => { + expect(resolveCorsOrigins('stg', PROD_ORIGINS, ' https://a.example , , https://b.example ')).toEqual([ + 'https://a.example', + 'https://b.example', + ]); + }); +}); diff --git a/infra/smalruby-bug-report/lib/cors-origins.ts b/infra/smalruby-bug-report/lib/cors-origins.ts new file mode 100644 index 00000000000..7c3cd2a7d90 --- /dev/null +++ b/infra/smalruby-bug-report/lib/cors-origins.ts @@ -0,0 +1,59 @@ +/** + * CORS 許可オリジンの組み立て (#1160)。 + * + * ローカルの dev server は 8601 から順に使う(scratch-gui = 8601 / Admin SPA = 8602)。 + * devpod で worktree を並行起動すると 8603 以降も割り当てられるため、非 prod では + * 範囲でまとめて許可する。HTTP API v2 の `allowOrigins` は**完全一致**で、ポートに + * ワイルドカードは使えないので列挙する。 + * + * 各 infra プロジェクトは独立した npm パッケージで共有モジュールを持てないため、 + * 同じ内容のファイルが他プロジェクトにもある。変更するときは揃えること。 + */ + +/** ローカル dev server に割り当てるポートの範囲(両端を含む)。 */ +export const LOCAL_DEV_PORT_FIRST = 8601; +export const LOCAL_DEV_PORT_LAST = 8610; + +/** ローカル開発で許可するオリジン(非 prod のみで使う)。 */ +export const localDevOrigins = (): string[] => { + const origins: string[] = []; + for (let port = LOCAL_DEV_PORT_FIRST; port <= LOCAL_DEV_PORT_LAST; port += 1) { + origins.push(`http://localhost:${port}`); + } + return origins; +}; + +/** そのオリジンがローカル開発用(本番に出してはいけないもの)か。 */ +export const isLocalOrigin = (origin: string): boolean => + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim()); + +/** + * ステージに応じた許可オリジンを決める。 + * + * env `CORS_ALLOWED_ORIGINS` が最優先。prod にローカルオリジンが混ざっていたら + * **deploy させない**(`DEV_BYPASS_TOKEN` の prod ガードと同じ考え方。CORS は + * 「開発中は緩めて戻し忘れる」が起きやすいので、戻し忘れを検知する側に倒す)。 + * @param stage - デプロイ先ステージ + * @param productionOrigins - どのステージでも許可する本番オリジン + * @param envValue - `CORS_ALLOWED_ORIGINS` の値(未設定なら undefined) + * @returns 許可オリジンの配列 + */ +export const resolveCorsOrigins = ( + stage: string, + productionOrigins: string[], + envValue?: string, +): string[] => { + const origins = envValue + ? envValue.split(',').map(o => o.trim()).filter(Boolean) + : [...productionOrigins, ...(stage === 'prod' ? [] : localDevOrigins())]; + + if (stage === 'prod') { + const local = origins.filter(isLocalOrigin); + if (local.length > 0) { + throw new Error( + `CORS_ALLOWED_ORIGINS must not contain local origins in prod: ${local.join(', ')}`, + ); + } + } + return origins; +}; diff --git a/infra/smalruby-bug-report/lib/smalruby-bug-report-stack.ts b/infra/smalruby-bug-report/lib/smalruby-bug-report-stack.ts index de8f3f8ee37..b41ce282575 100644 --- a/infra/smalruby-bug-report/lib/smalruby-bug-report-stack.ts +++ b/infra/smalruby-bug-report/lib/smalruby-bug-report-stack.ts @@ -12,6 +12,8 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as path from 'path'; import { Construct } from 'constructs'; +import { resolveCorsOrigins } from './cors-origins'; + export class SmalrubyBugReportStack extends cdk.Stack { public readonly reportsTable: dynamodb.Table; public readonly adminsTable: dynamodb.Table; @@ -24,9 +26,13 @@ export class SmalrubyBugReportStack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; - const corsOriginsEnv = process.env.CORS_ALLOWED_ORIGINS || - 'https://smalruby.app,https://smalruby.jp,http://localhost:8601'; - const corsAllowOrigins = corsOriginsEnv.split(',').map(o => o.trim()); + // CORS設定。非 prod ではローカル dev server の範囲(8601-8610)も許可する。 + const corsAllowOrigins = resolveCorsOrigins( + stage, + ['https://smalruby.app', 'https://smalruby.jp'], + process.env.CORS_ALLOWED_ORIGINS, + ); + const corsOriginsEnv = corsAllowOrigins.join(','); const googleClientId = process.env.GOOGLE_CLIENT_ID || ''; const microsoftClientId = process.env.MICROSOFT_CLIENT_ID || ''; diff --git a/infra/smalruby-classroom/lambda/tests/cors-origins.test.ts b/infra/smalruby-classroom/lambda/tests/cors-origins.test.ts new file mode 100644 index 00000000000..d3de9de263d --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/cors-origins.test.ts @@ -0,0 +1,95 @@ +/** + * CORS 許可オリジンの決定ロジック (#1160)。 + * + * ローカルの dev server は 8601 から順に使うため非 prod では範囲で許可するが、 + * その緩和が prod に漏れないことが本質。ここが崩れると本番 API が localhost から + * 叩けてしまうので、範囲そのものより「prod に入らない」ことを厚めに固定する。 + */ +import { + LOCAL_DEV_PORT_FIRST, + LOCAL_DEV_PORT_LAST, + isLocalOrigin, + localDevOrigins, + resolveCorsOrigins, +} from '../../lib/cors-origins'; + +const PROD_ORIGINS = ['https://smalruby.app', 'https://smalruby.jp']; + +describe('localDevOrigins', () => { + test('8601 から 8610 までを両端込みで並べる', () => { + const origins = localDevOrigins(); + + expect(origins).toHaveLength(LOCAL_DEV_PORT_LAST - LOCAL_DEV_PORT_FIRST + 1); + expect(origins[0]).toBe('http://localhost:8601'); + expect(origins[origins.length - 1]).toBe('http://localhost:8610'); + }); + + test('範囲外のポートは含まない', () => { + const origins = localDevOrigins(); + + expect(origins).not.toContain('http://localhost:8600'); + expect(origins).not.toContain('http://localhost:8611'); + }); +}); + +describe('isLocalOrigin', () => { + test.each([ + 'http://localhost:8601', + 'http://localhost', + 'https://localhost:8602', + 'http://127.0.0.1:8601', + 'http://[::1]:8601', + ])('%s はローカル扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(true); + }); + + test.each([ + 'https://smalruby.app', + 'https://smalruby.jp', + // ホスト名の一部に localhost を含むだけの別ドメインを取り違えない。 + 'https://localhost.example.com', + 'https://notlocalhost', + ])('%s は本番オリジン扱い', (origin) => { + expect(isLocalOrigin(origin)).toBe(false); + }); +}); + +describe('resolveCorsOrigins', () => { + test('非 prod は本番オリジン + ローカル範囲', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS); + + expect(origins).toEqual([...PROD_ORIGINS, ...localDevOrigins()]); + }); + + test('prod はローカルを一切含まない', () => { + const origins = resolveCorsOrigins('prod', PROD_ORIGINS); + + expect(origins).toEqual(PROD_ORIGINS); + expect(origins.some(isLocalOrigin)).toBe(false); + }); + + test('env の指定が既定より優先される', () => { + const origins = resolveCorsOrigins('stg', PROD_ORIGINS, 'https://example.com, http://localhost:9999'); + + expect(origins).toEqual(['https://example.com', 'http://localhost:9999']); + }); + + test('prod に localhost を渡した env は deploy させない', () => { + expect(() => + resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app,http://localhost:8601'), + ).toThrow(/must not contain local origins in prod/); + }); + + test('prod でも本番オリジンだけの env は通る', () => { + expect(resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app')).toEqual([ + 'https://smalruby.app', + ]); + }); + + test('env の空要素・余分な空白を落とす', () => { + expect(resolveCorsOrigins('stg', PROD_ORIGINS, ' https://a.example , , https://b.example ')).toEqual([ + 'https://a.example', + 'https://b.example', + ]); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 7c5a8aaedaa..138c24fd276 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -13,6 +13,8 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as path from 'path'; import { Construct } from 'constructs'; +import { resolveCorsOrigins } from './cors-origins'; + export class ClassroomStack extends cdk.Stack { public readonly classroomsTable: dynamodb.Table; public readonly membershipsTable: dynamodb.Table; @@ -32,10 +34,13 @@ export class ClassroomStack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; - // CORS設定 - const corsOriginsEnv = process.env.CORS_ALLOWED_ORIGINS || - 'https://smalruby.app,https://smalruby.jp,http://localhost:8601'; - const corsAllowOrigins = corsOriginsEnv.split(',').map(o => o.trim()); + // CORS設定。非 prod ではローカル dev server の範囲(8601-8610)も許可する。 + const corsAllowOrigins = resolveCorsOrigins( + stage, + ['https://smalruby.app', 'https://smalruby.jp'], + process.env.CORS_ALLOWED_ORIGINS, + ); + const corsOriginsEnv = corsAllowOrigins.join(','); // Google Client ID for id_token verification const googleClientId = process.env.GOOGLE_CLIENT_ID || ''; diff --git a/infra/smalruby-classroom/lib/cors-origins.ts b/infra/smalruby-classroom/lib/cors-origins.ts new file mode 100644 index 00000000000..7c3cd2a7d90 --- /dev/null +++ b/infra/smalruby-classroom/lib/cors-origins.ts @@ -0,0 +1,59 @@ +/** + * CORS 許可オリジンの組み立て (#1160)。 + * + * ローカルの dev server は 8601 から順に使う(scratch-gui = 8601 / Admin SPA = 8602)。 + * devpod で worktree を並行起動すると 8603 以降も割り当てられるため、非 prod では + * 範囲でまとめて許可する。HTTP API v2 の `allowOrigins` は**完全一致**で、ポートに + * ワイルドカードは使えないので列挙する。 + * + * 各 infra プロジェクトは独立した npm パッケージで共有モジュールを持てないため、 + * 同じ内容のファイルが他プロジェクトにもある。変更するときは揃えること。 + */ + +/** ローカル dev server に割り当てるポートの範囲(両端を含む)。 */ +export const LOCAL_DEV_PORT_FIRST = 8601; +export const LOCAL_DEV_PORT_LAST = 8610; + +/** ローカル開発で許可するオリジン(非 prod のみで使う)。 */ +export const localDevOrigins = (): string[] => { + const origins: string[] = []; + for (let port = LOCAL_DEV_PORT_FIRST; port <= LOCAL_DEV_PORT_LAST; port += 1) { + origins.push(`http://localhost:${port}`); + } + return origins; +}; + +/** そのオリジンがローカル開発用(本番に出してはいけないもの)か。 */ +export const isLocalOrigin = (origin: string): boolean => + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim()); + +/** + * ステージに応じた許可オリジンを決める。 + * + * env `CORS_ALLOWED_ORIGINS` が最優先。prod にローカルオリジンが混ざっていたら + * **deploy させない**(`DEV_BYPASS_TOKEN` の prod ガードと同じ考え方。CORS は + * 「開発中は緩めて戻し忘れる」が起きやすいので、戻し忘れを検知する側に倒す)。 + * @param stage - デプロイ先ステージ + * @param productionOrigins - どのステージでも許可する本番オリジン + * @param envValue - `CORS_ALLOWED_ORIGINS` の値(未設定なら undefined) + * @returns 許可オリジンの配列 + */ +export const resolveCorsOrigins = ( + stage: string, + productionOrigins: string[], + envValue?: string, +): string[] => { + const origins = envValue + ? envValue.split(',').map(o => o.trim()).filter(Boolean) + : [...productionOrigins, ...(stage === 'prod' ? [] : localDevOrigins())]; + + if (stage === 'prod') { + const local = origins.filter(isLocalOrigin); + if (local.length > 0) { + throw new Error( + `CORS_ALLOWED_ORIGINS must not contain local origins in prod: ${local.join(', ')}`, + ); + } + } + return origins; +};