@deployfoundation/foundation-deploy 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-rq4dc5ns.js";
11
+ } from "../chunk-rhg6yfht.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-9rasrppa.js";
17
+ } from "../chunk-k2rxb77g.js";
18
18
 
19
19
  // bin/app.ts
20
20
  import { execFileSync } from "node:child_process";
@@ -29,7 +29,7 @@ import {
29
29
  stackExists,
30
30
  stackOutput,
31
31
  toolVersion
32
- } from "../chunk-pygj3x43.js";
32
+ } from "../chunk-fjbc0ydk.js";
33
33
  import {
34
34
  BASE_SLACK_EVENTS,
35
35
  CAPABILITY_IDS,
@@ -45,7 +45,7 @@ import {
45
45
  requiredSlackScopes,
46
46
  skillsKey,
47
47
  slackCommandPrefix
48
- } from "../chunk-9rasrppa.js";
48
+ } from "../chunk-k2rxb77g.js";
49
49
 
50
50
  // src/deploy/config-sync.ts
51
51
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
@@ -793,7 +793,7 @@ function pagesDomainReport(state, baseUrl) {
793
793
  if (state.mode === "none" || state.domain === undefined)
794
794
  return baseUrl === undefined ? [] : [` pages are served at ${baseUrl}`];
795
795
  if (state.delegated)
796
- return [` pages are served at https://${state.domain}`];
796
+ return [` pages are served at https://<pageId>.${state.domain} (sign-in on ${state.domain})`];
797
797
  if (state.nameServers.length === 0)
798
798
  return [
799
799
  ` ${state.domain}: the pages zone is created by this deploy; its NS records follow on the next run.`
@@ -801,8 +801,8 @@ function pagesDomainReport(state, baseUrl) {
801
801
  return [
802
802
  ` ☐ add these NS records for ${state.domain} at your DNS provider:`,
803
803
  ...state.nameServers.map((server) => ` ${state.domain}. NS ${server}.`),
804
- ` until they resolve, pages are served at ${baseUrl ?? "the CloudFront default domain"}.`,
805
- " Deploy again once they do: that deploy issues the certificate and moves pages to the domain."
804
+ " until they resolve, pages are not served and page_publish says so.",
805
+ " Deploy again once they do: that deploy issues the certificate and turns pages on."
806
806
  ];
807
807
  }
808
808
 
@@ -1592,12 +1592,7 @@ function slackManifestFor(instance, env) {
1592
1592
  pages: instance.integrations.pages
1593
1593
  }, {
1594
1594
  ...env,
1595
- ...instance.integrations.pages && instance.pages.domain !== undefined ? {
1596
- PAGES_AUTH_REDIRECT_URL: [
1597
- `https://${instance.pages.domain}/_auth/callback`,
1598
- ...env.PAGES_AUTH_REDIRECT_URL === undefined ? [] : [env.PAGES_AUTH_REDIRECT_URL]
1599
- ].join(",")
1600
- } : {}
1595
+ ...instance.integrations.pages && instance.pages.domain !== undefined ? { PAGES_AUTH_REDIRECT_URL: `https://${instance.pages.domain}/_auth/callback` } : {}
1601
1596
  });
1602
1597
  }
1603
1598
 
@@ -1829,10 +1824,7 @@ async function reportSlackManifest(ctx) {
1829
1824
  COMMANDS_REQUEST_URL: await output(ctx, ctx.names.api, "CommandsUrl"),
1830
1825
  INTERACTIVE_REQUEST_URL: await output(ctx, ctx.names.api, "InteractiveUrl"),
1831
1826
  ...ctx.instance.integrations.pages ? {
1832
- PAGES_AUTH_REDIRECT_URL: [
1833
- await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl"),
1834
- await output(ctx, ctx.names.pages, "PagesDefaultAuthRedirectUrl")
1835
- ].join(",")
1827
+ PAGES_AUTH_REDIRECT_URL: await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl")
1836
1828
  } : {}
1837
1829
  });
1838
1830
  console.log(` manifest: ${JSON.stringify(manifest)}`);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-pygj3x43.js";
5
- import"../chunk-9rasrppa.js";
4
+ } from "../chunk-fjbc0ydk.js";
5
+ import"../chunk-k2rxb77g.js";
6
6
 
7
7
  // bin/release-account.ts
8
8
  import * as cdk2 from "aws-cdk-lib";
@@ -6,7 +6,7 @@ import {
6
6
  manifestKey,
7
7
  releaseCacheDir,
8
8
  verifyManifest
9
- } from "./chunk-9rasrppa.js";
9
+ } from "./chunk-k2rxb77g.js";
10
10
 
11
11
  // src/deploy/release.ts
12
12
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
@@ -747,6 +747,12 @@ var RawInstance = z2.object({
747
747
  newsletter: RawNewsletter,
748
748
  pages: RawPages
749
749
  }).strict().superRefine((doc, ctx) => {
750
+ if (doc.integrations.pages && doc.pages.domain === undefined)
751
+ ctx.addIssue({
752
+ code: z2.ZodIssueCode.custom,
753
+ path: ["pages", "domain"],
754
+ message: "integrations.pages needs pages.domain: each page is served from its own subdomain of it (docs/pages.md)"
755
+ });
750
756
  if (doc.pages.domain !== undefined && !doc.integrations.pages)
751
757
  ctx.addIssue({
752
758
  code: z2.ZodIssueCode.custom,
@@ -19,7 +19,7 @@ import {
19
19
  scheduledJobBindings,
20
20
  slackCommandPrefixes,
21
21
  webhookJobBindings
22
- } from "./chunk-9rasrppa.js";
22
+ } from "./chunk-k2rxb77g.js";
23
23
 
24
24
  // src/stacks/agent-stack.ts
25
25
  import * as cdk from "aws-cdk-lib";
@@ -2755,10 +2755,11 @@ var PAGES_VIEWER_REQUEST_SOURCE = `import cf from 'cloudfront';
2755
2755
  import crypto from 'crypto';
2756
2756
 
2757
2757
  var kvs = cf.kvs('__KVS_ID__');
2758
- var COOKIE = 'fpa';
2758
+ var DOMAIN = '__DOMAIN__';
2759
+ var COOKIE = '__Host-fpa';
2759
2760
  var KEY_NAME = '${PAGES_EDGE_KEY_NAME}';
2760
2761
  var HOST_HEADER = '${PAGES_HOST_HEADER}';
2761
- var PAGE_RE = /^\\/p\\/([a-z0-9]{24})(\\/.*)?$/;
2762
+ var PAGE_HOST_RE = /^([a-z0-9]{24})\\.(.+)$/;
2762
2763
  var VERSION_RE = /^\\/v([1-9][0-9]{0,5})(\\/.*)?$/;
2763
2764
  var EXPORT_RE = /^\\/exports\\/v([1-9][0-9]{0,5})\\/([^\\/]+)$/;
2764
2765
  var UNSAFE_RE = /(^|\\/)\\.\\.?(\\/|$)|\\/\\/|%2e|%2f|%5c|\\\\/i;
@@ -2785,6 +2786,11 @@ function redirect(location) {
2785
2786
  };
2786
2787
  }
2787
2788
 
2789
+ function toLambda(request, host) {
2790
+ request.headers[HOST_HEADER] = { value: host };
2791
+ return request;
2792
+ }
2793
+
2788
2794
  function equal(a, b) {
2789
2795
  if (a.length !== b.length) return false;
2790
2796
  var diff = 0;
@@ -2811,10 +2817,10 @@ function cookieValues(request) {
2811
2817
  return values;
2812
2818
  }
2813
2819
 
2814
- function objectPath(pageId, rest) {
2820
+ function objectPath(pageId, uri) {
2815
2821
  var prefix = '/pages/' + pageId;
2816
- if (rest === '/') return prefix + '/latest.html';
2817
- var version = VERSION_RE.exec(rest);
2822
+ if (uri === '/') return prefix + '/latest.html';
2823
+ var version = VERSION_RE.exec(uri);
2818
2824
  if (version) {
2819
2825
  var sub = version[2];
2820
2826
  if (sub.charAt(sub.length - 1) === '/') {
@@ -2824,7 +2830,7 @@ function objectPath(pageId, rest) {
2824
2830
  }
2825
2831
  return prefix + '/v' + version[1] + '/build' + sub;
2826
2832
  }
2827
- var download = EXPORT_RE.exec(rest);
2833
+ var download = EXPORT_RE.exec(uri);
2828
2834
  if (download) return prefix + '/v' + download[1] + '/exports/' + download[2];
2829
2835
  return null;
2830
2836
  }
@@ -2832,18 +2838,20 @@ function objectPath(pageId, rest) {
2832
2838
  async function handler(event) {
2833
2839
  var request = event.request;
2834
2840
  var uri = request.uri;
2835
- if (uri.indexOf('/_auth/') === 0) {
2836
- request.headers[HOST_HEADER] = { value: request.headers.host ? request.headers.host.value : '' };
2837
- return request;
2841
+ var host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
2842
+
2843
+ if (host === DOMAIN) {
2844
+ if (uri === '/_auth/start' || uri === '/_auth/callback') return toLambda(request, host);
2845
+ return reply(404, 'Not Found', 'Not found');
2838
2846
  }
2839
- var page = PAGE_RE.exec(uri);
2840
- if (!page || UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
2847
+ var page = PAGE_HOST_RE.exec(host);
2848
+ if (!page || page[2] !== DOMAIN) return reply(404, 'Not Found', 'Not found');
2841
2849
  var pageId = page[1];
2842
- var rest = page[2] || '';
2843
- if (rest === '') return redirect(uri + '/');
2844
- var version = VERSION_RE.exec(rest);
2850
+ if (uri === '/_auth/redeem') return toLambda(request, host);
2851
+ if (UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
2852
+ var version = VERSION_RE.exec(uri);
2845
2853
  if (version && version[2] === undefined) return redirect(uri + '/');
2846
- var target = objectPath(pageId, rest);
2854
+ var target = objectPath(pageId, uri);
2847
2855
  if (target === null) return reply(404, 'Not Found', 'Not found');
2848
2856
 
2849
2857
  var key;
@@ -2860,20 +2868,22 @@ async function handler(event) {
2860
2868
  return request;
2861
2869
  }
2862
2870
  }
2863
- return redirect('/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
2871
+ return redirect('https://' + DOMAIN + '/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
2864
2872
  }
2865
2873
  `;
2866
- function pagesViewerRequestCode(kvsId) {
2867
- return PAGES_VIEWER_REQUEST_SOURCE.replace("__KVS_ID__", kvsId);
2874
+ function pagesViewerRequestCode(kvsId, domain) {
2875
+ if (!/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(domain))
2876
+ throw new Error("invalid pages domain");
2877
+ return PAGES_VIEWER_REQUEST_SOURCE.replace("__KVS_ID__", kvsId).replace("__DOMAIN__", domain);
2868
2878
  }
2869
2879
 
2870
2880
  // src/stacks/pages-stack.ts
2871
2881
  function pagesDomainPlan(instance, delegated) {
2872
2882
  const { domain, hostedZoneId } = instance.pages;
2873
2883
  if (domain === undefined)
2874
- return { ownZone: false };
2884
+ throw new Error("hosted pages need pages.domain");
2875
2885
  const ownZone = hostedZoneId === undefined;
2876
- return ownZone && !delegated ? { pending: domain, ownZone } : { attached: domain, ownZone };
2886
+ return { domain, attached: !ownZone || delegated, ownZone };
2877
2887
  }
2878
2888
  var PAGES_CONTENT_SECURITY_POLICY = [
2879
2889
  "default-src 'self'",
@@ -2904,7 +2914,8 @@ class FoundationPages extends cdk7.Stack {
2904
2914
  const names = instanceNames(instance);
2905
2915
  const display = instance.displayName;
2906
2916
  const plan = pagesDomainPlan(instance, props.domainDelegated === true);
2907
- const domain = plan.attached;
2917
+ const pagesDomain = plan.domain;
2918
+ const domain = plan.attached ? pagesDomain : undefined;
2908
2919
  this.bucket = new s32.Bucket(this, "PagesBucket", {
2909
2920
  blockPublicAccess: s32.BlockPublicAccess.BLOCK_ALL,
2910
2921
  encryption: s32.BucketEncryption.S3_MANAGED,
@@ -2965,6 +2976,12 @@ class FoundationPages extends cdk7.Stack {
2965
2976
  actions: ["dynamodb:GetItem"],
2966
2977
  resources: [this.table.tableArn]
2967
2978
  }));
2979
+ authRole.addToPolicy(new iam8.PolicyStatement({
2980
+ sid: "PagesAuthClaimCode",
2981
+ actions: ["dynamodb:PutItem"],
2982
+ resources: [this.table.tableArn],
2983
+ conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": ["CODE#*"] } }
2984
+ }));
2968
2985
  authRole.addToPolicy(new iam8.PolicyStatement({
2969
2986
  sid: "PagesTableKey",
2970
2987
  actions: ["kms:Decrypt", "kms:DescribeKey"],
@@ -2983,7 +3000,7 @@ class FoundationPages extends cdk7.Stack {
2983
3000
  FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
2984
3001
  FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
2985
3002
  FOUNDATION_DISPLAY_NAME: display,
2986
- ...domain === undefined ? {} : { FOUNDATION_PAGES_DOMAIN: domain }
3003
+ FOUNDATION_PAGES_DOMAIN: pagesDomain
2987
3004
  }
2988
3005
  });
2989
3006
  const authUrl = authFunction.addFunctionUrl({ authType: lambda3.FunctionUrlAuthType.AWS_IAM });
@@ -3028,7 +3045,7 @@ class FoundationPages extends cdk7.Stack {
3028
3045
  runtime: cloudfront.FunctionRuntime.JS_2_0,
3029
3046
  keyValueStore: edgeKeys,
3030
3047
  comment: `${display} pages: cookie check and path mapping`,
3031
- code: cloudfront.FunctionCode.fromInline(pagesViewerRequestCode(edgeKeys.keyValueStoreId))
3048
+ code: cloudfront.FunctionCode.fromInline(pagesViewerRequestCode(edgeKeys.keyValueStoreId, pagesDomain))
3032
3049
  });
3033
3050
  const headers = new cloudfront.ResponseHeadersPolicy(this, "PagesHeaders", {
3034
3051
  comment: `${display} pages security headers`,
@@ -3050,16 +3067,21 @@ class FoundationPages extends cdk7.Stack {
3050
3067
  }
3051
3068
  },
3052
3069
  customHeadersBehavior: {
3053
- customHeaders: [{ header: "X-Robots-Tag", value: "noindex, nofollow", override: true }]
3070
+ customHeaders: [
3071
+ { header: "X-Robots-Tag", value: "noindex, nofollow", override: true },
3072
+ { header: "Cross-Origin-Resource-Policy", value: "same-origin", override: true },
3073
+ { header: "Cross-Origin-Opener-Policy", value: "same-origin", override: true },
3074
+ { header: "Origin-Agent-Cluster", value: "?1", override: true }
3075
+ ]
3054
3076
  }
3055
3077
  });
3056
- const zoneDomain = instance.pages.domain;
3057
- const zone = zoneDomain === undefined ? undefined : instance.pages.hostedZoneId !== undefined ? route532.HostedZone.fromHostedZoneId(this, "PagesParentZone", instance.pages.hostedZoneId) : new route532.PublicHostedZone(this, "PagesZone", {
3078
+ const zoneDomain = pagesDomain;
3079
+ const zone = instance.pages.hostedZoneId !== undefined ? route532.HostedZone.fromHostedZoneId(this, "PagesParentZone", instance.pages.hostedZoneId) : new route532.PublicHostedZone(this, "PagesZone", {
3058
3080
  zoneName: zoneDomain,
3059
3081
  comment: `${display} hosted pages (delegated from the company's DNS)`
3060
3082
  });
3061
3083
  let certificate;
3062
- if (domain !== undefined && zone !== undefined) {
3084
+ if (domain !== undefined) {
3063
3085
  const certificateRole = new iam8.Role(this, "PagesCertificateRole", {
3064
3086
  assumedBy: new iam8.ServicePrincipal("lambda.amazonaws.com"),
3065
3087
  description: `${display} issues the pages domain certificate in us-east-1`,
@@ -3093,7 +3115,11 @@ class FoundationPages extends cdk7.Stack {
3093
3115
  const issued = new cdk7.CustomResource(this, "PagesCertificate", {
3094
3116
  serviceToken: certificateFunction.functionArn,
3095
3117
  resourceType: "Custom::FoundationPagesCertificate",
3096
- properties: { DomainName: domain, HostedZoneId: zone.hostedZoneId }
3118
+ properties: {
3119
+ DomainName: domain,
3120
+ SubjectAlternativeNames: [`*.${domain}`],
3121
+ HostedZoneId: zone.hostedZoneId
3122
+ }
3097
3123
  });
3098
3124
  certificate = acm2.Certificate.fromCertificateArn(this, "PagesCertificateArn", issued.getAttString("CertificateArn"));
3099
3125
  }
@@ -3102,7 +3128,7 @@ class FoundationPages extends cdk7.Stack {
3102
3128
  ];
3103
3129
  this.distribution = new cloudfront.Distribution(this, "Distribution", {
3104
3130
  comment: `${display} pages`,
3105
- ...domain === undefined || certificate === undefined ? {} : { domainNames: [domain], certificate },
3131
+ ...domain === undefined || certificate === undefined ? {} : { domainNames: [domain, `*.${domain}`], certificate },
3106
3132
  defaultRootObject: "index.html",
3107
3133
  minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
3108
3134
  httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
@@ -3130,13 +3156,18 @@ class FoundationPages extends cdk7.Stack {
3130
3156
  }
3131
3157
  });
3132
3158
  cdk7.Annotations.of(this.distribution).acknowledgeWarning("@aws-cdk/aws-cloudfront-origins:listBucketSecurityRisk", "The viewer function forwards only mapped object keys and 404s `/`; defaultRootObject is set too. LIST only turns a missing key into 404 instead of 403.");
3133
- if (domain !== undefined && zone !== undefined) {
3159
+ if (domain !== undefined) {
3134
3160
  const target = route532.RecordTarget.fromAlias(new route53Targets2.CloudFrontTarget(this.distribution));
3135
3161
  new route532.ARecord(this, "PagesAliasA", { zone, recordName: `${domain}.`, target });
3136
3162
  new route532.AaaaRecord(this, "PagesAliasAaaa", { zone, recordName: `${domain}.`, target });
3163
+ new route532.ARecord(this, "PagesWildcardA", { zone, recordName: `*.${domain}.`, target });
3164
+ new route532.AaaaRecord(this, "PagesWildcardAaaa", {
3165
+ zone,
3166
+ recordName: `*.${domain}.`,
3167
+ target
3168
+ });
3137
3169
  }
3138
- const defaultUrl = `https://${this.distribution.distributionDomainName}`;
3139
- const baseUrl = domain === undefined ? defaultUrl : `https://${domain}`;
3170
+ const signInUrl = `https://${pagesDomain}`;
3140
3171
  const apiRole = new iam8.Role(this, "PagesApiRole", {
3141
3172
  assumedBy: new iam8.ServicePrincipal("lambda.amazonaws.com"),
3142
3173
  description: `${display} Pages API proxy (the only writer of the pages bucket and table)`,
@@ -3182,22 +3213,22 @@ class FoundationPages extends cdk7.Stack {
3182
3213
  environment: {
3183
3214
  FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
3184
3215
  FOUNDATION_PAGES_BUCKET_NAME: this.bucket.bucketName,
3185
- FOUNDATION_PAGES_BASE_URL: baseUrl,
3216
+ ...domain === undefined ? {} : { FOUNDATION_PAGES_DOMAIN: domain },
3186
3217
  FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
3187
3218
  FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName
3188
3219
  }
3189
3220
  });
3190
3221
  new cdk7.CfnOutput(this, "PagesApiFunctionArn", { value: this.apiFunction.functionArn });
3191
- new cdk7.CfnOutput(this, "PagesBaseUrl", { value: baseUrl });
3222
+ new cdk7.CfnOutput(this, "PagesBaseUrl", {
3223
+ description: "The sign-in host; each page is https://<pageId>.<this domain>",
3224
+ value: signInUrl
3225
+ });
3192
3226
  new cdk7.CfnOutput(this, "PagesDistributionDomainName", {
3193
3227
  value: this.distribution.distributionDomainName
3194
3228
  });
3195
3229
  new cdk7.CfnOutput(this, "PagesAuthRedirectUrl", {
3196
3230
  description: "Slack OAuth redirect URL for Sign in with Slack on the pages domain",
3197
- value: `${baseUrl}/_auth/callback`
3198
- });
3199
- new cdk7.CfnOutput(this, "PagesDefaultAuthRedirectUrl", {
3200
- value: `${defaultUrl}/_auth/callback`
3231
+ value: `${signInUrl}/_auth/callback`
3201
3232
  });
3202
3233
  new cdk7.CfnOutput(this, "PagesCustomDomainAttached", {
3203
3234
  value: domain === undefined ? "false" : "true"
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-rq4dc5ns.js";
13
+ } from "../chunk-rhg6yfht.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -43,7 +43,7 @@ import {
43
43
  skillsKey,
44
44
  skipBundle,
45
45
  verifyManifest
46
- } from "../chunk-9rasrppa.js";
46
+ } from "../chunk-k2rxb77g.js";
47
47
  export {
48
48
  BUILD_TIMEOUT,
49
49
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -5,7 +5,7 @@
5
5
  * Route 53 zone for exactly that subdomain and outputs its name servers. The
6
6
  * company adds those as NS records at its own DNS provider. Until they
7
7
  * resolve, issuing the certificate would leave CloudFormation waiting on ACM
8
- * validation, so the stack keeps serving on the CloudFront default domain.
8
+ * validation, so pages are not served (and publishing reports it) until then.
9
9
  * Before each `cdk deploy` this module looks the delegation up in public DNS
10
10
  * and, once it matches the zone, passes `-c pagesDomainDelegated=true`: that
11
11
  * deploy issues the certificate and attaches the domain.
@@ -92,7 +92,8 @@ export async function publicNs(name: string): Promise<string[]> {
92
92
  export function pagesDomainReport(state: PagesDomainState, baseUrl: string | undefined): string[] {
93
93
  if (state.mode === "none" || state.domain === undefined)
94
94
  return baseUrl === undefined ? [] : [` pages are served at ${baseUrl}`];
95
- if (state.delegated) return [` pages are served at https://${state.domain}`];
95
+ if (state.delegated)
96
+ return [` pages are served at https://<pageId>.${state.domain} (sign-in on ${state.domain})`];
96
97
  if (state.nameServers.length === 0)
97
98
  return [
98
99
  ` ${state.domain}: the pages zone is created by this deploy; its NS records follow on the next run.`,
@@ -100,7 +101,7 @@ export function pagesDomainReport(state: PagesDomainState, baseUrl: string | und
100
101
  return [
101
102
  ` ☐ add these NS records for ${state.domain} at your DNS provider:`,
102
103
  ...state.nameServers.map((server) => ` ${state.domain}. NS ${server}.`),
103
- ` until they resolve, pages are served at ${baseUrl ?? "the CloudFront default domain"}.`,
104
- " Deploy again once they do: that deploy issues the certificate and moves pages to the domain.",
104
+ " until they resolve, pages are not served and page_publish says so.",
105
+ " Deploy again once they do: that deploy issues the certificate and turns pages on.",
105
106
  ];
106
107
  }
@@ -190,14 +190,10 @@ async function reportSlackManifest(ctx: AwsContext): Promise<void> {
190
190
  EVENTS_REQUEST_URL: await output(ctx, ctx.names.api, "EventsUrl"),
191
191
  COMMANDS_REQUEST_URL: await output(ctx, ctx.names.api, "CommandsUrl"),
192
192
  INTERACTIVE_REQUEST_URL: await output(ctx, ctx.names.api, "InteractiveUrl"),
193
- // Both sign-in callbacks: the pages domain's, and the CloudFront default
194
- // name's, which links posted before a custom domain attached still use.
193
+ // One sign-in callback, on the pages domain itself.
195
194
  ...(ctx.instance.integrations.pages
196
195
  ? {
197
- PAGES_AUTH_REDIRECT_URL: [
198
- await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl"),
199
- await output(ctx, ctx.names.pages, "PagesDefaultAuthRedirectUrl"),
200
- ].join(","),
196
+ PAGES_AUTH_REDIRECT_URL: await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl"),
201
197
  }
202
198
  : {}),
203
199
  });
@@ -138,15 +138,10 @@ export function slackManifestFor(instance: Instance, env: SlackManifestEnv): Sla
138
138
  },
139
139
  {
140
140
  ...env,
141
- // A custom pages domain's callback is known from the instance file alone;
142
- // registering it early is harmless while the domain waits for delegation.
141
+ // The one sign-in callback, on the pages domain, is known from the
142
+ // instance file alone; registering it before delegation is harmless.
143
143
  ...(instance.integrations.pages && instance.pages.domain !== undefined
144
- ? {
145
- PAGES_AUTH_REDIRECT_URL: [
146
- `https://${instance.pages.domain}/_auth/callback`,
147
- ...(env.PAGES_AUTH_REDIRECT_URL === undefined ? [] : [env.PAGES_AUTH_REDIRECT_URL]),
148
- ].join(","),
149
- }
144
+ ? { PAGES_AUTH_REDIRECT_URL: `https://${instance.pages.domain}/_auth/callback` }
150
145
  : {}),
151
146
  },
152
147
  );
@@ -1,28 +1,34 @@
1
1
  /**
2
2
  * The CloudFront viewer-request function in front of every hosted page.
3
3
  *
4
- * It runs on each request, before the cache, in CloudFront's `cloudfront-js-2.0`
5
- * runtime, and does three things:
4
+ * Every page is its own origin, `<pageId>.<domain>`, so one page's script can
5
+ * never read another page, even for a viewer signed in to both. The function
6
+ * runs on each request, before the cache, in CloudFront's `cloudfront-js-2.0`
7
+ * runtime, and routes by Host:
6
8
  *
7
- * 1. `/_auth/*` passes through to the sign-in Lambda, with the viewer's Host
8
- * copied into `x-foundation-pages-host` (the Lambda's Function URL rejects
9
- * a foreign Host, and needs the real one to build Slack's redirect URI).
10
- * 2. `/p/<pageId>/…` needs a valid `fpa` cookie for that page: the HMAC the
11
- * sign-in Lambda made with the key this function reads from the attached
9
+ * 1. `<domain>` (the sign-in host) serves only `/_auth/start` and
10
+ * `/_auth/callback`, passed to the sign-in Lambda with the Host copied into
11
+ * `x-foundation-pages-host`. Everything else there is a 404.
12
+ * 2. `<pageId>.<domain>` serves that one page. `/_auth/redeem` passes to the
13
+ * Lambda (it sets the page host's own cookie). Anything else needs a valid
14
+ * `__Host-fpa` cookie whose page id is this host's: the HMAC the sign-in
15
+ * Lambda made with the key this function reads from the attached
12
16
  * KeyValueStore (`@deployfoundation/foundation-core/page-cookie` is the
13
- * signer; a test holds the two to one format). No cookie, a cookie for
14
- * another page, or an expired one is a 302 to `/_auth/start`.
17
+ * signer; a test holds the two to one format). No cookie, or an expired or
18
+ * foreign one, is a 302 to `https://<domain>/_auth/start`.
15
19
  * 3. A granted request is rewritten onto its S3 key (the layout in
16
20
  * `packages/capability-pages/src/layout.ts`): a trailing `/` gets
17
21
  * `index.html`, and an extensionless path is a single-page-app route that
18
22
  * gets the version's root `index.html` (how an open-slide deck's `/s/<id>`
19
- * route loads). `source/` and `uploads/` have no public path at all.
23
+ * route loads). `source/` and `uploads/` have no public path at all. The
24
+ * rewritten key carries the page id, so the cache can never serve one
25
+ * page's object on another page's host.
20
26
  *
21
- * Anything else is a 404. Dot segments and encoded separators are refused
22
- * before rewriting so no path can climb into another page's prefix.
27
+ * Any other host (the distribution's `*.cloudfront.net` name included) is a
28
+ * 404. Dot segments and encoded separators are refused before rewriting.
23
29
  *
24
30
  * The runtime is not Node: `var`, plain functions, and only the `crypto`
25
- * HMAC/hash subset. `__KVS_ID__` is replaced with the store id at synth.
31
+ * HMAC/hash subset. `__KVS_ID__` and `__DOMAIN__` are replaced at synth.
26
32
  */
27
33
 
28
34
  /** The KeyValueStore key holding the cookie HMAC key; the seeding resource writes it. */
@@ -34,10 +40,11 @@ export const PAGES_VIEWER_REQUEST_SOURCE = `import cf from 'cloudfront';
34
40
  import crypto from 'crypto';
35
41
 
36
42
  var kvs = cf.kvs('__KVS_ID__');
37
- var COOKIE = 'fpa';
43
+ var DOMAIN = '__DOMAIN__';
44
+ var COOKIE = '__Host-fpa';
38
45
  var KEY_NAME = '${PAGES_EDGE_KEY_NAME}';
39
46
  var HOST_HEADER = '${PAGES_HOST_HEADER}';
40
- var PAGE_RE = /^\\/p\\/([a-z0-9]{24})(\\/.*)?$/;
47
+ var PAGE_HOST_RE = /^([a-z0-9]{24})\\.(.+)$/;
41
48
  var VERSION_RE = /^\\/v([1-9][0-9]{0,5})(\\/.*)?$/;
42
49
  var EXPORT_RE = /^\\/exports\\/v([1-9][0-9]{0,5})\\/([^\\/]+)$/;
43
50
  var UNSAFE_RE = /(^|\\/)\\.\\.?(\\/|$)|\\/\\/|%2e|%2f|%5c|\\\\/i;
@@ -64,6 +71,11 @@ function redirect(location) {
64
71
  };
65
72
  }
66
73
 
74
+ function toLambda(request, host) {
75
+ request.headers[HOST_HEADER] = { value: host };
76
+ return request;
77
+ }
78
+
67
79
  function equal(a, b) {
68
80
  if (a.length !== b.length) return false;
69
81
  var diff = 0;
@@ -90,10 +102,10 @@ function cookieValues(request) {
90
102
  return values;
91
103
  }
92
104
 
93
- function objectPath(pageId, rest) {
105
+ function objectPath(pageId, uri) {
94
106
  var prefix = '/pages/' + pageId;
95
- if (rest === '/') return prefix + '/latest.html';
96
- var version = VERSION_RE.exec(rest);
107
+ if (uri === '/') return prefix + '/latest.html';
108
+ var version = VERSION_RE.exec(uri);
97
109
  if (version) {
98
110
  var sub = version[2];
99
111
  if (sub.charAt(sub.length - 1) === '/') {
@@ -103,7 +115,7 @@ function objectPath(pageId, rest) {
103
115
  }
104
116
  return prefix + '/v' + version[1] + '/build' + sub;
105
117
  }
106
- var download = EXPORT_RE.exec(rest);
118
+ var download = EXPORT_RE.exec(uri);
107
119
  if (download) return prefix + '/v' + download[1] + '/exports/' + download[2];
108
120
  return null;
109
121
  }
@@ -111,18 +123,20 @@ function objectPath(pageId, rest) {
111
123
  async function handler(event) {
112
124
  var request = event.request;
113
125
  var uri = request.uri;
114
- if (uri.indexOf('/_auth/') === 0) {
115
- request.headers[HOST_HEADER] = { value: request.headers.host ? request.headers.host.value : '' };
116
- return request;
126
+ var host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
127
+
128
+ if (host === DOMAIN) {
129
+ if (uri === '/_auth/start' || uri === '/_auth/callback') return toLambda(request, host);
130
+ return reply(404, 'Not Found', 'Not found');
117
131
  }
118
- var page = PAGE_RE.exec(uri);
119
- if (!page || UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
132
+ var page = PAGE_HOST_RE.exec(host);
133
+ if (!page || page[2] !== DOMAIN) return reply(404, 'Not Found', 'Not found');
120
134
  var pageId = page[1];
121
- var rest = page[2] || '';
122
- if (rest === '') return redirect(uri + '/');
123
- var version = VERSION_RE.exec(rest);
135
+ if (uri === '/_auth/redeem') return toLambda(request, host);
136
+ if (UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
137
+ var version = VERSION_RE.exec(uri);
124
138
  if (version && version[2] === undefined) return redirect(uri + '/');
125
- var target = objectPath(pageId, rest);
139
+ var target = objectPath(pageId, uri);
126
140
  if (target === null) return reply(404, 'Not Found', 'Not found');
127
141
 
128
142
  var key;
@@ -139,11 +153,12 @@ async function handler(event) {
139
153
  return request;
140
154
  }
141
155
  }
142
- return redirect('/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
156
+ return redirect('https://' + DOMAIN + '/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
143
157
  }
144
158
  `;
145
159
 
146
- /** The function source for one store; `kvsId` may be a CDK token. */
147
- export function pagesViewerRequestCode(kvsId: string): string {
148
- return PAGES_VIEWER_REQUEST_SOURCE.replace("__KVS_ID__", kvsId);
160
+ /** The function source for one store and pages domain; `kvsId` may be a CDK token. */
161
+ export function pagesViewerRequestCode(kvsId: string, domain: string): string {
162
+ if (!/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(domain)) throw new Error("invalid pages domain");
163
+ return PAGES_VIEWER_REQUEST_SOURCE.replace("__KVS_ID__", kvsId).replace("__DOMAIN__", domain);
149
164
  }
@@ -28,27 +28,28 @@ export interface FoundationPagesProps extends cdk.StackProps {
28
28
  /**
29
29
  * The deploy tool has seen the Foundation-made zone's NS delegation resolve
30
30
  * (`-c pagesDomainDelegated=true`), so the certificate can be issued and the
31
- * domain attached without the deploy hanging on validation. Irrelevant with
32
- * no `pages.domain`, and implied by `pages.hostedZoneId`.
31
+ * domain attached without the deploy hanging on validation. Implied by
32
+ * `pages.hostedZoneId`.
33
33
  */
34
34
  domainDelegated?: boolean;
35
35
  }
36
36
 
37
37
  /** How the stack serves pages for this instance and deploy. */
38
38
  export interface PagesDomainPlan {
39
- /** The custom domain, attached to the distribution in this deploy. */
40
- attached?: string;
41
- /** `pages.domain` is set but waits for its NS delegation. */
42
- pending?: string;
39
+ /** `pages.domain`: the sign-in host; each page is `<pageId>.<domain>`. */
40
+ domain: string;
41
+ /** The domain and its wildcard are attached to the distribution in this deploy. */
42
+ attached: boolean;
43
43
  /** Foundation creates the zone (no `pages.hostedZoneId`). */
44
44
  ownZone: boolean;
45
45
  }
46
46
 
47
47
  export function pagesDomainPlan(instance: Instance, delegated: boolean): PagesDomainPlan {
48
48
  const { domain, hostedZoneId } = instance.pages;
49
- if (domain === undefined) return { ownZone: false };
49
+ // The instance schema already requires it; per-page origins need a domain.
50
+ if (domain === undefined) throw new Error("hosted pages need pages.domain");
50
51
  const ownZone = hostedZoneId === undefined;
51
- return ownZone && !delegated ? { pending: domain, ownZone } : { attached: domain, ownZone };
52
+ return { domain, attached: !ownZone || delegated, ownZone };
52
53
  }
53
54
 
54
55
  /**
@@ -107,7 +108,9 @@ export class FoundationPages extends cdk.Stack {
107
108
  const names = namesFor(instance);
108
109
  const display = instance.displayName;
109
110
  const plan = pagesDomainPlan(instance, props.domainDelegated === true);
110
- const domain = plan.attached;
111
+ const pagesDomain = plan.domain;
112
+ // Set only once the domain is served; until then the Pages API says "not ready".
113
+ const domain = plan.attached ? pagesDomain : undefined;
111
114
 
112
115
  this.bucket = new s3.Bucket(this, "PagesBucket", {
113
116
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
@@ -185,6 +188,15 @@ export class FoundationPages extends cdk.Stack {
185
188
  resources: [this.table.tableArn],
186
189
  }),
187
190
  );
191
+ // One-time sign-in handoff codes: the only rows it may write, and only new ones.
192
+ authRole.addToPolicy(
193
+ new iam.PolicyStatement({
194
+ sid: "PagesAuthClaimCode",
195
+ actions: ["dynamodb:PutItem"],
196
+ resources: [this.table.tableArn],
197
+ conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": ["CODE#*"] } },
198
+ }),
199
+ );
188
200
  authRole.addToPolicy(
189
201
  new iam.PolicyStatement({
190
202
  sid: "PagesTableKey",
@@ -205,7 +217,7 @@ export class FoundationPages extends cdk.Stack {
205
217
  FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
206
218
  FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
207
219
  FOUNDATION_DISPLAY_NAME: display,
208
- ...(domain === undefined ? {} : { FOUNDATION_PAGES_DOMAIN: domain }),
220
+ FOUNDATION_PAGES_DOMAIN: pagesDomain,
209
221
  },
210
222
  });
211
223
  // IAM auth: only CloudFront, signing through origin access control, can call it.
@@ -260,7 +272,9 @@ export class FoundationPages extends cdk.Stack {
260
272
  runtime: cloudfront.FunctionRuntime.JS_2_0,
261
273
  keyValueStore: edgeKeys,
262
274
  comment: `${display} pages: cookie check and path mapping`,
263
- code: cloudfront.FunctionCode.fromInline(pagesViewerRequestCode(edgeKeys.keyValueStoreId)),
275
+ code: cloudfront.FunctionCode.fromInline(
276
+ pagesViewerRequestCode(edgeKeys.keyValueStoreId, pagesDomain),
277
+ ),
264
278
  });
265
279
  const headers = new cloudfront.ResponseHeadersPolicy(this, "PagesHeaders", {
266
280
  comment: `${display} pages security headers`,
@@ -282,7 +296,17 @@ export class FoundationPages extends cdk.Stack {
282
296
  },
283
297
  },
284
298
  customHeadersBehavior: {
285
- customHeaders: [{ header: "X-Robots-Tag", value: "noindex, nofollow", override: true }],
299
+ customHeaders: [
300
+ { header: "X-Robots-Tag", value: "noindex, nofollow", override: true },
301
+ // Pages are sibling origins on one site. CORP stops page A pulling
302
+ // page B's scripts or data in as subresources (the viewer's Lax
303
+ // cookie would be sent, since the hosts are same-site); COOP cuts
304
+ // window references between pages; Origin-Agent-Cluster turns off
305
+ // `document.domain`, so two pages can never merge into one origin.
306
+ { header: "Cross-Origin-Resource-Policy", value: "same-origin", override: true },
307
+ { header: "Cross-Origin-Opener-Policy", value: "same-origin", override: true },
308
+ { header: "Origin-Agent-Cluster", value: "?1", override: true },
309
+ ],
286
310
  },
287
311
  });
288
312
  // --- custom domain ---------------------------------------------------
@@ -290,22 +314,16 @@ export class FoundationPages extends cdk.Stack {
290
314
  // company delegates one name (NS records at its DNS provider) and never
291
315
  // hands over its main zone. With `pages.hostedZoneId` the parent zone is
292
316
  // already in this account and records go straight into it.
293
- const zoneDomain = instance.pages.domain;
294
- const zone: route53.IHostedZone | undefined =
295
- zoneDomain === undefined
296
- ? undefined
297
- : instance.pages.hostedZoneId !== undefined
298
- ? route53.HostedZone.fromHostedZoneId(
299
- this,
300
- "PagesParentZone",
301
- instance.pages.hostedZoneId,
302
- )
303
- : new route53.PublicHostedZone(this, "PagesZone", {
304
- zoneName: zoneDomain,
305
- comment: `${display} hosted pages (delegated from the company's DNS)`,
306
- });
317
+ const zoneDomain = pagesDomain;
318
+ const zone: route53.IHostedZone =
319
+ instance.pages.hostedZoneId !== undefined
320
+ ? route53.HostedZone.fromHostedZoneId(this, "PagesParentZone", instance.pages.hostedZoneId)
321
+ : new route53.PublicHostedZone(this, "PagesZone", {
322
+ zoneName: zoneDomain,
323
+ comment: `${display} hosted pages (delegated from the company's DNS)`,
324
+ });
307
325
  let certificate: acm.ICertificate | undefined;
308
- if (domain !== undefined && zone !== undefined) {
326
+ if (domain !== undefined) {
309
327
  const certificateRole = new iam.Role(this, "PagesCertificateRole", {
310
328
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
311
329
  description: `${display} issues the pages domain certificate in us-east-1`,
@@ -347,7 +365,12 @@ export class FoundationPages extends cdk.Stack {
347
365
  const issued = new cdk.CustomResource(this, "PagesCertificate", {
348
366
  serviceToken: certificateFunction.functionArn,
349
367
  resourceType: "Custom::FoundationPagesCertificate",
350
- properties: { DomainName: domain, HostedZoneId: zone.hostedZoneId },
368
+ // The sign-in host and every page host.
369
+ properties: {
370
+ DomainName: domain,
371
+ SubjectAlternativeNames: [`*.${domain}`],
372
+ HostedZoneId: zone.hostedZoneId,
373
+ },
351
374
  });
352
375
  certificate = acm.Certificate.fromCertificateArn(
353
376
  this,
@@ -363,7 +386,7 @@ export class FoundationPages extends cdk.Stack {
363
386
  comment: `${display} pages`,
364
387
  ...(domain === undefined || certificate === undefined
365
388
  ? {}
366
- : { domainNames: [domain], certificate }),
389
+ : { domainNames: [domain, `*.${domain}`], certificate }),
367
390
  // Never a bucket listing at `/`, even if the viewer function were detached.
368
391
  defaultRootObject: "index.html",
369
392
  minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
@@ -399,16 +422,21 @@ export class FoundationPages extends cdk.Stack {
399
422
  "@aws-cdk/aws-cloudfront-origins:listBucketSecurityRisk",
400
423
  "The viewer function forwards only mapped object keys and 404s `/`; defaultRootObject is set too. LIST only turns a missing key into 404 instead of 403.",
401
424
  );
402
- if (domain !== undefined && zone !== undefined) {
425
+ if (domain !== undefined) {
403
426
  const target = route53.RecordTarget.fromAlias(
404
427
  new route53Targets.CloudFrontTarget(this.distribution),
405
428
  );
406
429
  // Fully qualified, so the parent-zone case needs no zone name.
407
430
  new route53.ARecord(this, "PagesAliasA", { zone, recordName: `${domain}.`, target });
408
431
  new route53.AaaaRecord(this, "PagesAliasAaaa", { zone, recordName: `${domain}.`, target });
432
+ new route53.ARecord(this, "PagesWildcardA", { zone, recordName: `*.${domain}.`, target });
433
+ new route53.AaaaRecord(this, "PagesWildcardAaaa", {
434
+ zone,
435
+ recordName: `*.${domain}.`,
436
+ target,
437
+ });
409
438
  }
410
- const defaultUrl = `https://${this.distribution.distributionDomainName}`;
411
- const baseUrl = domain === undefined ? defaultUrl : `https://${domain}`;
439
+ const signInUrl = `https://${pagesDomain}`;
412
440
 
413
441
  // --- Pages API proxy -------------------------------------------------
414
442
  const apiRole = new iam.Role(this, "PagesApiRole", {
@@ -465,24 +493,23 @@ export class FoundationPages extends cdk.Stack {
465
493
  environment: {
466
494
  FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
467
495
  FOUNDATION_PAGES_BUCKET_NAME: this.bucket.bucketName,
468
- FOUNDATION_PAGES_BASE_URL: baseUrl,
496
+ ...(domain === undefined ? {} : { FOUNDATION_PAGES_DOMAIN: domain }),
469
497
  FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
470
498
  FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
471
499
  },
472
500
  });
473
501
 
474
502
  new cdk.CfnOutput(this, "PagesApiFunctionArn", { value: this.apiFunction.functionArn });
475
- new cdk.CfnOutput(this, "PagesBaseUrl", { value: baseUrl });
503
+ new cdk.CfnOutput(this, "PagesBaseUrl", {
504
+ description: "The sign-in host; each page is https://<pageId>.<this domain>",
505
+ value: signInUrl,
506
+ });
476
507
  new cdk.CfnOutput(this, "PagesDistributionDomainName", {
477
508
  value: this.distribution.distributionDomainName,
478
509
  });
479
510
  new cdk.CfnOutput(this, "PagesAuthRedirectUrl", {
480
511
  description: "Slack OAuth redirect URL for Sign in with Slack on the pages domain",
481
- value: `${baseUrl}/_auth/callback`,
482
- });
483
- // Registered as well, so links from before a custom domain attached keep signing in.
484
- new cdk.CfnOutput(this, "PagesDefaultAuthRedirectUrl", {
485
- value: `${defaultUrl}/_auth/callback`,
512
+ value: `${signInUrl}/_auth/callback`,
486
513
  });
487
514
  new cdk.CfnOutput(this, "PagesCustomDomainAttached", {
488
515
  value: domain === undefined ? "false" : "true",