Skip to content

Database

The package ships its schema as one migration. Copy it into your own migrations directory as the first migration, then apply it.

  1. Terminal window
    mkdir -p migrations
    cp node_modules/@ouncepage/core/migrations/0001_ounce.sql migrations/
  2. Terminal window
    npx wrangler d1 migrations apply <database> --local
    npx wrangler d1 migrations apply <database> --remote

Every statement is CREATE TABLE IF NOT EXISTS, so re-applying it is safe and it composes with an existing database.

Your own migrations follow, numbered after it. Page rows, section rows and setting rows are data: insert them with SQL, not with code that runs at boot.

migrations/0002_content.sql
INSERT INTO pages (slug, title, seo_title, seo_description)
VALUES ('/', 'Home', 'Example', 'An example site.');
INSERT INTO page_sections (page_id, key, position, data)
SELECT id, 'banner', 0, json('{"title":"Hello","body":""}') FROM pages WHERE slug = '/';
INSERT INTO settings (key, data) VALUES ('site', json('{"brand":"Example"}'));
Table Holds
settings One JSON blob per setting key. Plugin settings live here too, keyed plugin:<name>
pages Slug, title, SEO, Open Graph, extras for plugin page tabs, navigation, enabled
page_sections One row per section on a page. PRIMARY KEY (page_id, key)
redirects Old path to new path, written when a page is renamed
media R2 object metadata. The bytes are in R2, not here
media_uses Which content references which media URL. Rebuilt in full on every save
revisions Append-only history. See History and restore
editors Email to role. See Roles and permissions
mcp_tokens Hashed bearer tokens for /_ounce/mcp, issued at /admin/people

pages.navigation holds the menu this page shows, and page_sections.in_nav and nav_label hold whether a section is in a generated menu and what it is called there. in_nav is nullable on purpose: NULL means “follow the section type”, so changing the type’s default moves every page that never chose. See Navigation.

page_sections.data and settings.data are JSON text. Query into them with json_extract; write them with json() so SQLite stores valid JSON rather than a quoted string.

Binding Type Required
DB D1 Yes
MEDIA R2 Yes, for uploads

The names are read directly from cloudflare:workers and are not configurable.

Set with wrangler secret put unless marked as a plain var.

Name Used for
SITE_ORIGIN var. Absolute URLs in SEO previews and cache purging
CF_ACCESS_TEAM_DOMAIN var. Cloudflare Access JWT issuer
CF_ACCESS_AUD var. Cloudflare Access application audience tag
OUNCE_OWNERS var. Comma-separated emails that are always developer
CF_PURGE_ZONE_ID var. Zone to purge after a save
CF_PURGE_API_TOKEN secret. Token with Cache Purge on that zone
CF_ACCESS_DEV_EMAIL Local only. The email to sign in as in astro dev
CF_ACCESS_DEV_ROLE Local only. Force a role. Omit to use the real one

Put the two local ones in .dev.vars. Without CF_ACCESS_DEV_EMAIL there is no way to reach the admin on localhost, because there is no Access JWT to verify.

Cloudflare Access, and only Cloudflare Access. Ounce verifies the CF-Authorization JWT against your team’s JWKS, reads the email out of it, and looks that email up in editors.

Role resolution, in order:

  1. An email in OUNCE_OWNERS is a developer, whatever editors says.
  2. Otherwise, the row in editors.
  3. No row, and editors is empty: developer. This is the bootstrap case, so the first person through the door can grant everyone else a role.
  4. No row, and editors is not empty: viewer.

Rule 3 means an empty editors table makes every authenticated person a developer. Cloudflare Access decides who is authenticated, so that is only as open as your Access policy. Add one person to editors and it closes.

This is not an extension point. There is no hook to supply a different identity source.