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
This commit is contained in:
Pouya Lajevardi
2026-08-26 11:28:42 -04:00
co-authored by Claude Opus 5
parent e6abdf42e8
commit 6bf1167624
22 changed files with 1951 additions and 183 deletions
+7 -6
View File
@@ -170,14 +170,14 @@ neutral.
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
`documents-only arbitration`.
1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary
1. What the service is; sole-arbitrator and party-appointed
appointments.
2. **Tracks:** documents-only, expedited, full hearing.
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
4. Awards — form, reasoning, timing.
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
the page says so and describes what is available now (co-arbitration,
tribunal secretary) versus what follows designation. Honesty here is a
co-arbitration) versus what follows designation. Honesty here is a
differentiator, not a weakness — and misstating it is a conduct problem.
6. Fees, booking.
@@ -208,7 +208,7 @@ system design, and pre-dispute technical advisory.
`subcontract dispute arbitration Toronto`.
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
what an active litigation practice in the same matters brings to the room; the
what active litigation exposure in the same matters brings to the room; the
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
transit; typical process shape. Strongest immediate fit per brief §III.1.
@@ -243,7 +243,7 @@ a claim of existing volume.**
`accident benefits mediator Ontario`, `MIG dispute`.
Highest realistic near-term volume — it flows directly from the existing
personal-injury and SABS practice, and brief §IV.7 notes the segment is
personal-injury and SABS work, and brief §IV.7 notes the segment is
underserved by senior mediators. Unglamorous and worth doing well.
### `/practice/shareholder/`
@@ -274,7 +274,8 @@ a matter does not settle.
### `/fees/`
**Blocked on `AGENTS.md` Q4 — do not invent numbers.**
**Unblocked `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
in `docs/07-fees.md`; still do not invent numbers.**
Hourly rate; half-day and full-day mediation; preparation time policy;
cancellation terms; administrative fee; HST treatment; who pays and how costs
@@ -339,6 +340,6 @@ Dependency-ordered, so nothing is blocked mid-stream:
6. `/process/`, `/for-parties/`
7. `/insights/` plumbing, then the drafted articles
8. `/contact/` and the intake backend
9. `/fees/` — last, since it is blocked on Q4
9. `/fees/` — last, though no longer blocked: D14 confirmed the card
10. `/legal/*` — written to match the backend as actually built
11. Audit and cutover (`06-deployment.md`)
+9 -6
View File
@@ -29,7 +29,8 @@ detect padding instantly and discount everything after it.
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
card. Minutes of settlement.* Specificity is the credential.
- Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation;
co-arbitration and tribunal-secretary work is available now." Precision about
co-arbitration work is available now." (**No tribunal-secretary work** — D14
removed the rate and bars offering it; see `docs/07-fees.md`.) Precision about
what you cannot yet do makes the rest believable.
- Plain words over Latin. "Without prejudice" survives because it is a term of
art; *inter alia* does not.
@@ -49,8 +50,9 @@ detect padding instantly and discount everything after it.
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
rather than letting it settle in by default.
- Superlatives. No "leading", "premier", "top-rated", "best". LSO marketing rules,
and they read as insecure.
- Superlatives. No "leading", "premier", "top-rated", "best". They are
unverifiable, they read as insecure, and marketing rules for regulated
professions treat them as suspect.
- Outcome language that could be read as a guarantee.
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
"leverage", "synergy", "solutions".
@@ -68,7 +70,7 @@ detect padding instantly and discount everything after it.
Reused, adapted, across the hero, the About page, and the PDF bio:
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
> who is also a working litigator and a practising machine-learning and
> who is also close to live litigation and a practising machine-learning and
> infrastructure engineer. Built for commercial, construction, energy,
> technology, and cross-cultural disputes that turn on facts most neutrals take
> on faith: the contract, the code, the engineering documents, and the
@@ -115,7 +117,7 @@ redrawing the loop into a line.* First person: "my mark", not "our mark".
### About
400600 words of narrative, then structured credentials. Tell the three tracks
as one arc, not three lists: a JD and an active litigation practice; a parallel
as one arc, not three lists: a JD and active litigation exposure; a parallel
career in machine learning and infrastructure engineering; a company run
alongside both. The arc is the point — the credentialing pathway from Q.Med
through Q.Arb to C.Med-Arb is stated openly as in progress. The brief treats
@@ -150,7 +152,8 @@ Five steps with real timing. Say what happens if the matter does not settle —
counsel want to know the downside shape before they commit a client's day.
### Fees
**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from"
**Unblocked Q4/Q14 answered, D14.** Build from the confirmed card in
`docs/07-fees.md`. Plain table, no "starting from"
evasions, no "contact for pricing" after promising a rate card.
### For parties
+5 -5
View File
@@ -10,13 +10,13 @@ server-side fetch of the live site returns three words.
| | Now `[verified 2026-08-25]` | Target |
|---|---|---|
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
| Indexable pages | 1 | 19 + articles |
| Indexable pages | 1 | 17 + articles (19 fixed URLs, less the two `/legal/*` pages, which are `noindex` and excluded from the sitemap) |
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
| Meta description | none | Unique per page |
| `<meta viewport>` | **absent** | Present |
| Canonical URL | none | Every page |
| OG / Twitter tags | none | Every page |
| Structured data | none | Person, LegalService, Article, FAQ, Breadcrumb |
| Structured data | none | Person, ProfessionalService, Article, FAQ, Breadcrumb |
| `robots.txt` | 403 | Served |
| Sitemap | none | Generated at build |
| Favicon | none | Full set |
@@ -60,8 +60,8 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
| Type | Where | Notes |
|---|---|---|
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn**Q12**), `image`, `worksFor` |
| `LegalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real |
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn), `image`. **`jobTitle` = "Director of Firm Operations"; omit `worksFor`** — populating it either names the boutique (D16) or misstates the employer |
| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
| `Service` | Each practice page | `serviceType`, `provider` → Person, `areaServed` |
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
@@ -125,7 +125,7 @@ consistent name, address, and phone across all of them.
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
- [ ] Every page renders its full text with JavaScript disabled
- [ ] Rich Results Test passes on Person, LegalService, Article
- [ ] Rich Results Test passes on Person, ProfessionalService, Article
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
- [ ] Sitemap submitted to Google Search Console and Bing
- [ ] No page returns 200 for a URL that should 404
+31 -26
View File
@@ -1,7 +1,9 @@
# 05 — Intake, booking, and data handling
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
Existing infrastructure is documented in `AWS-Hosting-Guide.md` Parts 810.
Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is
recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 810 — a historical
record with a do-not-execute banner, superseded by §7 wherever they disagree.
**Read that guide before changing anything**; the resources already exist and
were built by hand in the console.
@@ -10,7 +12,7 @@ were built by hand in the console.
## What exists today
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
a verified sender on `smlcompany.ca`. `[verified 2026-08-25 — AWS-Hosting-Guide.md]`
a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
The shape is right. This is a hardening and rework pass, not a replacement.
@@ -73,7 +75,8 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
DynamoDB, `ca-central-1` — **Canadian data residency is a real selling point for
a Canadian legal practice, and the privacy policy will say so.** Confirm the
existing table's region and migrate if it is elsewhere (**Q10**).
existing table's region and migrate if it is elsewhere. `AGENTS.md` §7 records
`adr-intake-submissions` in `ca-central-1` `[verified 2026-08-26]`.
| Attribute | |
|---|---|
@@ -106,33 +109,33 @@ SES on submission:
of the no-retainer language, and a link to the privacy policy. This email is
the reason the form beats a `mailto:` link.
**Email authentication — verified 2026-08-26, and it is not in place.**
**Email authentication — in place as of 2026-08-26 (Q20).**
A DNS query of `smlcompany.ca` found **no SPF record and no DMARC record**. Mail
is on Google Workspace (MX `1 smtp.google.com`) with Google DKIM configured, and
the SES domain identity reports verified for sending — but neither SPF nor DMARC
exists.
**What is already in place** (Namecheap DNS and the SES console, both inspected
2026-08-26):
| Record | Status |
|---|---|
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **Live.** Matches SES exactly. Never delete |
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | Orphans from an earlier verification. Inert. **Leave them** — deleting the wrong three breaks DKIM |
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
Add both of these; neither conflicts with anything above:
SPF and DMARC were both added by Pouya and independently verified on
2026-08-26. Mail is on Google Workspace (MX `1 smtp.google.com`) with Google
DKIM configured, and the SES domain identity is verified for sending in
`ca-central-1`. **An earlier version of this spec said neither record existed;
that was true when written and is no longer.** The records now live are:
| Host | Type | Value |
|---|---|---|
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
A domain may publish **only one** `v=spf1` record, so both senders go in one
**What is already in place** (Namecheap DNS and the SES console, both inspected
2026-08-26):
| Record | Status |
|---|---|
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | **LIVE. Never delete.** All three resolve (NOERROR) and back the healthy `ca-central-1` SES identity — `DkimStatus: SUCCESS`. These are the records DMARC alignment rests on `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **BROKEN and inert.** Entered into Namecheap with the full name in the Host field, so the zone doubled the domain; they answer NXDOMAIN at the correct name. They belong to a stray `us-east-1` identity this project does not use. Harmless where they are — leaving them is the low-risk choice (Q21) `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
Notes that mattered when these were added, kept because they matter again on
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
the quotes literally and breaks the record.
@@ -140,7 +143,8 @@ the quotes literally and breaks the record.
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
the six CNAMEs above are doing, and it already works. The SPF record's real job
the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six
are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job
is authenticating **Google Workspace** mail, which currently has no SPF at all.
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
domain is configured later.
@@ -152,7 +156,7 @@ no visibility.
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
— silently, months later (Q20).
— silently, months later.
Failure handling: SES failure must never lose the submission. Write to DynamoDB
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
@@ -204,7 +208,8 @@ relationship · cookie and analytics disclosure · last-updated date.
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
practice whose privacy posture is part of its offer (**Q11**).
practice whose privacy posture is part of its offer — D15 settles this:
Plausible or Fathom, cookieless, no consent banner.
## Definition of done
+132 -66
View File
@@ -1,19 +1,22 @@
# 06 — Deployment and cutover
Authority: `AGENTS.md` §3 D3 (git + GitHub Actions → existing S3/CloudFront) and
D11 (build everything, one clean cutover).
Existing infrastructure: `AWS-Hosting-Guide.md`.
Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions**
→ existing S3/CloudFront) and D11 (build everything, one clean cutover).
Existing infrastructure: **`AGENTS.md` §7 is authoritative.**
`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was
originally built — it is a historical record carrying a do-not-execute banner,
not a procedure, and §7 wins wherever the two disagree (Q24).
---
## Topology
```
GitHub push to main
└─ GitHub Actions
Gitea push to main
└─ Gitea Actions (act_runner)
├─ npm ci && npm run build → ./dist
├─ assume AWS role via OIDC (no stored keys)
├─ aws s3 sync ./dist s3://<bucket>
├─ static scoped IAM user key (from Gitea secrets — NOT OIDC)
├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy)
└─ cloudfront create-invalidation
Namecheap DNS → CloudFront → S3 (OAC)
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
@@ -30,8 +33,11 @@ local clone at `/Users/pouya/Dev/Websites/adr-sml`.
**The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks
GitHub Actions syntax, so it is a near-direct port — the build steps, the
two-pass sync, and the cache headers are unchanged. `.github/workflows/deploy.yml`
stays in the repo as the OIDC reference in case the project ever moves.
three-pass sync, and the cache headers are unchanged. The GitHub Actions original,
with its OIDC role assumption, stays in the repo as
`docs/reference/github-actions-oidc.yml.example` — deliberately outside
`.github/workflows/`, because Gitea falls back to that directory when
`.gitea/workflows` is absent.
### The one real difference: no OIDC
@@ -39,8 +45,9 @@ Gitea is not an AWS OIDC provider. There is no role to assume, so deploys
authenticate with a **scoped IAM user** whose access key lives only in the
repository's Gitea secrets.
This is a genuine step down in security from the GitHub setup, and it should be
treated as one. The mitigations are the policy scope and the rotation schedule.
This is a genuine step down in security from an OIDC setup — which was designed
here but never built — and it should be treated as one. The mitigations are the
policy scope and the rotation schedule.
**Create the user:**
@@ -62,7 +69,7 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
{
"Sid": "WriteSiteObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
},
{
@@ -75,12 +82,34 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
}
```
No `s3:*`. No `cloudfront:*`. No wildcard resources. If a deploy step needs a
permission this policy lacks, the correct response is to question the step, not
to widen the policy.
Four actions on one bucket and one distribution. No `Action: "*"`, no
`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the
objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26:
`aws s3 sync` does not use it without `--acl`, and it is inert under Origin
Access Control with ACLs disabled. If a deploy step needs a permission this
policy lacks, the correct response is to question the step, not to widen the
policy.
3. Create an access key. **Copy it once** — AWS will not show the secret again.
**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual
reason.** `aws s3 sync` switches to multipart above its 8 MB
`multipart_threshold`; an interrupted multipart upload then cannot clean up its
own parts, and orphaned parts accrue storage charges that do not appear in the
bucket listing. What makes that safe today is simply that **nothing here comes
close to 8 MB** — the largest file the pipeline
uploads is well under it. The biggest source asset is
`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`,
Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font
files are smaller again. **Re-measure `./dist` after the first successful build**
— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one
as the mitigation, because it is not there.
**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an
un-optimised photograph. At that point either add an S3 lifecycle rule aborting
incomplete multipart uploads after 7 days (preferred — it costs no IAM
permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
### Gitea configuration
**Repository → Settings → Actions → Secrets:**
@@ -90,7 +119,10 @@ to widen the policy.
| `AWS_ACCESS_KEY_ID` | from the IAM user |
| `AWS_SECRET_ACCESS_KEY` | from the IAM user |
**The real values** (captured 2026-08-26, `aws-inventory.txt`):
**Repository → Settings → Actions → Variables** — not secrets. These are not
sensitive, and keeping them as variables means they appear in run logs where
they are useful for debugging. Values captured 2026-08-26 by
`scripts/aws-discover.sh`:
| Variable | Value |
|---|---|
@@ -105,23 +137,13 @@ IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
> **Read this before creating the key.** Account `327082975128` is shared across
> `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and
> `mlp-clientdb-prod-backups` — a law firm's client-database backups. A static
> deploy key for a marketing site lives in the same account. The scoped policy is
> `mlp-clientdb-prod-backups` — which **by its name** holds another business's
> production client-database backups; the contents were never inspected, only
> the bucket name observed. A static deploy key for a marketing site lives in
> the same account. The scoped policy is
> what keeps a compromised Gitea runner from reaching any of that. Do not widen
> it, and never put the `user/pouya` credentials in CI.
**Repository → Settings → Actions → Variables** (not secrets — these are not
sensitive, and keeping them as variables means they appear in run logs where they
are useful for debugging):
| Name | Value |
|---|---|
| `AWS_REGION` | e.g. `ca-central-1` |
| `S3_BUCKET` | the site bucket |
| `CLOUDFRONT_DISTRIBUTION_ID` | the `E...` ID |
| `INTAKE_ENDPOINT` | API Gateway invoke URL |
| `BOOKING_URL` | once chosen (Q5) |
### A runner must exist
Gitea Actions needs `act_runner` registered to this repository or its
@@ -130,8 +152,25 @@ organisation, and Actions enabled both site-wide in `app.ini`
queues silently and never runs — which looks exactly like a broken pipeline.
The workflow installs the AWS CLI if the runner image lacks it, and runs
`aws sts get-caller-identity` before touching anything, so a credential problem
fails loudly and early rather than halfway through a sync.
`aws sts get-caller-identity` before touching anything. **That check is
narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at
all, so it succeeds for any valid key regardless of policy. It catches a
missing, malformed, or revoked key; it does **not** catch an under-scoped
policy, which still fails halfway through a sync and leaves the bucket
partially updated. Read it as a key check, not a permissions check.
### The variable guard runs first
The workflow's first step — before checkout, before the build, before any AWS
call — fails the run if `AWS_REGION`, `S3_BUCKET`, or
`CLOUDFRONT_DISTRIBUTION_ID` is empty.
This exists because Gitea only added the `vars` context in 1.21. On an older
instance every `${{ vars.* }}` interpolates to an empty string with no warning,
the sync target becomes `s3://`, and the run dies halfway through with an error
that names nothing useful. The guard converts that into a clean failure that
says which variable is missing — **on every Gitea version**. A recorded version
number would have gone stale; the guard does not.
### Key rotation — an operational obligation
@@ -149,9 +188,9 @@ whole section exists to bound.
## Finding the AWS identifiers
`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution
`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
is on. Read-only; every call is a list or describe.
is on. Read-only; no call creates or mutates anything.
```bash
chmod +x scripts/aws-discover.sh
@@ -160,25 +199,28 @@ chmod +x scripts/aws-discover.sh
The output contains resource names and IDs but no secrets.
## Why OIDC and not access keys
## Why OIDC would have been better — and why it is unavailable
The alternative is a long-lived `AWS_ACCESS_KEY_ID` in GitHub secrets: a
credential that never expires, is invisible once set, and grants its permissions
to anyone who can reach the repository. OIDC issues a short-lived token per run,
scoped to this repository and this branch.
> **Do not execute this section.** It describes the design that was rejected
> because Gitea cannot support it. The live procedure is *Create the user* above.
> Nothing here should be created in AWS. Following it would add an unused GitHub
> federation trust to account `327082975128`.
One-time setup:
A static `AWS_ACCESS_KEY_ID` never expires, is invisible once set, and grants its
permissions to anyone who can reach the repository. OIDC issues a short-lived
token per run, scoped to one repository and one branch — strictly better, and the
reason the rotation schedule above is not optional here.
1. IAM → Identity providers → add OIDC provider `token.actions.githubusercontent.com`,
audience `sts.amazonaws.com`.
2. Create role `adr-site-deploy` trusting that provider, with a condition on
`token.actions.githubusercontent.com:sub` equal to
`repo:<org>/<repo>:ref:refs/heads/main` (**Q9**).
3. Attach a policy granting **only**: `s3:PutObject`, `s3:DeleteObject`,
`s3:ListBucket` on the site bucket, and `cloudfront:CreateInvalidation` on the
one distribution. Nothing else. No `s3:*`, no `cloudfront:*`.
4. Store the role ARN, bucket name, and distribution ID as repository
**variables** (they are not secrets), and reference them in the workflow.
It needs an identity provider AWS will federate with. GitHub and GitLab both
publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust
and no role to assume. That is the whole of the constraint (D3 as amended).
If the project ever moves to GitHub, the workflow to adopt is
`docs/reference/github-actions-oidc.yml.example`, and the setup is: register
`token.actions.githubusercontent.com` as an IAM OIDC provider with audience
`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim
matching the repository and `refs/heads/main`; attach the same four-action policy
given above; then delete `adr-sml-deploy` and its key.
## Cache policy
@@ -191,19 +233,33 @@ that does not update.
| `/_astro/*` (hashed) | `public, max-age=31536000, immutable` |
| Fonts | `public, max-age=31536000, immutable` |
| Images | `public, max-age=604800` |
| `robots.txt`, `sitemap*.xml` | `public, max-age=3600` |
| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` |
Sync in two passes: hashed assets first with the long TTL, then HTML with the
short one. Uploading HTML last means a user never fetches a new page whose assets
have not landed yet.
Sync in **three** passes, in this order: hashed assets and fonts with the long
TTL, then images, then everything else. Uploading HTML last means a user never
fetches a new page whose assets have not landed yet.
Two ordering dependencies are load-bearing and easy to break:
- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only
because `aws s3 sync` skips objects it has just uploaded. Reordering the
passes silently overwrites them with the HTML header.
- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those
prefixes from `--delete`, so hashed assets from previous deploys are kept
deliberately — pages still in a browser cache need them. Do not "fix" it.
`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header.
That is the intended behaviour: both should be re-fetched, and the table above
records what the pipeline actually does rather than an unimplemented ideal.
Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial
invalidation paths are a reliable source of confusing bugs.
## CloudFront configuration
- Origin: S3 with **Origin Access Control**, bucket not public. The guide's
Part 2.2 bucket policy already does this — verify it was not loosened.
- Origin: S3 with **Origin Access Control**, bucket not public. Verify the
bucket policy grants access only to the CloudFront distribution's OAC
principal and to nothing else, and that public access is still blocked.
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
- Default root object `index.html`.
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
@@ -215,11 +271,20 @@ invalidation paths are a reliable source of confusing bugs.
## Branch model
`main` is production; every push deploys. Work on short-lived branches, open a
PR, let CI build and run Lighthouse, merge.
`main` is production; a push to `main` is what triggers a deploy. Work on
short-lived branches, open a PR, merge.
**Pull request checks (blocking):** `npm run build` · `astro check` · lint ·
Lighthouse CI against the budgets in `04-seo-spec.md` · link check.
**The pipeline has never completed a run.** There is no `package-lock.json`, so
`npm ci` exits at step one; `src/pages/` is empty, so there is nothing to build;
and no record exists of an `act_runner` being registered. Treat "every push
deploys" as the design, not as current behaviour.
**Pull request checks — planned, not implemented:** `npm run build` ·
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
(only `push` on `main` and `workflow_dispatch`), and neither `npm run lint` nor
`npm run lighthouse` is wired — there is no ESLint config and no `lighthouserc`.
Nothing gates a merge today.
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
@@ -227,8 +292,9 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name.
1. Re-run the workflow at the last good tag, or
2. `git revert` and push, or
3. Restore from S3 object versioning — **enable versioning on the bucket if it is
off**; it is the difference between a rollback and a rebuild.
3. Restore from S3 object versioning — **already Enabled** on
`adr-smlcompany-site` `[verified 2026-08-26 — AGENTS.md §7]`. It is the
difference between a rollback and a rebuild; do not turn it off.
Then invalidate `/*`.
@@ -239,7 +305,7 @@ Then invalidate `/*`.
- [ ] No `TODO(pouya)` remains in any shipped page
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
- [ ] Q.Arb described as in progress everywhere it appears
- [ ] `/fees/` carries real numbers (Q4) or the page does not ship
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
- [ ] Privacy policy matches the backend as actually built
**Technical**
@@ -251,10 +317,10 @@ Then invalidate `/*`.
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
- [ ] 404 returns a 404 status
- [ ] Security headers present (`securityheaders.com` A or better)
- [ ] **SES identities verified for sending** (Q18) — `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
- [ ] **SES out of the sandbox** (Q19) — `aws sesv2 get-account --query 'ProductionAccessEnabled'`. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails
- [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set
- [ ] Booking link works, including the no-JavaScript fallback
- [ ] Booking link works, including the no-JavaScript fallback**conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
- [ ] Favicon set complete
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
- [ ] Tested at 320 px and at 200% zoom
@@ -264,7 +330,7 @@ Then invalidate `/*`.
- [ ] Bucket not publicly readable; OAC in force
- [ ] ACM certificate valid; Namecheap validation CNAME still present
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
- [ ] Billing alarm still active (guide Part 0.3)
- [ ] Billing budget/alarm still active `aws budgets describe-budgets --account-id 327082975128`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
**Post-cutover, same day**
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
+6 -5
View File
@@ -1,11 +1,12 @@
# 07 — Fee research and recommended rate card
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and D14 (two-tier
structure, **pending Pouya's sign-off — Q14**).
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and **D14 — a single
published rate card, confirmed by Pouya 2026-08-26 (Q4/Q14/Q15-Q17 answered).**
**Nothing in this document publishes until Pouya confirms the figures.** These
are researched recommendations, not decisions. This is business pricing
information, not legal or financial advice.
**The card below is confirmed and buildable.** The research that produced it is
retained for context, but the figures are decisions now, not recommendations —
see "Set by Pouya" below. This is business pricing information, not legal or
financial advice.
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
+11 -1
View File
@@ -47,9 +47,19 @@ approve elegant code containing a claim that should never have been published,
because professional-conduct compliance is not what it is looking at. On this
project that is the highest-stakes failure mode, so it gets its own pass.
**Verifying they are loaded.** `.claude/agents/` is the correct location. To
confirm the agents are live, invoke one directly:
```
Use the claims-auditor agent to audit README.md against AGENTS.md §4.
```
A verdict table back means both are wired. "No such agent" means the frontmatter
needs looking at.
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
wrong. That is the intended trade: explaining why a finding is mistaken costs
minutes, and a missed defect on a licensed professional's public marketing page
minutes, and a missed defect on this project's public marketing pages
costs a great deal more.
## The rule that makes it work
+749
View File
@@ -0,0 +1,749 @@
# 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`](../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
```
### 1.3 Set cache-control on the HTML (recommended)
Because we'll deploy by overwriting `index.html` later, you want browsers/CDN to re-check it often.
- Click `index.html`**Properties → 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:
```json
{
"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 DNS** → **ADD 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 DNS** → **ADD 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:
```json
{
"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 role* → `adr-intake-lambda-role`.
- **Create function.**
In the Code tab, replace the contents of `index.mjs` with:
```javascript
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 → **CORS** → **Configure**:
- 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:
```bash
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-submissions` → **Explore 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
```bash
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:
```jsx
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:
```bash
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-site` → **Upload** → drag `index.html` from the project folder → **Cache-Control:** `public, max-age=300, must-revalidate` → **Upload**.
Or from the terminal:
```bash
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:
```bash
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:
```bash
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.
### Privacy / PIPEDA hygiene (legal-services context)
- 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.*
@@ -0,0 +1,98 @@
# ---------------------------------------------------------------------------
# REFERENCE ONLY. This repository lives on self-hosted Gitea (AGENTS.md D3).
# The live pipeline is .gitea/workflows/deploy.yml.
#
# This file is kept because it is the better design: GitHub OIDC issues a
# short-lived token per run instead of a static key. If the project ever moves
# to GitHub, use this and delete the static IAM user. GitLab also federates
# to AWS by OIDC, but with entirely different CI syntax — this file is the
# design there, not the implementation.
# ---------------------------------------------------------------------------
name: Build and deploy
on:
push:
branches: [main]
workflow_dispatch:
# OIDC role assumption — a short-lived token per run, no static key.
# NOT the current posture: this repository deploys with a static IAM key.
# See docs/06-deployment.md for the live procedure and the IAM policy.
permissions:
contents: read
id-token: write
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci
- name: Type and template check
run: npm run check
- name: Build
run: npm run build
env:
PUBLIC_SITE_URL: https://adr.smlcompany.ca
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
# rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and
# BOOKING_URL — are recorded in docs/06-deployment.md.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
# Three passes: hashed immutable assets first, then images, HTML last.
# A visitor must
# never fetch a new page whose assets have not landed yet.
- name: Sync hashed assets
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
- name: Sync images
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "*" \
--include "*.avif" --include "*.webp" --include "*.jpg" \
--include "*.png" --include "*.svg" \
--cache-control "public, max-age=604800" \
--no-progress
- name: Sync HTML and the rest
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}" \
--paths "/*"
- name: Summary
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA::7}" >> "$GITHUB_STEP_SUMMARY"