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), or none. Every onboarding step below that mentions ticketing is gated on this choice, not optional busywork — a manual deployment genuinely has no webhook step, not a skipped one.
  • You have (or can get) gcloud access 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:

  1. §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/main only.
  2. §3 — create the GitHub Environment for this client and set every vars.* and secrets.* row the runbook's tables list, including the provider secrets your ticketing/email choice needs.
  3. §4 — decide now whether this client auto-deploys on push (AUTO_DEPLOY_ENVIRONMENTS) or stays on manual workflow_dispatch while it's new. Manual is the safer default for a first event.
  4. §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/event document 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:

  1. 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.
  2. 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:

ProviderStep
stripeFollow 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.
eventbriteCreate 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).
manualNo webhook to register. Upload the first attendee CSV through the admin Ticketing tab instead — that upload is the equivalent step for this provider.
noneNothing 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.

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#

  1. --check is green.
  2. Run the normal dispatch (docs/DEPLOY_RUNBOOK.md §5 step 3) — this is the first run whose content/build/hosting/post/smoke jobs actually execute, since config/event now exists.
  3. 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).
  4. Add this client to AUTO_DEPLOY_ENVIRONMENTS now, if they're ready to auto-deploy on merge to main — 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:

  1. Enable the Google Calendar API in the client's existing Firebase/GCP project.
  2. 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.
  3. Configure the consent screen with the client's approved name, support contact, privacy policy, domains, and https://www.googleapis.com/auth/calendar.app.created scope. This scope permits the dedicated calendar's creation; calendar.events alone does not.
  4. 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.
  5. 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.
  6. 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.
  7. Enable config/features.calendarSync in 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.

CarriedStays in the past project
config/theme: colours, fonts, style and layout choicesLogo and image files (the inventory lists them)
config/features and config/badgesconfig/bootstrap (the admin allowlist) and config/providers
config/event: tagline, timezone, operator and contact lines, social accounts, search description, registration action labelName, dates, venue, tracks, registration window, sender, Auth attestation, legal review
Live page documents and their sectionsAccounts, profiles, bookmarks and speakers
Live content blocks on those pages, including sponsor package blocksTickets, 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#

  1. Provision a new project for the new event (§1). Never point a template at the past project.

  2. 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-run
              

    Read 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.

  3. Write the template file, then review the JSON with the client:

    node scripts/export-event-template.cjs --out past-event-template.json
              

    Pass --page <id> (repeatable) to carry only some pages. The default pages fill the rest.

  4. 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" }
              }
              
  1. 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.json
              

    Init 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.
    • --force is 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-run reports 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/template with status applying. 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 to applied. 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.

  2. 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.

  3. 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.md first.
  • 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:

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:

  1. An operator downloads the event data package from Data export (or runs scripts/export-event-package.cjs for a large event) and checks it with --verify.
  2. The operator takes a Firebase managed export and copies the Storage bucket when the client wants a full backup.
  3. The operator sets the archive date and config/event.historicalDemo to keep the past site online and read-only, or disables Hosting. After historicalDemo is on, only Google sign-in opens the admin panel, so export first.
  4. 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.

This page is generated from docs/CLIENT_ONBOARDING.md. Edit the Markdown, not the HTML.