+ Internal tool. Generates the bucket policy statements that grant an
+ external AWS principal write access to one prefix. Merge these into the bucket policy —
+ do not replace it.
+
+
+
+
+
+
+
+
+ {arn && !arnLooksValid && (
+
+ That does not look like an IAM principal ARN. Expected{' '}
+ arn:aws:iam::123456789012:root,{' '}
+ arn:aws:iam::123456789012:role/NAME, or{' '}
+ arn:aws:iam::123456789012:user/NAME.
+
+ )}
+
+ {check === 'missing' && (
+
+ {prefix} was not found on Source Cooperative. Double-check the IDs —
+ though this is expected if you have not created it yet. The policy below is generated
+ either way.
+
+ Generate the policy to attach to the IAM role or user in your own AWS
+ account, so it can upload to your prefix in the Source Cooperative bucket. See{' '}
+ Upload Your Data for the full walkthrough.
+
+
+
+
+
+
+
+
+ {check === 'missing' && (
+
+ {prefix} was not found on Source Cooperative. Double-check the IDs —
+ though this is expected if you have not created it yet. The policy below is generated
+ either way.
+
+ )}
+ {policy ? (
+ <>
+
+ Attach this policy to your role or user. It grants read/write (including multipart
+ uploads) under {prefix}/.
+
+
+
+ This only grants permission on your side. Uploads will fail with{' '}
+ AccessDenied until Source Cooperative grants your ARN access on the
+ bucket. Email hello@source.coop with the ARN of
+ the role or user and the prefix {prefix}/.
+
+ >
+ ) : (
+
+ Enter an account ID to generate the policy.
+
+ )}
+
+
+ );
+}
diff --git a/src/policies.check.ts b/src/policies.check.ts
new file mode 100644
index 0000000..d88a404
--- /dev/null
+++ b/src/policies.check.ts
@@ -0,0 +1,80 @@
+// Self-check: node --experimental-strip-types src/policies.check.ts
+import assert from 'node:assert';
+import {
+ DEFAULT_BUCKET,
+ DEFAULT_REGION,
+ REGIONS,
+ REGIONS_BY_AREA,
+ bucketFor,
+ buildBucketPolicy,
+ buildIdentityPolicy,
+ isValidPrincipalArn,
+ joinPrefix,
+ existenceCheckUrl,
+} from './policies.ts';
+
+assert.equal(joinPrefix(' org ', 'product'), 'org/product');
+assert.equal(joinPrefix('/org/', ''), 'org');
+assert.equal(joinPrefix('org', ' '), 'org');
+
+for (const arn of [
+ 'arn:aws:iam::123456789012:root',
+ 'arn:aws:iam::123456789012:role/source-coop-upload',
+ 'arn:aws:iam::123456789012:user/data-uploader',
+]) {
+ assert.ok(isValidPrincipalArn(arn), arn);
+}
+for (const bad of ['123456789012', 'arn:aws:s3:::bucket', 'arn:aws:iam::12345:role/x', '']) {
+ assert.ok(!isValidPrincipalArn(bad), bad);
+}
+
+const identity = buildIdentityPolicy(DEFAULT_BUCKET, 'org/product');
+assert.equal(identity.Statement[0].Resource, `arn:aws:s3:::${DEFAULT_BUCKET}/org/product/*`);
+assert.ok(identity.Statement[0].Action.includes('s3:AbortMultipartUpload'));
+// s3:ListBucketMultipartUploads cannot be scoped to a prefix, so it must never
+// appear: it would expose every other provider's in-progress uploads.
+const grantsBucketWideMultipart = (policy) =>
+ JSON.stringify(policy).includes('ListBucketMultipartUploads');
+assert.ok(!grantsBucketWideMultipart(identity));
+
+const bucketPolicy = buildBucketPolicy(DEFAULT_BUCKET, 'org/product', 'arn:aws:iam::123456789012:root');
+assert.ok(bucketPolicy.Statement.every((s) => s.Principal.AWS === 'arn:aws:iam::123456789012:root'));
+assert.deepEqual(
+ bucketPolicy.Statement.map((s) => s.Sid),
+ ['Grant-org-product-Write', 'Grant-org-product-List'],
+);
+assert.equal(
+ buildBucketPolicy(DEFAULT_BUCKET, 'org', 'arn:aws:iam::123456789012:root').Statement[0].Sid,
+ 'Grant-org-Write',
+);
+assert.ok(!grantsBucketWideMultipart(bucketPolicy));
+// Every statement stays scoped: object actions to the prefix ARN, ListBucket by condition.
+for (const st of bucketPolicy.Statement) {
+ const scoped = st.Resource.endsWith('/org/product/*') || 'Condition' in st;
+ assert.ok(scoped, `unscoped statement: ${st.Sid}`);
+}
+
+// Every region must appear exactly once, in exactly one area group.
+assert.equal(new Set(REGIONS.map((r) => r.region)).size, REGIONS.length);
+assert.equal(
+ REGIONS_BY_AREA.reduce((n, [, regions]) => n + regions.length, 0),
+ REGIONS.length,
+);
+assert.ok(REGIONS.some((r) => r.region === DEFAULT_REGION));
+assert.equal(bucketFor(DEFAULT_REGION), DEFAULT_BUCKET);
+assert.equal(bucketFor('eu-west-1'), 'eu-west-1.opendata.source.coop');
+
+assert.equal(
+ existenceCheckUrl('org/product'),
+ 'https://source.coop/api/v1/products/org/product',
+);
+assert.equal(
+ existenceCheckUrl(' org/product/ '),
+ 'https://source.coop/api/v1/products/org/product',
+);
+// An account ID on its own is checkable too.
+assert.equal(existenceCheckUrl('org'), 'https://source.coop/api/v1/products/org');
+assert.equal(existenceCheckUrl(''), null);
+assert.equal(existenceCheckUrl('org/product/extra'), null);
+
+console.log('policies.ts: all checks passed');
diff --git a/src/policies.ts b/src/policies.ts
new file mode 100644
index 0000000..82b78f7
--- /dev/null
+++ b/src/policies.ts
@@ -0,0 +1,143 @@
+// Policy builders shared by the wizard pages. Pure functions — see policies.check.ts.
+
+// Regional data buckets — keep in sync with docs/about-source/infrastructure.md.
+export const REGIONS: {area: string; region: string; location: string}[] = [
+ {area: 'Americas', region: 'us-east-1', location: 'US East (N. Virginia)'},
+ {area: 'Americas', region: 'us-east-2', location: 'US East (Ohio)'},
+ {area: 'Americas', region: 'us-west-1', location: 'US West (N. California)'},
+ {area: 'Americas', region: 'us-west-2', location: 'US West (Oregon)'},
+ {area: 'Americas', region: 'ca-central-1', location: 'Canada (Central)'},
+ {area: 'Americas', region: 'sa-east-1', location: 'South America (São Paulo)'},
+ {area: 'Europe', region: 'eu-west-1', location: 'Europe (Ireland)'},
+ {area: 'Europe', region: 'eu-west-2', location: 'Europe (London)'},
+ {area: 'Europe', region: 'eu-west-3', location: 'Europe (Paris)'},
+ {area: 'Europe', region: 'eu-central-1', location: 'Europe (Frankfurt)'},
+ {area: 'Europe', region: 'eu-north-1', location: 'Europe (Stockholm)'},
+ {area: 'Asia Pacific', region: 'ap-northeast-1', location: 'Asia Pacific (Tokyo)'},
+ {area: 'Asia Pacific', region: 'ap-northeast-2', location: 'Asia Pacific (Seoul)'},
+ {area: 'Asia Pacific', region: 'ap-northeast-3', location: 'Asia Pacific (Osaka)'},
+ {area: 'Asia Pacific', region: 'ap-south-1', location: 'Asia Pacific (Mumbai)'},
+ {area: 'Asia Pacific', region: 'ap-southeast-1', location: 'Asia Pacific (Singapore)'},
+ {area: 'Asia Pacific', region: 'ap-southeast-2', location: 'Asia Pacific (Sydney)'},
+];
+
+export const bucketFor = (region: string): string => `${region}.opendata.source.coop`;
+
+/** Regions grouped by area, for