From af6092552581fdfe92a5202cb076b95443fd376f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sarah=20Mitchell=20=F0=9F=A4=96?= Date: Tue, 12 May 2026 14:05:02 -0700 Subject: [PATCH 01/50] fix(code-quality): address AI findings from issue #91 (#92) - UserMenu.web: correct aria-hidden to boolean value - UserMenu.native: remove unused menuRef; drop menuItemDangerText style (identical to menuItemText) - PricingTable.web.test: expand mocks to full Plan shape so filtering/highlight logic isn't masked by minimal objects - setup-full.mjs: add env to runInteractive opts JSDoc; pipe stdio in resolveGhRepo; use literals in dry-run preview block instead of reading acc keys set in same block --- .../__tests__/PricingTable.web.test.tsx | 55 +++++++++++++------ .../components/navigation/UserMenu.native.tsx | 10 +--- .../components/navigation/UserMenu.web.tsx | 2 +- scripts/setup-full.mjs | 7 ++- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/packages/billing/src/components/__tests__/PricingTable.web.test.tsx b/packages/billing/src/components/__tests__/PricingTable.web.test.tsx index 1a2e1794..1d9b8be3 100644 --- a/packages/billing/src/components/__tests__/PricingTable.web.test.tsx +++ b/packages/billing/src/components/__tests__/PricingTable.web.test.tsx @@ -3,28 +3,47 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PricingTable } from '../PricingTable.web'; +const MOCK_PLANS = [ + { + id: 'plan_free', + product_id: 'prod_test', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { ai_summarize: false }, + usage_limits: { collections: 3 }, + trial_period_days: 0, + is_public: true, + display_order: 0, + }, + { + id: 'plan_pro', + product_id: 'prod_test', + display_name: 'Pro', + description: 'Full access', + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: 'price_pro_monthly', + stripe_price_id_annual: null, + stripe_product_id: 'prod_pro', + features: { ai_summarize: true }, + usage_limits: { collections: 100 }, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, +]; + vi.mock('../../hooks/usePlanCatalog.js', () => ({ - usePlanCatalog: () => ({ - plans: [ - { - id: 'plan_free', - display_name: 'Free', - price_cents: 0, - billing_period: 'free', - }, - { - id: 'plan_pro', - display_name: 'Pro', - price_cents: 1900, - billing_period: 'monthly', - }, - ], - loading: false, - }), + usePlanCatalog: () => ({ plans: MOCK_PLANS, loading: false, error: null }), })); vi.mock('../../hooks/usePlan.js', () => ({ - usePlan: () => ({ data: { id: 'plan_pro' } }), + usePlan: () => ({ data: MOCK_PLANS[1] }), })); describe('PricingTable', () => { diff --git a/packages/shared/src/components/navigation/UserMenu.native.tsx b/packages/shared/src/components/navigation/UserMenu.native.tsx index 040af092..90b690e3 100644 --- a/packages/shared/src/components/navigation/UserMenu.native.tsx +++ b/packages/shared/src/components/navigation/UserMenu.native.tsx @@ -38,7 +38,6 @@ export interface UserMenuProps { */ export function UserMenu({ user, profile, navigation }: UserMenuProps) { const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); const avatarRef = useRef(null); const [avatarLayout, setAvatarLayout] = useState({ x: 0, @@ -87,7 +86,7 @@ export function UserMenu({ user, profile, navigation }: UserMenuProps) { return ( <> - + - - Sign Out - + Sign Out @@ -223,7 +220,4 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#374151', }, - menuItemDangerText: { - color: '#374151', // Keep same color as web for consistency - }, }); diff --git a/packages/shared/src/components/navigation/UserMenu.web.tsx b/packages/shared/src/components/navigation/UserMenu.web.tsx index eee1163b..e567c6d3 100644 --- a/packages/shared/src/components/navigation/UserMenu.web.tsx +++ b/packages/shared/src/components/navigation/UserMenu.web.tsx @@ -89,7 +89,7 @@ export function UserMenu({ user, profile }: UserMenuProps) { onClick={() => setIsOpen(false)} className='flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700' > - + Billing Date: Tue, 12 May 2026 14:10:56 -0700 Subject: [PATCH 02/50] feat: CalVer template releases with git-cliff changelog (issue #82) (#83) * feat: CalVer template releases with git-cliff changelog (issue #82) * fix: address PR #83 review feedback --- .github/workflows/release-template.yml | 67 ++++++++++++++++++++++++++ README.md | 10 ++++ UPGRADING.md | 61 +++++++++++++++++++++++ VERSIONING.md | 44 +++++++++++++++++ cliff.toml | 35 ++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 .github/workflows/release-template.yml create mode 100644 UPGRADING.md create mode 100644 VERSIONING.md create mode 100644 cliff.toml diff --git a/.github/workflows/release-template.yml b/.github/workflows/release-template.yml new file mode 100644 index 00000000..1e721f3c --- /dev/null +++ b/.github/workflows/release-template.yml @@ -0,0 +1,67 @@ +name: Release Template + +on: + workflow_dispatch: + inputs: + override_tag: + description: 'Override computed tag (e.g. 2026.003) — leave blank to auto-compute' + required: false + +jobs: + release: + name: Cut CalVer release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate override tag format + if: inputs.override_tag != '' + run: | + if [[ ! "${{ inputs.override_tag }}" =~ ^[0-9]{4}\.[0-9]{3}$ ]]; then + echo "Invalid tag format '${{ inputs.override_tag }}' — must match YYYY.NNN (e.g. 2026.003)" + exit 1 + fi + + - name: Compute next CalVer tag + id: tag + run: | + if [[ -n "${{ inputs.override_tag }}" ]]; then + echo "tag=${{ inputs.override_tag }}" >> $GITHUB_OUTPUT + else + YEAR=$(date +%Y) + LAST_N=$(git tag -l "${YEAR}.*" | sed "s/${YEAR}\.//" | sort -n | tail -1) + NEXT_N=$(( ${LAST_N:-0} + 1 )) + printf -v PADDED_N "%03d" $NEXT_N + echo "tag=${YEAR}.${PADDED_N}" >> $GITHUB_OUTPUT + fi + + - name: Generate release notes + uses: orhun/git-cliff-action@v3 + with: + config: cliff.toml + args: >- + --tag-pattern '^[0-9]{4}\.' + --tag ${{ steps.tag.outputs.tag }} + --latest + env: + OUTPUT: RELEASE_NOTES.md + GITHUB_REPO: ${{ github.repository }} + + - name: Create annotated tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a ${{ steps.tag.outputs.tag }} -m "Release ${{ steps.tag.outputs.tag }}" + git push origin ${{ steps.tag.outputs.tag }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + body_path: RELEASE_NOTES.md diff --git a/README.md b/README.md index 4ef3470b..4de91a3e 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,16 @@ For **native clean rebuilds**, simulator uninstall, and `prebuild --clean`, see Details: [docs/pr-preview-setup.md](docs/pr-preview-setup.md). +## Releases and versioning + +Template snapshots are tagged on `main` using **CalVer** (`2026.001`, `2026.002`, …). Each release includes generated notes covering what changed and whether there are any breaking steps. `@beakerstack/*` packages use independent **semver** on npm. + +| Trigger | Workflow | +| ------- | -------- | +| **Manual dispatch** | [release-template.yml](.github/workflows/release-template.yml) — cuts a new CalVer tag and GitHub Release | + +See [VERSIONING.md](VERSIONING.md) for what counts as a breaking change and [UPGRADING.md](UPGRADING.md) for how to pull template changes into an existing fork. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 00000000..c4e8a8cb --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,61 @@ +# Upgrading + +This guide covers how to pull BeakerStack changes into a fork you have already set up. See [VERSIONING.md](VERSIONING.md) for how template tags and package versions work. + +## Prerequisites + +Add the BeakerStack repo as an upstream remote if you have not already: + +```bash +git remote add upstream https://github.com/Artificer-Innovations/BeakerStack.git +``` + +--- + +## Option A — Upgrade to a full template snapshot + +Use this when you want to pull in everything from a specific tagged release as a single merge commit. + +```bash +# Fetch upstream commits and tags (tags land as local refs, not as upstream/TAG) +git fetch upstream --tags + +# Merge the snapshot tag into your branch +git merge 2026.003 +``` + +> **"Use this template" users:** If you created your repo using GitHub's "Use this template" button, git treats the histories as unrelated. Your first merge will need `--allow-unrelated-histories`: +> ```bash +> git merge --allow-unrelated-histories 2026.003 +> ``` +> Subsequent merges work without the flag. + +Resolve any conflicts, then review the release notes for that tag on GitHub for any breaking changes that need manual follow-up (new secrets, renamed variables, migration steps). + +## Option B — Cherry-pick specific changes + +Use this when you only want selected commits, not the full snapshot: + +```bash +git fetch upstream +git log upstream/main --oneline # find the commit(s) you want +git cherry-pick +``` + +## Option C — Upgrade an individual `@beakerstack/*` package + +Use this when a package dependency has shipped a new version and you want to update it in isolation: + +```bash +npm install @beakerstack/billing@x.y.z +``` + +Read the package's GitHub Release notes for that version — package release notes describe what changed at the API level, not the broader template context. + +--- + +## After any upgrade + +1. Re-run `npm install` to sync lockfile. +2. Check the release notes for any new required GitHub Actions secrets or variables. +3. Run `npm run type-check && npm run test` to catch regressions before pushing. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..b227038b --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,44 @@ +# Versioning + +BeakerStack uses two parallel versioning schemes — one for the monorepo template and one for its published packages. + +## Template releases — `YYYY.NNN` + +Template releases are **monorepo snapshot tags** on `main`. A tag like `2026.003` means: _this is what BeakerStack looked like at that point in time, and it is a good base to fork from._ + +Tags use **CalVer with a zero-padded sequence number** within the year: + +``` +2026.001 first release of 2026 +2026.002 second release of 2026 +2026.013 thirteenth release of 2026 +``` + +`NNN` increments arbitrarily — there is no meaning to how much time passes between releases. + +### What counts as a breaking change + +A template release is **breaking** if adopters who have forked the template need to take manual action before upgrading. That includes: + +- A new required GitHub Actions secret or repository variable +- A renamed or removed secret / variable that CI depends on +- A changed top-level folder structure (e.g. a directory moved or renamed) +- A changed setup script behavior (flags renamed, phases reordered, outputs changed) +- A new migration step needed to align an existing fork with the updated baseline + +Breaking changes are called out explicitly in the release notes generated for that tag. + +### `main` vs. tagged releases + +| Ref | What it is | Recommended for | +|-----|-----------|-----------------| +| `main` | Current stable HEAD | Following along with active development | +| `2026.NNN` | Snapshot tag | Starting a new fork; upgrading an existing fork in a controlled way | + +If you are forking BeakerStack to build a product, start from a tagged release so your upgrade story is clear from day one. + +## Package releases — semver + +`@beakerstack/*` packages published to npm use standard **semantic versioning** (`MAJOR.MINOR.PATCH`). Each package is versioned independently via changesets. Release notes for package releases live on the package's GitHub Release page. + +Package versions are independent of template CalVer tags. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..b43b4da4 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,35 @@ +[changelog] +header = "" +body = """ +{% if version %}\ +## {{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}\n +{% else %}\ +## [Unreleased]\n +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +{{ group }} +{% for commit in commits %} +- {{ commit.message | upper_first }}\ +{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}]({{ commit.remote.link }})){% endif %} + +{% endfor %} +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true + +commit_parsers = [ + { breaking = true, group = "### ⚠️ Breaking Changes" }, + { body = ".*BREAKING CHANGE.*", group = "### ⚠️ Breaking Changes" }, + { message = "^feat", group = "### Features" }, + { message = "^fix", group = "### Fixes" }, + { message = "^chore\\(deps", group = "### Dependencies" }, + { message = "^chore|^ci|^docs|^test|^refactor|^style|^perf", skip = true }, +] + +filter_commits = true +# Scope tag matching to CalVer template tags only — prevents anchoring to @beakerstack/* package tags +tag_pattern = "^[0-9]{4}\\." From 5eaf8f279261b1a9b1874f36d236cbf5be9f3f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sarah=20Mitchell=20=F0=9F=A4=96?= Date: Tue, 12 May 2026 19:19:59 -0700 Subject: [PATCH 03/50] fix(ci): handle PR base-branch retargeting via pull_request edited event (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): handle PR base-branch retargeting via pull_request edited event Closes #94 - pr-preview-environment.yml: add edited to types; guard preview-scope job so title/body-only edits (no changes.base.ref.from) don't trigger a redeploy — only real base-branch retargets do - test.yml: add explicit types including edited; add job-level if condition with the same base-change guard so tests re-run against the correct merge commit when a PR is retargeted - check-merge-strategy.yml: add edited to types so develop→main retargeting fires the reminder; existing if: github.base_ref == 'main' handles routing * fix: use truthy object check for edited event guard Copilot flagged that 'github.event.changes.base.ref.from != ""' is fragile: accessing a deeply nested path through a missing intermediate object may return null rather than empty string, making the comparison pass unexpectedly on title/body edits. Switch to 'github.event.changes.base' (truthy object check) in all three workflow guards. When the base branch is retargeted, changes.base is a JSON object (truthy). When it's a title/body edit, changes.base is absent (falsy). Also add the guard to check-merge-strategy.yml to avoid unnecessary comment churn on title/body edits. --- .github/workflows/check-merge-strategy.yml | 4 ++-- .github/workflows/pr-preview-environment.yml | 9 +++++++-- .github/workflows/test.yml | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-merge-strategy.yml b/.github/workflows/check-merge-strategy.yml index 285e0fc2..1545d5de 100644 --- a/.github/workflows/check-merge-strategy.yml +++ b/.github/workflows/check-merge-strategy.yml @@ -2,7 +2,7 @@ name: Check Merge Strategy on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, edited] permissions: pull-requests: write @@ -10,7 +10,7 @@ permissions: jobs: check-merge-strategy: - if: github.base_ref == 'main' + if: github.base_ref == 'main' && (github.event.action != 'edited' || github.event.changes.base) runs-on: ubuntu-latest steps: - name: Add merge strategy reminder comment diff --git a/.github/workflows/pr-preview-environment.yml b/.github/workflows/pr-preview-environment.yml index 30f33e83..37f1d2e2 100644 --- a/.github/workflows/pr-preview-environment.yml +++ b/.github/workflows/pr-preview-environment.yml @@ -10,6 +10,7 @@ on: - reopened - synchronize - ready_for_review + - edited - closed permissions: @@ -23,8 +24,12 @@ concurrency: jobs: preview-scope: - # Skip main→develop sync PRs (no feature preview; diff would match everything on develop). - if: github.event.action != 'closed' && (github.base_ref != 'develop' || github.head_ref != 'main') + # Skip main→develop sync PRs, closed events not followed by teardown, and edited events + # that only changed the PR title/body (not the base branch — those should redeploy). + if: >- + github.event.action != 'closed' && + (github.base_ref != 'develop' || github.head_ref != 'main') && + (github.event.action != 'edited' || github.event.changes.base) runs-on: ubuntu-latest outputs: run_deploy: ${{ steps.check.outputs.run_deploy }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d6f1046..64277cf4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,7 @@ on: - main - develop pull_request: + types: [opened, synchronize, reopened, edited] paths: # Application code and tests - 'apps/**' @@ -39,6 +40,8 @@ permissions: jobs: tests: + # On edited events, only run when the base branch was retargeted (not title/body-only edits). + if: github.event.action != 'edited' || github.event.changes.base runs-on: ubuntu-latest timeout-minutes: 45 From 3f41f1aa956671e99bddf8f1a11651cc4ac3def4 Mon Sep 17 00:00:00 2001 From: Brad Hefta-Gaub Date: Tue, 12 May 2026 19:20:48 -0700 Subject: [PATCH 04/50] Optional label bridge: sync org GitHub Project Status from project/status labels (#104) * feat(ci): optional project label bridge for org Kanban Status Add a GitHub Actions workflow that listens for project/status- labels on issues and pull requests, derives the matching Projects (v2) Status option (including In progress / In review for in-* slugs), and updates the board via GraphQL using ORG_PROJECT_GITHUB_TOKEN. GITHUB_TOKEN cannot write org projects, so the workflow is gated on repo variables and documents the classic PAT requirement. Document why the bridge exists for agent-driven workflows, how to configure variables and secrets, and link the doc from docs/README. Add scripts/github/setup-project-label-bridge.mjs and npm run setup:project-label-bridge to push Actions variables and the PAT secret through gh, reusing the same masked secret helpers as setup-full. * fix(scripts): capture gh stdout in project-label-bridge setup helper runGh() used inherited stdio for commands that need r.stdout (repo view, variable list), so spawnSync left stdout empty. Add captureStdout to pipe stdout/stderr and set cwd to REPO_ROOT for consistent gh resolution. Forward gh stderr on non-zero exit. * fix(ci): rename project bridge vars off reserved GITHUB_ prefix GitHub Actions configuration variables cannot start with GITHUB_ (reserved for built-in context). Use PROJECT_NUMBER and PROJECT_ORG in the workflow (vars.*), docs, and gh variable set in the setup helper. Keep optional env fallbacks GITHUB_PROJECT_NUMBER / GITHUB_PROJECT_ORG for local scripts that still export the old names. Document migration from the previous variable names. --- .github/workflows/project-label-bridge.yml | 177 ++++++++++ docs/README.md | 1 + docs/project-label-bridge.md | 90 +++++ package.json | 1 + scripts/github/setup-project-label-bridge.mjs | 315 ++++++++++++++++++ 5 files changed, 584 insertions(+) create mode 100644 .github/workflows/project-label-bridge.yml create mode 100644 docs/project-label-bridge.md create mode 100644 scripts/github/setup-project-label-bridge.mjs diff --git a/.github/workflows/project-label-bridge.yml b/.github/workflows/project-label-bridge.yml new file mode 100644 index 00000000..8e52bc02 --- /dev/null +++ b/.github/workflows/project-label-bridge.yml @@ -0,0 +1,177 @@ +# Optional: moves org GitHub Project (v2) cards when issues/PRs get mapped labels. +# The repo does not require this workflow; see docs/project-label-bridge.md for why, +# setup (secret + variables), and how to align labels with your board. +# +# Repository variables must NOT use the GITHUB_ prefix (reserved for built-in context). +# Use vars.PROJECT_NUMBER and vars.PROJECT_ORG — see docs/project-label-bridge.md. + +name: Project label bridge + +on: + issues: + types: [labeled] + pull_request: + types: [labeled] + +concurrency: + group: project-label-bridge-${{ github.event_name }}-${{ github.event.issue.node_id || github.event.pull_request.node_id }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + sync-status-from-label: + runs-on: ubuntu-latest + permissions: + contents: read + # Secrets are not available in job-level `if`; gate on vars only (forks can skip via empty var). + if: vars.PROJECT_NUMBER != '' + + steps: + - name: Resolve label → Status and update Project + env: + GH_TOKEN: ${{ secrets.ORG_PROJECT_GITHUB_TOKEN }} + LABEL_NAME: ${{ github.event.label.name }} + CONTENT_NODE_ID: ${{ github.event.issue.node_id || github.event.pull_request.node_id }} + PROJECT_ORG: ${{ vars.PROJECT_ORG || 'Artificer-Innovations' }} + PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::warning::ORG_PROJECT_GITHUB_TOKEN is not set; cannot update the org project." + exit 0 + fi + + # Labels: project/status- (lowercase a-z, digits, hyphens). + # → Project "Status" option: each hyphen becomes a space. Default: Title Case + # per word (e.g. backlog → Backlog, ready-for-qa → Ready For Qa). + # Exception: leading segment "in" with more segments → "In " + remaining + # words lowercased (Kanban: "In progress", "In review"). + if [[ "$LABEL_NAME" != project/status-* ]]; then + echo "Label '$LABEL_NAME' is not a project status label (expected prefix project/status-); skipping." + exit 0 + fi + slug="${LABEL_NAME#project/status-}" + slug="${slug,,}" + if [[ -z "$slug" || "$slug" == *[^a-z0-9-]* ]]; then + echo "Label '$LABEL_NAME' has an invalid slug after project/status- (use a-z, 0-9, hyphens only); skipping." + exit 0 + fi + + IFS='-' read -ra parts <<< "$slug" + n=${#parts[@]} + if [[ "$n" -eq 0 ]]; then + echo "Label '$LABEL_NAME' is empty after project/status-; skipping." + exit 0 + fi + if [[ "${parts[0],,}" == "in" && "$n" -eq 1 ]]; then + echo "Label '$LABEL_NAME' is incomplete (project/status-in); skipping." + exit 0 + fi + + if [[ "${parts[0],,}" == "in" && "$n" -ge 2 ]]; then + TARGET_STATUS="In" + for ((i = 1; i < n; i++)); do + w="${parts[i],,}" + [[ -z "$w" ]] && continue + TARGET_STATUS+=" $w" + done + else + TARGET_STATUS="" + for ((i = 0; i < n; i++)); do + w="${parts[i],,}" + [[ -z "$w" ]] && continue + [[ -n "$TARGET_STATUS" ]] && TARGET_STATUS+=" " + TARGET_STATUS+="${w^}" + done + fi + + echo "Mapping label '$LABEL_NAME' → Status '$TARGET_STATUS'" + + gh api graphql -f query=' + query($org: String!, $number: Int!) { + organization(login: $org) { + projectV2(number: $number) { + id + fields(first: 40) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + }' -f org="$PROJECT_ORG" -F number="$PROJECT_NUMBER" --jq . > project_meta.json + + PROJECT_ID=$(jq -r '.data.organization.projectV2.id' project_meta.json) + STATUS_FIELD_ID=$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name == "Status") | .id' project_meta.json) + OPTION_ID=$(jq -r --arg s "$TARGET_STATUS" '.data.organization.projectV2.fields.nodes[] | select(.name == "Status") | .options[] | select(.name == $s) | .id' project_meta.json) + + if [[ "$PROJECT_ID" == "null" || -z "$PROJECT_ID" ]]; then + echo "::error::Could not load project $PROJECT_ORG#$PROJECT_NUMBER (check vars PROJECT_ORG / PROJECT_NUMBER and token access)." + exit 1 + fi + if [[ "$STATUS_FIELD_ID" == "null" || -z "$STATUS_FIELD_ID" ]]; then + echo "::error::Project has no single-select field named 'Status'. Rename the field or adjust this workflow." + exit 1 + fi + if [[ "$OPTION_ID" == "null" || -z "$OPTION_ID" ]]; then + echo "::error::No Status option named '$TARGET_STATUS'. Add it to the project, or use a project/status- label that derives to an existing option name (see workflow comments)." + exit 1 + fi + + gh api graphql -f query=' + query($id: ID!) { + node(id: $id) { + ... on Issue { + projectItems(first: 30) { + nodes { + id + project { ... on ProjectV2 { id number } } + } + } + } + ... on PullRequest { + projectItems(first: 30) { + nodes { + id + project { ... on ProjectV2 { id number } } + } + } + } + } + }' -f id="$CONTENT_NODE_ID" --jq . > content_items.json + + ITEM_ID=$(jq -r --argjson n "$PROJECT_NUMBER" ' + .data.node.projectItems.nodes[] + | select(.project.number == $n) + | .id + ' content_items.json 2>/dev/null || true) + + if [[ -z "$ITEM_ID" || "$ITEM_ID" == "null" ]]; then + echo "Item not on project yet — adding content then setting Status." + ITEM_ID=$(gh api graphql -f query=' + mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { + item { id } + } + }' -f project="$PROJECT_ID" -f content="$CONTENT_NODE_ID" --jq -r '.data.addProjectV2ItemById.item.id') + fi + + gh api graphql -f query=' + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $project + itemId: $item + fieldId: $field + value: { singleSelectOptionId: $option } + } + ) { + projectV2Item { id } + } + }' -f project="$PROJECT_ID" -f item="$ITEM_ID" -f field="$STATUS_FIELD_ID" -f option="$OPTION_ID" --silent + + echo "Updated project item $ITEM_ID to Status='$TARGET_STATUS'." diff --git a/docs/README.md b/docs/README.md index 6181ed7e..2efa97d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Start on the repo root **[README.md](../README.md)** and **[QUICKSTART.md](../QU | [supabase-preview-setup.md](supabase-preview-setup.md) | Shared PR preview database and redirects | | [stripe-billing-setup.md](stripe-billing-setup.md) | Stripe + Supabase Edge billing (keys, webhooks, sync, local vs hosted) | | [reference/github-actions-secrets.md](reference/github-actions-secrets.md) | Actions secret/variable names (regenerate with `npm run docs:actions-secrets`) | +| [project-label-bridge.md](project-label-bridge.md) | **Optional:** label-driven org GitHub Project updates (`npm run setup:project-label-bridge`) | | [branch-protection-setup.md](branch-protection-setup.md) | Branch rules | ## OAuth (canonical order) diff --git a/docs/project-label-bridge.md b/docs/project-label-bridge.md new file mode 100644 index 00000000..0baacf36 --- /dev/null +++ b/docs/project-label-bridge.md @@ -0,0 +1,90 @@ +# Project label bridge (optional GitHub Actions) + +This repository **does not depend** on the [Project label bridge workflow](../.github/workflows/project-label-bridge.yml). CI, builds, and day-to-day development work the same if you never configure it. It exists only if you want **automation between issue labels and an organization GitHub Project (v2) board**. + +## Why it exists + +At **Artificer Innovations** we treat AI coding agents as part of the engineering team: they follow the same habits we expect from human engineers—issues, PRs, reviews, and incremental work you can trace in history. You can see that pattern in this repository, where multiple agent-led changes sit alongside human contributions. + +Humans still rely on **Kanban-style boards** to see flow at a glance. Agents can usually **label** issues and PRs, but we found that **fine-grained personal access tokens** often cannot grant **organization Projects** write access in a way agents can use reliably, so cards on the org board would not move even when work progressed. + +This workflow bridges that gap: **agents (or humans) only change labels**; GitHub Actions runs with a **classic PAT** (or another token that can write org projects) and updates the project **Status** field so the board stays in sync with the labels. + +If you do not use an org-level project board, or you move cards manually, you can ignore this workflow entirely. + +## When the workflow runs + +- Triggers on **`issues: labeled`** and **`pull_request: labeled`**. +- If the **`PROJECT_NUMBER`** repository variable is unset, the job is skipped (no failure). +- If `ORG_PROJECT_GITHUB_TOKEN` is unset, the run exits with a warning and succeeds (so forks and clones without secrets do not break). + +## Setup + +### Quick setup (`gh` + npm) + +If you use the [GitHub CLI](https://cli.github.com/) (`gh`) with permission to manage Actions **variables** and **secrets** on this repository: + +```bash +npm run setup:project-label-bridge -- --help +# Preview: +npm run setup:project-label-bridge -- --dry-run --number 5 --org Artificer-Innovations +# Apply variables (org optional); you are then prompted once for the PAT (masked typing on a real TTY, same idea as npm run setup:full): +npm run setup:project-label-bridge -- --number 5 --org Artificer-Innovations +# Non-interactive: pipe or file (avoid echoing the PAT in argv) +printf '%s' "$ORG_PROJECT_GITHUB_TOKEN" | npm run setup:project-label-bridge -- --number 5 --token-stdin +npm run setup:project-label-bridge -- --number 5 --token-file ~/.config/beakerstack/github-pat-project.txt +# Variables only (skip secret prompt entirely): +npm run setup:project-label-bridge -- --number 5 --skip-secret +``` + +The helper is [`scripts/github/setup-project-label-bridge.mjs`](../scripts/github/setup-project-label-bridge.mjs). It uses [`scripts/lib/setup-secret-input.mjs`](../scripts/lib/setup-secret-input.mjs) (`readMaskedLineIfTty`, `resolveSecretInputLine`) so pastes, paths to bare secret files, and `ORG_PROJECT_GITHUB_TOKEN=...` dotenv lines behave like **setup-full**. Flags: `--plain-secret-prompts` (typed echo), `--skip-secret`, `--dry-run`. If the env var **`ORG_PROJECT_GITHUB_TOKEN`** is already set, it is used without prompting. + +### Manual setup (GitHub UI) + +1. **Repository variable (required)** + In GitHub: **Settings → Secrets and variables → Actions → Variables** + - **`PROJECT_NUMBER`** — the number in the project URL, e.g. `https://github.com/orgs/YourOrg/projects/5` → `5`. + + > **Naming:** GitHub reserves the `GITHUB_` prefix for built-in workflow context. Repository/configuration variables **must not** start with `GITHUB_` (they can be rejected or ineffective). Do not use `GITHUB_PROJECT_NUMBER`. + +2. **Repository variable (optional)** + - **`PROJECT_ORG`** — organization login owning the project. If omitted, the workflow default is `Artificer-Innovations` (change the default in the workflow file if your org differs and you prefer not to set a variable). + + If you previously created **`GITHUB_PROJECT_NUMBER`** / **`GITHUB_PROJECT_ORG`**, delete them and recreate as **`PROJECT_NUMBER`** / **`PROJECT_ORG`**, or run `npm run setup:project-label-bridge` again so `gh variable set` uses the correct names. + +3. **Repository secret (required for the bridge to do anything)** + **Settings → Secrets and variables → Actions → Secrets** + - **`ORG_PROJECT_GITHUB_TOKEN`** — a **classic** personal access token with at least **`repo`** and **`project`** (and whatever your org requires for SSO). Fine-grained PATs are often insufficient for org Projects; see GitHub’s [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) note about `GITHUB_TOKEN` vs PAT/App for org projects. + +4. **Labels and Status names** + Use labels **`project/status-`** where `` is lowercase letters, digits, and hyphens (for example `project/status-ready-for-qa`). The workflow derives the GitHub Project **Status** option name automatically: + + - Hyphens become spaces. + - **Default:** each word is **title-cased** (first letter uppercase, rest lowercase), e.g. `project/status-backlog` → `Backlog`, `project/status-ready` → `Ready`, `project/status-planning` → `Planning`, `project/status-done` → `Done`. + - **Exception:** if the slug starts with **`in-`** and has more segments (`in-*`), the result is **`In`** plus a space, then the **remaining words in all lowercase** — so `project/status-in-progress` → **`In progress`** and `project/status-in-review` → **`In review`** (matching this repo’s Kanban wording). + + The derived string must match a **Status** single-select option on the project **exactly**. If you add a column, pick a kebab slug that produces that name, or rename the option in GitHub Projects to match what the label derives to. + + Examples for this board: + + | Label | Derived Status | + | ----- | ---------------- | + | `project/status-backlog` | Backlog | + | `project/status-ready` | Ready | + | `project/status-planning` | Planning | + | `project/status-in-progress` | In progress | + | `project/status-in-review` | In review | + | `project/status-done` | Done | + +5. **Items not yet on the board** + If an issue or PR is not already on the project, the workflow adds it, then sets **Status**. + +## Security notes + +- Treat **`ORG_PROJECT_GITHUB_TOKEN`** like any other powerful PAT: minimal lifetime, rotate on schedule, and restrict org/repo access at the PAT level where GitHub allows it. +- Workflows triggered from **fork pull requests** do not receive secrets; the bridge will no-op for those runs. + +## Related files + +- [`.github/workflows/project-label-bridge.yml`](../.github/workflows/project-label-bridge.yml) — derives **Status** from `project/status-` labels and GraphQL updates. +- [`scripts/github/setup-project-label-bridge.mjs`](../scripts/github/setup-project-label-bridge.mjs) — `gh` + masked secret prompts (`npm run setup:project-label-bridge`). diff --git a/package.json b/package.json index 781ddcfb..00819ec2 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "prepare": "husky install", "postinstall": "patch-package || true", "rename": "node ./scripts/rename-project.mjs", + "setup:project-label-bridge": "node scripts/github/setup-project-label-bridge.mjs", "docs:actions-secrets": "node ./scripts/generate-actions-secrets-doc.mjs", "docs:linkcheck": "npx --yes markdown-link-check@3.12.2 README.md QUICKSTART.md -c .markdown-link-check.json", "billing:sync-stripe": "node scripts/sync-billing-stripe.mjs --config apps/web/src/billing/billing-sync.json", diff --git a/scripts/github/setup-project-label-bridge.mjs b/scripts/github/setup-project-label-bridge.mjs new file mode 100644 index 00000000..8e2b2db0 --- /dev/null +++ b/scripts/github/setup-project-label-bridge.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Configure GitHub Actions variables (and optionally ORG_PROJECT_GITHUB_TOKEN) for + * .github/workflows/project-label-bridge.yml via gh. + * + * Secret entry reuses the same helpers as setup-full: masked typing on a TTY (unless + * --plain-secret-prompts), or paste / path to a bare secret file (see setup-secret-input). + * + * See: docs/project-label-bridge.md + */ + +import { spawnSync } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { promises as fs, openSync, ReadStream } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import url from 'node:url'; + +import { readMaskedLineIfTty, resolveSecretInputLine } from '../lib/setup-secret-input.mjs'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +const PAT_PRIMARY_KEY = 'ORG_PROJECT_GITHUB_TOKEN'; +const PAT_ALLOW_KEYS = new Set([PAT_PRIMARY_KEY]); + +function printHelp() { + console.log(`Usage: node scripts/github/setup-project-label-bridge.mjs [options] + +Configure Actions variables for the optional project-label-bridge workflow (gh CLI). + +Options: + --repo OWNER/NAME Target repository (default: gh repo view) + --number N PROJECT_NUMBER (required). Env: PROJECT_NUMBER (legacy: GITHUB_PROJECT_NUMBER). + --org LOGIN PROJECT_ORG (optional). Env: PROJECT_ORG (legacy: GITHUB_PROJECT_ORG). + --skip-secret Do not set ORG_PROJECT_GITHUB_TOKEN (no prompt, no stdin read) + --token-stdin Read entire classic PAT from process stdin (use with piped input only) + --token-file PATH Read PAT from file + --plain-secret-prompts Echo typed secrets (default: mask on a real TTY; same as setup-full) + --dry-run Print gh commands without running + -h, --help Show this help + +If no token option is given and ORG_PROJECT_GITHUB_TOKEN is not in the environment, you are +prompted once (masked typing / paste / file path) — same behavior as npm run setup:full secret prompts. + +Examples: + npm run setup:project-label-bridge -- --number 5 --org Artificer-Innovations + printf '%s' "$ORG_PROJECT_GITHUB_TOKEN" | npm run setup:project-label-bridge -- --number 5 --token-stdin +`); +} + +/** + * @returns {{ rl: import('node:readline/promises').ReadLine; promptInput: import('stream').Readable & { isTTY?: boolean; setRawMode?: (flag: boolean) => void } }} + */ +function createPromptInterface() { + let inStream = input; + if (!input.isTTY) { + try { + const fd = openSync('/dev/tty', 'r'); + inStream = new ReadStream(undefined, { fd }); + } catch { + /* interactive prompts may not work without a TTY */ + } + } + return { rl: createInterface({ input: inStream, output }), promptInput: inStream }; +} + +/** + * @param {import('node:readline/promises').ReadLine} rl + * @param {string} prompt + */ +async function rlQuestion(rl, prompt) { + try { + return await rl.question(prompt); + } catch (e) { + if (typeof e === 'object' && e !== null && /** @type {{ name?: string }} */ (e).name === 'AbortError') { + return ''; + } + throw e; + } +} + +/** + * @param {import('node:readline/promises').ReadLine} rl + * @param {import('stream').Readable & { isTTY?: boolean; setRawMode?: (flag: boolean) => void }} promptInput + * @param {{ plainSecretPrompts: boolean }} flags + * @param {string} prompt + */ +async function readSecretLineMaskedOrVisible(rl, promptInput, flags, prompt) { + if (flags.plainSecretPrompts) { + return (await rlQuestion(rl, prompt)).trim(); + } + const masked = await readMaskedLineIfTty(rl, promptInput, output, prompt); + if (masked === null) { + return (await rlQuestion(rl, prompt)).trim(); + } + return masked.trim(); +} + +/** + * @param {string} line + * @returns {Promise<{ pat: string; ignoredKeys: string[] }>} + */ +async function resolvePatLine(line) { + const resolved = await resolveSecretInputLine({ + line, + primaryKey: PAT_PRIMARY_KEY, + allowKeys: PAT_ALLOW_KEYS, + homedir, + repoRoot: REPO_ROOT, + readFileUtf8: (p) => fs.readFile(p, 'utf8'), + }); + const pat = resolved.primary || resolved.merged[PAT_PRIMARY_KEY] || ''; + return { pat, ignoredKeys: resolved.ignoredKeys }; +} + +function parseArgv(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + printHelp(); + process.exit(0); + } + /** @type {{ repo: string; number: string; org: string; dryRun: boolean; plainSecretPrompts: boolean; skipSecret: boolean; tokenStdin: boolean; tokenFile: string }} */ + const o = { + repo: '', + number: process.env.PROJECT_NUMBER || process.env.GITHUB_PROJECT_NUMBER || '', + org: process.env.PROJECT_ORG || process.env.GITHUB_PROJECT_ORG || '', + dryRun: false, + plainSecretPrompts: false, + skipSecret: false, + tokenStdin: false, + tokenFile: '', + }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '--dry-run') o.dryRun = true; + else if (a === '--plain-secret-prompts') o.plainSecretPrompts = true; + else if (a === '--skip-secret') o.skipSecret = true; + else if (a === '--token-stdin') o.tokenStdin = true; + else if (a === '--repo') o.repo = argv[++i] || ''; + else if (a === '--number') o.number = argv[++i] || ''; + else if (a === '--org') o.org = argv[++i] || ''; + else if (a === '--token-file') o.tokenFile = argv[++i] || ''; + else { + console.error(`Unknown option: ${a}`); + printHelp(); + process.exit(1); + } + } + return o; +} + +/** + * @param {string[]} args + * @param {{ input?: string | Buffer; dryRun?: boolean; captureStdout?: boolean }} [opts] + */ +function runGh(args, opts = {}) { + if (opts.dryRun) { + console.log(`[dry-run] gh ${args.map((x) => JSON.stringify(x)).join(' ')}`); + return { status: 0, stdout: '', stderr: '' }; + } + const capture = !!opts.captureStdout; + /** @type {import('node:child_process').StdioOptions} */ + let stdio; + if (opts.input !== undefined) { + stdio = capture ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'inherit']; + } else if (capture) { + stdio = ['ignore', 'pipe', 'pipe']; + } else { + stdio = ['inherit', 'inherit', 'inherit']; + } + const r = spawnSync('gh', args, { + encoding: 'utf8', + stdio, + input: opts.input, + cwd: REPO_ROOT, + }); + if (r.status !== 0 && r.stderr) { + process.stderr.write(r.stderr); + } + return r; +} + +function ghRepoDefault() { + const r = runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { + dryRun: false, + captureStdout: true, + }); + if (r.status !== 0) { + console.error('Could not run gh repo view. Install gh, auth with gh auth login, or pass --repo OWNER/NAME.'); + process.exit(1); + } + return (r.stdout || '').trim(); +} + +/** + * @param {string} token + * @param {string} repo + * @param {boolean} dryRun + */ +function ghSecretSetPat(token, repo, dryRun) { + const r = runGh(['secret', 'set', PAT_PRIMARY_KEY, '--repo', repo], { + dryRun, + input: `${token.replace(/\r?\n/g, '').trim()}\n`, + }); + if (r.status !== 0) process.exit(r.status || 1); +} + +async function readStdinUtf8() { + const chunks = []; + for await (const chunk of input) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8').trim(); +} + +async function main() { + const opts = parseArgv(process.argv.slice(2)); + + if (!opts.number) { + console.error('PROJECT_NUMBER is required. Pass --number N or set env PROJECT_NUMBER.'); + printHelp(); + process.exit(1); + } + + const repo = opts.repo || ghRepoDefault(); + if (!repo) { + console.error('Empty repository name.'); + process.exit(1); + } + + let pat = ''; + if (opts.skipSecret) { + console.log('Skipping ORG_PROJECT_GITHUB_TOKEN (--skip-secret).'); + } else if (opts.tokenStdin) { + pat = await readStdinUtf8(); + if (!pat) { + console.error('No data read from stdin (--token-stdin).'); + process.exit(1); + } + } else if (opts.tokenFile) { + try { + pat = (await fs.readFile(opts.tokenFile, 'utf8')).trim(); + } catch (e) { + console.error(`Failed to read --token-file: ${e}`); + process.exit(1); + } + if (!pat) { + console.error('Token file is empty.'); + process.exit(1); + } + } else if (process.env.ORG_PROJECT_GITHUB_TOKEN) { + pat = process.env.ORG_PROJECT_GITHUB_TOKEN.trim(); + console.log('Using ORG_PROJECT_GITHUB_TOKEN from environment.'); + } else if (opts.dryRun) { + console.log('[dry-run] Would prompt for ORG_PROJECT_GITHUB_TOKEN (interactive / paste / path).'); + } else { + const { rl, promptInput } = createPromptInterface(); + try { + const line = await readSecretLineMaskedOrVisible( + rl, + promptInput, + opts, + `${PAT_PRIMARY_KEY} — paste, path to file, or masked type (blank=skip): `, + ); + const { pat: resolvedPat, ignoredKeys } = await resolvePatLine(line); + if (ignoredKeys.length) { + const uniq = [...new Set(ignoredKeys)].sort(); + console.log(`[project-label-bridge] Secret input ignored unknown keys: ${uniq.join(', ')}`); + } + pat = resolvedPat; + } finally { + rl.close(); + } + if (!pat) { + console.log('Skipping ORG_PROJECT_GITHUB_TOKEN (blank input).'); + } + } + + const dry = opts.dryRun; + + let r = runGh(['variable', 'set', 'PROJECT_NUMBER', '--repo', repo, '--body', opts.number], { dryRun: dry }); + if (r.status !== 0) process.exit(r.status || 1); + + if (opts.org) { + r = runGh(['variable', 'set', 'PROJECT_ORG', '--repo', repo, '--body', opts.org], { dryRun: dry }); + if (r.status !== 0) process.exit(r.status || 1); + } else { + console.log( + 'Skipping PROJECT_ORG (optional). Workflow default org applies unless you set the variable in the UI.', + ); + } + + if (pat) { + ghSecretSetPat(pat, repo, dry); + } + + console.log(`Done. Target repo: ${repo}`); + if (!dry) { + const lr = runGh(['variable', 'list', '--repo', repo], { dryRun: false, captureStdout: true }); + if (lr.status === 0 && lr.stdout) { + const lines = lr.stdout.split('\n').filter((line) => /PROJECT_(NUMBER|ORG)/.test(line)); + if (lines.length) console.log(lines.join('\n')); + } + } else { + console.log(' (dry-run — nothing was written)'); + } + console.log('See docs/project-label-bridge.md for PAT scopes and label conventions.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From bbef2e6e5ac0b539a09c5d0558c6007a08243096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Morales=20=F0=9F=A4=96?= Date: Tue, 12 May 2026 19:26:11 -0700 Subject: [PATCH 05/50] feat(preview): CloudFront signed-cookie access control (issue #80) (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preview): add CloudFront signed-cookie access control Fixes: - Open redirect via protocol-relative URLs (//evil.com) in PreviewAuthFunction dest guard - Cookie Max-Age=604800 to match 7-day policy expiry - URL masking regression: reconstruct preview URL from vars.PR_PREVIEW_DOMAIN - MOBILE_ENABLED gate restored as separate deploy-mobile-* jobs - /_preview-auth uses CachingDisabled policy * fix(preview): safe decodeURIComponent in PreviewAuthFunction; sync inline CFN copy Wraps decodeURIComponent in try/catch so malformed %XX sequences return '/' instead of crashing the CloudFront Function. Keeps both the canonical PreviewAuthFunction.js and the inline CFN copy identical, and adds a comment noting they must stay in sync. * fix(preview): remove redundant dest initializer in PreviewAuthFunction var dest is always assigned by the try/catch on every code path; the initial '/' was flagged as a useless assignment by code-quality bot. Change to `var dest;` — no functional change. --- .github/workflows/deploy-staging.yml | 82 ++++++ .github/workflows/pr-preview-environment.yml | 90 ++++++- docs/preview-access-control.md | 183 ++++++++++++++ docs/reference/github-actions-secrets.md | 2 + infra/aws/functions/PreviewAuthFunction.js | 45 ++++ infra/aws/pr-preview-stack.yml | 150 ++++++++++- scripts/lib/setup-manifest.mjs | 14 + scripts/pr-preview/setup-signed-cookies.sh | 253 +++++++++++++++++++ 8 files changed, 815 insertions(+), 4 deletions(-) create mode 100644 docs/preview-access-control.md create mode 100644 infra/aws/functions/PreviewAuthFunction.js create mode 100755 scripts/pr-preview/setup-signed-cookies.sh diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index cbfe9e26..9de34b9d 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -8,6 +8,7 @@ on: permissions: contents: read id-token: write + deployments: write concurrency: group: staging-deploy @@ -146,6 +147,87 @@ jobs: --environment staging \ --region "${AWS_REGION_VALUE}" + - name: Generate staging signed cookie bootstrap URL + id: signed_cookies + if: secrets.CLOUDFRONT_SIGNING_KEY != '' + shell: bash + env: + CLOUDFRONT_SIGNING_KEY: ${{ secrets.CLOUDFRONT_SIGNING_KEY }} + CLOUDFRONT_SIGNING_KEY_ID: ${{ secrets.CLOUDFRONT_SIGNING_KEY_ID }} + run: | + set -euo pipefail + STAGING_DOMAIN="staging.${DOMAIN}" + RESOURCE="https://${STAGING_DOMAIN}/*" + EXPIRY=$(date -u -d "+30 days" +%s) + + POLICY_JSON=$(printf '{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%s}}}]}' \ + "${RESOURCE}" "${EXPIRY}") + + # CloudFront URL-safe base64: + → -, / → ~, = → _ + CF_POLICY=$(printf '%s' "${POLICY_JSON}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + + # Sign with RSA-SHA1 (CloudFront signed cookies requirement) + KEY_FILE=$(mktemp); chmod 600 "${KEY_FILE}" + printf '%s' "${CLOUDFRONT_SIGNING_KEY}" > "${KEY_FILE}" + CF_SIGNATURE=$(printf '%s' "${POLICY_JSON}" | \ + openssl dgst -sha1 -sign "${KEY_FILE}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + rm -f "${KEY_FILE}" + + BOOTSTRAP_URL="https://${STAGING_DOMAIN}/_preview-auth?policy=${CF_POLICY}&sig=${CF_SIGNATURE}&kid=${CLOUDFRONT_SIGNING_KEY_ID}&dest=/" + echo "BOOTSTRAP_URL=${BOOTSTRAP_URL}" >> "${GITHUB_OUTPUT}" + echo "STAGING_URL=https://${STAGING_DOMAIN}" >> "${GITHUB_OUTPUT}" + + - name: Create GitHub Deployment (staging) + uses: actions/github-script@v7 + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + STAGING_URL: ${{ steps.signed_cookies.outputs.STAGING_URL || format('https://staging.{0}', env.DOMAIN) }} + with: + script: | + const environmentUrl = process.env.BOOTSTRAP_URL || process.env.STAGING_URL; + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: 'staging', + auto_merge: false, + required_contexts: [], + description: 'Staging deployment', + }); + if (deployment.data.id) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: environmentUrl, + description: 'Staging deployed', + }); + } + + - name: Write Actions run summary + shell: bash + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + STAGING_URL: ${{ steps.signed_cookies.outputs.STAGING_URL || format('https://staging.{0}', env.DOMAIN) }} + run: | + set -euo pipefail + if [[ -n "${BOOTSTRAP_URL}" ]]; then + cat >> "${GITHUB_STEP_SUMMARY}" < Click the link to set your access cookie, then browse staging normally. Expires in 30 days. + SUMMARY + else + cat >> "${GITHUB_STEP_SUMMARY}" < "${KEY_FILE}" + CF_SIGNATURE=$(printf '%s' "${POLICY_JSON}" | \ + openssl dgst -sha1 -sign "${KEY_FILE}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + rm -f "${KEY_FILE}" + + ENCODED_DEST=$(printf '%s' "${DEST}" | python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.stdin.read().strip()))") + BOOTSTRAP_URL="https://${DEPLOY_DOMAIN}/_preview-auth?policy=${CF_POLICY}&sig=${CF_SIGNATURE}&kid=${CLOUDFRONT_SIGNING_KEY_ID}&dest=${ENCODED_DEST}" + echo "BOOTSTRAP_URL=${BOOTSTRAP_URL}" >> "${GITHUB_OUTPUT}" + echo "ACCESS_MODE=signed-cookies" >> "${GITHUB_OUTPUT}" + + - name: Create GitHub Deployment (PR preview) + if: steps.web.outcome == 'success' + uses: actions/github-script@v7 + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} + PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const environment = `pr-${process.env.PR_NUMBER}-preview`; + const prefix = process.env.PREVIEW_PREFIX || 'pr-'; + const webUrl = `https://deploy.${process.env.PREVIEW_DOMAIN}/${prefix}${process.env.PR_NUMBER}/`; + const environmentUrl = process.env.BOOTSTRAP_URL || webUrl; + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment, + auto_merge: false, + required_contexts: [], + description: `PR #${process.env.PR_NUMBER} preview`, + }); + if (deployment.data.id) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: environmentUrl, + description: 'Preview deployed', + }); + } + + # Publish edge routing only after a successful web build so a failed deploy cannot + # replace live CloudFront function code while leaving stale or missing S3 assets. - name: Publish PR path router (CloudFront) if: steps.web.outcome == 'success' shell: bash @@ -313,6 +389,8 @@ jobs: env: PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + BOOTSTRAP_URL: ${{ needs.deploy-preview.outputs.bootstrap-url }} + ACCESS_MODE: ${{ needs.deploy-preview.outputs.access-mode }} MOBILE_JOB_RESULT: ${{ needs.deploy-preview-mobile.result }} MOBILE_CHANNEL: ${{ needs.deploy-preview-mobile.outputs.mobile-channel }} MOBILE_UPDATE_URL: ${{ needs.deploy-preview-mobile.outputs.mobile-update-url }} @@ -330,10 +408,18 @@ jobs: const mobileChannel = process.env.MOBILE_CHANNEL; const mobileUpdateUrl = process.env.MOBILE_UPDATE_URL; + const bootstrapUrl = process.env.BOOTSTRAP_URL; const lines = []; lines.push(''); - if (webUrl) { - lines.push(`🌐 **Web preview:** ${webUrl}`); + if (webUrl || bootstrapUrl) { + const isSignedCookies = process.env.ACCESS_MODE === 'signed-cookies'; + if (isSignedCookies && bootstrapUrl) { + lines.push(`🔐 **Web preview (access link):** ${bootstrapUrl}`); + lines.push(''); + lines.push('> _Click the link above to set your access cookie, then browse the preview. The link expires in 7 days._'); + } else { + lines.push(`🌐 **Web preview:** ${webUrl}`); + } } if (mobileJobResult === 'skipped') { // Mobile disabled via MOBILE_ENABLED=false — omit mobile section diff --git a/docs/preview-access-control.md b/docs/preview-access-control.md new file mode 100644 index 00000000..8caab992 --- /dev/null +++ b/docs/preview-access-control.md @@ -0,0 +1,183 @@ +# Preview & Staging Access Control + +By default, PR preview and staging deployments are publicly accessible — anyone with the URL can view them. For teams that need to restrict access to internal stakeholders, BeakerStack supports **CloudFront signed cookies**. + +## How it works + +Access control is enforced at the CloudFront edge using [signed cookies](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PrivateContent.html). The mechanism is: + +1. **Setup (once):** An RSA key pair is provisioned. The public key is registered with CloudFront; the private key is stored as a GitHub Actions secret. +2. **CI at deploy time:** The workflow signs a custom policy document (specifying the resource URL and expiry) using the private key. This produces pre-computed cookie values — no crypto happens at request time. +3. **Bootstrap URL:** The signed cookie values are embedded in a short-lived bootstrap link (`/_preview-auth?policy=...&sig=...&kid=...`) that CI posts to the PR comment and GitHub Deployments. +4. **First visit:** The stakeholder clicks the bootstrap link. A CloudFront Function reads the cookie values from the query string and returns a 302 response that sets all three `CloudFront-*` cookies on the browser. +5. **Subsequent requests:** Every request to the preview domain carries the signed cookies. CloudFront validates the RSA-SHA1 signature at the edge and serves the content. Unsigned requests receive a 403. + +### Critical: two-behavior cache configuration + +The `/_preview-auth` path uses a **separate cache behavior** with no `TrustedKeyGroups`. This is required — without it, unauthenticated users cannot reach the cookie setter to acquire their cookies. The default behavior (all other paths) is where enforcement is applied. + +``` +/_preview-auth → CacheBehavior: PreviewAuthFunction, NO TrustedKeyGroups +/* → DefaultCacheBehavior: PRPathRouter/SPAFallback, TrustedKeyGroups enforced +``` + +## Quick start + +### Prerequisites + +- AWS credentials with the IAM permissions listed below +- `gh` CLI authenticated to your GitHub repo +- `aws`, `openssl`, and `jq` in PATH + +### Enable signed cookies for PR previews + +```bash +./scripts/pr-preview/setup-signed-cookies.sh \ + --stack-name "${PR_PREVIEW_STACK_NAME}" \ + --domain "${PR_PREVIEW_DOMAIN}" \ + --enable-preview +``` + +### Enable signed cookies for both PR previews and staging + +```bash +./scripts/pr-preview/setup-signed-cookies.sh \ + --stack-name "${PR_PREVIEW_STACK_NAME}" \ + --domain "${PR_PREVIEW_DOMAIN}" \ + --enable-preview \ + --enable-staging +``` + +The script: +1. Generates an RSA-2048 key pair +2. Uploads the public key to CloudFront and retrieves its ID +3. Updates the CloudFormation stack (`PreviewAccessControl`, `StagingAccessControl`, `CloudFrontSigningPublicKeyId` parameters) — this creates the key group and applies `TrustedKeyGroups` to the appropriate distributions +4. Writes `CLOUDFRONT_SIGNING_KEY` and `CLOUDFRONT_SIGNING_KEY_ID` to GitHub Actions secrets + +CloudFront distribution updates take **5–10 minutes** to propagate globally. + +### Use an existing private key + +If you've already generated a key pair for another reason: + +```bash +./scripts/pr-preview/setup-signed-cookies.sh \ + --stack-name "${PR_PREVIEW_STACK_NAME}" \ + --domain "${PR_PREVIEW_DOMAIN}" \ + --enable-preview \ + --private-key /path/to/private-key.pem +``` + +## CloudFormation parameters + +The following parameters control access on the shared infrastructure stack (`infra/aws/pr-preview-stack.yml`): + +| Parameter | Values | Default | Description | +|---|---|---|---| +| `PreviewAccessControl` | `public`, `signed-cookies` | `public` | Access mode for PR preview (deploy) distribution | +| `StagingAccessControl` | `public`, `signed-cookies` | `public` | Access mode for staging distribution | +| `CloudFrontSigningPublicKeyId` | string | `''` | CloudFront public key ID; required when either is `signed-cookies` | + +## GitHub Actions secrets + +Two optional secrets control CI signing behavior: + +| Secret | Description | +|---|---| +| `CLOUDFRONT_SIGNING_KEY` | RSA private key PEM. Set by `setup-signed-cookies.sh`. | +| `CLOUDFRONT_SIGNING_KEY_ID` | CloudFront public key ID. Set by `setup-signed-cookies.sh`. | + +When these secrets are absent, CI skips cookie signing and posts plain URLs (public mode). No workflow change is needed when switching modes — the presence of the secrets determines behavior. + +## Cookie details + +Three cookies are set on the preview domain: + +| Cookie | Description | +|---|---| +| `CloudFront-Policy` | Base64-encoded custom policy (resource + expiry) | +| `CloudFront-Signature` | RSA-SHA1 signature of the policy, signed with the private key | +| `CloudFront-Key-Pair-Id` | CloudFront public key ID, used to locate the verification key | + +All cookies use `HttpOnly; Secure; SameSite=Lax; Path=/`. `SameSite=Lax` allows cookies to be sent on top-level cross-site navigation (clicking a link from a GitHub PR comment). + +**Cookie TTL:** +- PR previews: 7 days +- Staging: 30 days + +**Signing algorithm:** RSA-SHA1 with CloudFront URL-safe base64 encoding (`+→-`, `/→~`, `=→_`). This is required by CloudFront's signed cookie specification — SHA-256 is not supported. + +**Custom policy vs canned policy:** This implementation uses `--custom-policy` (not the default canned policy). A custom policy is required for wildcard resource matching (e.g., `https://deploy.example.com/pr-42/*`). The canned policy only supports exact resource URLs. + +## IAM permissions + +The IAM credentials used by `setup-signed-cookies.sh` require: + +```json +{ + "Effect": "Allow", + "Action": [ + "cloudfront:CreatePublicKey", + "cloudformation:DescribeStacks", + "cloudformation:CreateChangeSet", + "cloudformation:DescribeChangeSet", + "cloudformation:ExecuteChangeSet", + "cloudformation:GetTemplateSummary" + ], + "Resource": "*" +} +``` + +The following are executed **indirectly via CloudFormation** (not by the script directly): + +- `cloudfront:CreateKeyGroup` — creates the `PreviewSigningKeyGroup` resource +- `cloudfront:UpdateDistribution` — applies `TrustedKeyGroups` to distributions +- `cloudfront:CreateFunction` / `cloudfront:UpdateFunction` / `cloudfront:PublishFunction` — manages the `PreviewAuthFunction` CloudFront Function + +The CI workflows (PR preview and staging deploy) only need: +- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` for S3 deploy and CloudFront invalidation +- `CLOUDFRONT_SIGNING_KEY` / `CLOUDFRONT_SIGNING_KEY_ID` for cookie signing (no AWS calls needed for signing) + +## How stakeholders access the preview + +After a PR preview deploy, CI posts the bootstrap URL to: +1. **PR comment** — tagged with ``, updated on each push +2. **GitHub Deployments** — visible in the PR's "Deployments" section and the repo's Environments tab + +After a staging deploy, CI posts to: +1. **GitHub Deployments** — `staging` environment, updated on each merge to `develop` +2. **Actions run summary** — visible directly in the workflow run without navigating to Environments + +**Access flow:** +1. Stakeholder clicks the bootstrap link from GitHub +2. Browser hits `/_preview-auth?policy=...&sig=...&kid=...&dest=/pr-42/` +3. CloudFront Function sets all three cookies and redirects to `dest` +4. All subsequent requests to the domain carry the signed cookies +5. CloudFront validates at the edge — no application-layer auth needed + +## Disabling signed cookies + +To revert to public access: + +1. Update the CloudFormation stack parameters to `PreviewAccessControl=public` and/or `StagingAccessControl=public`: + + ```bash + aws cloudformation deploy \ + --template-file infra/aws/pr-preview-stack.yml \ + --stack-name "${PR_PREVIEW_STACK_NAME}" \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + "PreviewAccessControl=public" \ + "StagingAccessControl=public" + ``` + +2. Remove the GitHub secrets `CLOUDFRONT_SIGNING_KEY` and `CLOUDFRONT_SIGNING_KEY_ID`. + +3. Optionally delete the CloudFront public key from the AWS console (CloudFront → Public keys). + +## Future access control modes + +The `PreviewAccessControl` and `StagingAccessControl` parameters are designed for future expansion. Values reserved for future use: + +- `basic-auth` — HTTP Basic Auth via Lambda@Edge (not yet implemented) +- `platform-auth` — SSO/OIDC integration (not yet implemented) diff --git a/docs/reference/github-actions-secrets.md b/docs/reference/github-actions-secrets.md index 75e6bec9..7642b938 100644 --- a/docs/reference/github-actions-secrets.md +++ b/docs/reference/github-actions-secrets.md @@ -35,6 +35,8 @@ This file lists repository **secrets** and **variables** the setup wizard can sy | `PREVIEW_STRIPE_WEBHOOK_SECRET` | no | preview | | `PREVIEW_BILLING_ALLOWED_ORIGINS` | yes | preview | | `PR_PREVIEW_CERTIFICATE_ARN` | no | preview | +| `CLOUDFRONT_SIGNING_KEY` | yes | preview | +| `CLOUDFRONT_SIGNING_KEY_ID` | yes | preview | | `EXPO_TOKEN` | no | expo | | `EXPO_PROJECT_ID` | no | expo | | `GOOGLE_SERVICES_PROJECT_NUMBER` | yes | google | diff --git a/infra/aws/functions/PreviewAuthFunction.js b/infra/aws/functions/PreviewAuthFunction.js new file mode 100644 index 00000000..6e070038 --- /dev/null +++ b/infra/aws/functions/PreviewAuthFunction.js @@ -0,0 +1,45 @@ +// CloudFront Function: reads pre-computed signed cookie values from query params +// and returns a synthetic 302 that sets all three CloudFront signed cookies. +// This runs on /_preview-auth — the path that has NO TrustedKeyGroups, so +// unauthenticated users can reach it to acquire their access cookies. +// +// NOTE: this file is the canonical source. The inline FunctionCode block in +// infra/aws/pr-preview-stack.yml must be kept identical. Both are deployed; +// the stack inline copy is what CloudFormation actually provisions. +function handler(event) { + var request = event.request; + var qs = request.querystring; + var policy = qs.policy ? qs.policy.value : null; + var sig = qs.sig ? qs.sig.value : null; + var kid = qs.kid ? qs.kid.value : null; + + if (!policy || !sig || !kid) { + return { + statusCode: 400, + statusDescription: 'Bad Request', + headers: { 'content-type': { value: 'text/plain' } }, + body: 'Missing required parameters: policy, sig, kid.' + }; + } + + // Restrict dest to a local path to prevent open redirect. + // decodeURIComponent throws on malformed % sequences — fall back to '/'. + // Also block protocol-relative URLs like //evil.com which startsWith('/') but are external. + var dest; + try { dest = qs.dest ? decodeURIComponent(qs.dest.value) : '/'; } catch (e) { dest = '/'; } + if (!dest.startsWith('/') || dest.startsWith('//')) dest = '/'; + + return { + statusCode: 302, + statusDescription: 'Found', + headers: { + location: { value: dest }, + 'cache-control': { value: 'no-store, no-cache, must-revalidate' } + }, + cookies: { + 'CloudFront-Policy': { value: policy, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Signature': { value: sig, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Key-Pair-Id': { value: kid, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' } + } + }; +} diff --git a/infra/aws/pr-preview-stack.yml b/infra/aws/pr-preview-stack.yml index 40c6587b..ffdc8b36 100644 --- a/infra/aws/pr-preview-stack.yml +++ b/infra/aws/pr-preview-stack.yml @@ -2,7 +2,8 @@ AWSTemplateFormatVersion: '2010-09-09' Description: > Three-environment infrastructure stack (production, staging, preview). Provisions S3 storage, CloudFront distributions, logging, DNS records, and - CloudFront Function for path-based PR preview routing on deploy.beakerstack.com. + CloudFront Functions for path-based PR preview routing and signed-cookie + access control on deploy.beakerstack.com and staging.beakerstack.com. Metadata: AWS::CloudFormation::Interface: @@ -23,6 +24,12 @@ Metadata: default: Preview Settings Parameters: - PreviewPrefix + - Label: + default: Access Control + Parameters: + - PreviewAccessControl + - StagingAccessControl + - CloudFrontSigningPublicKeyId ParameterLabels: DomainName: default: Root domain (e.g. beakerstack.com) @@ -38,6 +45,12 @@ Metadata: default: CloudFront log retention (days) PreviewPrefix: default: Prefix for preview deployments (e.g. pr-) + PreviewAccessControl: + default: Access control mode for PR preview (deploy) distribution + StagingAccessControl: + default: Access control mode for staging distribution + CloudFrontSigningPublicKeyId: + default: CloudFront public key ID for signed-cookie validation (leave blank when using public mode) Parameters: DomainName: @@ -68,10 +81,35 @@ Parameters: Type: String Default: pr- Description: Prefix used for preview folders (e.g. pr-123) + PreviewAccessControl: + Type: String + AllowedValues: [public, signed-cookies] + Default: public + Description: > + Set to signed-cookies to require CloudFront signed cookies for the PR preview + (deploy) distribution. Run scripts/pr-preview/setup-signed-cookies.sh first. + StagingAccessControl: + Type: String + AllowedValues: [public, signed-cookies] + Default: public + Description: > + Set to signed-cookies to require CloudFront signed cookies for the staging + distribution. Run scripts/pr-preview/setup-signed-cookies.sh first. + CloudFrontSigningPublicKeyId: + Type: String + Default: '' + Description: > + ID of the CloudFront public key used to validate signed cookies. Required when + PreviewAccessControl or StagingAccessControl is signed-cookies. Conditions: UseProvidedLogsBucketName: !Not [!Equals [!Ref LogsBucketName, '']] EncryptBuckets: !Equals [!Ref EnableS3BucketEncryption, 'true'] + EnablePreviewSignedCookies: !Equals [!Ref PreviewAccessControl, signed-cookies] + EnableStagingSignedCookies: !Equals [!Ref StagingAccessControl, signed-cookies] + EnableAnySignedCookies: !Or + - !Condition EnablePreviewSignedCookies + - !Condition EnableStagingSignedCookies Resources: # Shared logs bucket for all CloudFront distributions @@ -359,6 +397,65 @@ Resources: return request; } + # Cookie setter for the /_preview-auth bootstrap path. + # Reads pre-computed signed cookie values from query params and returns a 302 + # that sets all three CloudFront signed cookies. This function has no crypto; + # cookie values are pre-computed by CI using the private signing key. + PreviewAuthFunction: + Type: AWS::CloudFront::Function + Properties: + Name: !Sub '${AWS::StackName}-PreviewAuth' + AutoPublish: true + FunctionConfig: + Comment: Sets CloudFront signed cookies from pre-computed query param values + Runtime: cloudfront-js-2.0 + FunctionCode: | + function handler(event) { + var request = event.request; + var qs = request.querystring; + var policy = qs.policy ? qs.policy.value : null; + var sig = qs.sig ? qs.sig.value : null; + var kid = qs.kid ? qs.kid.value : null; + if (!policy || !sig || !kid) { + return { + statusCode: 400, + statusDescription: 'Bad Request', + headers: { 'content-type': { value: 'text/plain' } }, + body: 'Missing required parameters: policy, sig, kid.' + }; + } + var dest; + try { dest = qs.dest ? decodeURIComponent(qs.dest.value) : '/'; } catch (e) { dest = '/'; } + if (!dest.startsWith('/') || dest.startsWith('//')) dest = '/'; + return { + statusCode: 302, + statusDescription: 'Found', + headers: { + location: { value: dest }, + 'cache-control': { value: 'no-store, no-cache, must-revalidate' } + }, + cookies: { + 'CloudFront-Policy': { value: policy, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Signature': { value: sig, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Key-Pair-Id': { value: kid, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' } + } + }; + } + + # Key group associating the provisioned CloudFront public key with distributions. + # Created only when at least one distribution is in signed-cookies mode. + # The public key itself is provisioned outside CloudFormation by + # scripts/pr-preview/setup-signed-cookies.sh. + PreviewSigningKeyGroup: + Type: AWS::CloudFront::KeyGroup + Condition: EnableAnySignedCookies + Properties: + KeyGroupConfig: + Name: !Sub '${AWS::StackName}-preview-signing-keys' + Comment: Signing key group for PR preview and staging signed-cookie access + Items: + - !Ref CloudFrontSigningPublicKeyId + # Production CloudFront Distribution (beakerstack.com) ProdDistribution: Type: AWS::CloudFront::Distribution @@ -423,6 +520,21 @@ Resources: Bucket: !GetAtt LogsBucket.DomainName Prefix: cloudfront/staging/ IncludeCookies: false + # /_preview-auth: cookie setter — must have no TrustedKeyGroups so unauthenticated + # users can reach it. PreviewAuthFunction returns a synthetic 302 with signed cookies. + CacheBehaviors: + - PathPattern: /_preview-auth + AllowedMethods: [GET, HEAD] + CachedMethods: [GET, HEAD] + TargetOriginId: StagingOrigin + ViewerProtocolPolicy: redirect-to-https + Compress: false + # CachingDisabled — synthetic responses from CF Functions are not cached, but + # this policy is explicit and prevents any accidental cache sharing. + CachePolicyId: 4135872e-7369-4a65-bac3-7ea18cd55781 + FunctionAssociations: + - EventType: viewer-request + FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN DefaultCacheBehavior: AllowedMethods: [GET, HEAD, OPTIONS] CachedMethods: [GET, HEAD] @@ -435,6 +547,10 @@ Resources: FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt SPAFallbackFunction.FunctionMetadata.FunctionARN + TrustedKeyGroups: !If + - EnableStagingSignedCookies + - - !Ref PreviewSigningKeyGroup + - !Ref AWS::NoValue Origins: - Id: StagingOrigin DomainName: !GetAtt StagingBucket.RegionalDomainName @@ -469,6 +585,21 @@ Resources: Bucket: !GetAtt LogsBucket.DomainName Prefix: cloudfront/deploy/ IncludeCookies: false + # /_preview-auth: cookie setter — must have no TrustedKeyGroups so unauthenticated + # users can reach it. PreviewAuthFunction returns a synthetic 302 with signed cookies. + CacheBehaviors: + - PathPattern: /_preview-auth + AllowedMethods: [GET, HEAD] + CachedMethods: [GET, HEAD] + TargetOriginId: DeployOrigin + ViewerProtocolPolicy: redirect-to-https + Compress: false + # CachingDisabled — synthetic responses from CF Functions are not cached, but + # this policy is explicit and prevents any accidental cache sharing. + CachePolicyId: 4135872e-7369-4a65-bac3-7ea18cd55781 + FunctionAssociations: + - EventType: viewer-request + FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN DefaultCacheBehavior: AllowedMethods: [GET, HEAD, OPTIONS] CachedMethods: [GET, HEAD] @@ -477,10 +608,14 @@ Resources: Compress: true CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # SecurityHeadersPolicy - # Use PRPathRouter function for path-based routing + # PRPathRouter handles /pr-N/* → S3 prefix routing FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt PRPathRouterFunction.FunctionMetadata.FunctionARN + TrustedKeyGroups: !If + - EnablePreviewSignedCookies + - - !Ref PreviewSigningKeyGroup + - !Ref AWS::NoValue Origins: - Id: DeployOrigin DomainName: !GetAtt DeployBucket.RegionalDomainName @@ -622,6 +757,17 @@ Outputs: Value: !GetAtt PRPathRouterFunction.FunctionMetadata.FunctionARN Export: Name: !Sub '${AWS::StackName}-PRPathRouterFunctionArn' + PreviewAuthFunctionArn: + Description: ARN of the preview auth cookie-setter CloudFront function + Value: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN + Export: + Name: !Sub '${AWS::StackName}-PreviewAuthFunctionArn' + PreviewSigningKeyGroupId: + Description: CloudFront key group ID used for signed-cookie validation (empty when access control is public) + Condition: EnableAnySignedCookies + Value: !Ref PreviewSigningKeyGroup + Export: + Name: !Sub '${AWS::StackName}-PreviewSigningKeyGroupId' PreviewPrefixOutput: Description: Prefix used for PR preview deployments Value: !Ref PreviewPrefix diff --git a/scripts/lib/setup-manifest.mjs b/scripts/lib/setup-manifest.mjs index 38d8e540..12d30793 100644 --- a/scripts/lib/setup-manifest.mjs +++ b/scripts/lib/setup-manifest.mjs @@ -174,6 +174,20 @@ export const GITHUB_SECRETS = [ envKeys: ['PR_PREVIEW_CERTIFICATE_ARN'], group: 'preview', }, + { + type: 'secret', + name: 'CLOUDFRONT_SIGNING_KEY', + envKeys: ['CLOUDFRONT_SIGNING_KEY'], + optional: true, + group: 'preview', + }, + { + type: 'secret', + name: 'CLOUDFRONT_SIGNING_KEY_ID', + envKeys: ['CLOUDFRONT_SIGNING_KEY_ID'], + optional: true, + group: 'preview', + }, { type: 'secret', name: 'EXPO_TOKEN', diff --git a/scripts/pr-preview/setup-signed-cookies.sh b/scripts/pr-preview/setup-signed-cookies.sh new file mode 100755 index 00000000..7fb74a1d --- /dev/null +++ b/scripts/pr-preview/setup-signed-cookies.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Provisions CloudFront signed-cookie access control for PR preview and/or staging. +# +# What this script does: +# 1. Generates an RSA-2048 key pair (or accepts an existing PEM) +# 2. Uploads the public key to CloudFront +# 3. Updates the CloudFormation stack to create a key group and enable enforcement +# 4. Writes CLOUDFRONT_SIGNING_KEY + CLOUDFRONT_SIGNING_KEY_ID to GitHub Actions secrets +# +# Usage: +# ./scripts/pr-preview/setup-signed-cookies.sh [options] +# +# Required IAM permissions for the calling credentials: +# cloudfront:CreatePublicKey +# cloudformation:DescribeStacks, cloudformation:CreateChangeSet, +# cloudformation:ExecuteChangeSet, cloudformation:DescribeChangeSet +# (key group and distribution updates run via CloudFormation) +# +# See docs/preview-access-control.md for full documentation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# --- Defaults (can be overridden by env vars or flags) --- +STACK_NAME="${PR_PREVIEW_STACK_NAME:-}" +REGION="${PR_PREVIEW_AWS_REGION:-us-east-1}" +DOMAIN="${PR_PREVIEW_DOMAIN:-}" +GITHUB_REPO="" +ENABLE_PREVIEW=true +ENABLE_STAGING=false +EXISTING_PRIVATE_KEY="" +DRY_RUN=false + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +# --- Validate required inputs --- +if [[ -z "${STACK_NAME}" ]]; then + echo "Error: --stack-name or PR_PREVIEW_STACK_NAME is required" >&2 + exit 1 +fi +if [[ -z "${DOMAIN}" ]]; then + echo "Error: --domain or PR_PREVIEW_DOMAIN is required" >&2 + exit 1 +fi +if [[ "${ENABLE_PREVIEW}" == "false" && "${ENABLE_STAGING}" == "false" ]]; then + echo "Error: at least one of --enable-preview or --enable-staging must be set" >&2 + exit 1 +fi + +# Auto-detect GitHub repo from git remote +if [[ -z "${GITHUB_REPO}" ]]; then + REMOTE_URL=$(git -C "${REPO_ROOT}" remote get-url origin 2>/dev/null || true) + if [[ "${REMOTE_URL}" =~ github\.com[:/]([^/]+/[^/]+?)(\.git)?$ ]]; then + GITHUB_REPO="${BASH_REMATCH[1]}" + fi +fi +if [[ -z "${GITHUB_REPO}" ]]; then + echo "Error: --github-repo required (could not auto-detect from git remote)" >&2 + exit 1 +fi + +log() { echo "[setup-signed-cookies] $*"; } +warn() { echo "[setup-signed-cookies] WARN: $*" >&2; } + +[[ "${DRY_RUN}" == "true" ]] && log "DRY RUN — no changes will be made" + +# Validate required tools +for cmd in aws openssl jq; do + if ! command -v "${cmd}" &>/dev/null; then + echo "Error: ${cmd} is required but not found in PATH" >&2 + exit 1 + fi +done + +# ───────────────────────────────────────────────────────────────────────────── +# Step 1: Generate RSA-2048 key pair (or use existing) +# ───────────────────────────────────────────────────────────────────────────── +PRIVATE_KEY_FILE="" +PUBLIC_KEY_FILE="" + +cleanup() { + [[ -n "${PRIVATE_KEY_FILE:-}" ]] && rm -f "${PRIVATE_KEY_FILE}" + [[ -n "${PUBLIC_KEY_FILE:-}" ]] && rm -f "${PUBLIC_KEY_FILE}" +} +trap cleanup EXIT + +if [[ -n "${EXISTING_PRIVATE_KEY}" ]]; then + log "Step 1: Using existing private key: ${EXISTING_PRIVATE_KEY}" + PRIVATE_KEY_FILE="${EXISTING_PRIVATE_KEY}" + PUBLIC_KEY_FILE="$(mktemp)" + openssl rsa -in "${PRIVATE_KEY_FILE}" -pubout -out "${PUBLIC_KEY_FILE}" 2>/dev/null + # Don't delete the private key the user provided + trap 'rm -f "${PUBLIC_KEY_FILE}"' EXIT +else + log "Step 1: Generating RSA-2048 key pair..." + PRIVATE_KEY_FILE="$(mktemp)" + PUBLIC_KEY_FILE="$(mktemp)" + chmod 600 "${PRIVATE_KEY_FILE}" + if [[ "${DRY_RUN}" != "true" ]]; then + openssl genrsa -out "${PRIVATE_KEY_FILE}" 2048 2>/dev/null + openssl rsa -in "${PRIVATE_KEY_FILE}" -pubout -out "${PUBLIC_KEY_FILE}" 2>/dev/null + log "RSA-2048 key pair generated." + fi +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Step 2: Upload public key to CloudFront +# ───────────────────────────────────────────────────────────────────────────── +log "Step 2: Uploading public key to CloudFront..." +KEY_NAME="${STACK_NAME}-preview-signing-key" + +if [[ "${DRY_RUN}" == "true" ]]; then + PUBLIC_KEY_ID="DRY_RUN_KEY_ID" + log "DRY RUN: would create CloudFront public key '${KEY_NAME}'" +else + # jq -Rs encodes the PEM as a JSON string, preserving newlines + ENCODED_KEY_JSON=$(jq -Rs . < "${PUBLIC_KEY_FILE}") + KEY_CONFIG=$(jq -n \ + --arg ref "beakerstack-preview-$(date +%s)" \ + --arg name "${KEY_NAME}" \ + --argjson ek "${ENCODED_KEY_JSON}" \ + --arg comment "BeakerStack preview/staging signed-cookie signing key" \ + '{CallerReference: $ref, Name: $name, EncodedKey: $ek, Comment: $comment}') + + CREATE_KEY_RESPONSE=$(aws cloudfront create-public-key \ + --region "${REGION}" \ + --public-key-config "${KEY_CONFIG}" \ + --output json) + PUBLIC_KEY_ID=$(echo "${CREATE_KEY_RESPONSE}" | jq -r '.PublicKey.Id') + log "CloudFront public key created: ${PUBLIC_KEY_ID}" +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Step 3: Update CloudFormation stack to create key group and enable enforcement +# ───────────────────────────────────────────────────────────────────────────── +log "Step 3: Updating CloudFormation stack '${STACK_NAME}'..." + +PREVIEW_ACCESS_CONTROL="public" +[[ "${ENABLE_PREVIEW}" == "true" ]] && PREVIEW_ACCESS_CONTROL="signed-cookies" + +STAGING_ACCESS_CONTROL="public" +[[ "${ENABLE_STAGING}" == "true" ]] && STAGING_ACCESS_CONTROL="signed-cookies" + +if [[ "${DRY_RUN}" == "true" ]]; then + log "DRY RUN: would deploy stack with:" + log " PreviewAccessControl=${PREVIEW_ACCESS_CONTROL}" + log " StagingAccessControl=${STAGING_ACCESS_CONTROL}" + log " CloudFrontSigningPublicKeyId=${PUBLIC_KEY_ID}" +else + # --no-fail-on-empty-changeset: safe to re-run if nothing changed + # Unspecified parameters keep their current stack values (aws cloudformation deploy behavior) + aws cloudformation deploy \ + --template-file "${REPO_ROOT}/infra/aws/pr-preview-stack.yml" \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + "CloudFrontSigningPublicKeyId=${PUBLIC_KEY_ID}" \ + "PreviewAccessControl=${PREVIEW_ACCESS_CONTROL}" \ + "StagingAccessControl=${STAGING_ACCESS_CONTROL}" + + log "Stack update complete. CloudFront distribution changes propagate in ~5-10 min." +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Step 4: Write secrets to GitHub Actions +# ───────────────────────────────────────────────────────────────────────────── +log "Step 4: Writing secrets to GitHub (${GITHUB_REPO})..." + +if [[ "${DRY_RUN}" == "true" ]]; then + log "DRY RUN: would set CLOUDFRONT_SIGNING_KEY and CLOUDFRONT_SIGNING_KEY_ID on ${GITHUB_REPO}" +else + if ! command -v gh &>/dev/null; then + warn "gh CLI not found — set GitHub secrets manually:" + warn " CLOUDFRONT_SIGNING_KEY = contents of ${PRIVATE_KEY_FILE}" + warn " CLOUDFRONT_SIGNING_KEY_ID = ${PUBLIC_KEY_ID}" + warn " (delete the key file after copying: ${PRIVATE_KEY_FILE})" + # Prevent cleanup of private key file so user can retrieve it + PRIVATE_KEY_FILE="" + else + cat "${PRIVATE_KEY_FILE}" | gh secret set CLOUDFRONT_SIGNING_KEY \ + --repo "${GITHUB_REPO}" + gh secret set CLOUDFRONT_SIGNING_KEY_ID \ + --body "${PUBLIC_KEY_ID}" \ + --repo "${GITHUB_REPO}" + log "GitHub secrets written: CLOUDFRONT_SIGNING_KEY, CLOUDFRONT_SIGNING_KEY_ID" + fi +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── +cat < Date: Tue, 12 May 2026 21:33:15 -0700 Subject: [PATCH 06/50] fix(ci): avoid secrets in workflow if expressions (#107) (#108) --- .github/workflows/deploy-staging.yml | 9 +++++- .github/workflows/pr-preview-environment.yml | 12 +++++-- infra/aws/pr-preview-stack.yml | 34 ++++++++++++++++---- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 9de34b9d..bce278e5 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -149,13 +149,20 @@ jobs: - name: Generate staging signed cookie bootstrap URL id: signed_cookies - if: secrets.CLOUDFRONT_SIGNING_KEY != '' shell: bash env: CLOUDFRONT_SIGNING_KEY: ${{ secrets.CLOUDFRONT_SIGNING_KEY }} CLOUDFRONT_SIGNING_KEY_ID: ${{ secrets.CLOUDFRONT_SIGNING_KEY_ID }} run: | set -euo pipefail + # secrets.* is not allowed in step `if:` — gate here (direct URL access when neither secret is set). + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" && -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + exit 0 + fi + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" || -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + echo "::error::CLOUDFRONT_SIGNING_KEY and CLOUDFRONT_SIGNING_KEY_ID must both be set. Configure both secrets or neither." + exit 1 + fi STAGING_DOMAIN="staging.${DOMAIN}" RESOURCE="https://${STAGING_DOMAIN}/*" EXPIRY=$(date -u -d "+30 days" +%s) diff --git a/.github/workflows/pr-preview-environment.yml b/.github/workflows/pr-preview-environment.yml index 56b60dc3..4a3412ec 100644 --- a/.github/workflows/pr-preview-environment.yml +++ b/.github/workflows/pr-preview-environment.yml @@ -203,10 +203,10 @@ jobs: --region "${AWS_REGION_VALUE}" # Generate CloudFront signed cookies and post GitHub Deployment with access URL. - # Skipped when CLOUDFRONT_SIGNING_KEY is not set (public access mode). + # Skipped when neither signing secret is set (public access mode). Partial config fails the step. - name: Generate signed cookie bootstrap URL id: signed_cookies - if: steps.web.outcome == 'success' && secrets.CLOUDFRONT_SIGNING_KEY != '' + if: steps.web.outcome == 'success' shell: bash env: CLOUDFRONT_SIGNING_KEY: ${{ secrets.CLOUDFRONT_SIGNING_KEY }} @@ -214,6 +214,14 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail + # secrets.* is not allowed in step `if:` — gate here (public preview when neither secret is set). + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" && -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + exit 0 + fi + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" || -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + echo "::error::CLOUDFRONT_SIGNING_KEY and CLOUDFRONT_SIGNING_KEY_ID must both be set. Configure both secrets or neither." + exit 1 + fi PREVIEW_PREFIX_VALUE="${PREVIEW_PREFIX:-pr-}" DEPLOY_DOMAIN="deploy.${PREVIEW_DOMAIN}" DEST="/${PREVIEW_PREFIX_VALUE}${PR_NUMBER}/" diff --git a/infra/aws/pr-preview-stack.yml b/infra/aws/pr-preview-stack.yml index ffdc8b36..dc797b01 100644 --- a/infra/aws/pr-preview-stack.yml +++ b/infra/aws/pr-preview-stack.yml @@ -442,6 +442,28 @@ Resources: }; } + # Explicit no-cache policy for the /_preview-auth cookie-setter path. + # Uses a custom resource rather than the CachingDisabled managed policy UUID to avoid + # any account-level availability issues with AWS-managed policy IDs. + PreviewAuthCachePolicy: + Type: AWS::CloudFront::CachePolicy + Properties: + CachePolicyConfig: + Name: !Sub '${AWS::StackName}-preview-auth-no-cache' + Comment: Disables caching on the /_preview-auth cookie-setter path + DefaultTTL: 0 + MinTTL: 0 + MaxTTL: 1 + ParametersInCacheKeyAndForwardedToOrigin: + EnableAcceptEncodingGzip: false + EnableAcceptEncodingBrotli: false + CookiesConfig: + CookieBehavior: none + HeadersConfig: + HeaderBehavior: none + QueryStringsConfig: + QueryStringBehavior: none + # Key group associating the provisioned CloudFront public key with distributions. # Created only when at least one distribution is in signed-cookies mode. # The public key itself is provisioned outside CloudFormation by @@ -529,9 +551,9 @@ Resources: TargetOriginId: StagingOrigin ViewerProtocolPolicy: redirect-to-https Compress: false - # CachingDisabled — synthetic responses from CF Functions are not cached, but - # this policy is explicit and prevents any accidental cache sharing. - CachePolicyId: 4135872e-7369-4a65-bac3-7ea18cd55781 + # No-cache policy — synthetic responses from CF Functions are not cached, but + # an explicit policy prevents any accidental cache sharing. + CachePolicyId: !Ref PreviewAuthCachePolicy FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN @@ -594,9 +616,9 @@ Resources: TargetOriginId: DeployOrigin ViewerProtocolPolicy: redirect-to-https Compress: false - # CachingDisabled — synthetic responses from CF Functions are not cached, but - # this policy is explicit and prevents any accidental cache sharing. - CachePolicyId: 4135872e-7369-4a65-bac3-7ea18cd55781 + # No-cache policy — synthetic responses from CF Functions are not cached, but + # an explicit policy prevents any accidental cache sharing. + CachePolicyId: !Ref PreviewAuthCachePolicy FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN From ffe6d337a7a626a444af950b3af6776298a105a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rahul=20Iyer=20=F0=9F=A4=96?= Date: Tue, 12 May 2026 21:47:19 -0700 Subject: [PATCH 07/50] fix: dark mode for auth, billing, dashboard, and profile components (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dark-mode): ProfileStats member since contrast in dark mode * fix: add dark mode support across auth, billing, dashboard, and profile components Adds Tailwind dark: variants to 18 components that had no dark mode support. Converts MeteredUsageDemo progress bar from hardcoded inline styles to Tailwind classes to enable dark:bg-* theming. * fix: missing dark variants on DemoControlsPanel span and FeatureLimitRow fallback - DemoControlsPanel: "Current plan" inner span text-gray-900 → dark:text-white - FeatureLimitRow: text-gray-600 fallback class → dark:text-gray-400 * fix: dark mode contrast on billing pages, UsageIndicator, ProfileStats Fixes all remaining contrast issues flagged by Brad in PR #103: - BillingOverviewPage: H1 dark:text-white - BillingUsagePage: H1, 3× H2, reset-info card, footnote, two section cards - BillingPlansPage: H1, H2, subhead, welcome banner, loading/footer text - UsageIndicator (billing pkg): label, description (fixes "Summaries generated..." text), capLine — all missing dark variants; progress track replaced hardcoded inline style (#e5e7eb) with Tailwind bg-gray-200 dark:bg-gray-700 - ProfileStats: "Profile completion" row missing dark:text-gray-400 * fix(dark-mode): FormInput/AvatarUpload/Modal labels and inputs; restore changeset; add coverage test * fix(dark-mode): add dark:text-red-400 to AvatarUpload error message * fix unit tests * chore:fix formatting, lint * fix(dark-mode): FormInput input bg, FormError, InvoiceList/Table link colors * fix(dark-mode): PlanFeatureList X icon, BillingTabs active indicator --- QUICKSTART.md | 14 +-- README.md | 4 +- UPGRADING.md | 2 + VERSIONING.md | 8 +- .../src/__tests__/dark-mode-coverage.test.ts | 99 +++++++++++++++++++ .../auth/__tests__/postAuthRedirect.test.ts | 3 + apps/web/src/components/SocialLoginButton.tsx | 4 +- .../src/components/auth/SignupPlanSummary.tsx | 30 +++--- .../components/billing/BillingTabs.web.tsx | 6 +- .../billing/ConfirmDowngradeModal.web.tsx | 4 +- .../billing/ConstraintWarning.web.tsx | 2 +- .../billing/FeatureLimitRow.web.tsx | 18 ++-- .../components/billing/InvoiceList.web.tsx | 2 +- .../components/billing/InvoiceTable.web.tsx | 6 +- .../billing/PlanFeatureList.web.tsx | 6 +- .../components/billing/PlanFeatureRow.web.tsx | 6 +- .../components/billing/StatusBadge.web.tsx | 35 +++++-- .../dashboard/AISummarizeResult.tsx | 10 +- .../components/dashboard/BooleanGatesDemo.tsx | 14 +-- .../dashboard/DashboardDemoSection.tsx | 24 +++-- .../dashboard/DemoControlsPanel.tsx | 15 +-- .../components/dashboard/MeteredUsageDemo.tsx | 17 +--- .../components/dashboard/NumericCapsDemo.tsx | 21 ++-- .../AuthCallbackPage.coverage.test.tsx | 3 + .../src/pages/billing/BillingInvoicesPage.tsx | 10 +- .../src/pages/billing/BillingOverviewPage.tsx | 4 +- .../src/pages/billing/BillingPlansPage.tsx | 18 ++-- .../src/pages/billing/BillingUsagePage.tsx | 32 ++++-- apps/web/src/test/setup.ts | 47 +++++++++ docs/README.md | 18 ++-- docs/preview-access-control.md | 30 +++--- docs/project-label-bridge.md | 23 +++-- infra/aws/functions/PreviewAuthFunction.js | 33 +++++-- .../src/components/UsageIndicator.web.tsx | 25 +++-- .../src/components/forms/FormError.web.tsx | 4 +- .../src/components/forms/FormInput.web.tsx | 10 +- .../src/components/primitives/Button.web.tsx | 4 +- .../src/components/primitives/Modal.web.tsx | 9 +- .../components/profile/AvatarUpload.web.tsx | 22 +++-- .../components/profile/ProfileAvatar.web.tsx | 4 +- .../components/profile/ProfileEditor.web.tsx | 18 +++- .../components/profile/ProfileHeader.web.tsx | 24 +++-- .../components/profile/ProfileStats.web.tsx | 4 +- 43 files changed, 476 insertions(+), 216 deletions(-) create mode 100644 apps/web/src/__tests__/dark-mode-coverage.test.ts diff --git a/QUICKSTART.md b/QUICKSTART.md index 9b3d2660..04544827 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -111,14 +111,14 @@ npm run setup:full Options (see also `npm run setup:full -- --help`): -| Flag | Meaning | -| -------------------- | ------------------------------------ | -| `--dry-run` | No file writes; log-only GitHub sync | -| `--from=PHASE` | Resume at a phase (see table below) | -| `--skip-rename` | Skip template rename | -| `--skip-github` | Do not push secrets with `gh` | +| Flag | Meaning | +| -------------------- | ------------------------------------------- | +| `--dry-run` | No file writes; log-only GitHub sync | +| `--from=PHASE` | Resume at a phase (see table below) | +| `--skip-rename` | Skip template rename | +| `--skip-github` | Do not push secrets with `gh` | | `--skip-mobile` | Skip Expo/EAS/Google setup (web-only repos) | -| `--aws-profile=NAME` | Pass through to AWS bootstrap script | +| `--aws-profile=NAME` | Pass through to AWS bootstrap script | **Phase names** for `--from=` (order matters; later phases assume earlier work or merged `.env*` files): diff --git a/README.md b/README.md index 4de91a3e..88dda263 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,8 @@ Details: [docs/pr-preview-setup.md](docs/pr-preview-setup.md). Template snapshots are tagged on `main` using **CalVer** (`2026.001`, `2026.002`, …). Each release includes generated notes covering what changed and whether there are any breaking steps. `@beakerstack/*` packages use independent **semver** on npm. -| Trigger | Workflow | -| ------- | -------- | +| Trigger | Workflow | +| ------------------- | --------------------------------------------------------------------------------------------------------- | | **Manual dispatch** | [release-template.yml](.github/workflows/release-template.yml) — cuts a new CalVer tag and GitHub Release | See [VERSIONING.md](VERSIONING.md) for what counts as a breaking change and [UPGRADING.md](UPGRADING.md) for how to pull template changes into an existing fork. diff --git a/UPGRADING.md b/UPGRADING.md index c4e8a8cb..830ea03f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -25,9 +25,11 @@ git merge 2026.003 ``` > **"Use this template" users:** If you created your repo using GitHub's "Use this template" button, git treats the histories as unrelated. Your first merge will need `--allow-unrelated-histories`: +> > ```bash > git merge --allow-unrelated-histories 2026.003 > ``` +> > Subsequent merges work without the flag. Resolve any conflicts, then review the release notes for that tag on GitHub for any breaking changes that need manual follow-up (new secrets, renamed variables, migration steps). diff --git a/VERSIONING.md b/VERSIONING.md index b227038b..c9a27f19 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -30,10 +30,10 @@ Breaking changes are called out explicitly in the release notes generated for th ### `main` vs. tagged releases -| Ref | What it is | Recommended for | -|-----|-----------|-----------------| -| `main` | Current stable HEAD | Following along with active development | -| `2026.NNN` | Snapshot tag | Starting a new fork; upgrading an existing fork in a controlled way | +| Ref | What it is | Recommended for | +| ---------- | ------------------- | ------------------------------------------------------------------- | +| `main` | Current stable HEAD | Following along with active development | +| `2026.NNN` | Snapshot tag | Starting a new fork; upgrading an existing fork in a controlled way | If you are forking BeakerStack to build a product, start from a tagged release so your upgrade story is clear from day one. diff --git a/apps/web/src/__tests__/dark-mode-coverage.test.ts b/apps/web/src/__tests__/dark-mode-coverage.test.ts new file mode 100644 index 00000000..5187334e --- /dev/null +++ b/apps/web/src/__tests__/dark-mode-coverage.test.ts @@ -0,0 +1,99 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const MONOREPO_ROOT = resolve(__dirname, '../../../..'); + +function walk(dir: string, ext: string, skip: string[] = []): string[] { + const results: string[] = []; + try { + for (const entry of readdirSync(dir)) { + if (skip.includes(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...walk(full, ext, skip)); + } else if (entry.endsWith(ext)) { + results.push(full); + } + } + } catch { + // directory doesn't exist — skip + } + return results; +} + +const SCAN_FILES = [ + ...walk(join(MONOREPO_ROOT, 'packages/shared/src/components'), '.web.tsx', [ + '__tests__', + ]), + ...walk(join(MONOREPO_ROOT, 'packages/billing/src/components'), '.web.tsx', [ + '__tests__', + ]), + ...walk(join(MONOREPO_ROOT, 'apps/web/src/components/billing'), '.tsx', [ + '__tests__', + ]), + ...walk(join(MONOREPO_ROOT, 'apps/web/src/pages/billing'), '.tsx', [ + '__tests__', + ]), +]; + +// Extract string literals that look like they contain Tailwind class names. +// Scans ALL quoted strings in the file (not just className=) to catch dynamic +// class strings built via template literals and variable assignments. +function extractClassLikeStrings(src: string): string[] { + const results = new Set(); + const re = /['"]([^'"]{4,})['"]/g; + let m; + while ((m = re.exec(src)) !== null) { + const s = m[1]; + if ( + /\b(?:text|bg|border|rounded|flex|grid|font|shadow|hover|dark)[-:][a-zA-Z0-9/[\].]+/.test( + s + ) + ) { + results.add(s); + } + } + return [...results]; +} + +// Light text shades that are hard to read on dark backgrounds +const NEEDS_DARK_TEXT = + /\btext-(?:gray|slate|zinc|neutral|stone)-(?:600|700|800|900|950)\b/; +// Light backgrounds that are painful in dark mode +const NEEDS_DARK_BG = /\bbg-(?:white|(?:gray|slate|zinc)-(?:50|100|200|300))\b/; + +describe('dark mode coverage', () => { + it('scans at least one file', () => { + expect(SCAN_FILES.length).toBeGreaterThan(0); + }); + + it('class strings with light text colors have a dark:text- counterpart', () => { + const violations: string[] = []; + for (const file of SCAN_FILES) { + const src = readFileSync(file, 'utf8'); + for (const cls of extractClassLikeStrings(src)) { + if (NEEDS_DARK_TEXT.test(cls) && !/\bdark:text-/.test(cls)) { + violations.push( + `${file.replace(MONOREPO_ROOT + '/', '')}: "${cls.slice(0, 80)}"` + ); + } + } + } + expect(violations).toEqual([]); + }); + + it('class strings with light backgrounds have a dark:bg- counterpart', () => { + const violations: string[] = []; + for (const file of SCAN_FILES) { + const src = readFileSync(file, 'utf8'); + for (const cls of extractClassLikeStrings(src)) { + if (NEEDS_DARK_BG.test(cls) && !/\bdark:bg-/.test(cls)) { + violations.push( + `${file.replace(MONOREPO_ROOT + '/', '')}: "${cls.slice(0, 80)}"` + ); + } + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/apps/web/src/auth/__tests__/postAuthRedirect.test.ts b/apps/web/src/auth/__tests__/postAuthRedirect.test.ts index 62067b2b..00ebd231 100644 --- a/apps/web/src/auth/__tests__/postAuthRedirect.test.ts +++ b/apps/web/src/auth/__tests__/postAuthRedirect.test.ts @@ -140,6 +140,9 @@ function storageMock(store: Record) { removeItem: (k: string) => { delete store[k]; }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, }; } diff --git a/apps/web/src/components/SocialLoginButton.tsx b/apps/web/src/components/SocialLoginButton.tsx index 6d1db961..02fa2ce2 100644 --- a/apps/web/src/components/SocialLoginButton.tsx +++ b/apps/web/src/components/SocialLoginButton.tsx @@ -33,8 +33,8 @@ export function SocialLoginButton({ disabled={isLoading} className={` w-full flex items-center justify-center gap-3 px-4 py-2 - border border-gray-300 rounded-md shadow-sm - bg-white hover:bg-gray-50 text-gray-700 + border border-gray-300 dark:border-gray-600 rounded-md shadow-sm + bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed diff --git a/apps/web/src/components/auth/SignupPlanSummary.tsx b/apps/web/src/components/auth/SignupPlanSummary.tsx index 3b623f1f..c560aa40 100644 --- a/apps/web/src/components/auth/SignupPlanSummary.tsx +++ b/apps/web/src/components/auth/SignupPlanSummary.tsx @@ -36,8 +36,10 @@ export function PlanIntentSummary({ if (loading) { return ( -
-

Loading plan…

+
+

+ Loading plan… +

); } @@ -80,15 +82,15 @@ export function PlanIntentSummary({

{mode === 'login' ? 'Plan from pricing' : 'Your selection'} @@ -96,14 +98,14 @@ export function PlanIntentSummary({

{catalogPlan.display_name}

{savingsCallout ? ( -

+

{savingsCallout}

) : null} @@ -111,16 +113,18 @@ export function PlanIntentSummary({

{priceHeadline}

-

{priceSubline}

+

+ {priceSubline} +

{bullets.length > 0 ? ( -
    +
      {bullets.map((line, i) => (
    • {line}
    • ))} diff --git a/apps/web/src/components/billing/BillingTabs.web.tsx b/apps/web/src/components/billing/BillingTabs.web.tsx index 46e0a03b..1c61a914 100644 --- a/apps/web/src/components/billing/BillingTabs.web.tsx +++ b/apps/web/src/components/billing/BillingTabs.web.tsx @@ -13,7 +13,7 @@ const tabs: { to: string; end?: boolean; label: string }[] = [ export function BillingTabs() { return (