mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
Refresh tokens rotate on use, and DoWithSession serializes refreshes per DID with an in-process mutex. That is the right mechanism and it protects nothing once there are two instances: both can refresh the same account at the same time, the slower one presents a refresh token the auth server has already superseded, gets invalid_grant, and isAuthError deletes the session. The user is signed out mid-push, and the session another instance had just legitimately refreshed is destroyed along with it. oauth_sessions gains a rev that increments on every write. A store that has read a session writes with a compare-and-swap against the revision it read and gets ErrSessionRevConflict if anyone wrote first, so a stale writer can no longer replace rotated tokens with invalidated ones. The persist callback treats that conflict as an ordinary outcome rather than an error, since leaving the newer state alone is exactly right. The delete path is now guarded by the same signal. An auth error on a session whose revision has moved since we read it means "someone else refreshed this", not "this session is dead", so it retries once against the newer tokens instead of deleting. Exactly once: a second failure means staleness was not the problem, and looping would hold the per-DID lock while getting the same answer. The guard is deliberately conservative. A store without revisions, no recorded revision, a failed lookup, a session that is simply gone: all answer "not advanced" and keep the previous delete-on-error behavior. Wrongly claiming a concurrent refresh would keep a genuinely dead session alive with no way out but waiting; wrongly missing one costs a re-login. The sentinel lives in pkg/auth/oauth rather than next to the SQLite store, because pkg/appview/db already imports pkg/auth/oauth and the other direction would be an import cycle. The db package re-exports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
359 lines
13 KiB
SQL
359 lines
13 KiB
SQL
-- ATCR AppView Database Schema
|
|
-- This file contains the complete base schema for fresh database installations.
|
|
-- Migrations (in migrations/*.yaml) handle changes to existing databases.
|
|
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
did TEXT PRIMARY KEY,
|
|
handle TEXT NOT NULL,
|
|
pds_endpoint TEXT NOT NULL,
|
|
avatar TEXT,
|
|
default_hold_did TEXT,
|
|
oci_client TEXT DEFAULT '',
|
|
registry_domain TEXT DEFAULT '',
|
|
last_seen TIMESTAMP NOT NULL,
|
|
UNIQUE(handle)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
|
|
|
|
CREATE TABLE IF NOT EXISTS manifests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com)
|
|
schema_version INTEGER NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
config_digest TEXT,
|
|
config_size INTEGER,
|
|
artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown
|
|
subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers)
|
|
created_at TIMESTAMP NOT NULL,
|
|
UNIQUE(did, repository, digest),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
|
|
|
|
CREATE TABLE IF NOT EXISTS repository_annotations (
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY(did, repository, key),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
|
|
|
|
CREATE TABLE IF NOT EXISTS layers (
|
|
manifest_id INTEGER NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
layer_index INTEGER NOT NULL,
|
|
annotations TEXT,
|
|
PRIMARY KEY(manifest_id, layer_index),
|
|
FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
|
|
|
|
CREATE TABLE IF NOT EXISTS manifest_references (
|
|
manifest_id INTEGER NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
platform_architecture TEXT,
|
|
platform_os TEXT,
|
|
platform_variant TEXT,
|
|
platform_os_version TEXT,
|
|
is_attestation BOOLEAN DEFAULT FALSE,
|
|
reference_index INTEGER NOT NULL,
|
|
PRIMARY KEY(manifest_id, reference_index),
|
|
FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
|
|
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
UNIQUE(did, repository, tag),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
|
|
|
|
-- rev increments on every successful write, so a writer that read revision N can
|
|
-- tell whether anyone has written since. Refresh tokens rotate on use, and the
|
|
-- per-DID mutex that serializes refreshes is in-process only, so without this a
|
|
-- second instance's concurrent refresh looks identical to a dead session and
|
|
-- gets it deleted out from under the user. See OAuthStore.SaveSession.
|
|
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
account_did TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
session_data TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
rev INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(account_did, session_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS oauth_auth_requests (
|
|
state TEXT PRIMARY KEY,
|
|
request_data TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS ui_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
did TEXT NOT NULL,
|
|
handle TEXT NOT NULL,
|
|
pds_endpoint TEXT NOT NULL,
|
|
oauth_session_id TEXT,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
|
|
CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id TEXT PRIMARY KEY,
|
|
did TEXT NOT NULL,
|
|
handle TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
secret_hash TEXT NOT NULL UNIQUE,
|
|
-- hex(sha256(secret)), for O(1) lookup during authentication. Nullable
|
|
-- because rows predating migration 0028 are backfilled lazily on their
|
|
-- next successful auth (the plaintext is not recoverable from the bcrypt
|
|
-- hash). See ValidateDeviceSecret.
|
|
secret_lookup TEXT,
|
|
ip_address TEXT,
|
|
location TEXT,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMP NOT NULL,
|
|
last_used TIMESTAMP,
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_secret_lookup ON devices(secret_lookup);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
|
|
|
|
CREATE TABLE IF NOT EXISTS pending_device_auth (
|
|
device_code TEXT PRIMARY KEY,
|
|
user_code TEXT NOT NULL UNIQUE,
|
|
device_name TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
approved_did TEXT,
|
|
approved_at TIMESTAMP,
|
|
device_secret TEXT,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
|
|
CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS repository_stats (
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
pull_count INTEGER NOT NULL DEFAULT 0,
|
|
last_pull TIMESTAMP,
|
|
push_count INTEGER NOT NULL DEFAULT 0,
|
|
last_push TIMESTAMP,
|
|
PRIMARY KEY(did, repository),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS repository_stats_daily (
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
pull_count INTEGER NOT NULL DEFAULT 0,
|
|
push_count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY(did, repository, date),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS jetstream_cursor (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
cursor INTEGER NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Stripe webhook idempotency + ordering. One row per successfully processed
|
|
-- Stripe event. Dedups redelivered events (event_id PRIMARY KEY) and lets us
|
|
-- drop stale out-of-order deliveries (compare event_created per customer).
|
|
CREATE TABLE IF NOT EXISTS stripe_processed_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
customer_id TEXT,
|
|
event_created INTEGER NOT NULL,
|
|
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_stripe_events_customer ON stripe_processed_events(customer_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS stars (
|
|
starrer_did TEXT NOT NULL,
|
|
owner_did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
PRIMARY KEY(starrer_did, owner_did, repository),
|
|
FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE,
|
|
FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository);
|
|
CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did);
|
|
|
|
CREATE TABLE IF NOT EXISTS hold_captain_records (
|
|
hold_did TEXT PRIMARY KEY,
|
|
owner_did TEXT NOT NULL,
|
|
public BOOLEAN NOT NULL,
|
|
allow_all_crew BOOLEAN NOT NULL,
|
|
deployed_at TEXT,
|
|
region TEXT,
|
|
successor TEXT,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS hold_crew_approvals (
|
|
hold_did TEXT NOT NULL,
|
|
user_did TEXT NOT NULL,
|
|
approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
PRIMARY KEY(hold_did, user_did)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS hold_crew_denials (
|
|
hold_did TEXT NOT NULL,
|
|
user_did TEXT NOT NULL,
|
|
denial_count INTEGER NOT NULL DEFAULT 1,
|
|
next_retry_at TIMESTAMP NOT NULL,
|
|
last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY(hold_did, user_did)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
|
|
|
|
-- Cached hold crew memberships from Jetstream
|
|
-- Enables reverse lookup: "which holds is user X a member of?"
|
|
CREATE TABLE IF NOT EXISTS hold_crew_members (
|
|
hold_did TEXT NOT NULL,
|
|
member_did TEXT NOT NULL,
|
|
rkey TEXT NOT NULL,
|
|
role TEXT,
|
|
permissions TEXT, -- JSON array
|
|
tier TEXT,
|
|
added_at TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (hold_did, member_did)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
|
|
CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
|
|
CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
|
|
|
|
CREATE TABLE IF NOT EXISTS repo_pages (
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
description TEXT,
|
|
avatar_cid TEXT,
|
|
user_edited BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
PRIMARY KEY(did, repository),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
|
|
|
|
CREATE TABLE IF NOT EXISTS crypto_keys (
|
|
name TEXT PRIMARY KEY,
|
|
key_data BLOB NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS webhooks (
|
|
id TEXT PRIMARY KEY,
|
|
user_did TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
secret TEXT,
|
|
triggers INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_fired_at TIMESTAMP NULL,
|
|
FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did);
|
|
|
|
CREATE TABLE IF NOT EXISTS scans (
|
|
hold_did TEXT NOT NULL,
|
|
manifest_digest TEXT NOT NULL,
|
|
user_did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
critical INTEGER NOT NULL DEFAULT 0,
|
|
high INTEGER NOT NULL DEFAULT 0,
|
|
medium INTEGER NOT NULL DEFAULT 0,
|
|
low INTEGER NOT NULL DEFAULT 0,
|
|
total INTEGER NOT NULL DEFAULT 0,
|
|
scanner_version TEXT,
|
|
scanned_at TIMESTAMP NOT NULL,
|
|
PRIMARY KEY(hold_did, manifest_digest)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
|
|
|
|
CREATE TABLE IF NOT EXISTS advisor_suggestions (
|
|
manifest_digest TEXT PRIMARY KEY,
|
|
suggestions_json TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS taken_down_subjects (
|
|
src TEXT NOT NULL,
|
|
did TEXT NOT NULL,
|
|
repo TEXT NOT NULL DEFAULT '',
|
|
cts TIMESTAMP NOT NULL,
|
|
PRIMARY KEY (src, did, repo)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_taken_down_subjects_did ON taken_down_subjects(did);
|
|
CREATE INDEX IF NOT EXISTS idx_taken_down_subjects_did_repo ON taken_down_subjects(did, repo);
|
|
|
|
CREATE TABLE IF NOT EXISTS labeler_cursor (
|
|
src TEXT PRIMARY KEY,
|
|
cursor INTEGER NOT NULL
|
|
);
|
|
|
|
-- Leader election for background workers. Exactly one AppView instance may run
|
|
-- the Jetstream consumer, backfill, labeler subscriber and cleanup loops; see
|
|
-- pkg/appview/db/leases.go for why, and pkg/appview/leases for the renewal loop.
|
|
--
|
|
-- acquired_at and expires_at are Unix milliseconds, not TIMESTAMP text: libSQL
|
|
-- normalizes date-like TEXT on the way in, and Go's driver and CURRENT_TIMESTAMP
|
|
-- disagree on format, so a string comparison in the acquire statement would be
|
|
-- comparing incompatible shapes. Integer comparison is the whole safety property
|
|
-- here, so it does not get to be subtle.
|
|
CREATE TABLE IF NOT EXISTS instance_leases (
|
|
lease_name TEXT PRIMARY KEY,
|
|
holder_id TEXT NOT NULL,
|
|
fence INTEGER NOT NULL DEFAULT 0,
|
|
acquired_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_instance_leases_expires ON instance_leases(expires_at);
|