On this page
  1. Normalized email addresses
  2. Owner-count guards
  3. Reading the allowlist
  4. listEditors
  5. Adding and removing editors
  6. insertEditor
  7. deleteEditor
  8. removeOwnerIfNotLast
  9. insertOwnerIfEmpty
  10. Changing roles
  11. setEditorRole
  12. demoteOwnerIfNotLast
  13. Types

Auth store (@glw907/cairn-cms/auth-store)

This subpath holds the D1 editor-provisioning functions the engine’s own editors-routes uses to back the ManageEditors screen: read the allowlist, add or remove an editor, and change a role, with the owner-count guards that keep at least one owner-capability editor on the roster. It is server-only surface (no svelte export condition), for a site that provisions or manages editors from its own server code, a setup script, or a migration, outside the admin screen.

Anything proposed here must be a D1 editor-roster read or write, provisioning surface only. The auth-flow functions the engine’s magic-link guard uses internally, findEditor, issueToken, recentlyIssued, consumeToken, createSession, resolveSession, and deleteSession, stay unexported here. They back the login flow only; no real caller outside the engine needs them, so they are not proven surface. A token or session primitive belongs on /auth-crypto instead, even one this store’s own rows carry a hash of.

Every function takes the site’s AUTH_DB D1Database binding as its first argument, the same binding CairnEnv (see core) types and a site’s wrangler.jsonc declares. The engine’s own editors-routes calls these exact functions, over the same rows, so a consumer script and the ManageEditors screen stay consistent.

import { listEditors, insertEditor } from '@glw907/cairn-cms/auth-store';

The TypeScript types in src/lib/auth/store.ts and src/lib/auth/types.ts are the source of truth, and the export-coverage gate checks every name here against them.

Normalized email addresses

The store trims and lowercases every email argument before it matches or writes a row. Pass an address as a user typed it. Backup@Site.com and backup@site.com are the same editor to every function on this page, and the rows the store returns carry the lowercased form.

The normalization is part of the contract, not a convenience. An editor’s email is their identity: the editor table’s primary key, the login form’s lookup key, and the column sessions and magic-link tokens join on. That column is case-sensitive. A row written under one case would be unreachable to a lookup under another, leaving a site with an editor who can never sign in but who still counts toward the owner-count guards below.

Compare an address the store returns against an address from elsewhere, such as a user record in the site’s own database, only after lowercasing both sides.

Owner-count guards

For an owner-capability row, deleteEditor and setEditorRole write unconditionally: they do not know or enforce that at least one owner must remain. removeOwnerIfNotLast and demoteOwnerIfNotLast carry that guard, the same invariant the ManageEditors screen enforces on every removal and role change. A caller that might act on an owner row should call the guard variant rather than deleteEditor/setEditorRole directly, to avoid stranding a site with no owner.

Both guards take an ownerRoles argument, the site’s owner-capability role names. Derive it from the site’s declared vocabulary with ownerLevelRoles rather than writing the names out. A hand-written list that omits a name the vocabulary maps to owner capability undercounts the roster’s owners. The guard then refuses a safe removal, or allows an unsafe one when the omitted name sits on the row the caller removes.

Both guards return false for two outcomes: the row is the last owner-capability row, or no owner-capability row matched the email. Neither outcome writes anything. To tell them apart, read the roster with listEditors first.


Reading the allowlist

listEditors

Stability tier: Extension API.

declare function listEditors(db: D1Database): Promise<EditorRow[]>;

The full allowlist, sorted by email.


Adding and removing editors

insertEditor

Stability tier: Extension API.

declare function insertEditor(
  db: D1Database,
  email: string,
  displayName: string,
  role: string,
  now: number,
): Promise<void>;

Add an editor to the allowlist. now is an epoch-millisecond timestamp. Rejects with the underlying D1 primary-key constraint error when the email is already on the allowlist, including under a different case: the store normalizes first, so Backup@Site.com collides with an existing backup@site.com row rather than adding a second one.

deleteEditor

Stability tier: Extension API.

declare function deleteEditor(db: D1Database, email: string): Promise<void>;

Remove an editor and cut their live access: any session and pending magic-link token for the email go too. Writes unconditionally, with no owner-count guard; prefer removeOwnerIfNotLast for a row that might be the last owner.

removeOwnerIfNotLast

Stability tier: Extension API.

declare function removeOwnerIfNotLast(
  db: D1Database,
  email: string,
  ownerRoles: string[],
): Promise<boolean>;

Remove an owner-capability editor only when another owner-capability row remains. ownerRoles is the site’s owner-capability role name set (not the literal 'owner' string), derived with ownerLevelRoles, so a site with more than one owner-level role name stays safe. The count check runs inside the same statement as the delete, so two concurrent removals cannot both pass a separate check and strand the allowlist below one owner. Returns false and writes nothing when this is the last owner-capability row or when no owner-capability row matched the email; on success the editor’s session and pending token go too, the same as deleteEditor.

insertOwnerIfEmpty

Stability tier: Extension API.

declare function insertOwnerIfEmpty(
  db: D1Database,
  email: string,
  displayName: string,
  now: number,
): Promise<boolean>;

Insert the bootstrap owner row when the allowlist is empty, in one atomic statement, so two concurrent bootstrap requests race safely to exactly one inserted row. Returns whether this call performed the insert; a non-empty table writes nothing and returns false.

The inserted row’s role is the literal 'owner', hardcoded, and the function does not check it against the site’s vocabulary. A site whose vocabulary renames or drops 'owner' should insert its own bootstrap row with insertEditor instead. The function also grants owner capability to whatever address the caller passes, so the caller owns the decision that this address is the site’s first owner.


Changing roles

setEditorRole

Stability tier: Extension API.

declare function setEditorRole(db: D1Database, email: string, role: string): Promise<void>;

Change an editor’s role. Writes unconditionally, with no owner-count guard; prefer demoteOwnerIfNotLast for a row that might be the last owner.

demoteOwnerIfNotLast

Stability tier: Extension API.

declare function demoteOwnerIfNotLast(
  db: D1Database,
  email: string,
  ownerRoles: string[],
  newRole: string,
): Promise<boolean>;

Demote an owner-capability editor to newRole only when another owner-capability row remains, the same atomic guard removeOwnerIfNotLast uses, over the same ownerRoles set from ownerLevelRoles. Returns false and writes nothing when this is the last owner-capability row or when no owner-capability row matched the email.


Types

Stability tier: Extension API.

NameStabilitySignatureMeaning
EditorRowExtension APItype EditorRow = { email: string; displayName: string; role: string }An allowlist row as the store reads it: email, displayName, and the bare role name. The store has no access to a site’s declared vocabulary, so it never resolves capability; a caller that needs a full Editor resolves capability itself and spreads it onto this shape.

Edit this page on GitHub(opens in a new tab)