A booking link fills in the attendee, and the Booker ships as a site

/{owner}/{slug}?name=&email=&notes= starts the details step filled in,
as a Cal.com link does: hanzo.ai's sales page sends its visitors here
with the answers they gave. The page reads them once and takes them off
the address before anything reports it.

That needs @hanzo/booker 8.3.6's prefill, so the app moves to the 8.x
line: @hanzo/gui 8.3.5 and React 19. The committed lockfile no longer
matched package.json and is regenerated.

cal.hanzo.ai is served from the Sites plane slug cal-web, and nothing
published there: the image lane pinned a values file universe deleted,
and the forge copy runs no workflows. hanzo.yml gains the site lane and
.github/workflows calls hanzoai/ci; the Dockerfile and the dead lanes go.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
This commit is contained in:
2026-09-27 15:00:35 -07:00
parent a2608269c3
commit b74b0d4e01
10 changed files with 1949 additions and 6275 deletions
-15
View File
@@ -1,15 +0,0 @@
# Keep the build context lean and let the image build its own deps/bundle.
# node_modules is re-installed in-container (host symlinks/arch would not port);
# dist is rebuilt by `pnpm build` in stage 1.
**/node_modules
**/dist
*.tsbuildinfo
.git
.gitignore
.DS_Store
.env
.env.local
Dockerfile
.dockerignore
deploy
LLM.md
+19
View File
@@ -0,0 +1,19 @@
name: CI/CD
# The caller and nothing else: hanzo.yml says how this repo gates and ships,
# and hanzoai/ci reads it. The hanzoai org's runners serve github.com, so this
# is the plane that builds cal.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
secrets: inherit
-15
View File
@@ -1,15 +0,0 @@
# Canonical caller — every knob lives in /hanzo.yml, none here.
# Runs on the git-runner fleet at git.hanzo.ai, the only pool serving these
# labels. github.com resolves only .github/workflows and has no runner for
# them, so a caller placed there is a gate that cannot be scheduled.
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
secrets: inherit
-172
View File
@@ -1,172 +0,0 @@
# Build the image, prove it pulls, and pin it. This is the whole lane.
#
# The number universe pins is DERIVED, never a sha: a CR that names a commit
# cannot be read as "which release is live", and pin.sh in universe refuses a
# sha outright — so a sha-only build (what this used to be) produced images no
# reviewed pin could ever consume. imgver gives the one number every builder on
# this fleet uses: max(declared app/package.json, published ghcr) + a patch.
#
# cal's tag carries a leading v — its git tags and its ghcr tags are v0.2.x, and
# pin.sh honours whatever convention the values file already holds. So imgver's
# bare number gets a v at the two places a human reads it (the image tag and the
# git tag); universe's pin.sh re-adds it from cal-web.yaml's own spelling.
#
# The pin is cal-web, NOT cal: values/hanzo/cal.yaml is the booking BACKEND on a
# different image and is frozen by a ratchet. cal-web.yaml is this web image.
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-cal-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
jobs:
build:
# Must name a label a runner ACTUALLY advertises. The git-runners offer
# linux-amd64; a label nothing offers does not fail, it queues
# until the 24h timeout — silence, not an error.
runs-on: [linux-amd64]
steps:
# Full history + tags: this job TAGS the release, so it needs the tag
# namespace to push into and a non-shallow clone to describe from.
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
# One implementation (hanzoai/ci bin/imgver), every builder. cal declares
# its floor in app/, so the published tag at ghcr carries the series.
- id: ver
uses: hanzoai/ci/.github/actions/imgver@v1
with: { repo: ghcr.io/hanzoai/cal }
env: { GH_PAT: '${{ secrets.GH_PAT }}' }
# Stamp before the build so the tree that gets tagged is byte-identical to
# the tree that was built. cal's version lives in app/, not the repo root.
- name: Stamp app/package.json with the release version
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
sed -i.bak -E "s/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/\1${v}\2/" app/package.json && rm -f app/package.json.bak
echo "app/package.json -> $(grep -m1 '\"version\"' app/package.json)"
# Kept alongside the semver, for forensics only. Nothing pins it.
- id: meta
run: echo "tag=sha-$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
with: { driver: docker-container, driver-opts: network=host }
# Without this the push is unauthenticated and 403s at the registry.
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: |
ghcr.io/hanzoai/cal:v${{ steps.ver.outputs.version }}
ghcr.io/hanzoai/cal:${{ steps.meta.outputs.tag }}
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
# build-push-action can exit 0 before the manifest resolves, so a green run
# could still mean a future ImagePullBackOff. Prove it pulls BEFORE the pin
# moves — pinning an image the registry cannot serve has no rollback path.
- name: Verify the pushed image resolves
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cal:v${{ steps.ver.outputs.version }}"
for i in 1 2 3 4 5 6; do
docker buildx imagetools inspect "$img" >/dev/null 2>&1 && { echo "resolved $img"; exit 0; }
sleep 5
done
echo "::error::pushed image never resolved: $img"; exit 1
# THE STEP THAT SHIPS. Everything above publishes an image; cd.hanzo.ai
# reconciles from universe's values files and never reads the registry, so
# an image nothing points at is just bytes in ghcr. pin.sh resolves tag AND
# digest from ONE lookup and writes them together, so the pod cannot report
# a new version while serving old bytes.
- name: Ship it
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://api.hanzo.ai' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
# pin.sh probes the registry before it moves anything; these let it read
# cal's private manifest instead of falling back to anonymous.
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.ver.outputs.version }}"
# Secrets come from KMS, never from a file or a repo variable. The org
# is carried by the credential, not by the path.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT} — image v${VERSION} is pushed but NOT deployed"; exit 1; }
PIN_TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // empty')
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS (path deploy, env ${KMS_SECRET_ENV}) — image v${VERSION} is pushed but NOT deployed"; exit 1; }
echo "::add-mask::${PIN_TOKEN}"
# cd reads THIS copy. universe also exists under hanzoai/ and is a
# different, staler commit — pinning there deploys nothing, silently.
git clone --quiet --depth 1 \
"https://x-access-token:${PIN_TOKEN}@git.hanzo.ai/hanzo/universe" /tmp/universe
# Another service can land its own pin between our clone and our push.
# A shallow clone has no common history to rebase onto, so RE-APPLY
# rather than merge: pinning is idempotent and derives everything from
# the file it finds, so the retry re-checks the monotonic guard too.
for attempt in 1 2 3 4 5; do
/tmp/universe/charts/app/pin.sh cal-web "$VERSION"
if git -C /tmp/universe push --quiet origin HEAD:main 2>/dev/null; then
echo "pinned cal-web to v${VERSION} — cd.hanzo.ai rolls it out"; exit 0
fi
echo "universe moved under us (attempt ${attempt}/5) — re-applying on the new tip"
git -C /tmp/universe fetch --quiet --depth 1 origin main
git -C /tmp/universe reset --hard --quiet FETCH_HEAD
done
echo "::error::image v${VERSION} shipped but the pin never landed — cal is published and unreachable"; exit 1
# ONLY after the image is proven pullable and pinned, so a git tag can never
# name a version the registry lacks. Bookkeeping is last so it cannot
# un-ship a good build. universe pins the image, not this tag.
- name: Tag the version this image ships
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
git config user.name 'hanzo-ci'
git config user.email '[email protected]'
# Idempotent: a run that shipped its image and then died leaves the tag
# already there, and `git tag` on an existing name is fatal.
if git rev-parse -q --verify "refs/tags/v${v}" >/dev/null; then
echo "tag v${v} already exists"
else
git tag "v${v}"
git push origin "v${v}"
fi
echo "v${v} tagged"
-51
View File
@@ -1,51 +0,0 @@
# syntax=docker/dockerfile:1
# cal.hanzo.ai — Cal's <Booker> (debranded as @hanzo/cal) as a static SPA,
# served by hanzoai/spa (Go static server, :3000). Self-contained multi-stage:
# stage 1 compiles the Vite bundle with the pinned pnpm; stage 2 serves it. A
# fresh `docker build .` needs no pre-built dist on the host (app/dist is
# gitignored), so CI and local builds are identical.
# --- stage 1: build app/dist ---
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable
# Manifests first: the install layer caches independently of source edits.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY app/package.json app/package.json
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Sources, then build → app/dist. @hanzo/cal is workspace TS source that
# Vite transpiles; @calcom/atoms is consumed as its pre-built ESM bundle.
COPY . .
RUN pnpm build
# --- stage 2: serve the bundle with the Hanzo static SPA server ---
# hanzoai/spa falls back to index.html for any client-routed path so the SPA
# router resolves /{owner}/{slug} and /booking/{uid} on the client.
FROM ghcr.io/hanzoai/spa:sha-b9d3a13-amd64
COPY --from=build /app/app/dist /public
# Ship the fork attribution alongside the served bundle (MIT @calcom/atoms).
COPY NOTICE /NOTICE
ENV PORT=3000
ENV ROOT=/public
# Allow same-origin + inline bootstrap (React hydration) and connect-src https:
# so the browser can reach the Base booking API at cal.hanzo.ai/v1/calendar.
# The variable is SPA_CSP: that is the name hanzoai/spa reads, and the
# HANZO_STATIC_CSP spelled here before was read by nothing.
#
# It is still read by nothing. hanzoai/spa sets Content-Security-Policy only on
# the branch where framing is refused, so with ALLOW_FRAMING=true it sends no
# policy at all — the whole header, on every path, not just frame-ancestors.
# Confirmed against ghcr.io/hanzoai/spa:sha-b9d3a13: `curl -sI /` returns no CSP
# and no X-Frame-Options. Making this value ship takes a change to that shared
# image — set the CSP unconditionally and let framing choose frame-ancestors
# rather than choose whether there is a policy — and the value below is written
# to be correct the day it does.
ENV SPA_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-src 'self'; media-src 'self' https:; frame-ancestors *"
# /embed is meant to be framed by other people's pages, which hanzoai/spa
# otherwise refuses with X-Frame-Options: DENY + frame-ancestors 'none'. The
# allowance is per-server, so it covers /booking/<uid> too — and a uid IS a
# credential: it reads the attendee and cancels the meeting. What framing could
# steal there is a click on Cancel, which is why cancelling takes two.
ENV ALLOW_FRAMING=true
EXPOSE 3000
+22 -30
View File
@@ -8,27 +8,27 @@ booking is created in Base.
- `app` — the Vite + React SPA (`@hanzo/cal-app`). Routes: `/` landing,
`/{owner}/{slug}` the Booker, `/booking/{uid}` confirmation + cancel, `/embed`
the framed Booker a third-party page loads.
- `Dockerfile` — self-contained multi-stage: stage 1 builds the Vite bundle with
the pinned pnpm; stage 2 is `FROM ghcr.io/hanzoai/spa` and copies the bundle to
`/public` (SPA fallback). A fresh `docker build .` needs no pre-built `app/dist`
(it is gitignored), so CI and local builds are identical.
- `.hanzo/workflows/deploy.yml` — native CI on the self-hosted pool
`hanzo-build-linux-amd64`; builds and pushes `ghcr.io/hanzoai/cal:sha-<short>`.
It never deploys: the rollout is a reviewed tag pin on the `cal` App CR in
`hanzoai/universe`, and cd.hanzo.ai's selfHeal would undo anything CI patched.
- `hanzo.yml` — the gate (frozen install + build) and the site lane, which
publishes `app/dist` to the Sites plane slug `cal-web`. universe serves that
prefix at cal.hanzo.ai (`cal-hanzo-ai-static`) with the response headers in
`cal-hanzo-ai-headers`, and routes `/v1/calendar` to the Base backend. The
caller is `.github/workflows/cicd.yml`. There is no image.
- `deploy/mirror-to-forge.sh` — server-side mirror-IN resync (idempotent).
## The UI package
`@hanzo/booker` (published from `hanzoai/gui/pkgs/calendar`) — a universal
(web + native) Booker built on hanzogui primitives, pinned at `^0.1.0`. It is
NOT Cal's atoms; it re-implements the flow and speaks Cal's API-v2 shapes.
(web + native) Booker built on hanzogui primitives, at `^8.3.6` on `@hanzo/gui`
8.3.5 and React 19. It is NOT Cal's atoms; it re-implements the flow and speaks
Cal's API-v2 shapes. 8.3.6 added `prefill`, the name, email and notes the
details step starts with.
Two build facts follow from it and are easy to trip over:
- `@hanzo/booker` imports its peer under the name `hanzogui`. Vite aliases that
to `@hanzo/gui`; `app/tsconfig.json` repeats the alias in `paths`, or the type
checker cannot resolve it. Change one, change both.
- Do not chase `@hanzo/booker@8.x`: it peer-requires `@hanzo/gui >= 8`, and the
app is pinned to the 7.3.0 line. 8.1.1 adds no props 0.1.0 lacks.
- `/{owner}/{slug}?name=&email=&notes=` fills the details step, as a Cal.com
link does; hanzo.ai's sales page sends its visitors here that way. The page
reads the three once and takes them off the address before anything reports
it, because `@hanzo/event` stamps the URL on every event.
- The app's own `gui.ts` and `main.tsx` import `hanzogui`, which Vite aliases to
`@hanzo/gui`; `app/tsconfig.json` repeats the alias in `paths`. Change one,
change both.
## Backend contract
`app/src/api.ts` resolves the base (`VITE_CAL_API`, default `/v1/calendar`), and
@@ -69,18 +69,10 @@ and cancels the booking, so it never leaves the booking page.
full-viewport overlay with no script at all; the bar is drawn in the loader's
own DOM under `all:initial !important`, which a scriptless injection cannot
remove, and it says whose dialog it is.
- `ALLOW_FRAMING=true` is set in the Dockerfile because `ghcr.io/hanzoai/spa`
otherwise sends `X-Frame-Options: DENY` + `frame-ancestors 'none'` and the
embed silently refuses to render. **It also means cal serves no CSP at all.**
That server sets the header only on the branch where framing is refused, so
with framing allowed the whole policy is dropped on every path — measured
against `sha-b9d3a13`: no CSP and no X-Frame-Options on `/` or `/booking/x`.
The value is spelled `SPA_CSP`, the name the server actually reads
(`HANZO_STATIC_CSP` was read by nothing), and it ships the day that image sets
the header unconditionally and lets framing pick `frame-ancestors` instead of
picking whether there is a policy at all. `SPA_*` is also the runtime-config
namespace there, so the value is additionally templated into a public
`/config.json` — public either way, but worth knowing.
- Framing is decided by the edge now, not by this repo: the Sites plane
answers with universe's `cal-hanzo-ai-headers`, which today send
`X-Frame-Options: DENY`, so a third-party page cannot frame `/embed` until
that middleware lets it.
- Framing reaches `/booking/<uid>` too, since the allowance is per-server. The
uid is the capability that reads the attendee and cancels the meeting, so
cancelling takes two clicks, and `Keep it` is the one that sits where the first
@@ -136,6 +128,6 @@ from `useBooker`'s `selectSlot` / `submit` / the failed-submit branch — not a
fork of the UI here and not a wrapper around `fetch`.
## Homing
Canonical origin is the forge, `git.hanzo.ai/hanzoai/cal`, which is also the CI
executor. (The repo is `cal`, not `calendar`, because `hanzoai/calendar` is a
GitHub (`github.com/hanzoai/cal`) is home and builds it; the forge copy is a
mirror. (The repo is `cal`, not `calendar`, because `hanzoai/calendar` is a
full fork of `calcom/cal.diy` — a different artifact.)
+8 -8
View File
@@ -10,19 +10,19 @@
"preview": "vite preview --host --port 3000"
},
"dependencies": {
"@hanzo/booker": "^0.1.0",
"@hanzo/booker": "^8.3.6",
"@hanzo/event": "^0.3.21",
"@hanzo/font": "^1.8.11",
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native-web": "^0.20.0",
"@hanzo/gui": "8.3.5",
"@hanzogui/config": "8.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-native-web": "^0.21.2",
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.3",
"vite": "5.4.11"
+22
View File
@@ -1,16 +1,37 @@
import { Booker } from "@hanzo/booker";
import { useLayoutEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { calendarApi } from "../api";
import { booked, useShown } from "../analytics";
/** The query names a link may fill the details step with, as a Cal.com link does. */
const PREFILL = ["name", "email", "notes"] as const;
// The public booking page: the native Hanzo <Booker> (universal Tamagui, on
// @hanzo/booker) for /{owner}/{slug}, pointed at the native /v1/calendar backend.
// On success we route to the confirmation view by the new booking's uid.
//
// `?name=`, `?email=` and `?notes=` fill in the details step: hanzo.ai's sales
// page sends its visitor here with the answers they already gave. They are read
// once and taken off the address before anything reports the page, because
// @hanzo/event stamps the URL on every event and an attendee is not the
// warehouse's to keep.
export function Booking() {
const { owner = "", slug = "" } = useParams();
const navigate = useNavigate();
const target = { host: owner, eventSlug: slug };
const [prefill] = useState(() => {
const q = new URLSearchParams(window.location.search);
return Object.fromEntries(PREFILL.map((k) => [k, q.get(k) ?? undefined]));
});
useLayoutEffect(() => {
const url = new URL(window.location.href);
if (!PREFILL.some((k) => url.searchParams.has(k))) return;
for (const k of PREFILL) url.searchParams.delete(k);
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
}, []);
useShown("page", target);
@@ -19,6 +40,7 @@ export function Booking() {
host={owner}
eventSlug={slug}
apiUrl={calendarApi}
prefill={prefill}
onBooked={(booking) => {
booked("page", target);
if (booking?.uid) navigate(`/booking/${booking.uid}`);
+12 -5
View File
@@ -1,13 +1,20 @@
# cal.hanzo.ai — the Booker SPA over Hanzo Base /v1/calendar.
#
# Gate only. The image for this repo is built and pushed by the native builder
# already in this repo (.hanzo/workflows/deploy.yml → ghcr.io/hanzoai/cal:<sha>);
# declaring `images:` here would push a second, differently-tagged copy of the
# same commit. What was missing is the assertion, so that is what this adds:
# the workspace installs against the committed lockfile and every package builds.
# One gate and one lane. The gate installs against the committed lockfile and
# builds; the site lane publishes app/dist to the Sites plane slug `cal-web`,
# which universe serves at cal.hanzo.ai (static-sites.yaml, cal-hanzo-ai-static)
# beside /v1/calendar on the Base backend. There is no image: nothing in the
# cluster runs this SPA.
test:
- name: build
run: |
set -euo pipefail
corepack enable
pnpm install --frozen-lockfile
pnpm build
site:
slug: cal-web
dir: app/dist
build: corepack enable && pnpm install --frozen-lockfile && pnpm build
on: [main]
+1866 -5979
View File
File diff suppressed because it is too large Load Diff