More modules

Product analytics, the affiliate programme, the audit log, in-app feedback, the pre-launch waitlist and the public demo mode, and how to switch each one on or off.

Beyond sign-in, payments and credits, ShipKit ships six smaller modules. Each is a vertical slice under src/features/<name>/, and each can be removed with /delete-feature <name> (see Deleting features). Some are on as soon as you clone, some wait for a switch.

ModuleOut of the boxSwitch
AnalyticsOffanalytics.posthog.key in src/config/app-config.ts
AffiliateOnDelete the feature to turn it off
Audit logOnDelete the feature to turn it off
FeedbackOnDelete the feature to turn it off
WaitlistOffwaitlist: true in src/config/app-config.ts, then deploy
Demo modeOffDEMO_MODE=true, on a separate demo deployment only

The admin pages below are gated by permissions (affiliate.read, audit.read and so on); see Permissions and Admin console.

Analytics (PostHog)

Analytics has two halves, and both stay silent until you set a PostHog project key:

// src/config/app-config.ts
analytics: {
  posthog: {
    key: 'phc_…',                       // empty = no script, no events
    host: 'https://us.i.posthog.com',   // or the EU cluster
    assets: 'https://us-assets.i.posthog.com',
    ui: 'https://us.posthog.com',
  },
},

The key is a public, write-only token, which is why it sits in config and not in secrets.

  • In the browser, src/features/analytics/components/posthog.tsx loads the SDK lazily and records page views, including client-side navigations. It talks to PostHog through your own origin at /api/ph, so ad blockers see first-party traffic; that proxy has its own per-IP rate limit (ANALYTICS_RATE_LIMIT in wrangler.jsonc). Persistence is localStorage, not cookies. Autocapture and session recording are off. Signed-in visitors are identified by user id, and signing out resets the id.
  • On the server, any code can call track() from @/core/analytics/events. ShipKit already sends signed_up, signed_in, account_deleted, checkout_started, subscription_started, purchase_completed, waitlist_joined and waitlist_approved. Events are snake_case, past tense, with a few flat properties; never secrets or free text.

track() is best-effort: a PostHog failure is logged and never fails the action it describes. With the feature deleted, track() stays and does nothing, so call sites need no changes.

Affiliate programme

Any user can join from Affiliate (/affiliate) in the app sidebar. They get a permanent code and a link, /r/<code>.

  1. A click on the link sets an AFFILIATE_REF cookie for 30 days and redirects to the home page. It is a server route because the marketing pages are prerendered and cannot set cookies.
  2. When a new account is created with that cookie, it is attributed to the affiliate, once and for good. Unknown codes and self-referrals are ignored without failing the signup.
  3. Every order.paid from that user, renewals included, creates a commission at 20% of the amount. It stays pending for a 30-day refund holdback; a full refund in that window voids it. A nightly job then marks it approved.
  4. Once the approved balance reaches $50, the affiliate can request a payout, naming where to send it.
  5. An admin reviews requests under Revenue → Affiliates (/admin/affiliate), sends the money through whatever they already use, and marks the request paid or rejected. ShipKit does not move money itself. Rejecting releases the commissions so they can be requested again.

All four numbers (rate, holdback, minimum payout, attribution window) are operator settings under Settings → Affiliates, so you can change them without a deploy. A changed rate applies to the next order, not to past ones. For a negotiated deal, set commissionBps and negotiatedRate on that affiliate's row. An account with an unanswered payout request cannot be deleted until the request is settled.

Audit log

audit() from @/core/audit/events records who did what to which thing. Sign-ins and other auth events, role and ban changes, API key management, credit adjustments, plan changes and every payment event are already covered; payment events have no actor because they come from a webhook. Add a call anywhere your code changes account or security state:

import { audit, requestMeta } from '@/core/audit/events'

await audit({
  action: 'project.deleted', // <area>.<verb>
  actorId: context.session.user.id,
  actorEmail: context.session.user.email,
  targetType: 'project',
  targetId: project.id,
  meta: { name: project.name }, // never secrets or whole request bodies
  ...requestMeta(getRequest().headers),
})

Rows are stored in D1 and shown under Developers → Audit log (/admin/audit), in each user's drawer, and through /api/v1/admin/audit. They are pruned after 180 days (audit.retention_days, minimum 7). When an account is deleted its rows stay, since that is the point of an audit trail, but the email address in them is cleared. Like track(), audit() is best-effort and becomes a no-op if you delete the feature.

Feedback

Help & feedback in the user menu at the bottom of the sidebar opens a short form: a kind (bug, idea, question, other), a message, and up to three screenshots of up to 5 MB each, picked, pasted or dropped. Screenshots are stored in R2 and served only to their owner and to admins. Both limits are settings (feedback.image_limit, feedback.image_max_mb).

Admins triage under People → Feedback (/admin/feedback): statuses are new, seen and closed. A reply marks a new thread as seen, notifies the user in the app and emails them. Users read their threads and replies under My feedback in the account dialog.

Waitlist

For a closed launch, set waitlist: true in src/config/app-config.ts and deploy. It is a build-time switch because the prerendered marketing pages need the matching call to action: while it is on, the landing page buttons point to /waitlist instead of /dashboard.

With the gate on, a user who signs in is placed in the queue and sent to /waitlist, which shows their position and lets them leave an optional note about what they want to make. Anyone who can open the admin console skips the queue. Admins approve people under People → Waitlist (/admin/waitlist); approval sends an email and an in-app notification, and the user gets in on their next visit. Approval can be revoked.

Set the flag back to false and deploy to open the doors. The queue and its history stay in the table.

Public demo mode

A second deployment of the same code can serve as a live demo. Set DEMO_MODE=true on that deployment and nowhere else.

  • The login page becomes a single button. Each click creates a throwaway account (demo-…@demo.invalid) with the demo role: the admin console plus every *.read permission, derived from the registry, so a new feature's read permission shows up automatically. Every write is refused by the same assertPermission check a real role hits.
  • Every other sign-in method is refused, so no real identities collect there. The button is rate limited.
  • A banner across the app explains the read-only console and gives the Stripe test card.
  • The nightly demo.purge-visitors job erases demo accounts older than a day, uploads included.

To set one up, copy wrangler.jsonc with its own Worker name, D1 database and R2 bucket, use test-mode payment keys, and fill it with data using bun run db:seed --remote. Seed dates are relative to the day the script ran, so re-run it now and then. See Deploy.

Demo mode exists to show your product to prospects. Never set DEMO_MODE on a real deployment: it hands console access to anyone who clicks.