Client onboarding
The operator's start-to-finish checklist for standing up one client event, from a signed
agreement to a live, launch-ready site. This is the provisioning and content checklist; the
infrastructure steps it points into live in docs/DEPLOY_RUNBOOK.md so they
are documented once. When you finish, hand the client docs/handbook/for-clients.md
and docs/handbook/for-event-staff.md — see Handoff
below.
Done when: node scripts/init-event.cjs --check exits 0 and the client's first two admins can
sign in and edit content.
0. Prerequisites#
Before you start provisioning:
- The client has answered
docs/handbook/for-clients.md's "What you will need to give us" list: event name/dates/timezone/venue, the public URL they want, who the first admins are, their ticketing choice, and a sender address on their own domain. - You know which ticketing provider this is:
stripe(native sales through the organizer's Stripe account),eventbrite,manual(CSV import), ornone. Every onboarding step below that mentions ticketing is gated on this choice, not optional busywork — amanualdeployment genuinely has no webhook step, not a skipped one. - You have (or can get)
gcloudaccess to create a GCP project, and Firebase Console access to add Firebase to it.
1. Project provisioning#
This is entirely docs/DEPLOY_RUNBOOK.md's job — do not duplicate it here.
Work through, in order:
- §0–2 — name the project, set up Workload Identity Federation (once per GCP org/project if
not already done), create the project, enable its APIs, create the per-client deploy service
account and bind it to
refs/heads/mainonly. - §3 — create the GitHub Environment for this client and set every
vars.*andsecrets.*row the runbook's tables list, including the provider secrets your ticketing/email choice needs. - §4 — decide now whether this client auto-deploys on push (
AUTO_DEPLOY_ENVIRONMENTS) or stays on manualworkflow_dispatchwhile it's new. Manual is the safer default for a first event. - §5 — run the bootstrap dispatch (provision + functions only), confirming it succeeds
before you touch this document again. Do not run the normal dispatch yet — there is no
config/eventdocument for it to build content from.
Stop here and come back to this document once the bootstrap dispatch is green.
2. init-event: the answers file#
init-event.cjs is the one-time content bootstrap (docs/DEPLOY_RUNBOOK.md §5 step 2) — it does
not run from deploy-client.yml, and it is not repeatable in the way a deploy is: re-running it
never overwrites anything the client has already edited (seeded: true bookkeeping), so running it
again by mistake is safe, not silent data loss.
Build a client-answers.json from the client's intake (see scripts/lib/answers.cjs for the
prompt list and validation this file goes through — the same file interactive mode would produce
answering the same questions one at a time, and
docs/examples/demo-answers.json for a filled-in example). At
minimum it needs the event name, dates, timezone, venue, the public URL, the ticketing provider
choice (and its event ID if eventbrite), the email provider choice, and the sender address.
export GOOGLE_APPLICATION_CREDENTIALS=<path to your own gcloud ADC, for this one-time step>
export EVENT_FIREBASE_PROJECT_ID=<the client's GCP project id>
node scripts/init-event.cjs --answers client-answers.json --admin <first-admin@client-domain>
Repeat --admin for a second address if you already know it — item 5 below still applies if you
don't yet. --admin seeds the operator tier (branding, features, access, deployment settings).
Pass --staff <email> for anyone who should run content, schedule, speakers, and attendees without
the deployment settings; staff can also be granted later from admin Settings → Access
(docs/ADMIN_GUIDE.md, "Who can do what").
This seeds config/*, the fifteen default pages, placeholder CMS content, the two legal page
templates (flagged reviewRequired, see item 4 below), the client-visible email template
overrides, and the placeholder branding assets. It then prints the manual checklist below and the
seven-row launch-readiness table as warnings — init itself never fails on an unmet item; only
--check (§6) gates going live.
3. The §5.6 items#
These are the steps a deploy cannot automate — the same list init-event.cjs prints after seeding
(scripts/lib/checklist.cjs, spec §5.6), reproduced here with the command for each so this document
stands on its own as a checklist you can work top to bottom.
1–2. Firebase Auth (manual console steps)#
Firebase Console → Authentication:
- Sign-in method → enable Google. Set the project support email and the public-facing app name — both appear in the Google consent dialog. Emailed one-time codes work without this; Google sign-in specifically does not.
- Settings → Authorized domains → add the client's custom domain (the one from
EVENT_PUBLIC_URL) and the Hosting default domain (<EVENT_HOSTING_SITE>.web.app). Sign-in fails silently on any domain not on this list — this bites operators who verify the custom domain and forget the default one is still needed for previews and the pre-cutover URL.
There is no Admin SDK read for "is Google sign-in enabled" — nothing in the platform can check this for you. Once both steps are done, record it:
node scripts/init-event.cjs --attest-auth
This stamps config/event.auth (googleProviderEnabled, authorizedDomainsConfigured,
attestedAt, attestedBy) and — because enabling Google sign-in changes what the seeded privacy
policy is true about — refreshes the two legal templates' sign-in clause through the normal seed
path (client edits are still never overwritten). This is what clears the Auth row in --check.
3. Firebase Hosting custom domain#
Firebase Hosting → add the custom domain, complete DNS verification (a TXT record), and wait for
certificate issuance before announcing the site. Both steps can take anywhere from minutes to about
a day; re-check readiness (§6) rather than assuming it finished. If this client's domain is a
*.eventrunner.org subdomain rather than their own domain, see the wildcard-vs-per-client DNS note
in docs/DEPLOY_RUNBOOK.md (issue #66) — the attach mechanics are the same Hosting custom-domain
flow either way.
4. Sender domain (SPF/DKIM/DMARC)#
Emailed one-time codes are this platform's only sign-in path that depends on mail, and an unauthenticated sender domain is exactly the condition that gets those codes quarantined by the institutional mail filters a lot of this buyer set runs. Get the client to publish, in their DNS, on the domain their sender address uses:
- an SPF record (or include) for the email provider,
- a DKIM TXT record from the provider console,
- a DMARC policy.
Then confirm:
node scripts/verify-sender-domain.cjs
Reads config/event.sender.email for the domain unless you pass --domain, and — on a pass —
is the only writer of config/event.sender.domainVerified/domainVerifiedAt, which is what
clears the sender row in --check. A provider with no domain API of its own (webhook, console)
can never get an automated pass; for those, confirm the DNS yourself and record it with
--attest:
node scripts/verify-sender-domain.cjs --attest
Don't skip this step because "the emails are going out fine in testing" — quarantine is inconsistent across mail filters and shows up as an intermittent, hard-to-reproduce client complaint weeks into the event, not a clean failure during setup.
Tell the client that the admin Email log keeps each sent-message row for 90 days. A row includes the recipient address and may include the subject and rendered body. Automatic maintenance removes the row after 90 days. Sign-in codes and speaker invitations do not store their subject or body.
5. Ticketing (capability-gated)#
Complete the step for the provider this client selected. The checklist
(scripts/lib/checklist.cjs) supports native Stripe sales, Eventbrite, manual CSV import, and
deployments with no ticketing provider:
| Provider | Step |
|---|---|
stripe | Follow docs/operator/stripe-ticketing.md from §1 to §5. It names the owners, sets up a test deployment, binds TICKETING_API_TOKEN and TICKETING_WEBHOOK_SECRET, registers and checks the webhook, and opens the catalog. The site shows Get tickets only after the catalog is published. |
eventbrite | Create the API token in the ticketing provider (TICKETING_API_TOKEN — already set as a GitHub Environment secret per the runbook §3). Generate TICKETING_WEBHOOK_SECRET with openssl rand -hex 32 and set it the same way. Eventbrite does not sign deliveries, so the script registers this secret in the webhook URL, and the function refuses a delivery without it. Then register the webhook: node scripts/register-ticketing-webhook.cjs. The script registers every Eventbrite action the adapter handles, refunds included. If it names an older webhook at the same endpoint, delete that webhook in Eventbrite. Confirm the delivery via getTicketingStatus (admin Settings, or the Cloud Functions log for the registration call). |
manual | No webhook to register. Upload the first attendee CSV through the admin Ticketing tab instead — that upload is the equivalent step for this provider. |
none | Nothing to do. There is no token, no webhook, and no import. |
register-ticketing-webhook.cjs checks the provider's own registerWebhook capability
(typeof provider.registerWebhook === 'function'), never a hardcoded provider-name comparison —
today only eventbrite implements it, but the script (and this checklist) does not need to change
if a future provider adds the capability.
6. Cloud Billing#
Google Cloud Console → confirm the Cloud Billing API is enabled for the deploying service account. Without it, the functions deploy fails its pre-flight check with a 403 that reads like an unrelated permissions error, not a billing one — check this before the first real deploy dispatch, not after debugging a cryptic failure.
7. First operator, then a second#
The first operator (from --admin above, or config/bootstrap.adminEmails) signs in and confirms
the admin panel loads. Then, from admin Settings → Access, grant a second operator — do not
leave config/bootstrap as a single point of failure for a client who could lose access to one
inbox. --check (§6) requires at least two operator accounts for exactly this reason; staff
accounts do not count, because a staff account cannot restore anyone's access. Grant the client's
day-to-day editors as staff from the same page.
Legal review sign-off#
Not numbered in §5.6 (it's set at seed time, not checked off separately), but it gates go-live:
init-event.cjs seeds the privacy policy and terms of service from provider-aware templates
(scripts/lib/legal.cjs) with config/event.legal.reviewRequired = true and every jurisdiction- or
policy-dependent clause marked [Client legal review required]. While that flag is set, the admin
panel shows a persistent banner and the public pages carry a visible notice. There is no script for
this step — it is a human sign-off: the client's counsel reviews the seeded pages (edited in the
admin Content editor like any other page) and an admin clears legal.reviewRequired from admin
Settings once they're comfortable. Nothing in the platform ever asserts another organization's
terms on the client's behalf; the templates are a starting point, not a finished document.
4. Readiness gating (--check)#
Once every item above is done (or deliberately attested), confirm launch readiness read-only:
node scripts/init-event.cjs --check
This prints the same seven-row table init-event.cjs prints as warnings, but here it gates:
exit 0 only when every row is met (legal review cleared, sender domain verified/attested, at least
two admins, seeded-content threshold, and the rest — see scripts/lib/readiness.cjs for the full
row list and each row's remedy text). Exit non-zero means at least one row is still UNMET; the
remedy column says what to run or click to clear it. Re-run after each fix — this is meant to be
run repeatedly, not once.
5. Go live#
--checkis green.- Run the normal dispatch (
docs/DEPLOY_RUNBOOK.md§5 step 3) — this is the first run whosecontent/build/hosting/post/smokejobs actually execute, sinceconfig/eventnow exists. - Confirm
docs/DEPLOY_RUNBOOK.md§6's verification steps: the smoke job passed, the custom domain resolves and serves the deployed site (not a certificate-pending placeholder), and a fresh sign-in works end to end (Google if enabled, and an emailed code). - Add this client to
AUTO_DEPLOY_ENVIRONMENTSnow, if they're ready to auto-deploy on merge tomain— or keep them on manual dispatch and revisit later (docs/DEPLOY_RUNBOOK.md§4).
6. Optional: Google Calendar sync (config/features.calendarSync)#
This feature is off by default. ADR 0003 is proposed and still needs owner acceptance. Keep the flag off until that acceptance and client-specific verification are recorded. Existing file exports continue to work.
After acceptance, the operator and the client's authorized project owner must:
- Enable the Google Calendar API in the client's existing Firebase/GCP project.
- Enable Google as a Firebase Authentication provider. Check the OAuth web client and its
redirect URI, including
https://<auth-domain>/__/auth/handler. Add the deployed site domain to Firebase's authorized domains. Follow Firebase's Google provider setup. - Configure the consent screen with the client's approved name, support contact, privacy
policy, domains, and
https://www.googleapis.com/auth/calendar.app.createdscope. This scope permits the dedicated calendar's creation;calendar.eventsalone does not. - Configure the intended audience and synthetic test users. Follow Google's current verification requirements for the scope and audience. Record the result; do not assume a fixed review time.
- Test the grant with an existing Google sign-in and an email sign-in. The latter links the chosen Google account to the event account, as the control states. A refused grant must leave file exports available.
- In a test deployment, verify creation, reload and reuse, changed session details, removal of the final bookmark, retry after a failed write, account changes, and preservation of events added by the attendee. Tokens must stay in memory. Only the calendar ID is saved locally; clearing browser storage can create a new calendar.
- Enable
config/features.calendarSyncin the admin Features editor only after these checks pass. Sync runs while the personal schedule page is open. Confirm the production audience can grant access. Leave or set the flag off if setup or verification is incomplete.
Reuse a past event as a template#
Each event runs in its own Firebase project. A second event, or next year's edition of the same event, gets a new project, not a new section of the old one. This keeps accounts, tickets, pitches and logs of one event out of the other. A template carries the parts the client already approved: branding, page templates and selected static settings.
What a template carries#
The export reads an allowlist. Anything not on the list stays in the past project, including any collection added in a later release.
| Carried | Stays in the past project |
|---|---|
config/theme: colours, fonts, style and layout choices | Logo and image files (the inventory lists them) |
config/features and config/badges | config/bootstrap (the admin allowlist) and config/providers |
config/event: tagline, timezone, operator and contact lines, social accounts, search description, registration action label | Name, dates, venue, tracks, registration window, sender, Auth attestation, legal review |
| Live page documents and their sections | Accounts, profiles, bookmarks and speakers |
| Live content blocks on those pages, including sponsor package blocks | Tickets, orders, payments and ticket_sync_queue |
| Session pitches and the pitch call, schedule, sponsor organizations, timeline and updates | |
| Sent email rows, sign-in codes, rate limits, admin logs and every other record |
The privacy and terms content is not carried. Init builds it again from the new project's providers, and legal review starts again. A block that init builds from settings is not carried either: the home page title, the When and Where lines, the venue blocks, and the support and conduct contact links. Init builds them from the new event's answers. A block that shows a stored file stays out: copy the file into the new project and add the block there. The inventory names the file by its storage path only, never by a download link.
A page in the template must keep the safety rules of a page saved in the admin. Only the default
system pages may be system pages, each at its own route. Other pages cannot use a reserved route,
such as /admin or /pitch. No two pages may share an id, a path or a section.
A content block in the template must keep the rules of a block saved in the content editor. Each required field has a value, and a sponsor package limit is a whole number of 1 or more. Rich text is cleaned by the editor's sanitizer before it is stored, so a script or an event handler in a hand-edited file never reaches the site. A block that breaks a rule is named, and nothing is written.
Steps#
Provision a new project for the new event (§1). Never point a template at the past project.
Print the inventory from the past project. This writes nothing:
export EVENT_FIREBASE_PROJECT_ID=<the past event's project id> node scripts/export-event-template.cjs --dry-runRead every row. "Review" rows name each block that mentions the past event's name or year, names an email address, or quotes a person, and each carried contact address. They name the block, not its text. "Feature switches carried" lists each switch and marks the ones that open public intake.
Write the template file, then review the JSON with the client:
node scripts/export-event-template.cjs --out past-event-template.jsonPass
--page <id>(repeatable) to carry only some pages. The default pages fill the rest.Write the new answers file (§2). It needs the new name, dates later than every date of the past event, the sender address and a named owner. The owner must also be an operator:
{ "event": { "name": "...", "shortName": "...", "timezone": "...", "days": [...], "sender": { "email": "..." } }, "owner": { "name": "...", "email": "owner@client-domain" } }
Seed the new project from the template:
export EVENT_FIREBASE_PROJECT_ID=<the new event's project id> node scripts/init-event.cjs --answers next-answers.json --admin owner@client-domain --template past-event-template.jsonInit refuses the template, and writes nothing, in each of these cases:
- The project is the past project.
- The project already holds any event record: configuration, pages, content, email templates, accounts, speakers, sessions, organizations, timeline entries, updates, tickets, orders, pitches, admin logs, email rows, or any other collection.
- The project has Firebase Authentication accounts. Init checks this with real credentials. Under the Firestore emulator alone it cannot, so it prints a warning: confirm in the console that the project has no accounts.
--forceis given.- The answers file has no dates, or a date that is not later than the past event.
- The answers file has no named owner, or the owner is not an operator.
--dry-runreports the writes first.If the apply stops part way (a network error, a closed laptop), run the same command again with the same template file. The first write of an apply is
config/templatewith statusapplying. A rerun with the same template continues from there, as long as the project holds only what the apply writes. Init then sets the status toapplied. A different template, or a project that has gained accounts or other records since, is refused. In that case delete the project and start again from a new one.Work through §3 in full for the new project. The sender domain, ticketing provider, Auth steps and Cloud Billing start unmet, even when the past event passed them. Confirm that the billing account and the provider accounts are the ones for the new event.
Run
--check(§4). A project made from a template has one more row, Template reuse. It passes when the apply finished, every event date is later than the past event, and the named owner is still an operator. Each reused block counts as seeded until an editor saves it, so review every page before launch.
Accounts across separate events#
Accounts belong to one project. An attendee of both events signs in to the new site and gets a new
account. Their profile, bookmarks, badges and tickets do not move. Grant operators and staff again
with --admin, --staff or admin Settings → Access. Invite speakers again. To show that someone came
before, an admin records it in Past attendance on that person's record in the new event
(docs/ADMIN_GUIDE.md).
Export and archive of the past event#
- The template file holds no accounts, but it holds the client's copy. Keep it with the client's records. Do not put it in a public repository or issue.
- Take the past event's own exports before you change it: the attendee export and the session
materials archive (
docs/ADMIN_GUIDE.md). - Set the past event's archive date in admin Event settings. Its pages then show as back issues.
With ticket sales, follow "Archiving an event with ticket sales" in
docs/DEPLOY_RUNBOOK.mdfirst. - The past project keeps its records until the client asks for their removal. The new event does not read from the past project, so removing the past project later does not change the new event.
Handoff to the client#
Once the site is live, point the client at the two handbook pages instead of re-explaining their content here:
docs/handbook/for-clients.md— what they bought, what v1 does and doesn't include, and how to reach support.docs/handbook/for-event-staff.md— the narrative walkthrough of what staff can change without a developer: pages, schedule, speakers, attendees, live updates, theme, badges. Pair it withdocs/ADMIN_GUIDE.mdfor the task-by-task reference to every admin surface.
After the event: data export and archive#
The client keeps its data. At the end of the engagement, hand over the event's records and agree how long they are kept. Archive and hand off a past event is the runbook:
- An operator downloads the event data package from Data export (or runs
scripts/export-event-package.cjsfor a large event) and checks it with--verify. - The operator takes a Firebase managed export and copies the Storage bucket when the client wants a full backup.
- The operator sets the archive date and
config/event.historicalDemoto keep the past site online and read-only, or disables Hosting. AfterhistoricalDemois on, only Google sign-in opens the admin panel, so export first. - The handoff record names the package date, the parts included, who received each file, and the retention period.
Explain the difference between access to the cloud project, the client's ownership of the data, and the portable export. The runbook's first table states it.
Do not send the client screenshots of another client's event data, and do not put their attendee
list, admin emails, or any other client-specific detail in a public GitHub issue or discussion —
see docs/handbook/for-clients.md's "How to start" note.