Database
Migrations
Section titled “Migrations”The package ships its schema as one migration. Copy it into your own migrations directory as the first migration, then apply it.
-
Terminal window mkdir -p migrationscp node_modules/@ouncepage/core/migrations/0001_ounce.sql migrations/ -
Terminal window npx wrangler d1 migrations apply <database> --localnpx 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.
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"}'));Tables
Section titled “Tables”| 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.
Bindings
Section titled “Bindings”| Binding | Type | Required |
|---|---|---|
DB |
D1 | Yes |
MEDIA |
R2 | Yes, for uploads |
The names are read directly from cloudflare:workers and are not
configurable.
Environment
Section titled “Environment”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.
Authentication
Section titled “Authentication”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:
- An email in
OUNCE_OWNERSis adeveloper, whatevereditorssays. - Otherwise, the row in
editors. - No row, and
editorsis empty:developer. This is the bootstrap case, so the first person through the door can grant everyone else a role. - No row, and
editorsis 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.