Files
adr-sml/docs/reference/AWS-Hosting-Guide.md
Pouya LajevardiandClaude Opus 5 6bf1167624 fix: sweep D3 amendment through the specs; correct inverted DKIM table
The re-audit of the deploy-guard change surfaced defects well outside the
diff, including one that would have broken production mail.

docs/05-backend-spec.md had the two SES DKIM sets exactly inverted, labelling
the three records that resolve as "orphans" and the three NXDOMAIN records as
"Live. Never delete". Entry (j) corrected this in AGENTS.md §7 and the
correction never reached docs/05. Since SES has no custom MAIL FROM, DKIM is
the only thing satisfying DMARC, so acting on that table would have silently
broken intake mail authentication.

Also in this change:

- .gitea/workflows/deploy.yml gains a guard as steps[0] that fails the run,
  naming the variable, if AWS_REGION, S3_BUCKET or CLOUDFRONT_DISTRIBUTION_ID
  is empty — how a Gitea too old for the vars context manifests. Verified
  fail-closed under bash -e, sh -e and bash -euo pipefail.
- AGENTS.md Current Truth: SPF and DMARC recorded as present (Q20), the
  matching §10 High risk row retired, three duplicate Q rows removed.
- docs/reference/AWS-Hosting-Guide.md tracked and given a do-not-execute
  banner; it was an executable procedure for the architecture D1/D3 replace.
- Copy decks: "a working litigator" and "an active litigation practice"
  replaced with the register's own wording; LegalService JSON-LD replaced with
  ProfessionalService; tribunal-secretary offers removed per D14; nine stale
  question blockers swept.
- astro.config.mjs: prefetchAll disabled — it injected JS into every page
  against the zero-JS convention with no decision recorded.
- src/data/site.ts: unregistered response-time commitment nulled (Q27);
  OBA section names downgraded to [assumed] (Q28).
- s3:AbortMultipartUpload reasoning corrected to measure ./dist, not the repo.

Opens Q27, Q28, Q29. AGENTS.md entry (q) records the full resolution,
including the findings declined and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 11:28:42 -04:00

41 KiB
Raw Permalink Blame History

Hosting adr.smlcompany.ca on AWS — A Step-by-Step Guide


REFERENCE ONLY — DO NOT EXECUTE

This is a historical record of how the existing AWS infrastructure was built. It is not a procedure to follow. The live deployment procedure is docs/06-deployment.md; the authoritative inventory of what actually exists is AGENTS.md §7.

Following this document would, among other things: create an IAM user with AdministratorAccess in account 327082975128 — which AGENTS.md §10 rates High blast-radius; rebuild the site through the standalone-HTML pipeline that D1 and D3 replace; and wire SES to addresses this project does not use.

Known contradictions with Current Truth, all of which §7 and the specs win:

This guide says Current Truth
Intake mail to adr@ / intake@smlcompany.ca info@smlcompany.ca — §4, D18
Lambda runtime Node.js 20.x nodejs24.x — §7
"the SES sandbox is perfectly fine and free" Sandbox is a confirmed blocker, Q19
rebuild-standalone.py / sections.jsx / a ~2.2 MB self-contained index.html Astro static build — D1
SES policy with "Resource": "*" Scope it; see §10 on this account
"You (a lawyer, not a sysadmin)" — the original audience line, corrected in place §4 records licence status as NOT ESTABLISHED; the word is barred outright
The consent line "does not create a lawyer-client relationship" Superseded by NO_RETAINER_NOTICE in src/data/site.ts, written to avoid exactly that phrasing

Retained because it is the only record of how the bucket, distribution, certificate, DNS, Lambda, DynamoDB table, and SES identities came to exist. Read it for that. Do not run it.

Audience: the site owner — comfortable clicking around, new to AWS. Goal: Get the revamped site live at https://adr.smlcompany.ca with a working intake form whose submissions are stored in a database and emailed to adr@smlcompany.ca.

Architecture you're building:

                     ┌───────────────────────┐
   Browser ─────►    │   CloudFront (CDN)    │  ◄── ACM (free TLS cert)
   adr.smlcompany.ca │     HTTPS + cache     │
                     └──────────┬────────────┘
                                │
                                ▼
                     ┌───────────────────────┐
                     │   S3 bucket (origin)  │   ← your standalone HTML + /assets
                     │ adr-smlcompany-site   │
                     └───────────────────────┘

   Form submit ─►  API Gateway ─►  Lambda ─┬─►  DynamoDB  (permanent record)
                                          └─►  SES       (emails adr@smlcompany.ca)

   DNS stays at Namecheap   (you add a CNAME for `adr` + cert/DKIM validation records)

Total time: about 23 hours the first time, in chunks. Most steps take a minute or two of clicking but DNS propagation and CloudFront deploys mean there's some waiting.

Total monthly cost at low traffic: under $1 USD. S3, CloudFront, Lambda, DynamoDB, and SES will all stay in or near their free tiers.


⚠️ A note about DNS choice

You've chosen to keep DNS at Namecheap rather than move to Route 53. That's perfectly fine and is actually cheaper (no $0.50/month hosted zone) and lower-risk (your existing MX records and email keep working untouched). The trade-offs:

  • CNAMEs can't sit at the apex. Your apex smlcompany.ca will not be servable on CloudFront from Namecheap DNS — only subdomains like adr.smlcompany.ca. This is a DNS standard, not a Namecheap limitation. Since you're using a subdomain, you're fine. If you ever want the apex on CloudFront, you'd either move DNS to Route 53 (alias records can be at apex) or use Namecheap's "URL Redirect Record" feature to redirect the apex to the subdomain.
  • You'll add records by hand. Each time AWS asks you to publish a DNS record (for cert validation, for SES DKIM, etc.), you'll copy/paste it into Namecheap → Advanced DNS yourself, instead of AWS writing it for you.
  • No automatic DNS updates. Not really a downside at this scale — just something to know.

Part 0 — Prerequisites (15 min)

You said you already have an AWS account. Quick hardening pass:

0.1 Sign in as a non-root IAM user

  • AWS strongly recommends you don't use the root account day-to-day. If you've been using root: in the console, open IAM → Users → Create user. Name it pouya-admin. Attach the AWS-managed policy AdministratorAccess. Enable console access with a custom password.
  • Sign out and sign back in as pouya-admin going forward. Reserve the root login for billing changes only.

0.2 Turn on MFA for the root account

  • IAM → Security credentials (under your root user) → Assign MFA device → use Authy / Google Authenticator / 1Password.

0.3 Set a billing alarm

  • Console → Billing and Cost Management → Budgets → Create budget.
  • Template: Monthly cost budget, $20 USD, notify at 80% and 100% to pouya@meshkinilaw.ca.
  • This catches misconfiguration before it gets expensive.

0.4 Set your region

  • Top-right of the AWS console: switch the region selector to Canada (Central) — ca-central-1.
  • Everything in this guide is in ca-central-1 except ACM (which for CloudFront must live in us-east-1 — explained in Part 3) and CloudFront itself (which is global).

0.5 (Optional but useful) Install the AWS CLI

  • macOS: brew install awscli then aws configure and paste an access key generated from IAM → your user → Security credentials.
  • You don't strictly need it — every step below has a console path — but a few things (S3 sync, CloudFront invalidations) are much faster from the terminal.

Part 1 — Put the website in an S3 bucket (15 min)

S3 is just object storage. We'll create one bucket, drop your standalone HTML and the assets/ folder in it, and leave it private — CloudFront will be the only thing allowed to read from it.

1.1 Create the bucket

  • Console → S3 → Create bucket.
  • Bucket name: adr-smlcompany-site (must be globally unique across all of AWS — if it's taken, add a suffix like -2026).
  • Region: Canada (Central) ca-central-1.
  • Block all public access: leave the box checked (yes, fully blocked — CloudFront will use an Origin Access Control to read from it).
  • Bucket versioning: Enable. This gives you a free undo if you ever overwrite the site with a broken version.
  • Leave everything else default. Create bucket.

1.2 Upload your files

Your export contains a few HTML files. The one you want to serve is SML ADR Site (Standalone).html — that's the ~2.2 MB self-contained build with everything inlined.

  • Open the bucket → Upload.
  • Add files → select SML ADR Site (Standalone).html.
  • IMPORTANT: before uploading, rename it locally to index.html (CloudFront's default root object). Or upload as-is and use the S3 console to rename it after upload (Actions → Rename).
  • Also upload your assets/ folder using Add folder so that assets/sml-logo-full.png and assets/sml-logo-mark.png end up at s3://adr-smlcompany-site/assets/....

After upload, your bucket should contain:

index.html
assets/
  sml-logo-full.png
  sml-logo-mark.png

Because we'll deploy by overwriting index.html later, you want browsers/CDN to re-check it often.

  • Click index.htmlProperties → Edit metadata.
  • Add metadata: System defined → Cache-Control → public, max-age=300, must-revalidate (5 minutes).
  • For the images in assets/, leave defaults (they can cache for much longer; CloudFront will use defaults).

Part 2 — Put CloudFront in front of S3 (20 min including wait)

CloudFront is AWS's CDN. It gives you HTTPS, global edge caching, and lets you put a real domain in front of an otherwise-private S3 bucket.

2.1 Create the distribution

  • Console → CloudFront → Create distribution.
  • Origin domain: click the dropdown and pick your bucket — adr-smlcompany-site.s3.ca-central-1.amazonaws.com. The console will offer a "Use website endpoint" suggestion — ignore that, leave the REST endpoint selected.
  • Origin access: select Origin access control settings (recommended).
    • Click Create new OAC. Name: adr-smlcompany-oac. Signing behavior: Sign requests. Origin type: S3. Create.
    • You'll see a yellow banner saying "You must update the S3 bucket policy." Note this — we'll do it in a moment.
  • Viewer protocol policy: Redirect HTTP to HTTPS.
  • Allowed HTTP methods: GET, HEAD (default).
  • Cache policy: CachingOptimized (managed).
  • Origin request policy: leave blank.
  • Response headers policy: SecurityHeadersPolicy (managed) — adds HSTS, X-Frame-Options, etc.
  • Compress objects automatically: Yes.
  • Price class: Use only North America and Europe (cheaper; your clients aren't in Tokyo).
  • Web Application Firewall (WAF): Do not enable for now. (Could add later if needed; ~$5/mo.)
  • Alternate domain names (CNAMEs): leave blank for now — we'll add adr.smlcompany.ca in Part 6, after the cert exists.
  • Custom SSL certificate: leave Default CloudFront Certificate for now.
  • Default root object: index.html.
  • Standard logging: Off (can enable later).
  • Create distribution.

2.2 Update the S3 bucket policy

After creating the distribution, you'll see a banner "Copy policy" with a JSON snippet — that snippet allows your specific CloudFront distribution to read from S3.

  • Click Copy policy.
  • Open the S3 bucket → Permissions → Bucket policy → Edit → paste → Save changes.

The policy looks roughly like:

{
  "Version": "2008-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipal",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::adr-smlcompany-site/*",
    "Condition": {
      "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT-ID>:distribution/<DIST-ID>" }
    }
  }]
}

2.3 Wait for "Deployed"

  • CloudFront → your distribution → wait until Last modified shows a timestamp and the status reads Deployed (515 min the first time).
  • Then visit the Distribution domain name shown at the top — something like d123abc4xyz.cloudfront.net. Your site should load over HTTPS.
  • If you see XML access-denied: the bucket policy isn't saved yet, or index.html isn't named exactly that.

Checkpoint: site loads on the *.cloudfront.net URL. We'll attach your real domain in Part 6.


Part 3 — Get a free SSL certificate (10 min, validation later)

CloudFront requires its TLS certificate to live in us-east-1, regardless of where the rest of your stack lives. This trips up everyone the first time.

3.1 Request the cert

  • Top-right region selector → switch to US East (N. Virginia) — us-east-1. (You'll switch back to ca-central-1 after this part.)
  • Console → Certificate Manager → Request certificate → Request a public certificate.
  • Domain names:
    • adr.smlcompany.ca
    • (Optional, recommended) Add a second name: *.smlcompany.ca. A wildcard means you'll be able to use the same cert for www.smlcompany.ca, mail.smlcompany.ca, etc. without re-requesting.
  • Validation method: DNS validation (the recommended option — uses a CNAME record).
  • Key algorithm: RSA 2048.
  • Request.

You'll land on the cert page in Pending validation state. ACM will show you one or two CNAME records of the form _abc123.adr.smlcompany.ca_xyz789.acm-validations.aws.. Leave this tab open — you'll publish these in Namecheap in Part 5.


Part 4 — Open Namecheap's DNS panel (2 min)

We're not migrating DNS, but we will be coming back to this panel four times across the rest of the guide (ACM cert validation, the CloudFront CNAME, three SES DKIM records). So get familiar with where it is now.

4.1 Locate Advanced DNS

  • Log in to Namecheap → Domain List.
  • Find smlcompany.ca → click Manage on its row.
  • Click the Advanced DNS tab. This is where you'll add every record below. (Do not touch the Domain tab's Nameservers section — leave it set to Namecheap BasicDNS.)

4.2 Make a "before" screenshot (1 min)

Take a screenshot of the current Host Records table. You won't need to touch any of the existing rows — they're handling your email and anything else you have set up. The screenshot is just an undo reference in case you ever paste over the wrong row.

4.3 How to add a record in Namecheap (reference for later steps)

Namecheap's row-add UX:

  • Scroll to the Host Records section → click ADD NEW RECORD.
  • Pick a Type from the dropdown (A, AAAA, CNAME, TXT, MX, etc.).
  • Host: the subdomain part only. So for adr.smlcompany.ca the Host is adr. For the apex itself, use @. For something like _abc123.adr.smlcompany.ca, use _abc123.adr.
  • Value: what AWS gives you. Important: Namecheap will sometimes append a trailing dot to CNAME values when it shows them back — that's normal. When entering a CNAME, you can include or omit the trailing dot; both work.
  • TTL: Automatic (~30 min) is fine. For records you'll change often (testing), pick a low TTL like 5 min.
  • Click the green checkmark on the right to save the row.

That's it — you'll do this five-ish times over Parts 5, 7, and 9.


Part 5 — Validate the ACM cert via Namecheap (10 min including wait)

Back to the cert you requested in Part 3.

  • Region selector → us-east-1.

  • ACM → your pending cert → click into it.

  • You'll see one (or two, if you added the wildcard) CNAME validation records of the form:

    Name:  _abc1234567890.adr.smlcompany.ca.
    Value: _xyz9876543210.acm-validations.aws.
    

    ACM gives you a Copy button next to each — handy.

  • Switch tab to Namecheap → smlcompany.ca → Advanced DNSADD NEW RECORD:

    • Type: CNAME Record
    • Host: the bit before .smlcompany.ca in the Name field. For example, if ACM shows _abc1234567890.adr.smlcompany.ca., the Host you enter in Namecheap is _abc1234567890.adr. (Drop the trailing .smlcompany.ca — Namecheap appends it automatically.)
    • Target: the Value from ACM, e.g. _xyz9876543210.acm-validations.aws. (trailing dot is fine).
    • TTL: Automatic.
    • Click the green checkmark.
  • If you added the wildcard *.smlcompany.ca in Part 3, you'll see a second validation row in ACM — add a second CNAME the same way. (Often ACM gives the same Name/Value for the apex and wildcard, in which case you only need one CNAME.)

  • Back in ACM, refresh the cert page after 210 min. Status flips from Pending validation to Issued. If it's still pending after 15 minutes, you've almost certainly got a Host typo — re-check that what's in Namecheap matches what ACM shows, character for character.


Part 6 — Attach the cert + domain to CloudFront (10 min including wait)

  • Region selector → us-east-1 (CloudFront is global but lives under us-east-1 in the console nav).
  • CloudFront → your distribution → General → Settings → Edit.
  • Alternate domain name (CNAME): add adr.smlcompany.ca. (Add www.adr.smlcompany.ca too if you want both — otherwise leave as just the one.)
  • Custom SSL certificate: dropdown → select the cert you just issued.
  • Security policy: TLSv1.2_2021.
  • Save changes.
  • Wait ~510 min for Deployed status again.

Part 7 — Point DNS at CloudFront via Namecheap (3 min)

  • Grab your CloudFront distribution domain from the CloudFront console (top of the distribution page) — it looks like d123abc4xyz.cloudfront.net.
  • Namecheap → smlcompany.ca → Advanced DNSADD NEW RECORD:
    • Type: CNAME Record
    • Host: adr
    • Target: your CloudFront domain, e.g. d123abc4xyz.cloudfront.net. (trailing dot optional)
    • TTL: 5 min (for the initial setup — you can raise it to Automatic once everything's stable)
    • Save with the green checkmark.

About IPv6: a CNAME delegates resolution to the target's records, and CloudFront serves both A (IPv4) and AAAA (IPv6) records. So a single CNAME automatically covers both — you don't need a separate AAAA record like you would with a Route 53 alias.

About the apex: Namecheap DNS can't put a CNAME at @ (the apex smlcompany.ca). That's a hard DNS-standards limit, not Namecheap's fault. Since you're using adr.smlcompany.ca, this doesn't affect you. If you also wanted smlcompany.ca (without the adr.) to land on the site, the easiest route is Namecheap's URL Redirect Record type: Host @, Target https://adr.smlcompany.ca, Type Unmasked (301).

Within a couple of minutes, https://adr.smlcompany.ca should serve your site.

Checkpoint: open https://adr.smlcompany.ca in an incognito window. You should see the revamped site, with a green padlock, no warnings.


Part 8 — Backend: DynamoDB table + Lambda + API Gateway (45 min)

Now the intake form. The flow:

Browser POST → API Gateway (HTTPS) → Lambda function → DynamoDB.put_item()
                                                    → SES.send_email() to adr@smlcompany.ca

8.1 Create the DynamoDB table

  • Region → ca-central-1.
  • Console → DynamoDB → Tables → Create table.
  • Table name: adr-intake-submissions.
  • Partition key: submissionId (String).
  • Sort key: leave blank.
  • Settings: Default settings — this gives you on-demand capacity (you pay per request, ~$0 at your volume) and encryption at rest by default.
  • Create.
  • After it's Active: click the table → Backups → Point-in-time recovery → Edit → Turn on. Costs cents/month and lets you restore to any second in the last 35 days.

8.2 Create the Lambda execution role (IAM)

  • IAM → Roles → Create role.
  • Trusted entity: AWS service → use case Lambda.
  • Permissions: attach these AWS managed policies for now:
    • AWSLambdaBasicExecutionRole (lets it write CloudWatch logs)
  • Role name: adr-intake-lambda-role. Create.
  • After creation: open the role → Add permissions → Create inline policy → JSON tab → paste:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:ca-central-1:*:table/adr-intake-submissions"
    },
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    }
  ]
}

Name it adr-intake-lambda-inline. Save.

8.3 Create the Lambda function

  • Console → Lambda → Create function.
  • Author from scratch.
  • Function name: adr-intake-handler.
  • Runtime: Node.js 20.x.
  • Architecture: arm64 (cheaper).
  • Execution role: Use an existing roleadr-intake-lambda-role.
  • Create function.

In the Code tab, replace the contents of index.mjs with:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { randomUUID } from "crypto";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ca-central-1" }));
const ses = new SESv2Client({ region: "ca-central-1" });

const TABLE       = "adr-intake-submissions";
const FROM_ADDR   = "adr@smlcompany.ca";      // must be SES-verified (Part 9)
const NOTIFY_ADDR = "adr@smlcompany.ca";      // must be SES-verified while SES is in sandbox
const ALLOWED_ORIGIN = "https://adr.smlcompany.ca";

const CORS = {
  "Access-Control-Allow-Origin":  ALLOWED_ORIGIN,
  "Access-Control-Allow-Methods": "POST,OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const handler = async (event) => {
  // Preflight
  if (event.requestContext?.http?.method === "OPTIONS") {
    return { statusCode: 204, headers: CORS };
  }

  let body;
  try {
    body = JSON.parse(event.body || "{}");
  } catch {
    return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_json" }) };
  }

  // Extract + light validation — adjust to taste.
  const name     = (body.name     || "").toString().trim().slice(0, 200);
  const org      = (body.org      || "").toString().trim().slice(0, 200);
  const email    = (body.email    || "").toString().trim().slice(0, 200);
  const phone    = (body.phone    || "").toString().trim().slice(0, 50);
  const matter   = (body.matter   || "").toString().trim().slice(0, 100);
  const message  = (body.message  || "").toString().trim().slice(0, 5000);
  const honeypot = (body.website  || "").toString();  // bot trap; see Part 10

  if (honeypot) {
    // Silently accept and drop — looks successful to bots.
    return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
  }
  if (!name || !email || !message) {
    return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "missing_fields" }) };
  }
  if (!EMAIL_RE.test(email)) {
    return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_email" }) };
  }

  const submissionId = randomUUID();
  const submittedAt  = new Date().toISOString();
  const sourceIp     = event.requestContext?.http?.sourceIp || "unknown";
  const userAgent    = event.headers?.["user-agent"] || "unknown";

  // 1) Store in DynamoDB
  await ddb.send(new PutCommand({
    TableName: TABLE,
    Item: { submissionId, submittedAt, name, org, email, phone, matter, message, sourceIp, userAgent },
  }));

  // 2) Email adr@smlcompany.ca
  const text =
`New intake form submission

Name:    ${name}
Org:     ${org || "(not provided)"}
Email:   ${email}
Phone:   ${phone || "(not provided)"}
Service: ${matter || "(not provided)"}

Message:
${message}

Submission ID: ${submissionId}
Submitted:     ${submittedAt}
IP:            ${sourceIp}

Reply directly to this email — it will route to the submitter.
`;

  await ses.send(new SendEmailCommand({
    FromEmailAddress: FROM_ADDR,
    Destination: { ToAddresses: [NOTIFY_ADDR] },
    Content: {
      Simple: {
        Subject: { Data: `New intake: ${name}${org ? " — " + org : ""}`, Charset: "UTF-8" },
        Body:    { Text: { Data: text, Charset: "UTF-8" } },
      }
    },
    ReplyToAddresses: [email],   // hitting Reply in your inbox goes straight to the submitter
  }));

  return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true, submissionId }) };
};

Note: This version uses adr@smlcompany.ca as both From and To (per the simpler Option B in Part 9.3). The "Reply-To" header is set to the submitter's email, so when you hit Reply in your mail client, the response goes to them — not to yourself.

  • Click Deploy.
  • Set the runtime timeout to 10 seconds: Configuration → General configuration → Edit → Timeout: 10 sec → Save.

If you see a "module not found" error on first invocation (rare but possible — AWS sometimes drops packages from the included SDK between runtime versions), you'll need to deploy your code as a zip with node_modules. Locally: npm init -y && npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sesv2, put your index.mjs next to node_modules/, then zip -r function.zip index.mjs node_modules package*.json and upload via Lambda → Code → Upload from → .zip file. The three SDK packages are normally pre-installed in the Node.js 20.x runtime, so you should be able to skip this step.

8.4 Create the HTTP API in API Gateway

  • Console → API Gateway → Create API → HTTP API → Build.
  • Integrations: click Add integration → Lambda → region ca-central-1 → function adr-intake-handler.
  • API name: adr-intake-api.
  • Configure routes:
    • Method: POST
    • Path: /submissions
    • Integration target: adr-intake-handler
  • Configure stages: leave default ($default, auto-deploy enabled).
  • Create.

After it's created:

  • Open the API → CORSConfigure:
    • Access-Control-Allow-Origin: https://adr.smlcompany.ca
    • Access-Control-Allow-Methods: POST
    • Access-Control-Allow-Headers: content-type
    • Save.
  • Note the Invoke URL at the top — looks like https://abc123.execute-api.ca-central-1.amazonaws.com. Your endpoint is <invoke-url>/submissions.

8.5 Quick smoke-test (without the front-end)

From your terminal:

curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Test User","org":"Test","email":"test@example.com","phone":"+1-416-555-0100","matter":"Mediation","message":"This is a test."}'

Expected response: {"ok":true,"submissionId":"..."}.

At this point the DynamoDB write should succeed, but SES will fail until Part 9. Check CloudWatch → Log groups → /aws/lambda/adr-intake-handler — you'll see the error there. That's fine; Part 9 fixes it.

To see the row landed in the DB: DynamoDB → Tables → adr-intake-submissionsExplore table items.


Part 9 — Verify your sender domain in SES (15 min)

By default SES is in sandbox: it can only send from verified identities to verified identities. For a low-volume contact-form notifier sending only to yourself, the sandbox is perfectly fine and free.

9.1 Verify the domain smlcompany.ca

  • Region → ca-central-1.
  • Console → Amazon SES → Configuration → Identities → Create identity.
  • Identity type: Domain.
  • Domain: smlcompany.ca.
  • Use a custom MAIL FROM domain: skip (optional).
  • DKIM: Easy DKIM, RSA 2048-bit. Leave Publish DNS records to Route 53 unchecked (you don't have a Route 53 hosted zone).
  • Create.

You'll land on the identity page with three CNAME records that SES wants published. They look like:

Name:  abc1234567890._domainkey.smlcompany.ca
Value: abc1234567890.dkim.amazonses.com

Name:  def0987654321._domainkey.smlcompany.ca
Value: def0987654321.dkim.amazonses.com

Name:  ghi5555555555._domainkey.smlcompany.ca
Value: ghi5555555555.dkim.amazonses.com

Add each in Namecheap → Advanced DNS → ADD NEW RECORD:

  • Type: CNAME Record
  • Host: the part before .smlcompany.ca — e.g. abc1234567890._domainkey
  • Target: the SES value, e.g. abc1234567890.dkim.amazonses.com
  • TTL: Automatic
  • Save with the green checkmark. Repeat for the other two.

⚠️ If you currently have any other DKIM CNAMEs for smlcompany.ca from your existing email provider (e.g. Google Workspace's google._domainkey), leave them alone. SES's DKIM uses different selector names, so it won't collide. Multiple DKIM keys on the same domain is normal and supported.

After 515 minutes, refresh the SES identity page. The DKIM status flips to Successful and the overall identity status flips to Verified. If it's still pending after 30 minutes, check the Host fields in Namecheap for typos.

Bonus — SPF alignment for SES. Your existing v=spf1 ... TXT record at the apex tells the world which servers may send mail "as" smlcompany.ca. If you want SES-sent mail to pass SPF too (improves deliverability of intake notifications), add include:amazonses.com to the existing SPF record. Edit it in Namecheap so it becomes e.g.: v=spf1 include:_spf.google.com include:amazonses.com ~all. Don't create a second SPF TXT record — only one is allowed per domain.

9.2 Verify the recipient

While SES is in sandbox, the To: address also has to be verified.

  • SES → Identities → Create identity → Email address → adr@smlcompany.ca → Create.
  • AWS sends a verification email to that address. Click the link. Status → Verified.

9.3 Verify the From address

The Lambda above uses intake@smlcompany.ca as the From. Verify it too:

  • SES → Identities → Create identity → Email address → intake@smlcompany.ca → Create.
  • Either have your email provider deliver mail at that alias to your real inbox, or just use adr@smlcompany.ca as the From in the Lambda code and skip this step.

9.4 Retest

curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Test 2","org":"Test","email":"test@example.com","matter":"Mediation","message":"Now with email."}'

You should get the success response AND see an email arrive at adr@smlcompany.ca within a few seconds.

9.5 (Optional, later) Request production access

If you ever want the form to send a confirmation email back to the submitter, you'll need to exit sandbox. SES Console → top right → Request production access. AWS asks a few questions about how you'll use it; approval is usually under 24 hr for legitimate business use.


Part 10 — Wire the front-end form to your API (20 min)

How the site is bundled

Your site export uses a custom format from Anthropic's Artifacts bundler:

  • SML ADR Site (Standalone).html — a single 2.2 MB file containing the rendered HTML plus all JSX, JavaScript, fonts, and the logo PNG bundled together as gzipped+base64 entries inside a <script type="__bundler/manifest"> JSON blob. This is the file you uploaded to S3 as index.html.
  • components-standalone/*.jsx — the loose JSX source files (sections, hero, nav, etc.). The standalone HTML was originally built from these but isn't automatically rebuilt when you edit them.

So if you change a JSX file, you have two options:

Approach What you upload to S3 Pros Cons
Stay with the bundled HTML (recommended) One file (index.html) Same as before. Fast page load. CloudFront caches it well. Need to "rebundle" after JSX edits.
Switch to loose files (standalone-src).html + the whole components-standalone/ folder + tweaks-panel.jsx + assets/ No rebuild step — just upload changed JSX. Extra ~500ms first page load while Babel compiles JSX in the browser. Many small files.

This guide assumes you stay with the bundled HTML, because that's the architecture you started with.

The form code is already updated

The components-standalone/sections.jsx file has been edited. The new Contact component:

  • Adds two new required-flag-aware fields: Email (required, type="email") and Phone (optional, type="tel").
  • Wires onSubmit to a real fetch() POST against your API Gateway endpoint.
  • Adds submitting and error state so the button shows "Sending…" while in flight and a clear maroon-bordered error message on failure.
  • Adds a hidden honeypot field (website) to silently drop bot submissions.
  • Adds a small-print consent line under the submit button. Superseded — do not use this wording: the live text is NO_RETAINER_NOTICE in src/data/site.ts, which deliberately avoids the phrase below. Historical text: "Submitting this form does not create a lawyer-client relationship. By submitting, you consent to storage of this information by SML Company in Canada for the purpose of responding to your inquiry."
  • Extends the shared Field component to accept type and required props, rendering a gold asterisk next to required-field labels.

The API endpoint is hard-coded at the top of the Contact section:

const INTAKE_API_URL = 'https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com/submissions';

If your API Gateway URL ever changes, update that one constant and rebuild (next step).

Rebuilding the standalone HTML (rebuild-standalone.py)

A small Python script sits alongside the JSX in the project folder. It reads the original SML ADR Site (Standalone).html, swaps in the current contents of components-standalone/sections.jsx, re-compresses, and writes out a fresh index.html that's ready to upload to S3.

From a terminal:

cd "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web"
python3 rebuild-standalone.py

You'll see output like:

  patched sections.jsx         -> 8830e633-... (42,792 bytes → 10,513 gz → 14,020 b64)
Wrote .../Pouya Personal Branding Web/index.html

That index.html is the file you upload to S3.

The script is intentionally limited to sections.jsx (where the form lives). If you ever want to edit the hero, nav, or any other component, open rebuild-standalone.py and uncomment the relevant line in the JSX_FILES mapping after discovering each component's UUID (the script docstring explains how).

For the first run we already did this for you — a fresh index.html containing the email/phone form is sitting in the project folder right now, ready to upload.

Deploying the change

  1. Upload the new index.html to your S3 bucket adr-smlcompany-site, replacing the existing index.html. S3 versioning (enabled in Part 1.1) keeps the old version recoverable if anything goes wrong.

    Console path: S3 → adr-smlcompany-siteUpload → drag index.html from the project folder → Cache-Control: public, max-age=300, must-revalidateUpload.

    Or from the terminal:

    aws s3 cp \
      "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web/index.html" \
      s3://adr-smlcompany-site/index.html \
      --cache-control 'public, max-age=300, must-revalidate'
    
  2. Invalidate CloudFront so users see the new version immediately rather than waiting for the 5-minute cache to expire.

    Console: CloudFront → your distribution → Invalidations → Create invalidation → object path: /index.html (and / for safety) → Create. Costs $0.005 per path (first 1,000 paths/month are free).

    Or from the terminal:

    aws cloudfront create-invalidation \
      --distribution-id <YOUR-DIST-ID> \
      --paths '/' '/index.html'
    
  3. Hard-refresh https://adr.smlcompany.ca in an incognito window. Confirm the form now shows the Email and Phone fields and the consent line under the submit button.


Part 11 — End-to-end smoke test (10 min)

In an incognito window:

  1. Open https://adr.smlcompany.ca. Confirm green padlock, all sections render, logos load.
  2. Open the browser devtools → Network tab. Submit the intake form with realistic values.
  3. Confirm the network call to your API Gateway returns 200.
  4. Within 30 seconds, check adr@smlcompany.ca — you should have a "New intake: …" email.
  5. Open the DynamoDB table → Explore table items → you should see your test row.
  6. (Optional) Try submitting from curl with the honeypot field set — {"website":"http://spam"}. You should get a 200 but no email and no DB row (silent drop).

If all five pass, you're live.


Part 12 — Day-2 operations

How to update the site

  1. Re-export the standalone HTML.
  2. Upload to S3 as index.html (overwrites; old version preserved by versioning).
  3. CloudFront invalidate /index.html (and / for safety).

Doable in 2 minutes via the CLI:

aws s3 cp index.html s3://adr-smlcompany-site/index.html \
  --cache-control 'public, max-age=300, must-revalidate'
aws cloudfront create-invalidation \
  --distribution-id <DIST-ID> --paths '/' '/index.html'

Monitoring

  • CloudWatch alarm — Lambda errors: CloudWatch → Alarms → Create alarm → Metric: Lambda → ByFunctionName → adr-intake-handler → Errors → Statistic Sum, period 5 min, threshold >= 1. Notify via an SNS topic that emails you. Alerts you within minutes if the form starts failing.
  • CloudFront 5xx error rate alarm: same pattern, threshold > 1%.
  • Set both with low thresholds — your traffic is low enough that any sustained error matters.

Backups & retention

  • DynamoDB PITR (Part 8.1) gives you 35-day rollback.
  • S3 versioning (Part 1.1) gives you forever-rollback on the site files.
  • Consider a quarterly export of the DynamoDB table to S3 if you want a clean audit trail.
  • Everything lives in ca-central-1. CloudFront caches static HTML at edge locations globally, but your form submissions never touch CloudFront — they go directly to API Gateway in ca-central-1.
  • Consider adding a one-line consent notice under the form: "By submitting this form, you consent to its storage by SML Company in Canada for the purpose of responding to your inquiry. We do not share this information with third parties."
  • DynamoDB rows include the submitter's IP and user-agent for abuse defense. If you'd rather not store those, remove sourceIp and userAgent from the PutCommand Item.

Cost expectations (USD, monthly)

Service Expected Notes
Namecheap DNS $0 Included with your domain registration.
S3 <$0.05 3 MB of files + a few requests.
CloudFront $0.101 Free tier covers first 1 TB out/month.
ACM cert $0 Free.
API Gateway <$0.05 $1 per million requests.
Lambda $0 Free tier covers 1M requests/mo.
DynamoDB $0 On-demand, low volume.
SES $0 First 62k emails/mo from Lambda free.
Total under $1

What to do if something breaks

  • Site won't load: check CloudFront Status = Deployed, and that the Namecheap CNAME for adr points to the CloudFront domain (paste the value from dig adr.smlcompany.ca CNAME or nslookup adr.smlcompany.ca to verify it actually resolves to a .cloudfront.net host).
  • 403 from CloudFront: the S3 bucket policy isn't right — re-copy from CloudFront's "Origins → Edit" page.
  • TLS error: ACM cert is in us-east-1, not ca-central-1; or the Alternate domain name on the CloudFront distribution doesn't match exactly.
  • Form returns 500: open CloudWatch logs for the Lambda — almost always an unverified SES identity or a permissions gap on the role.
  • Email not arriving: SES is still in sandbox AND the destination isn't verified, OR the From address isn't verified.

Appendix A — File / resource manifest

When you're done, here's what you should be able to point at in your AWS console:

Resource Name / ID
S3 bucket adr-smlcompany-site (ca-central-1)
CloudFront dist E… (CNAME: adr.smlcompany.ca)
ACM certificate for adr.smlcompany.ca (us-east-1)
DNS provider Namecheap (Advanced DNS for smlcompany.ca)
DynamoDB table adr-intake-submissions (ca-central-1)
Lambda function adr-intake-handler (ca-central-1)
IAM role adr-intake-lambda-role
API Gateway adr-intake-api (HTTP API, ca-central-1)
SES verified ids domain smlcompany.ca, email adr@smlcompany.ca, intake@…

Appendix B — Future enhancements (when you want them)

  • Admin dashboard for submissions: build a tiny password-protected page that calls a second Lambda (GET /submissions) to list the DynamoDB table. Or just use the DynamoDB console for now — it's perfectly serviceable for low volume.
  • Confirmation email to submitter: request SES production access (Part 9.5), then add a second SendEmailCommand call in the Lambda thanking them and setting expectations.
  • Calendar booking: integrate Calendly or Cal.com link inside the "Thank you" view.
  • File uploads on intake (e.g., a PDF of the dispute summary): add S3 presigned-URL generation in the Lambda, let the front-end upload directly to a private bucket. Keep file size limits sane.
  • Move DNS to Route 53 later: if you ever want apex (smlcompany.ca) on CloudFront, or want AWS to manage records for you automatically, the migration is straightforward — inventory Namecheap records, recreate them in a new Route 53 hosted zone, switch nameservers at Namecheap. Doable in ~30 min once you have a downtime window for any DNS-sensitive integrations.
  • Bilingual (EN/FA) routing: add a lang query param or subpath, serve from the same S3 bucket via CloudFront behaviors.
  • Search / analytics over submissions: stream DynamoDB updates to a small OpenSearch index, or just export weekly to a private S3 bucket and query with Athena.
  • WAF in front of CloudFront: if you ever see scraper/bot traffic, add AWS WAF with the AWS-managed core rule set (~$5/mo + per-request).

End of guide. If you hit a wall on any specific step, come back here and tell me which Part and what the screen says — most issues are 1-line fixes.