diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f982ea8..ec92480e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.4.1](https://github.com/source-cooperative/source.coop/compare/v1.4.0...v1.4.1) (2026-07-02) + + +### Bug Fixes + +* correct oauth2 redirect URI ([a5d0e07](https://github.com/source-cooperative/source.coop/commit/a5d0e0753f12a611a9d29b5fa7f0d4fb24ef1206)) + ## [1.4.0](https://github.com/source-cooperative/source.coop/compare/v1.3.0...v1.4.0) (2026-07-01) diff --git a/package-lock.json b/package-lock.json index 2e910366..66e99266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "source-cooperative", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "source-cooperative", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-dynamodb": "^3.767.0", diff --git a/package.json b/package.json index b353932b..2d647b3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "source-cooperative", - "version": "1.4.0", + "version": "1.4.1", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/scripts/apply-opendata-web-identity.ts b/scripts/apply-opendata-web-identity.ts deleted file mode 100644 index 9babf8e6..00000000 --- a/scripts/apply-opendata-web-identity.ts +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env tsx -/** - * Bulk-attach `s3_web_identity_role` authentication to every S3 data connection - * whose bucket ends with `opendata.source.coop`, pointing them all at one - * customer-owned IAM role. The proxy assumes this role via - * `AssumeRoleWithWebIdentity`, so no long-lived credentials are stored (see - * S3WebIdentityRoleAuthenticationSchema in src/types/data-connection.ts). - * - * Run once per environment table. Staging buckets are named - * dev.*.opendata.source.coop, so scope to them with BUCKET_PREFIX=dev. : - * ROLE_ARN=arn:aws:iam::123456789012:role/SourceCoopOpenData BUCKET_PREFIX=dev. \ - * npx tsx scripts/apply-opendata-web-identity.ts sc-dev-data-connections - * - * Idempotent: a connection already pointing at ROLE_ARN is left untouched. - * Any other existing authentication on a matching bucket IS overwritten — the - * point of this script is to switch every open-data bucket onto the one role. - * - * Environment variables: - * ROLE_ARN - IAM role ARN the proxy assumes (required) - * BUCKET_PREFIX - Only touch buckets starting with this (e.g. "dev." for - * staging). Unset = every *.opendata.source.coop bucket. - * AWS_REGION - AWS region (default: us-east-1) - * AWS_PROFILE - AWS profile to use (optional) - * DRY_RUN - Set (to anything) to preview changes without writing - * - * Self-check (no AWS calls): npx tsx scripts/apply-opendata-web-identity.ts --self-check - */ - -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { - DynamoDBDocumentClient, - ScanCommand, - UpdateCommand, -} from "@aws-sdk/lib-dynamodb"; - -const OPENDATA_SUFFIX = "opendata.source.coop"; - -// Mirrors IAM_ROLE_ARN_REGEX in src/types/data-connection.ts — keep in sync. -// ponytail: duplicated rather than imported to keep this one-off script free of -// the app's module graph (matches scripts/backfill-allowed-visibilities.ts). -const IAM_ROLE_ARN_REGEX = - /^arn:aws[a-z-]*:iam::\d{12}:role\/[A-Za-z0-9+=,.@/_-]+$/; - -// Bucket must end at a label boundary: "opendata.source.coop" itself or -// ".opendata.source.coop", never "myopendata.source.coop". An optional -// prefix narrows further, e.g. "dev." to hit only staging's -// dev.*.opendata.source.coop buckets. -function matchesOpenDataBucket( - bucket: string | undefined, - prefix = "", -): boolean { - if (!bucket) return false; - if (prefix && !bucket.startsWith(prefix)) return false; - return bucket === OPENDATA_SUFFIX || bucket.endsWith(`.${OPENDATA_SUFFIX}`); -} - -interface DataConnectionItem { - data_connection_id: string; - details?: { provider?: string; bucket?: string }; - authentication?: { type?: string; role_arn?: string }; -} - -function isConditionalCheckFailed(err: unknown): boolean { - return err instanceof Error && err.name === "ConditionalCheckFailedException"; -} - -async function apply( - tableName: string, - roleArn: string, - bucketPrefix: string, - dryRun: boolean, -) { - const region = process.env.AWS_REGION || "us-east-1"; - - console.log(`Table: ${tableName}`); - console.log(`Role ARN: ${roleArn}`); - console.log(`Bucket: ${bucketPrefix || "*"}.${OPENDATA_SUFFIX}`); - console.log(`Region: ${region}`); - console.log(`Dry run: ${dryRun}`); - console.log(""); - - const dbClient = new DynamoDBClient({ region }); - const client = DynamoDBDocumentClient.from(dbClient, { - marshallOptions: { removeUndefinedValues: true }, - }); - - const auth = { type: "s3_web_identity_role", role_arn: roleArn }; - - let lastEvaluatedKey: Record | undefined; - let scanned = 0; - let updated = 0; - let skipped = 0; // not an open-data S3 bucket - let already = 0; // already pointing at this role - let errors = 0; - - do { - const result = await client.send( - new ScanCommand({ - TableName: tableName, - ProjectionExpression: "data_connection_id, details, authentication", - ExclusiveStartKey: lastEvaluatedKey, - }), - ); - const items = (result.Items || []) as DataConnectionItem[]; - lastEvaluatedKey = result.LastEvaluatedKey; - scanned += items.length; - - for (const item of items) { - if ( - item.details?.provider !== "s3" || - !matchesOpenDataBucket(item.details?.bucket, bucketPrefix) - ) { - skipped++; - continue; - } - - if ( - item.authentication?.type === auth.type && - item.authentication?.role_arn === auth.role_arn - ) { - already++; - continue; - } - - const from = item.authentication?.type ?? "(none)"; - if (dryRun) { - console.log( - `[DRY RUN] ${item.data_connection_id} (${item.details.bucket}): ${from} -> s3_web_identity_role`, - ); - updated++; - continue; - } - - try { - await client.send( - new UpdateCommand({ - TableName: tableName, - Key: { data_connection_id: item.data_connection_id }, - UpdateExpression: "SET authentication = :auth", - // Only touch records that still exist; PK guard keeps a deleted - // connection from being resurrected by a racing write. - ConditionExpression: "attribute_exists(data_connection_id)", - ExpressionAttributeValues: { ":auth": auth }, - }), - ); - console.log( - `${item.data_connection_id} (${item.details.bucket}): ${from} -> s3_web_identity_role`, - ); - updated++; - } catch (err) { - if (isConditionalCheckFailed(err)) { - already++; - continue; - } - errors++; - console.error(`Error updating ${item.data_connection_id}:`, err); - } - } - - console.log( - `Progress: scanned=${scanned}, updated=${updated}, already=${already}, skipped=${skipped}, errors=${errors}`, - ); - } while (lastEvaluatedKey); - - console.log(""); - console.log("apply complete."); - console.log(` Total scanned: ${scanned}`); - console.log(` Updated: ${updated}`); - console.log(` Already set: ${already} (already on this role)`); - console.log(` Skipped: ${skipped} (not an open-data S3 bucket)`); - console.log(` Errors: ${errors}`); -} - -function selfCheck() { - const ok = (cond: boolean, msg: string) => { - if (!cond) throw new Error(`self-check failed: ${msg}`); - }; - ok(matchesOpenDataBucket("dev.us-west-2.opendata.source.coop"), "subdomain"); - ok(matchesOpenDataBucket("opendata.source.coop"), "bare suffix"); - ok(!matchesOpenDataBucket("source-coop-data"), "unrelated bucket"); - ok(!matchesOpenDataBucket("myopendata.source.coop"), "label boundary"); - ok(!matchesOpenDataBucket(undefined), "missing bucket"); - ok( - matchesOpenDataBucket("dev.us-west-2.opendata.source.coop", "dev."), - "prefix match", - ); - ok( - !matchesOpenDataBucket("prod.us-west-2.opendata.source.coop", "dev."), - "prefix excludes prod", - ); - ok( - IAM_ROLE_ARN_REGEX.test("arn:aws:iam::123456789012:role/SourceCoopOpenData"), - "valid arn", - ); - ok( - IAM_ROLE_ARN_REGEX.test("arn:aws-us-gov:iam::123456789012:role/path/Name"), - "govcloud pathed arn", - ); - ok(!IAM_ROLE_ARN_REGEX.test("arn:aws:iam::123:role/Short"), "bad account id"); - ok(!IAM_ROLE_ARN_REGEX.test("not-an-arn"), "not an arn"); - console.log("self-check passed"); -} - -function usage() { - console.error( - "Usage: ROLE_ARN= [BUCKET_PREFIX=dev.] [DRY_RUN=1] npx tsx scripts/apply-opendata-web-identity.ts ", - ); - console.error(" npx tsx scripts/apply-opendata-web-identity.ts --self-check"); - console.error(""); - console.error("Examples:"); - console.error( - " # staging: only dev.*.opendata.source.coop buckets", - ); - console.error( - " ROLE_ARN=arn:aws:iam::123456789012:role/SourceCoopOpenData BUCKET_PREFIX=dev. npx tsx scripts/apply-opendata-web-identity.ts sc-dev-data-connections", - ); -} - -const arg = process.argv[2]; - -if (arg === "--self-check") { - selfCheck(); - process.exit(0); -} - -if (!arg) { - usage(); - process.exit(1); -} - -const roleArn = process.env.ROLE_ARN; -if (!roleArn) { - console.error("ROLE_ARN is required.\n"); - usage(); - process.exit(1); -} -if (!IAM_ROLE_ARN_REGEX.test(roleArn)) { - console.error(`Invalid ROLE_ARN: ${roleArn}`); - process.exit(1); -} - -const bucketPrefix = process.env.BUCKET_PREFIX || ""; -const dryRun = process.env.DRY_RUN !== undefined; - -apply(arg, roleArn, bucketPrefix, dryRun).catch((err) => { - console.error("apply failed:", err); - process.exit(1); -}); diff --git a/scripts/backfill-allowed-visibilities.ts b/scripts/backfill-allowed-visibilities.ts deleted file mode 100644 index 7dc324bb..00000000 --- a/scripts/backfill-allowed-visibilities.ts +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env tsx -/** - * Migrate the legacy `allowed_data_modes` field to `allowed_visibilities` on - * data-connections records, mapping legacy values to ProductVisibility values - * along the way. - * - * Value mapping: - * "open" -> "public" - * "private" -> "restricted" - * "subscription" -> "restricted" - * Values already conforming to ProductVisibility are passed through. - * - * ZERO-DOWNTIME, TWO-PHASE DESIGN - * ------------------------------- - * `allowed_data_modes` and `allowed_visibilities` are read by two independently - * deployed services (this app and the data proxy). A single step that adds the - * new field and drops the old one would break whichever reader is still on the - * other version. So the migration is split so that both fields coexist for the - * whole rollout, and the destructive removal is deferred to the very end: - * - * MODE=backfill (default) — ADDITIVE. Sets `allowed_visibilities` from the - * mapped `allowed_data_modes` and leaves `allowed_data_modes` in place. - * Guarded by `attribute_not_exists(allowed_visibilities)`, so it is - * idempotent and never clobbers a value newer code already wrote. - * - * MODE=cleanup — DESTRUCTIVE. Removes `allowed_data_modes` only. Guarded by - * `attribute_exists(allowed_visibilities)`, so it never strips the legacy - * field off a record that has no replacement. Run this ONLY after every - * reader (app + data proxy) has been deployed reading `allowed_visibilities`. - * - * Rollout order: deploy read-both code (app + proxy) -> run backfill -> - * deploy read-new-only code (app + proxy) -> run cleanup. The backfill is safe - * to run before or after the code deploy because both fields remain present. - * - * Usage: - * npx tsx scripts/backfill-allowed-visibilities.ts - * - * Examples: - * npx tsx scripts/backfill-allowed-visibilities.ts sc-dev-data-connections - * MODE=cleanup npx tsx scripts/backfill-allowed-visibilities.ts sc-prod-data-connections - * - * Environment variables: - * MODE - "backfill" (default, additive) or "cleanup" (destructive) - * AWS_REGION - AWS region (default: us-east-1) - * AWS_PROFILE - AWS profile to use (optional) - * DRY_RUN - Set (to anything) to preview changes without writing - */ - -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { - DynamoDBDocumentClient, - ScanCommand, - UpdateCommand, -} from "@aws-sdk/lib-dynamodb"; - -const LEGACY_TO_NEW: Record = { - open: ["public", "unlisted"], - private: ["restricted"], - subscription: ["restricted"], -}; - -type Mode = "backfill" | "cleanup"; - -interface DataConnectionItem { - data_connection_id: string; - allowed_data_modes?: string[]; - allowed_visibilities?: string[]; -} - -function mapValues(modes: string[]): string[] { - const mapped = modes.flatMap((m) => LEGACY_TO_NEW[m] ?? m); - return Array.from(new Set(mapped)); -} - -function isConditionalCheckFailed(err: unknown): boolean { - return err instanceof Error && err.name === "ConditionalCheckFailedException"; -} - -async function migrate(tableName: string, mode: Mode, dryRun: boolean) { - const region = process.env.AWS_REGION || "us-east-1"; - - console.log(`Mode: ${mode}`); - console.log(`Table: ${tableName}`); - console.log(`Region: ${region}`); - console.log(`Dry run: ${dryRun}`); - console.log(""); - - const dbClient = new DynamoDBClient({ region }); - const client = DynamoDBDocumentClient.from(dbClient, { - marshallOptions: { removeUndefinedValues: true }, - }); - - let lastEvaluatedKey: Record | undefined; - let scanned = 0; - let updated = 0; - let skipped = 0; - // Records that matched the field-presence pre-check but failed the write's - // ConditionExpression (e.g. already backfilled, or no new field to keep). - let guarded = 0; - let errors = 0; - - do { - const result = await client.send( - new ScanCommand({ - TableName: tableName, - ProjectionExpression: - "data_connection_id, allowed_data_modes, allowed_visibilities", - ExclusiveStartKey: lastEvaluatedKey, - }), - ); - const items = (result.Items || []) as DataConnectionItem[]; - lastEvaluatedKey = result.LastEvaluatedKey; - scanned += items.length; - - for (const item of items) { - // Both phases only ever touch records that still carry the legacy field. - // Once `allowed_data_modes` is gone, the record is fully migrated. - if (item.allowed_data_modes === undefined) { - skipped++; - continue; - } - - if (mode === "backfill") { - // Mirror the live ConditionExpression in dry-run: only records missing - // the new field would actually be written. - if (item.allowed_visibilities !== undefined) { - guarded++; - continue; - } - const next = mapValues(item.allowed_data_modes); - - if (dryRun) { - console.log( - `[DRY RUN] Would set ${item.data_connection_id}: allowed_visibilities=${JSON.stringify(next)} (allowed_data_modes left in place)`, - ); - updated++; - continue; - } - - try { - await client.send( - new UpdateCommand({ - TableName: tableName, - Key: { data_connection_id: item.data_connection_id }, - UpdateExpression: - "SET allowed_visibilities = :allowed_visibilities", - ConditionExpression: "attribute_not_exists(allowed_visibilities)", - ExpressionAttributeValues: { ":allowed_visibilities": next }, - }), - ); - updated++; - } catch (err) { - if (isConditionalCheckFailed(err)) { - // A concurrent writer populated allowed_visibilities first; leave it. - guarded++; - continue; - } - errors++; - console.error(`Error updating ${item.data_connection_id}:`, err); - } - } else { - // cleanup: never strip the legacy field off a record that has no - // replacement value yet. - if (item.allowed_visibilities === undefined) { - guarded++; - continue; - } - - if (dryRun) { - console.log( - `[DRY RUN] Would remove allowed_data_modes from ${item.data_connection_id}`, - ); - updated++; - continue; - } - - try { - await client.send( - new UpdateCommand({ - TableName: tableName, - Key: { data_connection_id: item.data_connection_id }, - UpdateExpression: "REMOVE allowed_data_modes", - ConditionExpression: "attribute_exists(allowed_visibilities)", - }), - ); - updated++; - } catch (err) { - if (isConditionalCheckFailed(err)) { - guarded++; - continue; - } - errors++; - console.error(`Error updating ${item.data_connection_id}:`, err); - } - } - } - - console.log( - `Progress: scanned=${scanned}, updated=${updated}, skipped=${skipped}, guarded=${guarded}, errors=${errors}`, - ); - } while (lastEvaluatedKey); - - console.log(""); - console.log(`${mode} complete.`); - console.log(` Total scanned: ${scanned}`); - console.log(` Updated: ${updated}`); - console.log(` Skipped: ${skipped} (no allowed_data_modes)`); - console.log(` Guarded: ${guarded} (condition not met)`); - console.log(` Errors: ${errors}`); -} - -function usage() { - console.error( - "Usage: [MODE=backfill|cleanup] npx tsx scripts/backfill-allowed-visibilities.ts ", - ); - console.error(""); - console.error("Examples:"); - console.error( - " npx tsx scripts/backfill-allowed-visibilities.ts sc-dev-data-connections", - ); - console.error( - " MODE=cleanup npx tsx scripts/backfill-allowed-visibilities.ts sc-prod-data-connections", - ); - console.error(""); - console.error("Environment variables:"); - console.error( - " MODE - 'backfill' (default, additive) or 'cleanup' (destructive)", - ); - console.error(" AWS_REGION - AWS region (default: us-east-1)"); - console.error(" AWS_PROFILE - AWS profile to use (optional)"); - console.error( - " DRY_RUN - Set (to anything) to preview changes without writing", - ); -} - -const tableName = process.argv[2]; - -if (!tableName) { - usage(); - process.exit(1); -} - -const mode = (process.env.MODE || "backfill") as Mode; -if (mode !== "backfill" && mode !== "cleanup") { - console.error(`Invalid MODE: ${mode} (expected "backfill" or "cleanup")`); - console.error(""); - usage(); - process.exit(1); -} - -const dryRun = process.env.DRY_RUN !== undefined; - -migrate(tableName, mode, dryRun).catch((err) => { - console.error(`${mode} failed:`, err); - process.exit(1); -}); diff --git a/scripts/migrate-restricted-opendata-to-unlisted.ts b/scripts/migrate-restricted-opendata-to-unlisted.ts deleted file mode 100644 index a225efde..00000000 --- a/scripts/migrate-restricted-opendata-to-unlisted.ts +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env tsx -/** - * One-time migration: reconcile pre-existing products with the now-enforced - * `DataConnection.allowed_visibilities` invariant (issue #343, after #338). - * - * Before #338, `allowed_visibilities` was unenforced, so products could be - * created `restricted` on the open data program connection even though that - * connection only permits `public`/`unlisted`. This rewrites those products' - * visibility from `restricted` -> `unlisted` (the closest permitted - * non-public-but-accessible visibility). - * - * A product is migrated iff: - * - its primary mirror's `connection_id` is the open data connection, AND - * - its `visibility` is `restricted`. - * - * Before scanning, the target connection's `allowed_visibilities` is read and - * asserted (`restricted` not allowed, `unlisted` allowed) so we never migrate - * to a value that is itself disallowed. - * - * ponytail: scoped narrowly to the restricted-on-open-data case from the issue - * title — the only disallowed->allowed mapping with a defined target. A general - * "any product outside its connection's allowed set" sweep has no obvious target - * for every case; add it when a second concrete case appears. - * - * Usage: - * npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts - * - * Examples: - * npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts sc-dev-products - * npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts sc-prod-products - * - * Environment variables: - * AWS_REGION - AWS region (default: us-east-1) - * AWS_PROFILE - AWS profile to use (optional) - * DYNAMODB_ENDPOINT - Override endpoint (e.g. http://localhost:8000 for local) - * CONNECTION_ID - Open data connection id (default: aws-opendata-us-west-2) - * DATA_CONNECTIONS_TABLE - Data connections table name - * (default: with trailing "products" - * replaced by "data-connections") - * DRY_RUN - Set (to anything) to report affected products without writing - */ - -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { - DynamoDBDocumentClient, - GetCommand, - ScanCommand, - UpdateCommand, -} from "@aws-sdk/lib-dynamodb"; -import { ProductVisibility } from "@/types/product"; - -const FROM = ProductVisibility.Restricted; -const TO = ProductVisibility.Unlisted; - -interface ProductMirror { - connection_id?: string; -} - -interface ProductItem { - account_id: string; - product_id: string; - visibility?: string; - metadata?: { - primary_mirror?: string; - mirrors?: Record; - }; -} - -/** connection_id of a product's primary mirror, or undefined if unresolvable. */ -function primaryConnectionId(item: ProductItem): string | undefined { - const key = item.metadata?.primary_mirror; - if (!key) return undefined; - return item.metadata?.mirrors?.[key]?.connection_id; -} - -async function assertConnectionAllows( - client: DynamoDBDocumentClient, - tableName: string, - connectionId: string -) { - const { Item } = await client.send( - new GetCommand({ - TableName: tableName, - Key: { data_connection_id: connectionId }, - }) - ); - if (!Item) { - throw new Error( - `Connection "${connectionId}" not found in ${tableName}; cannot verify allowed_visibilities.` - ); - } - const allowed: string[] = Item.allowed_visibilities ?? []; - console.log( - `Connection "${connectionId}" allowed_visibilities: ${JSON.stringify(allowed)}` - ); - if (allowed.includes(FROM)) { - throw new Error( - `Connection "${connectionId}" still allows "${FROM}" — migration premise is false, aborting.` - ); - } - if (!allowed.includes(TO)) { - throw new Error( - `Connection "${connectionId}" does not allow "${TO}" — would migrate to a disallowed value, aborting.` - ); - } -} - -async function migrate(productsTable: string, dryRun: boolean) { - const region = process.env.AWS_REGION || "us-east-1"; - const endpoint = process.env.DYNAMODB_ENDPOINT; - const connectionId = process.env.CONNECTION_ID || "aws-opendata-us-west-2"; - const dataConnectionsTable = - process.env.DATA_CONNECTIONS_TABLE || - productsTable.replace(/products$/, "data-connections"); - - console.log(`Migrate "${FROM}" -> "${TO}" on connection: ${connectionId}`); - console.log(`Products table: ${productsTable}`); - console.log(`Data connections table: ${dataConnectionsTable}`); - console.log(`Region: ${region}`); - if (endpoint) console.log(`Endpoint: ${endpoint}`); - console.log(`Dry run: ${dryRun}`); - console.log(""); - - const dbClient = new DynamoDBClient({ region, ...(endpoint ? { endpoint } : {}) }); - const client = DynamoDBDocumentClient.from(dbClient, { - marshallOptions: { removeUndefinedValues: true }, - }); - - // Honor the issue's note: confirm the target connection's allowed set first. - await assertConnectionAllows(client, dataConnectionsTable, connectionId); - console.log(""); - - let lastEvaluatedKey: Record | undefined; - let scanned = 0; - let updated = 0; - let skipped = 0; - let errors = 0; - - do { - const result = await client.send( - new ScanCommand({ - TableName: productsTable, - ProjectionExpression: "account_id, product_id, visibility, metadata", - ExclusiveStartKey: lastEvaluatedKey, - }) - ); - const items = (result.Items || []) as ProductItem[]; - lastEvaluatedKey = result.LastEvaluatedKey; - scanned += items.length; - - for (const item of items) { - if ( - item.visibility !== FROM || - primaryConnectionId(item) !== connectionId - ) { - skipped++; - continue; - } - - const id = `${item.account_id}/${item.product_id}`; - - if (dryRun) { - console.log(`[DRY RUN] Would update ${id}: visibility ${FROM} -> ${TO}`); - updated++; - continue; - } - - try { - await client.send( - new UpdateCommand({ - TableName: productsTable, - Key: { - account_id: item.account_id, - product_id: item.product_id, - }, - // Guard against a concurrent change since the scan read it. - ConditionExpression: "visibility = :from", - UpdateExpression: "SET visibility = :to", - ExpressionAttributeValues: { ":from": FROM, ":to": TO }, - }) - ); - console.log(`Updated ${id}: visibility ${FROM} -> ${TO}`); - updated++; - } catch (err) { - errors++; - console.error(`Error updating ${id}:`, err); - } - } - - console.log( - `Progress: scanned=${scanned}, updated=${updated}, skipped=${skipped}, errors=${errors}` - ); - } while (lastEvaluatedKey); - - console.log(""); - console.log("Migration complete."); - console.log(` Total scanned: ${scanned}`); - console.log(` Updated: ${updated}${dryRun ? " (dry run)" : ""}`); - console.log(` Skipped: ${skipped}`); - console.log(` Errors: ${errors}`); -} - -const productsTable = process.argv[2]; - -if (!productsTable) { - console.error( - "Usage: npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts " - ); - console.error(""); - console.error("Examples:"); - console.error( - " npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts sc-dev-products" - ); - console.error( - " npx tsx scripts/migrate-restricted-opendata-to-unlisted.ts sc-prod-products" - ); - console.error(""); - console.error("Environment variables:"); - console.error(" AWS_REGION - AWS region (default: us-east-1)"); - console.error(" AWS_PROFILE - AWS profile to use (optional)"); - console.error(" DYNAMODB_ENDPOINT - Override endpoint (local testing)"); - console.error(" CONNECTION_ID - Open data connection id"); - console.error(" DATA_CONNECTIONS_TABLE - Data connections table name"); - console.error( - " DRY_RUN - Set to report affected products without writing" - ); - process.exit(1); -} - -const dryRun = process.env.DRY_RUN !== undefined; - -migrate(productsTable, dryRun).catch((err) => { - console.error("Migration failed:", err); - process.exit(1); -}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 498e8c95..d7e57b58 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -5,7 +5,20 @@ const region = process.env.AWS_REGION || "us-east-1"; // In dev, we use middleware to serve auth pages. // In production, we use auth.source.coop to serve auth pages. -const frontendUrl = process.env.NEXT_PUBLIC_ORY_UI_URL || ""; +const frontendAuthUrl = process.env.NEXT_PUBLIC_ORY_UI_URL || ""; + +const frontendUrl = + // 1. Check if running in the browser + typeof window !== "undefined" + ? window.location.origin + : // 2. Check if running on Vercel Production + process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : // 3. Check if running on Vercel Preview/Development + process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : // 4. Fallback to local development + "http://localhost:3000"; export const CONFIG = { // Object storage configuration @@ -43,21 +56,21 @@ export const CONFIG = { auth: { api: { backendUrl: process.env.NEXT_PUBLIC_ORY_SDK_URL, - frontendUrl, + frontendUrl: frontendAuthUrl, }, accessToken: process.env.ORY_PROJECT_API_KEY || "", oauth2: { clientId: process.env.ORY_OAUTH2_CLIENT_ID || "", clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET || "", - redirectUri: `${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/api/internal/oauth2/callback`, + redirectUri: `${frontendUrl}/api/internal/oauth2/callback`, }, routes: { // https://www.ory.sh/docs/reference/api#tag/frontend/operation/createBrowserLoginFlow - login: `${frontendUrl}/self-service/login/browser`, + login: `${frontendAuthUrl}/self-service/login/browser`, // https://www.ory.sh/docs/reference/api#tag/frontend/operation/createBrowserLogoutFlow - logout: `${frontendUrl}/self-service/logout/browser`, + logout: `${frontendAuthUrl}/self-service/logout/browser`, }, },