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.
| Module | Out of the box | Switch |
|---|---|---|
| Analytics | Off | analytics.posthog.key in src/config/app-config.ts |
| Affiliate | On | Delete the feature to turn it off |
| Audit log | On | Delete the feature to turn it off |
| Feedback | On | Delete the feature to turn it off |
| Waitlist | Off | waitlist: true in src/config/app-config.ts, then deploy |
| Demo mode | Off | DEMO_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.tsxloads 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_LIMITinwrangler.jsonc). Persistence islocalStorage, 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 sendssigned_up,signed_in,account_deleted,checkout_started,subscription_started,purchase_completed,waitlist_joinedandwaitlist_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>.
- A click on the link sets an
AFFILIATE_REFcookie for 30 days and redirects to the home page. It is a server route because the marketing pages are prerendered and cannot set cookies. - 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.
- Every
order.paidfrom that user, renewals included, creates a commission at 20% of the amount. It stayspendingfor a 30-day refund holdback; a full refund in that window voids it. A nightly job then marks itapproved. - Once the approved balance reaches $50, the affiliate can request a payout, naming where to send it.
- 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 thedemorole: the admin console plus every*.readpermission, derived from the registry, so a new feature's read permission shows up automatically. Every write is refused by the sameassertPermissioncheck 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-visitorsjob 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.