Skip to content

Architecture

Overview

A package registry for versioned packages (web components, ES modules, or any file bundle). Signed-in humans and CI tokens upload packages, test them by version ID, then publish to named channels. Organizations load packages from a channel using a load_* key in browser JS, and their admins self-administer CI tokens, packages, channels and key rotation by signing in as a human — with full power over packages their organization owns.

Stack: Cloudflare Worker (TypeScript) + D1 (SQLite) + R2 (object storage)


Core design

Versions and channels are completely separate.

  • Versions — immutable upload artifacts. Each upload creates a new ver_*-keyed version. Versions know nothing about channels.
  • Channels — named pointers to a version (prod, test, beta, v1, anything). Each channel is independent with its own rollback history. Channels are open by default; a channel can be marked protected so only privileged CI tokens / member humans can publish or roll it back. A channel is an explicitly-created managed resource: an organization admin creates it, after which it can sit empty until a version is published (its version_id is null while empty). Publishing never creates a channel — it targets one that already exists, or returns 404. A package can hold at most 10 channels.

This separation means:

  • You can publish the same version to multiple channels simultaneously
  • Working on beta never interferes with prod
  • Rollback on one channel doesn't affect others
  • A single deploy (upload + publish to a channel) and promote (publish an existing version to another channel) compose cleanly from these two primitives

Data model

packages

The catalog of packages. id is a prefixed pkg_* ID, auto-generated at creation — stable forever even if name changes. Row IDs across the data model follow a Stripe-style prefix scheme (pkg_, prj_, org_, ver_, cit_, …). owner_organization_id is the tenant that owns the package — never empty: every package belongs to exactly one organization — operator-created packages name a customer org via the required organizationId, so there is no operator-owned tenant. project_id is NOT NULL — every package lives in exactly one project within its owning org. Packages do not move between projects; reorganising means creating a new package in the destination project. The name is a handle — unique within its project (UNIQUE (project_id, name); the project is the namespace), so a name can address a package (a name → id lookup endpoint resolves it); the stable pkg_ id is unaffected by a rename.

package_versions

One row per upload. Truly immutable — never modified after creation. No pointers to other versions. uploaded_by records the display name of the actor that created the version, captured at upload time (operator for operator uploads); it is denormalized on purpose so attribution survives the uploading token being deleted. label (a human version name like v1.2.3) and commit_sha (the exact source commit) are optional source provenance, supplied at upload and frozen alongside uploaded_by — opaque strings the registry stores and displays but never parses. A package's repo_url (a mutable org-admin setting on the packages row) lets the panel link a version's commit to its source on GitHub.

package_channels

One row per active channel per package. Holds the current version_id and current_history_id (pointer into the rollback chain).

channel_history

A linked list per channel. Each publish creates a new row pointing to the previous one via previous_history_id. Rollback walks this chain backwards — every step lands on a version that was actually published to that channel. The same chain is exposed read-only as the channel's publish log via GET /packages/{id}/channels/{channel}/history.

organizations

Tenant entity. Name, active flag, the max_packages / max_ci_tokens / max_projects self-service quotas, and workos_organization_id — the 1:1 link to a WorkOS organization (nullable; NULL only for a pre-link row). The load_* load key lives in the separate load_keys table (see below). An organization is the billing unit and the parent of CI tokens, projects, and packages it owns; its human members live in WorkOS. Every organization is a real customer tenant — the operator is purely the control plane (an OPERATOR_ALLOWLIST email owning nothing), so operator-created packages/CI tokens always name a customer org via the required organizationId.

load_keys

The browser-side publishable credential (load_*), one row per key. An org owns a set of keys: exactly one current key (expires_at NULL) plus zero or more retiring keys (expires_at set — still valid until that instant; re-rotating never cuts an existing grace window short, so several can coexist). The hash is the at-rest lookup, but a load key is publishable, so its full value is retrievable (GET /me/keys) — not shown-once like a secret (pre-2026-06-10 rows are prefix-only until rotated).

projects

The org-internal sub-tenancy boundary. Every package belongs to exactly one project; CI tokens and members are granted at project level. Every organization starts with zero projects, and the admin creates their first project explicitly before any package or CI token can be created. POST /v1/{,me/}packages and POST /v1/{,me/}ci-tokens return 422 with a guiding empty-state message until the org has at least one project. Name renamable, unique within the org. Block-and-clear delete — Worker returns 409 if any packages still reference the project; the admin must delete those packages first — a package's project is fixed at create time, so packages cannot be moved.

ci_tokens

CI-token name, hashed token (ci_ prefix required). A ci_* token is a CI/machine credential — its scope is the set of projects it has grants on (via ci_token_project_access). owner_organization_id marks the org that manages this token — never empty: operator-created tokens name a customer org via the required organizationId. created_by_workos_user_id records the human who created the token (null for operator-created tokens), resolved to the current WorkOS name at render time. The lifecycle is create → use → delete (no soft on/off), matching GitHub PATs / Stripe / WorkOS API keys.

ci_token_project_access

Per-grant authorization for CI tokens. (ci_token_id, project_id, can_publish_protected, granted_at). A grant row = upload + publish/rollback of the project's open channels for every package in the project; can_publish_protected = 1 additionally permits protected channels. Worker enforces a min-1-grant invariant — revoking a token's last grant returns 409 ("delete the token instead").

member_package_access + member_project_access

Per-package + per-project grants for human members (signed-in WorkOS users with the member role) — keyed on WorkOS user id. A member_package_access row grants upload + publish/rollback of the package's open channels (can_publish_protected unlocks protected). A member_project_access row does the same for every package in a project. A member's effective access on a package is the UNION of both — granted if either contributes; can_publish_protected = true if either has it. Members are not rows in this database; WorkOS owns the human identity. These tables are resource authorization (which package/project, what may be done), not identity. Admins do not need rows here — ownership of the org grants full power.

audit_log

One row per mutating action — upload, publish, rollback, the channel/version/package lifecycle, project lifecycle, and org / CI-token / member / project / grant management. Append-only, with every displayable name frozen at write time so a subsequent rename or delete never changes what a historical row shows (the enterprise audit guarantee). The row denormalizes the actor's type + id + name, the actor's owning organization id + name, the affected package id + name, and the affected project id + name. actor_type is operator, ci_token, or user (a signed-in human; a user row's actor_id is the WorkOS user id). action is a stable dotted verb (version.upload, channel.publish, project.create, project.access.grant, …); detail is an optional JSON blob — *.rename rows carry previousName + newName, channel.publish carries versionId (and fromChannel for promotes), grant rows carry projectId. Destructive handlers (*.delete) pass the about-to-be-removed name into the response body so recordAudit freezes it before the row vanishes. Read via GET /packages/{id}/audit (per package), GET /me/projects/{id}/audit (per project), and GET /audit (operator, everything); responses return the frozen columns straight from the row — no read-time JOINs. The web panel resolves user-actor ids to real names from WorkOS.

publish v1 → prod   (history: h1)
publish v2 → prod   (history: h1 ← h2)
publish v4 → prod   (history: h1 ← h2 ← h4)

rollback prod → v2  (history pointer moves to h2)
rollback prod → v1  (history pointer moves to h1)

Workflows

A signed-in admin runs these workflows on packages its org owns via the /v1/me/packages/{id}/... mirror routes — the full owned-package lifecycle: upload, publish, rollback, create/delete channels, set protection, delete/clean up versions, rename, and delete the package itself (the last guarded by a confirmName name echo). Each is gated by package ownership (requireOwnedPackage) instead of a per-package grant.

Upload

POST /packages/{id}/upload                  (operator / CI token / member)
POST /me/packages/{id}/upload               (admin, own packages)
  → validate the bundle is non-empty (no required entry filename)
  → store files in R2: packages/{pkg_id}/versions/{ver_id}/...
  → insert package_versions row
  → return versionId (no channel affected)

Create channel

POST /packages/{id}/channels            { channel, protected? }   (operator)
POST /me/packages/{id}/channels         { channel, protected? }   (admin, own packages)
  → validate channel name (CHANNEL_RE + reserved names) — 400 on failure
  → reject if the package already has 10 channels — 403
  → reject if the channel already exists — 409
  → insert package_channels row with version_id = NULL (empty channel)

Publish to channel

POST /packages/{id}/channels/{channel}     { versionId }   (operator / CI token / member)
POST /me/packages/{id}/channels/{channel}  { versionId }   (admin, own packages)
  → 404 if the channel does not exist — publishing never creates one
  → insert channel_history row (previous_history_id = current)
  → update package_channels (version_id, current_history_id)

Rollback

POST /packages/{id}/channels/{channel}/rollback              (operator / CI token / member)
POST /me/packages/{id}/channels/{channel}/rollback           (admin, own packages)
  → read current_history_id from package_channels
  → follow previous_history_id one step back
  → update package_channels to previous version

Set channel protection

PATCH /packages/{id}/channels/{channel}   { protected }
PATCH /me/packages/{id}/channels/{channel}   { protected }   (admin, own packages)
  → flip the channel's protected flag
  → protected channels reject publish/rollback from CI tokens
    whose grant on the package's project has can_publish_protected = 0
    (and from members without can_publish_protected on either the
    per-package or per-project grant — access is the UNION). The
    operator and admins operating their own packages always bypass.

File serving

Clients call GET /packages/{id}/channels/prod (or any channel):

json
{
  "packageId": "pkg_…",
  "channel": "prod",
  "versionId": "ver_…",
  "publishedAt": 1234567890,
  "baseUri": "https://cdn.packdog.dev/packages/pkg_…/versions/ver_…"
}

A load is two requests, served from two different places:

  1. Resolve the channel — the call above goes to the Packdog Worker, which reads the channel's current version and returns the small JSON above (metadata only). The Worker runs on Cloudflare's global network, so this is handled at the data center nearest your user.
  2. Fetch the files — your consumer then requests ${baseUri}/… directly from R2 (Cloudflare's object storage), not through the Worker. The Worker is out of the path for the actual bytes.

Where files come from

Files are stored in Cloudflare R2 and delivered over Cloudflare's global edge network (hundreds of cities worldwide). The authoritative copy lives in one region; the first request for a file in a given region pulls it into that region's nearest edge cache, and every later request there is served locally. So after a file's first load in, say, Europe, European users get it from a European edge — no round-trip back to the origin region.

Caching

Every version has a unique, immutable URL: the ver_* ID is in the path, and a version's bytes never change (publishing moves a channel pointer — it never rewrites a version). So files are served with Cache-Control: public, max-age=31536000, immutable and cached at the edge effectively forever. There is no cache to invalidate: a new publish produces a new baseUri, which your consumer discovers on its next channel-resolve. This is the model CDNs like jsDelivr and unpkg use for versioned assets.

The channel-resolve response (request 1) is itself cached for 60 seconds, so a burst of loads from one client doesn't re-hit the Worker on every call.

There is no required entry filename. A version is a bundle of files; the consumer builds its own load URL by appending its entry to baseUri${baseUri}/index.js for a <script>/ES-module, ${baseUri}/index.html for an <iframe src>, or whatever the build emits. Relative asset paths inside the bundle resolve against baseUri automatically, so a self-contained index.html + assets works as an iframe. baseUri tracks the channel's current version, so loads update on publish/rollback without touching the consumer's code.


How loads are counted

A load is one successful channel-resolve — request 1 above (GET /packages/{id}/channels/{channel} returning a version). That single call is the only thing Packdog meters, and it's worth being precise about what does and doesn't count:

  • The file fetch (request 2) is never counted. Only the resolve call is. Whether the browser then pulls one file or fifty from baseUri, fresh from the origin or straight off the edge cache, makes no difference to your load count.
  • Repeat resolves from one client de-duplicate to roughly once per 60 seconds — the resolve response is cached that long, so a visitor whose page resolves the same channel several times in a minute counts about once, not once per call.
  • Counting happens server-side, in the Worker, the moment a channel resolves to a version — independent of any caching. Caching files at the edge forever can't hide loads, because loads were never measured at the file layer in the first place.

The figure you see in the panel (org / project / package Overview) and from GET /v1/me/usage* is exactly this count, computed with the same definition and weighting as billing — so the total a usage graph shows is the total you'd be invoiced, by construction. Usage history currently covers the last ~90 days.

Burst rate limits

Loads carry generous per-organization and per-IP rate limits to keep the platform stable for everyone. They sit well above normal traffic — you'll only meet them at abuse-level spikes, not real use. They're a stability backstop, not a usage cap: your monthly load allowance is set by your plan (see Limits), not by these rate limits. Planning unusually heavy traffic? Get in touch and we'll make sure you have the headroom.


Auth

Five levels:

  • Public: GET /health and GET / (JSON API index).
  • Organization key (Bearer load_...): GET /packages/{id}/channels/{channel} — read-only, scoped to packages the organization owns — a load_* key loads its own org's packages and nothing else. Rate limited per source IP — bucketing by token would let an attacker rotating random tokens get a fresh budget per token.
  • CI token (Bearer ci_...): a CI/machine credential — upload versions and publish/roll back packages. Scope is the set of projects the token has grants on (multi-project grants via ci_token_project_access). Each grant carries an independent can_publish_protected flag that unlocks the project's protected channels for that token. Token must include ci_ prefix — tokens without it are rejected. Rate limited per CI-token identity.
  • Signed-in user (a session — Bearer <JWT>): the human-identity tier. Human identity runs on WorkOS (AuthKit), so Packdog never stores passwords and enterprise SSO / SAML / SCIM are available through it. The Worker verifies the session JWT against WorkOS's published keys (JWKS), reads the org_id and role claims, and maps the org to a Packdog organization. It drives /v1/me/*. An admin can manage CI tokens, projects and members, rotate the load key, view usage, and create + fully operate the packages the organization owns (upload, publish, rollback — including protected channels; ownership is the authorization, no grant needed). A member is scoped to packages granted to them — the UNION of member_package_access (per-package) and member_project_access (per-project); can_publish_protected on either grant unlocks protected channels. Self-service is bounded by operator-set max_packages / max_ci_tokens / max_projects quotas. Rate limited per user.
  • Operator: used by the Packdog operator for service management (creating organizations, rotating keys, deletion). The master/root token. Not customer-facing — see contact info if you need an operator operation performed.

ci_* tokens are SHA-256 hashed at rest, shown once at creation and never recoverable — rotation issues a new value. load_* keys are also hashed (the hash is the lookup), but a load key is publishable (it ships in page source by design), so its full value is retrievable via GET /me/keys — not shown-once (Stripe/Supabase/Firebase show publishable keys permanently). Only the prefix appears in compact listings (load_a3f8e2c1, ci_a3f8e2c1). A signed-in user holds no Packdog-issued token at all; the credential is a short-lived WorkOS session, verified per request. Cross-tenant access on /v1/me/* returns 404 (not 403) so existence isn't leaked.

Deactivating an organization cascades to its ci_* CI tokens. The cascade is enforced lazily at lookup time — reactivating the org brings every CI token back. Human members are managed in WorkOS, separately.


Web panel

A web UI at app.packdog.dev (Next 16 + WorkOS AuthKit + shadcn, deployed to Vercel) sits in front of the same API. The two tiers have different information architecture — the operator is org-first (cross-org oversight is the job), the customer tier is project-first (project is the workspace, org is the settings shell).

  • Operator panel at /operator/* — the Packdog operator manages every organization, package and CI token in the registry. Sidebar: Overview / Organizations / Packages / Activity.
  • Customer panel at /(org)/* — sidebar is contextual on URL (driven by panelContextFromPath) and holds nav only; the workspace identity (org name + project picker) lives in the top header bar ([≡] | Acme Corp | 📁 Marketing site ⇅). Inside a project (/projects/{id}/*) the sidebar shows Packages / CI tokens / Members / Activity / Settings + an "Organization" footer link; in org-settings (/organization/*) it shows Projects / Members / CI tokens / Activity / Settings. Each tab is its own route (deep-linkable). The project picker (ProjectPicker in the header) is a DropdownMenu — clicking opens a list of projects + "Projects" + "+ New project" footer item that opens the create dialog directly. member-role users land on /my-packages — a flat list of packages they've been granted.

The customer tier authorizes straight from the WorkOS session — there is no Packdog-issued admin token. Members and invitations live in WorkOS. Two onboarding paths: the operator provisions an org and invites its first admin, or a new admin self-serves — signs up, names a workspace, and provisions their own org (gated to an approved allowlist during the controlled launch). Either way the admin then invites the rest of the team; WorkOS sends the invitation email and hosts the accept page. The CLI and the API stay first-class — the panel is an additional surface, not a replacement.

The panel sends the user's WorkOS session as the Bearer token to the same /v1/me/* endpoints the CLI uses; the Worker verifies that session against WorkOS's JWKS. The operator panel uses the operator token.


Design Decisions & Edge Cases

Atomic writes via D1 batch

setChannel() and deleteVersionFromDb() use env.DB.batch() to wrap their multi-statement writes in a single D1 transaction — concurrent publishes can't orphan history rows, and version-deletion can't leave dangling references. rollbackChannel() uses a conditional UPDATE (matching the previous current_history_id) and returns 409 Conflict if a concurrent publish raced; this avoids overwriting a fresh publish with a stale rollback.

Why manual deletion order?

deletePackageFromDb() manually deletes in order (history → channels → versions → access → package) instead of using ON DELETE CASCADE. This is intentional:

  • Explicit is safer than CASCADE (prevents accidental data loss)
  • Clear what happens when you delete
  • Easy to add soft delete later if needed

What happens when you delete a version?

deleteVersionFromDb() splices the deleted version out of channel history by setting previous_history_id = NULL. This means:

  • Rollback will stop at that point (can't go further back)
  • This is safer than re-linking the chain (which could skip important versions)
  • Protected versions (last 10 per channel) cannot be deleted

What happens when you publish the same version twice?

Publishing the same version to the same channel creates a new history entry. This is fine:

  • It's idempotent (no harm done)
  • Rollback will go back to the same version (which is correct)
  • History accurately reflects what happened

Why channels are explicitly created

Publishing used to auto-create a channel from whatever name it was given. That was a footgun: a CI token who typo'd --channel=stagee silently created a junk channel and believed they'd shipped to stage. Channels are now managed resources — created deliberately by an organization admin — so publishing only ever targets a channel that already exists. A typo is a 404, not a new ghost channel. Channel-name validation (CHANNEL_RE + reserved names) consequently lives at the create endpoint, not the publish endpoint.

Channel protection (branch-protection model)

Channels are open by default — any CI token with a grant on the package's project / member with package- or project-grant can publish or roll them back. Marking a channel protected (PATCH .../channels/{channel} with { "protected": true }) restricts publish and rollback on that channel to CI tokens whose grant on the package's project carries can_publish_protected = 1 (or members whose per-package OR per-project grant carries can_publish_protected = 1 — the UNION). The pattern mirrors Git branch protection: keep test (or similar) open for fast iteration, protect prod so only trusted tokens cut releases. The operator's OPERATOR_TOKEN and admin/owner-role humans always bypass protection on packages they own.

Packdog runs on Cloudflare Workers, D1, and R2.