@deployfoundation/foundation-deploy 0.2.14 → 0.2.16

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.
@@ -46,6 +46,13 @@ import {
46
46
  plannedLicenseCheck,
47
47
  verifyLicense,
48
48
  } from "./license.ts";
49
+ import {
50
+ type PagesDomainState,
51
+ pagesDomainMode,
52
+ pagesDomainReport,
53
+ pagesDomainState,
54
+ publicNs,
55
+ } from "./pages-domain.ts";
49
56
  import { AGENT_DOCKERFILE, FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
50
57
  import {
51
58
  type ReleaseRequest,
@@ -239,6 +246,7 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
239
246
  ...(opts.runtimeSecretDigest === undefined
240
247
  ? []
241
248
  : ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`]),
249
+ ...(await pagesDomainContext(ctx)),
242
250
  ];
243
251
  await run(
244
252
  [
@@ -259,6 +267,39 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
259
267
  );
260
268
  }
261
269
 
270
+ /** The custom pages domain's state, read from the pages stack and public DNS. */
271
+ async function pagesDomain(ctx: AwsContext): Promise<PagesDomainState> {
272
+ return pagesDomainState(ctx.instance, {
273
+ output: (key) => optionalOutput(ctx, ctx.names.pages, key),
274
+ resolveNs: publicNs,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * `-c pagesDomainDelegated=true` once a Foundation-made pages zone is
280
+ * delegated, so this deploy issues the certificate and attaches the domain
281
+ * (`pages-domain.ts`). A dry run looks nothing up and leaves it pending.
282
+ */
283
+ async function pagesDomainContext(ctx: AwsContext): Promise<string[]> {
284
+ if (pagesDomainMode(ctx.instance) !== "own-zone" || ctx.dryRun === true) return [];
285
+ return (await pagesDomain(ctx)).delegated ? ["-c", "pagesDomainDelegated=true"] : [];
286
+ }
287
+
288
+ /** Where pages are served, and the NS checklist step while a custom domain waits. */
289
+ export async function reportPagesDomain(ctx: AwsContext): Promise<void> {
290
+ if (!ctx.instance.integrations.pages) return;
291
+ console.log("▶ hosted pages");
292
+ if (ctx.dryRun === true) {
293
+ console.log(
294
+ ` <${ctx.names.pages}.PagesBaseUrl>; NS records from <${ctx.names.pages}.PagesNameServers>`,
295
+ );
296
+ return;
297
+ }
298
+ const state = await pagesDomain(ctx);
299
+ const baseUrl = await optionalOutput(ctx, ctx.names.pages, "PagesBaseUrl");
300
+ for (const line of pagesDomainReport(state, baseUrl)) console.log(line);
301
+ }
302
+
262
303
  /** A stack output that may not exist yet (a stack deployed before it was added). */
263
304
  async function optionalOutput(
264
305
  ctx: AwsContext,
@@ -370,6 +411,12 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
370
411
  ? `<${api}.CrmPolicyFingerprint>`
371
412
  : await optionalOutput(ctx, api, "CrmPolicyFingerprint")
372
413
  : undefined;
414
+ // Pages have a stack of their own, present only with the integration.
415
+ const pagesApiFunctionArn = ctx.instance.integrations.pages
416
+ ? dry
417
+ ? `<${ctx.names.pages}.PagesApiFunctionArn>`
418
+ : await optionalOutput(ctx, ctx.names.pages, "PagesApiFunctionArn")
419
+ : undefined;
373
420
  const otterProxyFunctionArn = dry
374
421
  ? `<${api}.OtterProxyFunctionArn>`
375
422
  : await optionalOutput(ctx, api, "OtterProxyFunctionArn");
@@ -414,6 +461,7 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
414
461
  ...(browserProxyFunctionArn === undefined ? {} : { browserProxyFunctionArn }),
415
462
  ...(crmProxyFunctionArn === undefined ? {} : { crmProxyFunctionArn }),
416
463
  ...(crmPolicyFingerprint === undefined ? {} : { crmPolicyFingerprint }),
464
+ ...(pagesApiFunctionArn === undefined ? {} : { pagesApiFunctionArn }),
417
465
  ...(otterProxyFunctionArn === undefined ? {} : { otterProxyFunctionArn }),
418
466
  ...(knockProxyFunctionArn === undefined ? {} : { knockProxyFunctionArn }),
419
467
  ...(upworkProxyFunctionArn === undefined ? {} : { upworkProxyFunctionArn }),
@@ -630,6 +678,7 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
630
678
  ...(runtimeSecretDigest === undefined ? {} : { runtimeSecretDigest }),
631
679
  });
632
680
  await deployStacks(deployed);
681
+ await reportPagesDomain(ctx);
633
682
  if (ctx.instance.integrations.knock) {
634
683
  console.log(`▶ Knock OAuth public-client registration (${ctx.names.secretKnockOauthClient})`);
635
684
  console.log(` ${await syncKnockOAuthClient(ctx)}`);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The custom pages domain's delegation, as the deploy tool sees it.
3
+ *
4
+ * With `pages.domain` and no `pages.hostedZoneId`, the pages stack creates a
5
+ * Route 53 zone for exactly that subdomain and outputs its name servers. The
6
+ * company adds those as NS records at its own DNS provider. Until they
7
+ * resolve, issuing the certificate would leave CloudFormation waiting on ACM
8
+ * validation, so the stack keeps serving on the CloudFront default domain.
9
+ * Before each `cdk deploy` this module looks the delegation up in public DNS
10
+ * and, once it matches the zone, passes `-c pagesDomainDelegated=true`: that
11
+ * deploy issues the certificate and attaches the domain.
12
+ *
13
+ * Sticky: once the stack reports this domain attached, it stays attached
14
+ * without another lookup, so a transient DNS failure can never detach a
15
+ * working domain. A changed `pages.domain` starts over.
16
+ */
17
+ import { Resolver } from "node:dns/promises";
18
+
19
+ export type PagesDomainMode = "none" | "own-zone" | "parent-zone";
20
+
21
+ export interface PagesDomainInstance {
22
+ integrations: { pages: boolean };
23
+ pages: { domain?: string; hostedZoneId?: string };
24
+ }
25
+
26
+ export interface PagesDomainDeps {
27
+ /** A pages stack output, or undefined when the stack or output does not exist yet. */
28
+ output(key: string): Promise<string | undefined>;
29
+ /** NS records for a name from public DNS; empty when there are none. */
30
+ resolveNs(name: string): Promise<string[]>;
31
+ }
32
+
33
+ export interface PagesDomainState {
34
+ mode: PagesDomainMode;
35
+ domain?: string;
36
+ /** The certificate and alias may be (or already are) attached. */
37
+ delegated: boolean;
38
+ /** The Foundation-made zone's name servers, for the NS checklist step. */
39
+ nameServers: string[];
40
+ }
41
+
42
+ export function pagesDomainMode(instance: PagesDomainInstance): PagesDomainMode {
43
+ if (!instance.integrations.pages || instance.pages.domain === undefined) return "none";
44
+ return instance.pages.hostedZoneId === undefined ? "own-zone" : "parent-zone";
45
+ }
46
+
47
+ function normal(name: string): string {
48
+ return name.trim().toLowerCase().replace(/\.$/, "");
49
+ }
50
+
51
+ export async function pagesDomainState(
52
+ instance: PagesDomainInstance,
53
+ deps: PagesDomainDeps,
54
+ ): Promise<PagesDomainState> {
55
+ const mode = pagesDomainMode(instance);
56
+ const domain = instance.pages.domain;
57
+ if (mode === "none" || domain === undefined) return { mode, delegated: false, nameServers: [] };
58
+ // The company's own zone is authoritative already: nothing to wait for.
59
+ if (mode === "parent-zone") return { mode, domain, delegated: true, nameServers: [] };
60
+ const nameServers = ((await deps.output("PagesNameServers")) ?? "")
61
+ .split(",")
62
+ .map(normal)
63
+ .filter((server) => server !== "");
64
+ if (
65
+ (await deps.output("PagesCustomDomainAttached")) === "true" &&
66
+ (await deps.output("PagesCustomDomain")) === domain
67
+ )
68
+ return { mode, domain, delegated: true, nameServers };
69
+ if (nameServers.length === 0) return { mode, domain, delegated: false, nameServers };
70
+ let answer: string[];
71
+ try {
72
+ answer = (await deps.resolveNs(domain)).map(normal);
73
+ } catch {
74
+ answer = [];
75
+ }
76
+ const delegated = nameServers.every((server) => answer.includes(server));
77
+ return { mode, domain, delegated, nameServers };
78
+ }
79
+
80
+ /** Public DNS, not the machine's resolver: a split-horizon office network must not decide. */
81
+ export async function publicNs(name: string): Promise<string[]> {
82
+ const resolver = new Resolver({ timeout: 5_000, tries: 2 });
83
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
84
+ try {
85
+ return await resolver.resolveNs(name);
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /** The lines the deploy prints: a checklist step while pending, the address once attached. */
92
+ export function pagesDomainReport(state: PagesDomainState, baseUrl: string | undefined): string[] {
93
+ if (state.mode === "none" || state.domain === undefined)
94
+ return baseUrl === undefined ? [] : [` pages are served at ${baseUrl}`];
95
+ if (state.delegated) return [` pages are served at https://${state.domain}`];
96
+ if (state.nameServers.length === 0)
97
+ return [
98
+ ` ${state.domain}: the pages zone is created by this deploy; its NS records follow on the next run.`,
99
+ ];
100
+ return [
101
+ ` ☐ add these NS records for ${state.domain} at your DNS provider:`,
102
+ ...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.",
105
+ ];
106
+ }
@@ -76,6 +76,11 @@ export interface RuntimeSecretInputs {
76
76
  crmProxyFunctionArn?: string;
77
77
  /** Hash of the CRM policy baked into the deployed proxy; never a credential. */
78
78
  crmPolicyFingerprint?: string;
79
+ /**
80
+ * `FoundationPages.PagesApiFunctionArn` — the only pages binding the
81
+ * container gets. Bucket, table and cookie key stay behind the proxy.
82
+ */
83
+ pagesApiFunctionArn?: string;
79
84
  /**
80
85
  * `FoundationApi.OtterProxyFunctionArn` — the ONLY Otter binding given to the
81
86
  * shell-capable container. The API credential remains readable solely by
@@ -217,6 +222,9 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
217
222
  ...(inputs.crmPolicyFingerprint !== undefined && inputs.crmPolicyFingerprint !== ""
218
223
  ? { FOUNDATION_CRM_POLICY_FINGERPRINT: inputs.crmPolicyFingerprint }
219
224
  : {}),
225
+ ...(inputs.pagesApiFunctionArn !== undefined && inputs.pagesApiFunctionArn !== ""
226
+ ? { FOUNDATION_PAGES_API_FUNCTION_ARN: inputs.pagesApiFunctionArn }
227
+ : {}),
220
228
  ...(inputs.otterProxyFunctionArn !== undefined && inputs.otterProxyFunctionArn !== ""
221
229
  ? { FOUNDATION_OTTER_PROXY_FUNCTION_ARN: inputs.otterProxyFunctionArn }
222
230
  : {}),
@@ -33,6 +33,7 @@ import {
33
33
  cdkDeploy,
34
34
  currentRuntimeSecretDigest,
35
35
  refreshRuntimeSecret,
36
+ reportPagesDomain,
36
37
  syncKnockOAuthClient,
37
38
  syncRuntimeSecret,
38
39
  } from "./deploy.ts";
@@ -189,6 +190,16 @@ async function reportSlackManifest(ctx: AwsContext): Promise<void> {
189
190
  EVENTS_REQUEST_URL: await output(ctx, ctx.names.api, "EventsUrl"),
190
191
  COMMANDS_REQUEST_URL: await output(ctx, ctx.names.api, "CommandsUrl"),
191
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.
195
+ ...(ctx.instance.integrations.pages
196
+ ? {
197
+ PAGES_AUTH_REDIRECT_URL: [
198
+ await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl"),
199
+ await output(ctx, ctx.names.pages, "PagesDefaultAuthRedirectUrl"),
200
+ ].join(","),
201
+ }
202
+ : {}),
192
203
  });
193
204
  console.log(` manifest: ${JSON.stringify(manifest)}`);
194
205
  console.log(
@@ -411,8 +422,9 @@ export async function setup(
411
422
  step(13, "deploy the instance's own pipeline, if it has one");
412
423
  await deployPipelineStack(ctx);
413
424
 
414
- step(14, "the Slack app manifest for the deployed API");
425
+ step(14, "the Slack app manifest for the deployed API, and the pages domain");
415
426
  await reportSlackManifest(ctx);
427
+ await reportPagesDomain(ctx);
416
428
 
417
429
  step(15, "smoke test the runtime");
418
430
  await smokeTest(ctx);
@@ -33,17 +33,27 @@ export interface SlackManifestInstance {
33
33
  displayName: string;
34
34
  /** Rendered into `/<prefix>-<suffix>` for every slash command. */
35
35
  commandPrefix: string;
36
+ /**
37
+ * The instance hosts pages: the app then asks for the `openid` user scope
38
+ * that Sign in with Slack needs, and registers the pages sign-in redirect.
39
+ */
40
+ pages?: boolean;
36
41
  }
37
42
 
38
43
  export interface SlackManifestEnv {
39
44
  EVENTS_REQUEST_URL?: string;
40
45
  COMMANDS_REQUEST_URL?: string;
41
46
  INTERACTIVE_REQUEST_URL?: string;
47
+ /**
48
+ * Comma-separated sign-in callbacks: the pages stack's `PagesAuthRedirectUrl`
49
+ * and `PagesDefaultAuthRedirectUrl` outputs.
50
+ */
51
+ PAGES_AUTH_REDIRECT_URL?: string;
42
52
  }
43
53
 
44
54
  export interface SlackManifest {
45
55
  features: { slash_commands?: Array<{ command: string }> };
46
- oauth_config: { scopes: { bot: string[] } };
56
+ oauth_config: { scopes: { bot: string[]; user?: string[] }; redirect_urls?: string[] };
47
57
  settings: {
48
58
  event_subscriptions?: { request_url: string; bot_events: string[] };
49
59
  interactivity?: unknown;
@@ -75,6 +85,21 @@ export function buildSlackManifest(
75
85
  "app-manifest.yml: bot scopes are generated from the Foundation capability registry; remove them from the template",
76
86
  );
77
87
  manifest.oauth_config = { scopes: { bot: requiredSlackScopes(CAPABILITY_IDS) } };
88
+ if (instance.pages === true) {
89
+ // Sign in with Slack (OpenID Connect) for hosted pages: a user scope and
90
+ // the sign-in callback. Without the URL (the pages stack is not deployed
91
+ // yet) the scope alone is harmless and the redirect is added next time.
92
+ manifest.oauth_config.scopes.user = ["openid"];
93
+ const redirects = [
94
+ ...new Set(
95
+ (env.PAGES_AUTH_REDIRECT_URL ?? "")
96
+ .split(",")
97
+ .map((url) => url.trim())
98
+ .filter((url) => url !== ""),
99
+ ),
100
+ ];
101
+ if (redirects.length > 0) manifest.oauth_config.redirect_urls = redirects;
102
+ }
78
103
 
79
104
  // Every command a registered capability needs must be in the template; the
80
105
  // template may carry more (help, drive) than the registry knows about.
@@ -106,7 +131,23 @@ export function buildSlackManifest(
106
131
  export function slackManifestFor(instance: Instance, env: SlackManifestEnv): SlackManifest {
107
132
  return buildSlackManifest(
108
133
  readFileSync(SLACK_APP_MANIFEST_PATH, "utf8"),
109
- { displayName: instance.displayName, commandPrefix: slackCommandPrefix(instance) },
110
- env,
134
+ {
135
+ displayName: instance.displayName,
136
+ commandPrefix: slackCommandPrefix(instance),
137
+ pages: instance.integrations.pages,
138
+ },
139
+ {
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.
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
+ }
150
+ : {}),
151
+ },
111
152
  );
112
153
  }
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export { FoundationCi, type FoundationCiProps } from "./stacks/ci-stack.ts";
52
52
  export { FoundationData, type FoundationDataProps } from "./stacks/data-stack.ts";
53
53
  export { FoundationNetwork, type FoundationNetworkProps } from "./stacks/network-stack.ts";
54
54
  export { NewsletterStack, type NewsletterStackProps } from "./stacks/newsletter-stack.ts";
55
+ export { FoundationPages, type FoundationPagesProps } from "./stacks/pages-stack.ts";
55
56
  export {
56
57
  BUILD_TIMEOUT,
57
58
  DEFAULT_BUILDSPEC_PATH,
@@ -401,6 +401,82 @@ export class FoundationAgent extends cdk.Stack {
401
401
  );
402
402
  }
403
403
 
404
+ /**
405
+ * Hosted pages belong to the conversations that made them, and every read
406
+ * or write goes through the Pages API, which checks the conversation. The
407
+ * shell-capable agent (and the ReadOnlyAccess role it can assume, which
408
+ * would otherwise read any bucket) must not reach the bucket, table, cookie
409
+ * key or edge key store directly: that would let one channel's turn read
410
+ * another channel's pages, or mint a viewing cookie. Matched by name
411
+ * pattern, like the CRM table, so retained resources stay covered and
412
+ * FoundationAgent needs nothing from FoundationPages.
413
+ *
414
+ * The execution role also gets its one pages grant here: invoking the Pages
415
+ * API, by function name. Both go in a managed policy of their own so the
416
+ * roles' inline policies (the execution role's is near IAM's size limit)
417
+ * are unchanged for instances without pages.
418
+ */
419
+ private pagesPolicy(
420
+ id: string,
421
+ role: iam.Role,
422
+ instance: Instance,
423
+ options: { invoke: boolean },
424
+ ): void {
425
+ if (!provisionsIntegration(instance, "pages")) return;
426
+ const stack = cdk.Stack.of(this);
427
+ const table = stack.formatArn({
428
+ service: "dynamodb",
429
+ resource: "table",
430
+ resourceName: "*-PagesTable*",
431
+ });
432
+ const statements = [
433
+ new iam.PolicyStatement({
434
+ sid: "DenyDirectPagesAccess",
435
+ effect: iam.Effect.DENY,
436
+ actions: [
437
+ "s3:*",
438
+ "dynamodb:*",
439
+ "secretsmanager:GetSecretValue",
440
+ "secretsmanager:BatchGetSecretValue",
441
+ "cloudfront-keyvaluestore:*",
442
+ ],
443
+ resources: [
444
+ `arn:${stack.partition}:s3:::*pagesbucket*`,
445
+ `arn:${stack.partition}:s3:::*pagesbucket*/*`,
446
+ table,
447
+ `${table}/index/*`,
448
+ stack.formatArn({
449
+ service: "secretsmanager",
450
+ resource: "secret",
451
+ resourceName: `${this.names.secretPagesCookieKey}-??????`,
452
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
453
+ }),
454
+ `arn:${stack.partition}:cloudfront::${stack.account}:key-value-store/*`,
455
+ ],
456
+ }),
457
+ ];
458
+ if (options.invoke)
459
+ statements.push(
460
+ new iam.PolicyStatement({
461
+ sid: "PagesApiInvoke",
462
+ actions: ["lambda:InvokeFunction"],
463
+ resources: [
464
+ stack.formatArn({
465
+ service: "lambda",
466
+ resource: "function",
467
+ resourceName: this.names.pagesApiFunctionName,
468
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
469
+ }),
470
+ ],
471
+ }),
472
+ );
473
+ new iam.ManagedPolicy(this, id, {
474
+ description: `${instance.displayName} hosted pages: only through the Pages API`,
475
+ roles: [role],
476
+ statements,
477
+ });
478
+ }
479
+
404
480
  private denyDirectUpworkApprovalAccess(
405
481
  role: iam.Role,
406
482
  upworkApprovalTable: dynamodb.ITable | undefined,
@@ -484,6 +560,7 @@ export class FoundationAgent extends cdk.Stack {
484
560
  this.denyDirectKnockSecretAccess(role, props);
485
561
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
486
562
  this.denyDirectEtradeAccess(role, props);
563
+ this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
487
564
  role.addToPolicy(
488
565
  new iam.PolicyStatement({
489
566
  sid: "DenyAgentCoreBrowserMetadata",
@@ -576,6 +653,7 @@ export class FoundationAgent extends cdk.Stack {
576
653
  this.denyDirectKnockSecretAccess(role, props);
577
654
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
578
655
  this.denyDirectEtradeAccess(role, props);
656
+ this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
579
657
 
580
658
  // Foundation's own repository when a release names the image, because the
581
659
  // runtime then pulls across accounts. The pull also needs Foundation's
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The CloudFront viewer-request function in front of every hosted page.
3
+ *
4
+ * It runs on each request, before the cache, in CloudFront's `cloudfront-js-2.0`
5
+ * runtime, and does three things:
6
+ *
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
12
+ * 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`.
15
+ * 3. A granted request is rewritten onto its S3 key (the layout in
16
+ * `packages/capability-pages/src/layout.ts`): a trailing `/` gets
17
+ * `index.html`, and an extensionless path is a single-page-app route that
18
+ * 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.
20
+ *
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.
23
+ *
24
+ * 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.
26
+ */
27
+
28
+ /** The KeyValueStore key holding the cookie HMAC key; the seeding resource writes it. */
29
+ export const PAGES_EDGE_KEY_NAME = "cookie-key-v1";
30
+ /** Header the function sets for the sign-in Lambda. */
31
+ export const PAGES_HOST_HEADER = "x-foundation-pages-host";
32
+
33
+ export const PAGES_VIEWER_REQUEST_SOURCE = `import cf from 'cloudfront';
34
+ import crypto from 'crypto';
35
+
36
+ var kvs = cf.kvs('__KVS_ID__');
37
+ var COOKIE = 'fpa';
38
+ var KEY_NAME = '${PAGES_EDGE_KEY_NAME}';
39
+ var HOST_HEADER = '${PAGES_HOST_HEADER}';
40
+ var PAGE_RE = /^\\/p\\/([a-z0-9]{24})(\\/.*)?$/;
41
+ var VERSION_RE = /^\\/v([1-9][0-9]{0,5})(\\/.*)?$/;
42
+ var EXPORT_RE = /^\\/exports\\/v([1-9][0-9]{0,5})\\/([^\\/]+)$/;
43
+ var UNSAFE_RE = /(^|\\/)\\.\\.?(\\/|$)|\\/\\/|%2e|%2f|%5c|\\\\/i;
44
+ var USER_RE = /^[UW][A-Z0-9]{2,31}$/;
45
+ var EXP_RE = /^[0-9]{1,12}$/;
46
+
47
+ function reply(status, description, body) {
48
+ return {
49
+ statusCode: status,
50
+ statusDescription: description,
51
+ headers: {
52
+ 'content-type': { value: 'text/plain; charset=utf-8' },
53
+ 'cache-control': { value: 'no-store' }
54
+ },
55
+ body: { encoding: 'text', data: body }
56
+ };
57
+ }
58
+
59
+ function redirect(location) {
60
+ return {
61
+ statusCode: 302,
62
+ statusDescription: 'Found',
63
+ headers: { location: { value: location }, 'cache-control': { value: 'no-store' } }
64
+ };
65
+ }
66
+
67
+ function equal(a, b) {
68
+ if (a.length !== b.length) return false;
69
+ var diff = 0;
70
+ for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
71
+ return diff === 0;
72
+ }
73
+
74
+ function validCookie(value, pageId, key, now) {
75
+ if (typeof value !== 'string' || value.length > 256) return false;
76
+ var parts = value.split('.');
77
+ if (parts.length !== 5 || parts[0] !== 'v1' || parts[1] !== pageId) return false;
78
+ if (!USER_RE.test(parts[2]) || !EXP_RE.test(parts[3]) || Number(parts[3]) <= now) return false;
79
+ var mac = crypto.createHmac('sha256', key).update(parts.slice(0, 4).join('.')).digest('hex');
80
+ return equal(mac, parts[4]);
81
+ }
82
+
83
+ function cookieValues(request) {
84
+ var cookie = request.cookies ? request.cookies[COOKIE] : undefined;
85
+ if (!cookie) return [];
86
+ var values = [cookie.value];
87
+ if (cookie.multiValue) {
88
+ for (var i = 0; i < cookie.multiValue.length; i++) values.push(cookie.multiValue[i].value);
89
+ }
90
+ return values;
91
+ }
92
+
93
+ function objectPath(pageId, rest) {
94
+ var prefix = '/pages/' + pageId;
95
+ if (rest === '/') return prefix + '/latest.html';
96
+ var version = VERSION_RE.exec(rest);
97
+ if (version) {
98
+ var sub = version[2];
99
+ if (sub.charAt(sub.length - 1) === '/') {
100
+ sub = sub + 'index.html';
101
+ } else if (sub.slice(sub.lastIndexOf('/') + 1).indexOf('.') === -1) {
102
+ sub = '/index.html';
103
+ }
104
+ return prefix + '/v' + version[1] + '/build' + sub;
105
+ }
106
+ var download = EXPORT_RE.exec(rest);
107
+ if (download) return prefix + '/v' + download[1] + '/exports/' + download[2];
108
+ return null;
109
+ }
110
+
111
+ async function handler(event) {
112
+ var request = event.request;
113
+ 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;
117
+ }
118
+ var page = PAGE_RE.exec(uri);
119
+ if (!page || UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
120
+ var pageId = page[1];
121
+ var rest = page[2] || '';
122
+ if (rest === '') return redirect(uri + '/');
123
+ var version = VERSION_RE.exec(rest);
124
+ if (version && version[2] === undefined) return redirect(uri + '/');
125
+ var target = objectPath(pageId, rest);
126
+ if (target === null) return reply(404, 'Not Found', 'Not found');
127
+
128
+ var key;
129
+ try {
130
+ key = await kvs.get(KEY_NAME);
131
+ } catch (err) {
132
+ return reply(503, 'Service Unavailable', 'Pages sign-in is starting up. Try again in a minute.');
133
+ }
134
+ var now = Math.floor(Date.now() / 1000);
135
+ var values = cookieValues(request);
136
+ for (var i = 0; i < values.length; i++) {
137
+ if (validCookie(values[i], pageId, key, now)) {
138
+ request.uri = target;
139
+ return request;
140
+ }
141
+ }
142
+ return redirect('/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
143
+ }
144
+ `;
145
+
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);
149
+ }