NebulaKit Documentation
Single source of truth for setup, development, and deployment. This page is intentionally detailed so you can move from first run to production without guesswork.
Start Here
This repository is the site at nebulakit.starspace.group. It runs on the
NebulaKit platform but is not a template, and there is no customization workflow to complete
— identity lives in src/lib/site.config.ts and nowhere else.
- Clone the repository.
- Install dependencies and run the app locally.
- Apply database migrations locally.
- Configure OAuth and owner access through the setup flow.
- Run tests and coverage before shipping changes.
Quick Start
Then clone your repository, run the commands below, and open http://localhost:4278.
Bun is the recommended default for this repo. Switch to npm commands if your environment requires it.
terminal bun
# Install dependencies
$
bun install
# Start development server
$
bun run dev
# Apply local D1 migrations
$
bun run db:migrate:local
# Build for production
$
bun run build
# Type-check and tests
$
bun run check
$
bun run test
# Coverage report
$
bun run test:coverage
# Deploy to Cloudflare Pages
$
bun run deploy
What You Get Out of the Box
This starter already ships with the main product surfaces wired together. The fastest way to understand the repo is to think of it as a Cloudflare-first app shell with auth, theming, content management, and AI entry points already in place.
Core App Shell
- Keyboard-first navigation with command palette for fast route switching.
- Theme system with persistent light and dark preferences.
- Responsive layout, navigation, footer, and shared metadata components.
Account and Admin Flow
- Setup-first authentication flow for owner configuration.
- Login, signup, profile, reset, and admin routes are already scaffolded.
- Cloudflare D1 and KV are used for setup state and application data.
Content and AI Surfaces
- CMS-style content routes and admin tooling are included for structured content.
- Chat route becomes your primary AI surface once a provider is configured.
- Command palette can expose AI-related navigation when providers are available.
Analytics and Operations
- First-party, cookie-free analytics at
/admin/stats— traffic, audience, and growth, with no third-party script. - A Cloudflare plan-limit meter that projects whether today's traffic will exhaust your request allowance.
- Per-admin permission so operators can see stats without owner access.
How To Use the App
Use the app in this order if you want the least confusing first run. That sequence matches how the repo is structured and avoids most setup-related false alarms.
- Open
/setupfirst on a fresh environment and configure owner credentials. - Complete
/setupbefore expecting sign-in or AI features to work. - Sign in through
/auth/loginor create an account through/auth/signup. - Open the command palette with Ctrl/Cmd + K to move between major routes quickly. On a phone, tap the search icon in the navbar or choose "Search & commands" in the menu.
- Use
/chatfor AI interactions,/profilefor account settings, and/adminfor operator tasks. - Use the theme toggle to verify light and dark presentation while you customize branding.
Common First-Run Checks
- If auth looks broken, re-check setup lock state and provider credentials first.
- If chat is missing from navigation, verify AI provider configuration and route access.
- If admin tools are unavailable, confirm you are signed in as the configured owner.
Where To Extend
- Modify routes under
src/routeswhen changing page behavior. - Use
src/lib/componentsfor reusable UI and shell elements. - Keep business logic in
src/lib/servicesand shared helpers insrc/lib/utils.
UI Kit
Standard elements live in src/lib/ui and import from one place. Every one uses
the theme's CSS variables only, works in light and dark, and is usable by keyboard. See them
all running, with the code for each, at /components.
import { Button, TextInput, Select, Dialog, Tabs, Table, toast } from '$lib/ui'; - Actions — Button (four variants, three sizes, link form, loading state), ButtonGroup, Menu, DropdownMenu (checkbox and radio items), Command (a filterable command palette, inline or as a Ctrl+K dialog), Toggle and ToggleGroup.
- Forms — TextInput, Textarea, Select, Checkbox, RadioGroup, Switch,
Slider, Combobox, Calendar, DatePicker, InputOTP, InputGroup, NumberInput, TagInput and
FileDrop, all wired the way Field wires them: a real label, the hint and error in
aria-describedby. - Feedback — Alert, toasts, Badge, Progress, Spinner and Skeleton.
- Overlays — Dialog and AlertDialog (native
<dialog>), Sheet (from any side, so also a drawer), Popover, HoverCard, Tooltip and ContextMenu. - Navigation — Tabs, Accordion, Breadcrumbs, Pagination, Menubar, NavigationMenu, Sidebar and Stepper.
- Layout — Container, Stack, Separator, AspectRatio, ScrollArea, Resizable and Collapsible.
- Data display — Card, Item, Table, DataTable (search, sort, paging, selection), BarChart, LineChart and DonutChart (plain SVG, each with a hidden data table for screen readers), Stat, Carousel, Timeline, Avatar, AvatarGroup, Kbd, CodeBlock, Prose and EmptyState.
That is every element shadcn/ui ships, and more. None of it needs a dependency beyond Svelte, and none of it is a paid add-on: the kit is MIT-licensed with the rest of the project.
Toasts need one <Toaster />, which the root layout already renders. Raise
one from anywhere with toast.success('Saved'); pass 0 as the duration
to keep it until it is dismissed.
The catalog is checked: a test fails when a component is added to src/lib/ui,
or a widget is registered, without an entry in src/lib/ui/catalog.ts. Full
reference: docs/UI_KIT.md.
Modules
Where the UI kit gives you elements, modules give you the blocks every app ends up building.
They live in src/lib/modules, are built from $lib/ui, and are
shown running, with the accessibility behaviour each one keeps, at /modules.
import { SignInForm, PricingTable, DataGrid, KanbanBoard } from '$lib/modules'; - Account — sign-in, sign-up, password reset, profile, sessions, two-factor.
- Settings — appearance, notifications, API keys, webhooks, cookie consent, privacy.
- Marketing — hero, pricing, comparison, FAQ, testimonials, footer, contact.
- App shell — app shell, notification center, global search, wizard, error states.
- Data & teams — data grid, kanban, comments, files, calendar, tree, audit log.
- Commerce — billing, invoices, checkout, cart, reviews, status page, changelog.
Each module takes its data and copy as props, emits events, and never fetches. All are mobile first from 320px, use CSS variables only, and work by keyboard and screen reader.
Shop, Payments and Email
The shop is the @nebulakit/commerce package in packages/commerce (products, cart pricing, checkout, orders, Stripe, Printful), versioned on its own. It stays
inert until configured: with no products /shop shows an empty catalog, with no
Stripe key orders wait for a manual payment, and with no email key nothing is sent. Apply
migrations 0023, 0024 and 0025 with bun run db:migrate:local.
Storefront
Shoppers browse /shop and /shop/[slug], add products to a cart
kept in their browser, and check out at /cart. Prices, tax and shipping are
always worked out on the server from the catalog; the cart only remembers what to ask
for. Physical products ask for a shipping address. With Stripe configured the shopper
goes on to pay; without it the order waits for a manual payment.
Regional pricing
/admin/settings/pricing (owner only) lowers prices for shoppers in countries
where money buys more, using a World Bank price level table shipped with the kit. It is off
until you turn it on, and it only ever lowers a price. Set how much of the gap to pass on,
the largest discount, which products it covers (digital only by default) and per-country overrides,
and preview any country before saving. An order takes the least generous country among the
one Cloudflare sees and the shipping address, so a VPN alone earns nothing. The cart shows
the server's quoted total (POST /api/v1/checkout/quote) and each order
records the multiplier and countries used. Settings are also at GET/PUT /api/v1/pricing, with scopes pricing:read and pricing:write. Refresh the table with bun run update:price-index.
Shop settings are environment variables, never request fields: COMMERCE_TAX_RATE_PERCENT, COMMERCE_PRICES_INCLUDE_TAX, COMMERCE_FLAT_SHIPPING_CENTS, STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET (webhook URL /api/v1/webhooks/stripe).
Payments, status and email
The Stripe webhook answers 503 until STRIPE_WEBHOOK_SECRET is set,
so Stripe keeps retrying. It handles checkout.session.completed and checkout.session.async_payment_succeeded (the order becomes paid), checkout.session.async_payment_failed and checkout.session.expired (a pending order is cancelled). A payment that is smaller
than the order total, or in another currency, leaves the order pending for a person to check.
Replays change nothing a second time.
After paying, the shopper returns to /cart?order=…, which asks GET /api/v1/orders/[id]/status every 1.5 seconds for 20 seconds. That public endpoint
returns the status word only, never an email, address or total, and the page says plainly when
payment is still being confirmed.
Email is optional. Set RESEND_API_KEY and EMAIL_FROM (and EMAIL_REPLY_TO if you want one). A receipt is sent when an order becomes paid
and a shipping notice when it is marked shipped, each at most once per order: the email_log table refuses a second send. Without a key nothing is sent and the
log records what was skipped. Email colours come from emailTheme in src/lib/site.config.ts.
Drag and Drop
The widget board is a working feature of this site, not a demo you read about. Open the showcase to rearrange a live board; the layout you make is saved in your own browser.
Using it in your app
<WidgetBoard> takes a layout and a list of columns, and reports a new
layout through on:change. It stores nothing itself, so where a layout is saved
stays your decision.
<WidgetBoard bind:widgets {columns} on:change={(e) => save(e.detail.widgets)} /> Registering a widget takes three edits and none of them is the board: an entry in src/lib/widgets/manifest.ts, a line in src/lib/widgets/index.ts, and the component itself. Six standard widgets ship —
stat, clock, checklist, meter, notes and links — all running at /components. The two actions underneath the board — use:draggable and use:dropzone — work on any markup, so a sortable list or a nav reorder needs
no board at all.
Keyboard control
Every pointer gesture has a keyboard equivalent, announced through a live region. Focus a drag handle, then:
- Space or Enter — pick the widget up, and put it down again.
- Arrow up and down — move it within its column.
- Arrow left and right — move it to the column either side.
- Escape — cancel, returning it to where it started.
On a touchscreen, hold a handle briefly before dragging — a swipe stays a swipe, so the page still scrolls. Dragging near the top or bottom edge scrolls the page with you.
The rule that will bite you
A widget's stored state must be inert. A value that changes on a timer — a price, a clock, a
connection count — goes out through the widget's live event and comes back in
through the board's live prop; it must never be written into widget.title, which is persisted. Ignoring this is how a dashboard rewrites its
whole layout every thirty seconds and burns a day's storage quota from one open tab.
Full reference, including the reorder contract and the component checklist: docs/WIDGET_BOARD.md.
The Badge
The badge page hands out "Proudly built with NebulaKit" in six forms — Markdown for a README, HTML, a web component, and React, Svelte and Vue components — in three wordings and on either a dark or a light ground. Pick, preview, copy.
An app generated from the template already wears it: the footer carries BuiltWithNebulaKit.svelte, and showBuiltWithBadge: false in src/lib/site.config.ts removes it. NebulaKit is MIT-licensed, so the badge is a courtesy
rather than a condition.
Endpoints
/badge.svg?variant=proudly|built|powered&theme=dark|light is the image a
README embeds. /badge.js defines <nebulakit-badge>, which renders in a shadow root and follows the
reader's own colour scheme. /badge-mark.svg?size=64 is the mark alone, for the cases
the pill does not cover.
All three are served with Access-Control-Allow-Origin: *, because the badge is
embedded cross-origin by definition. None of them loads a font, a stylesheet or a tracker:
the mark is drawn inline and every colour is a literal, so the badge renders on a page that
has never heard of this site. An unknown variant or theme renders the
default badge rather than a 400 — a broken image in someone else's README tells them nothing.
Working With AI in This Repo
Treat AI as a fast pair programmer, not as a source of truth. It is useful here because the repo already includes app structure, tests, and strong conventions, which gives the assistant real context to work against.
Good Prompts
- Point the assistant at a concrete file, route, failing test, or command.
- Ask it to write or update tests first when changing behavior.
- Ask for narrow fixes instead of broad rewrites unless you want architectural change.
Good Validation Habits
- Have the assistant explain which route, store, or service controls the behavior.
- Require executable validation after changes, not only a diff summary.
- Always finish by running check, tests, and coverage.
What AI Is Best At Here
- Tracing a route from UI to service layer and identifying the smallest edit surface.
- Adding tests around setup, auth, chat, or command palette behavior.
- Summarizing repo conventions such as Cloudflare bindings, migrations, and theme rules.
If you are using an AI coding agent, keep requests concrete: mention the page or failing test, state the desired behavior, and ask for the smallest validating change that solves it.
Core Commands
These scripts are defined in package.json and are the canonical local workflow.
Development
npm run devruns on host 0.0.0.0, port 4278.npm run previewpreviews the production build on port 4278.npm run checkruns Svelte sync plus svelte-check.
Testing
npm run testruns Vitest in CI mode.npm run test:watchruns Vitest in watch mode.npm run test:e2eruns Playwright tests.npm run test:allruns unit tests, then E2E tests.
Deploy and Validation
npm run deploybuilds then deploys .svelte-kit/cloudflare.npm run validate:contrastchecks theme contrast.npm run validate:allruns check + test + contrast validation.
Cloudflare Bindings
NebulaKit is configured for Cloudflare Pages with these bindings in wrangler.toml:
DBas D1 database binding (database name: nebulakit-site-db).KVas KV namespace for runtime config and flags.BUCKETas R2 bucket binding.- Queue producer binding is documented but commented out by default.
Set app secrets in Cloudflare dashboard or Wrangler secrets for production. Avoid committing raw secrets to source control.
Database Migrations
Migrations are ordered SQL files under migrations/ and tracked by D1. Never edit
or delete existing migration files once committed to main.
# Apply pending migrations to local D1
npm run db:migrate:local
# Apply pending migrations to remote D1
npm run db:migrate
# List migration status
npm run db:migrate:list When schema changes are needed, create a new file with the next sequence number (for
example, 0006_add_feature_flag.sql) and use ALTER TABLE or new CREATE statements. npm run check:migrations (also a CI step) fails on a bad file name, a duplicate or
skipped number, or an edit to a migration already on the base branch.
Authentication and Setup Flow
Sign-in is GitHub or Discord OAuth, or email and password, with a setup-first workflow. The
cookie holds an opaque session token; only its SHA-256 digest is stored in D1, so a leaked
table gives no usable cookie. Set SESSION_SECRET (it signs the OAuth state
cookie) and SETUP_SECRET (the bootstrap password for /setup) as
Worker secrets. The main routes are /setup, /auth/login, and /reset.
1. Configure
Open /setup and submit GitHub OAuth credentials plus admin GitHub username.
2. Lock Setup
After the admin logs in the first time, setup is locked to prevent accidental reconfiguration.
3. Reset When Needed
/reset clears setup-related KV keys and session cookie. Admins can disable reset
route access.
Secrets at Rest
AI provider keys and OAuth client secrets saved from the admin screens are encrypted
(AES-256-GCM, bound to their record) before they are written to KV, and the admin UI only
shows that a key is configured plus its last four characters. Set the ENCRYPTION_KEY secret (bun run generate:encryption-key) so the key
lives outside KV; without it the app generates one and keeps it in KV, which encrypts the
values but does not defend against someone who can read the whole namespace. To rotate, move
the old value to ENCRYPTION_KEY_PREVIOUS; values are re-encrypted when read.
Plaintext keys saved by earlier versions are migrated on first read.
Security Hardening
Every response carries HSTS, X-Content-Type-Options: nosniff, a frame policy
and a referrer policy, and the Content-Security-Policy forbids object embeds, a
foreign base and framing by other sites. Static files get the same headers from _headers. Password sign-in is throttled per address and per email. After
signing in, ?next= returns you to a same-site path and never to another host.
The Markdown variant of a page is never cached, and /media answers byte
ranges so video plays in Safari. See docs/SECURITY.md.
Admin Analytics
/admin/stats is the built-in analytics surface: traffic over a 1, 7, 30, or 90-day
window, views by route, the most-read CMS items, referrers, countries, an audience breakdown,
and user and content growth over time. It is first-party — there is no third-party script, no
account, and no API key to provision.
Everything collected is a daily aggregate counter in D1. There are no cookies, no identifiers, and no IP addresses anywhere in the feature. The User-Agent is read to classify the request and then discarded, so only coarse buckets (operating system, browser, device, language, viewport) are ever stored, and country comes from the Cloudflare edge rather than from an IP lookup. Because nothing per-visitor is retained, this needs no consent banner.
Turning It On
- Apply migrations
0007through0009, plus0012for the Top content table, withdb:migrate:local(ordb:migratefor remote). - Collection starts on the next request. Traffic and audience panels fill in as visits arrive.
- Country stays
(unknown)in local development — it is supplied by the Cloudflare edge. - Top content appears once a published CMS item is read. It is the only counter whose row count grows with your catalogue rather than with a fixed list, so on a large catalogue watch it — the retention cron is what keeps it in hand.
Granting Access
The owner always sees Stats. Any other admin needs the can_view_stats flag, which
defaults to off so existing admins do not gain access on upgrade:
UPDATE users SET can_view_stats = 1 WHERE email = '[email protected]'; The permission is re-read from the database on every request, so revoking it takes effect without waiting for a sign-out.
Retention
The counter tables grow one row per day per dimension unless pruned. Set CRON_SECRET and have any scheduler POST to the retention endpoint; rows older
than 400 days are removed.
curl -X POST https://your-app/api/cron/prune-view-stats \
-H "Authorization: Bearer $CRON_SECRET"Platform Usage Meter
The same page tracks billable Function invocations — a larger set than page
views, since bots, /api/* calls, 404s, and non-GET requests all count against
your plan. It projects whether today will exhaust the free 100,000-request daily allowance
before the UTC reset.
Treat it as an early warning and a floor, not a bill: the Cloudflare dashboard remains authoritative.
Reading Stats From a Dashboard
An external dashboard can read the same numbers as JSON. The owner issues a stats token under "Dashboard access" on /admin/analytics; it is shown once, and only its hash is stored. The
dashboard then sends it as a bearer token:
curl https://your-app/api/stats/summary?window=7 \
-H "Authorization: Bearer nks_…" The response is read-only and aggregate-only — exactly what /admin/stats shows.
Rotate replaces the token; Revoke cuts the dashboard off on its next read. A site that has
never issued one answers 401 to every request.
Connecting Google Analytics
Optional, and off until you turn it on. The owner opens /admin/analytics, pastes either the GA4 Measurement ID (G-ABCD123456) or the whole gtag.js snippet — the ID is read out of it — and saves. A toggle
pauses the tag without discarding the ID, and Disconnect removes it entirely.
The tag loads on public pages only. /admin, /api, and /setup are excluded, so your own admin traffic stays out of the numbers, and
page views are sent on every client-side navigation rather than by gtag's automatic tracking,
which would count only the first page of a visit.
This is additive: the built-in stats keep working, and reports are read in Google's own console. Google Analytics sets cookies and collects per-visitor data, which is a different privacy posture from the built-in counters — review your privacy policy and add a consent banner where your jurisdiction requires one. Only the owner can change the connection.
Roles and Permissions
Access is a ladder, owner > superadmin > admin > user, plus a list of named areas each admin may open. One rule does the work: you can only act on someone strictly below you, and you can only hand out a role strictly below your own. An admin cannot promote, demote or delete a peer, a superadmin or the owner, and nobody can promote themselves.
The owner is configuration
The owner is set by GITHUB_OWNER_ID (or setup), never by the users.role column, so no request can assign or remove it. Superadmins hold
every area, including pii, and cannot be limited.
Areas for admins
cms, pages, users (view the list), users-manage (change roles and delete, below their own role), stats, pii, api-keys, contact and lighthouse. Settings, auth keys, AI keys, the analytics connection, setup
and reset stay with the owner and superadmins. Only the owner or a superadmin can change
an admin's areas.
Managing access
On /admin/users choose Manage access on a row to set the role
and tick the areas. The sidebar only shows links the signed-in admin can use, but every load
and API also checks the permission itself. Role and areas are re-read from the database on
every request, so a change applies on the next click.
Upgrading
Migration 0015 adds users.role and user_permissions. Existing admins keep every area they could reach before
(and can_view_stats becomes the stats permission); nobody is
promoted and owner-only areas are not handed out. is_admin and can_view_stats stay in sync. To add an area, add it to src/lib/access/permissions.ts, guard its routes with requirePermission, and give its nav entry a permission.
Audit Log
/admin/audit (owners and superadmins) lists who changed what: user role changes, deletes and merges, content publish and delete, settings and key changes, API key create and revoke. It records changes only, never page views, and never a secret value: a key or setting change notes that it changed, and any detail field named like a token, secret, password or key is redacted. Filter by actor and action, search, and open a row for its detail.
Rows are kept 365 days; a scheduled job prunes older ones (pruneAudit in src/lib/server/audit.ts, run daily as the prune-audit job). Apply
migration 0016 (shared with sign-in tracking) with bun run db:migrate:local. Details: docs/AUDIT_LOG.md.
Sign-in Tracking
The owner can see who is signed in and where each sign-in came from at /admin/sign-ins, and sign any session out. Recording is off by default, because it is the one place the site stores an IP address; the rest of the site stays cookie-free and IP-free. The owner switches it on on that page, under a plain-language disclosure.
While on, each sign-in attempt, failures included, stores the method, whether it worked, the
IP address (shown masked unless the owner reveals personal data), a rough device label such
as “Chrome on macOS” (never the raw browser string), the country, and when a live session
was last used. Passwords and typed email addresses are never stored, visitors who are not
signing in are never recorded, and attempts are deleted after 90 days. Recording can never
block a sign-in. If you turn it on, say so in your privacy policy. Details: docs/SIGN_IN_TRACKING.md.
Testing and Quality Gates
NebulaKit follows Test-Driven Development. Write failing tests first, then implementation, then refactor.
# Run all tests
npm run test
# Run tests in watch mode
npm run test:watch
# Check coverage
npm run test:coverage
# Run E2E tests
npm run test:e2e
# Run all tests (unit + E2E)
npm run test:all Coverage guidance in repository docs is mixed: several files reference 90 percent, while AI assistant workflow instructions enforce 95 percent as a stricter floor. For new work, target 95 percent or higher to satisfy both interpretations safely.
Project Structure
NebulaKit/
├── .github/ # Copilot and workflow instructions
├── src/
│ ├── lib/
│ │ ├── components/ # Reusable UI components
│ │ ├── services/ # Business logic
│ │ ├── stores/ # Svelte stores
│ │ ├── types/ # Shared type definitions
│ │ └── utils/ # Helpers
│ ├── routes/ # SvelteKit routes
│ │ ├── api/ # API endpoints
│ │ ├── auth/ # Authentication pages
│ │ ├── chat/ # Chat UI
│ │ ├── setup/ # First-time setup flow
│ │ └── documentation/ # This page
│ ├── app.css # Global styles & theme
│ └── app.html # HTML template
├── tests/ # unit/integration/e2e tests
├── migrations/ # Immutable D1 migration files
├── docs/ # Extended project docs
└── wrangler.toml # Cloudflare bindings/configDeployment to Cloudflare Pages
- Push your repository to GitHub.
- In Cloudflare dashboard, open Pages and connect the repository.
- Use build command
npm run build. - Use output directory
.svelte-kit/cloudflare. - Add D1, KV, and R2 bindings to the Pages project settings.
- Add required environment variables and secrets.
- Deploy and verify auth, setup flow, and database connectivity.
The local deploy script already uses wrangler pages deploy .svelte-kit/cloudflare.
Troubleshooting
- If setup API reports KV unavailable, create KV namespaces and update wrangler.toml binding IDs.
- If login fails after setup, confirm OAuth callback URL and ensure GitHub credentials are valid.
- If migrations fail, run
npm run db:migrate:listand check migration numbering. - If command palette entries are missing, verify AI provider status and authentication state.
SEO, Redirects and Lighthouse
Structured data
SharingMeta emits one JSON-LD @graph per page: Organization,
WebSite, WebPage, plus a BreadcrumbList, an ItemList or an Article when the page passes
them in its structured prop. Ids are built from site.url, and
a noindex page emits none. Notes in docs/STRUCTURED_DATA.md.
Redirects
Admins manage old-to-new addresses at /admin/redirects (migration 0017_redirects.sql): pick 301, 302, 307 or 308 and see how many times each
one was used. A redirect is looked up only when the site would otherwise answer "not
found", so it never hides a live page and costs healthy pages nothing. The new address
is a path on this site or an https address on an allowed host: the site's own, plus any
you list in REDIRECT_ALLOWED_HOSTS (comma-separated). Loops and off-site targets
are refused when you save, and again when a visitor is redirected.
Canonical host
The session cookie belongs to one host, so a visitor signed in on the bare domain would
look signed out on www. The site sends www. + the host in site.url to that host (301 for reads, 308 for anything else), except
webhook and cron paths. It never redirects on localhost, *.pages.dev,
previews or tunnels. Every host that is not the one in site.url is served
with X-Robots-Tag: noindex, so a preview never competes with production in
search.
Security contact
/.well-known/security.txt (RFC 9116) tells researchers where to send a
vulnerability report. Set SECURITY_CONTACT to an email address or an https
URL (comma-separated for several) and the file is served, with an Expires date recomputed on every request, 180 days ahead. Until then the
path answers 404.
Lighthouse, two ways
The scores on the home page come from bun run lighthouse, which runs
Lighthouse locally and commits the reports. /admin/lighthouse is the live
counterpart: it audits the public pages in the sitemap through Google's PageSpeed
Insights API (migration 0018_lighthouse_audits.sql), lists them worst
first with the audits that failed, and has a Run now button.
PageSpeed can only reach a public URL, so site.url has to be real. PAGESPEED_API_KEY is optional and only raises the rate limit. Notes in docs/LIGHTHOUSE_MONITORING.md.
Scheduled Jobs
Cloudflare Pages cannot run Cron Triggers, so a standalone Worker in cron-worker/ wakes on a schedule and POSTs to the app's /api/cron/* routes with CRON_SECRET, retrying three times with
exponential backoff. The app claims each job once per scheduled minute, so a retry or
overlapping delivery never runs it twice, and records every run.
/admin/scheduled-jobs lists each job with its last run, status and next
expected run, and has a Run now button for the owner.
cd cron-worker
# set TARGET_URL in wrangler.toml to your site's origin
bunx wrangler deploy
bunx wrangler secret put CRON_SECRET # same value as on the Pages project It needs migration 0019_scheduled_job_runs.sql. Adding a job and the details
are in docs/SCHEDULED_JOBS.md.
Pages and the Site API
Two packages live in packages/, each versioned on its own: @nebulakit/builder (drag-and-drop pages stored as data) and @nebulakit/security (API keys). Security updates are forced to every site; the
builder is opt-in.
Pages
Build pages at /admin/pages: drag blocks from the palette onto the page or
into a Section or Column, reorder and nest them, edit each block's fields, hide a block
on phones, tablets or desktops, preview at each width, and publish. Mouse, touch and
keyboard all work: every drag has Add, Move up/down, Move in and Move out buttons, plus
Alt+Arrow keys, and Undo and Redo (Ctrl/⌘+Z) cover every edit. A check list flags images
without a description, skipped heading levels and vague link text. Published pages are
served at /p/[slug] and listed in the sitemap; the site's own routes always
win over a page. Every save that changes a page keeps a revision (the newest 50), listed
under History in the editor and at /api/v1/pages/[slug]/revisions;
restoring one is itself saved, so it can be undone. A page is a JSON document (format
version 2: a tree of blocks), never code, and it is rendered without raw HTML.
API keys
The owner manages access at /admin/api-keys: create a key with scopes and
an expiry, see when it was last used, revoke it in one click, and read the call log. A
key is shown once and stored hashed.
The site API
Everything is reachable under /api/v1, described at /api/v1/openapi.json. Send keys as Authorization: Bearer nk_….
Published pages are public to read; writes need a key with the right scope or an admin
session that sends JSON.
ORCHESTRATOR_KEY_SHA256 connects the Ammoura control room. Apply migrations 0020–0022 with bun run db:migrate:local.
Agent Readiness
This site publishes a machine-readable discovery layer so search crawlers and AI agents can find it, read it efficiently, and understand how to interact with it. Everything below is live and needs no configuration.
- /robots.txt — crawl rules, explicit entries for AI crawlers (GPTBot, ClaudeBot, PerplexityBot and others), and Content Signals declaring how the content may be used.
- /sitemap.xml — every public page plus all published CMS content, regenerated on request so newly published items appear immediately.
- Landing pages under
/who,/where,/what,/whenand/why— hand-written SEO pages fromsrc/lib/landing/content/. This site has not published any yet, so those routes return 404 and the sitemap lists none. Start one withbun run landing new <family> <slug>and check it withbun run landing report; seedocs/SEO_LANDING_PAGES.md. - /.well-known/api-catalog — an RFC 9727 catalog of this deployment's APIs.
- /.well-known/agent-skills/index.json — short guides teaching an agent how to read content and contact the site, each with a SHA-256 digest.
- /auth.md — how agents authenticate (and what is not offered).
- /api/health — service health, used as the catalog's status link.
Reading pages as Markdown
Any page can be fetched as Markdown instead of HTML by sending an Accept: text/markdown header. Browsers are unaffected — HTML remains the
default. Responses include an x-markdown-tokens estimate so an agent can budget context
before reading.
curl -H 'Accept: text/markdown' https://nebulakit.starspace.group/ In-browser tools (WebMCP)
When opened by a WebMCP-capable agent, this site registers tools for searching content, listing pages, reading a page as Markdown, navigating, and switching theme. They are read-and-navigate only, restricted to this site's own origin, and run with the visitor's existing permissions.
Content usage policy
The shipped default is fully permissive — search=yes, ai-input=yes, ai-train=yes — which suits an open template. If you build a site with proprietary content, change CONTENT_SIGNAL in src/lib/agent-discovery.ts before launching; every
robots.txt group picks the change up automatically.
DNS-based discovery (DNS-AID) is the one piece that must be added by hand, since DNS records
live with your provider rather than in this repo. See docs/AGENT_READINESS.md for the exact records and the DNSSEC requirement.
References
Contributing
Use TDD, keep changes small and reviewable, and run tests plus checks before opening a pull request.
- Write tests before implementation changes.
- Run npm run check, npm run test, and npm run test:coverage.
- Prefer Cloudflare-native services and minimal external dependencies.
- Do not edit past migration files; create a new one instead.