Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions crates/tokker-worker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,55 @@
//! serve(harness, runtime, req, env, ctx).await
//! }
//! ```
//!
//! Bindings follow plan.md §5.1: D1 (`DB`) for the price tables, KV (`KV`)
//! for hot JSON responses, R2 (`BUCKET`) for evidence snapshots and CSV
//! downloads, and the Workers rate limiting binding (`RATE_LIMITER`) for
//! anonymous IP limits. The crons live in `wrangler.toml` `[triggers]`,
//! hand-maintained until Cratefield's manifest cron field lands
//! (plan.md §5.2 G7a).

#![forbid(unsafe_code)]

use std::sync::OnceLock;

use cratefield_core::{Harness, Venture};
use cratefield_runtime_cloudflare::{Cloudflare, serve};
use cratefield_runtime_cloudflare::{Cloudflare, serve, serve_scheduled};
use tokker_api::TokkerApi;
use worker::{Context, Env, Request, Response, event};
use worker::{Context, Env, Request, Response, ScheduleContext, ScheduledEvent, event};

static INSTANCE: OnceLock<(Harness, Cloudflare)> = OnceLock::new();

/// The harness and its runtime, built once per isolate.
///
/// The runtime is constructed once and cloned into the builder —
/// `Cloudflare` is `Clone` (it carries binding names, not bindings) — so
/// the fetch and scheduled entry points share one composition.
fn instance() -> &'static (Harness, Cloudflare) {
INSTANCE.get_or_init(|| {
let runtime = Cloudflare::new()
.db("DB")
.kv("KV")
.blob("BUCKET")
.rate_limiter("RATE_LIMITER");
let harness = Harness::builder()
.venture(Venture::new("tokker", "tokker.dev").public_url("https://tokker.dev"))
.venture(
Venture::new("tokker", "tokker.dev")
.public_url("https://tokker.dev")
// The site that embeds the API (plan.md §5): build()
// refuses a venture with no CORS origin at all.
.cors_origins(["https://tokker.dev"]),
)
.module(TokkerApi)
.runtime(Cloudflare::new().db("DB"))
.runtime(runtime.clone())
.build()
.expect("tokker harness is valid");
let runtime = Cloudflare::new().db("DB");
(harness, runtime)
})
}

/// Worker fetch entry point.
/// Worker fetch entry point: the harness router answers `/v1/*` and the
/// built-in probes (`/__health`, `/__ready`, `/__surface`).
///
/// # Errors
///
Expand All @@ -44,3 +67,11 @@ pub async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result<Respo
let (harness, runtime) = instance();
serve(harness, runtime, req, env, ctx).await
}

/// Worker cron entry point: fans the scheduled event out to every module's
/// `scheduled` under the runtime's scheduled limits.
#[event(scheduled)]
pub async fn scheduled(event: ScheduledEvent, env: Env, ctx: ScheduleContext) {
let (harness, runtime) = instance();
serve_scheduled(harness, runtime, event, env, ctx).await;
}
107 changes: 107 additions & 0 deletions migrations/0001_core_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
-- Tokker core tables (migration 0001): the public price index, one table per
-- dataset collection in docs/SCHEMA.md, loaded from data/pricing.json by the
-- repo-mirror work (plan.md §5.2 G1).
--
-- Table privacy: these tables hold nothing personal — published price data
-- only (plan.md §5.1 table_privacy: holds nothing).
--
-- These tables are applied by wrangler (`migrations_dir` in wrangler.toml,
-- `wrangler d1 migrations apply DB --local` for dev). The module's Rust-side
-- `Migrations` stay EMPTY: on Workers the runtime never applies SQL itself.
--
-- Conventions: `id` is the stable dataset id, never reused (CLAUDE.md rule 3).
-- Scalar columns are the plain numeric form the API filters on; the dataset's
-- "unknown" and null both collapse to NULL here (the verbatim row — including
-- every "unknown" — is `json`, which is what `/v1/*` returns).

CREATE TABLE providers (
id TEXT PRIMARY KEY, -- slug, e.g. "openrouter"
name TEXT NOT NULL,
type TEXT CHECK (type IN ('first_party', 'aggregator', 'inference_host', 'cloud')),
-- NULL = plan-only vendor
country TEXT, -- ISO 3166-1 alpha-2, or NULL
api_offer_count INTEGER NOT NULL DEFAULT 0,
subscription_count INTEGER NOT NULL DEFAULT 0,
json TEXT NOT NULL
);

CREATE TABLE models (
id TEXT PRIMARY KEY, -- canonical model_slug
name TEXT NOT NULL, -- model_name
creator TEXT NOT NULL, -- model_creator
open_weights TEXT NOT NULL CHECK (open_weights IN ('true', 'false', 'unknown')),
context_window INTEGER, -- NULL when "unknown"
json TEXT NOT NULL
);

CREATE TABLE offers (
id TEXT PRIMARY KEY, -- "<provider_id>/<model_slug>"
provider_id TEXT NOT NULL REFERENCES providers(id),
model_id TEXT NOT NULL REFERENCES models(id),
creator TEXT NOT NULL, -- model_creator; the /v1/offers filter
provider_type TEXT NOT NULL CHECK (provider_type IN ('first_party', 'aggregator', 'inference_host', 'cloud')),
open_weights TEXT NOT NULL CHECK (open_weights IN ('true', 'false', 'unknown')),
currency TEXT NOT NULL, -- ISO 4217; native currency first (CLAUDE.md rule 4)
input_per_mtok REAL, -- native currency; NULL when "unknown"
output_per_mtok REAL,
usd_input_per_mtok REAL, -- the row's derived usd block, at fx.date
usd_output_per_mtok REAL,
usd_blended_3to1 REAL, -- the 3:1 input:output blend used for ranking
context_window INTEGER,
max_output INTEGER,
batch_discount_pct REAL,
region_notes TEXT,
source TEXT NOT NULL, -- the exact URL the row came from
confidence TEXT NOT NULL CHECK (confidence IN ('official_page', 'official_docs', 'secondary')),
last_verified_at TEXT NOT NULL, -- ISO date; shown everywhere (plan.md §3)
json TEXT NOT NULL
);

CREATE INDEX offers_creator ON offers(creator);
CREATE INDEX offers_provider ON offers(provider_id);
CREATE INDEX offers_model ON offers(model_id);
CREATE INDEX offers_open_weights ON offers(open_weights);
CREATE INDEX offers_usd_input ON offers(usd_input_per_mtok);
CREATE INDEX offers_cheapest ON offers(model_id, usd_blended_3to1); -- /v1/models/{slug}/cheapest

CREATE TABLE plans (
id TEXT PRIMARY KEY, -- "<vendor_id>/<plan_slug>"
vendor_id TEXT NOT NULL REFERENCES providers(id),
vendor_name TEXT NOT NULL,
product TEXT NOT NULL,
plan_name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('chat', 'coding_agent', 'ide', 'token_plan', 'api_credit_bundle')),
currency TEXT NOT NULL,
price_month REAL, -- native currency; NULL when "unknown"
price_year REAL,
usd_price_month REAL,
usd_price_year REAL,
last_verified_at TEXT NOT NULL,
json TEXT NOT NULL
);

CREATE INDEX plans_vendor ON plans(vendor_id);
CREATE INDEX plans_category ON plans(category);

CREATE TABLE sources (
id TEXT PRIMARY KEY, -- "<part>/<provider_id>"; sources[] has no id in the dataset, so the loader assigns this
provider_id TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL CHECK (method IN ('json_api', 'html_static', 'html_js_rendered', 'docs_markdown', 'pdf', 'manual')),
endpoint TEXT NOT NULL,
volatility TEXT NOT NULL CHECK (volatility IN ('high', 'medium', 'low')),
fetch_ok INTEGER NOT NULL, -- boolean
part TEXT NOT NULL, -- the part file that produced the entry
notes TEXT,
json TEXT NOT NULL
);

CREATE INDEX sources_provider ON sources(provider_id);

CREATE TABLE fx (
currency TEXT PRIMARY KEY, -- ISO 4217; one row per fx.rates entry
rate REAL NOT NULL CHECK (rate > 0), -- one unit of base in that currency
base TEXT NOT NULL,
date TEXT NOT NULL, -- the rate date for the whole dataset
json TEXT NOT NULL
);
66 changes: 66 additions & 0 deletions tools/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// Smoke-test a running Tokker Worker: the harness built-ins (/__health,
// /__ready, /__surface) and the 404 fallback. Start the Worker first
// (`npx wrangler dev --local` from the repo root), then run
// `node tools/smoke.mjs [base-url]`. SMOKE_URL or the first argument set the
// base URL (default http://127.0.0.1:8787); prints one PASS/FAIL line per
// check and exits 1 if any check fails.

const BASE = process.env.SMOKE_URL || process.argv[2] || 'http://127.0.0.1:8787';

let failed = false;

/** Run one check: name plus an async function answering {ok, detail}. */
async function check(name, fn) {
try {
const { ok, detail } = await fn();
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
if (!ok) failed = true;
} catch (err) {
console.log(`FAIL ${name} — ${err.message}`);
failed = true;
}
}

/** GET a path, returning the response and its text (empty on a read error). */
async function get(path) {
const res = await fetch(BASE + path, { redirect: 'manual' });
const body = await res.text().catch(() => '');
return { res, body };
}

/** {ok, detail} for a plain status expectation. */
function atStatus(res, want, body) {
const ok = want(res.status);
return { ok, detail: `status ${res.status}${ok ? '' : `: ${body.slice(0, 200)}`}` };
}

await check('/__health answers 2xx', async () => {
const { res, body } = await get('/__health');
return atStatus(res, (s) => s >= 200 && s < 300, body);
});

// Locally the D1 binding is wired, so /__ready's SELECT 1 answers 200.
await check('/__ready answers 2xx (SELECT 1 through the DB port)', async () => {
const { res, body } = await get('/__ready');
return atStatus(res, (s) => s >= 200 && s < 300, body);
});

await check('/__surface answers 2xx JSON', async () => {
const { res, body } = await get('/__surface');
let json = null;
try {
json = JSON.parse(body);
} catch {
// left null; the detail below says so
}
const ok = res.status >= 200 && res.status < 300 && json !== null;
return { ok, detail: `status ${res.status}, ${json === null ? 'body is not JSON' : 'JSON'}` };
});

await check('unknown path answers 404', async () => {
const { res, body } = await get('/__smoke-not-a-route');
return atStatus(res, (s) => s === 404, body);
});

process.exit(failed ? 1 : 0);
120 changes: 120 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# The Tokker API Worker (plan.md §5): the Cratefield harness on
# cratefield-runtime-cloudflare, served from crates/tokker-worker.
#
# Build: worker-build is expected on PATH (`cargo install worker-build`);
# output lands in the crate's gitignored `build/`.
#
# Secrets — set per environment with `wrangler secret put`, or in the
# untracked `.dev.vars` for local dev; never values in this file:
# HARNESS_SECRET >= 32 bytes; provides the harness Signer port
# ADMIN_TOKEN unlocks the admin variant of GET /__surface
# HARNESS_SECRET_PREVIOUS only during key rotation

name = "tokker-api"
main = "crates/tokker-worker/build/worker/shim.mjs"
compatibility_date = "2026-09-01"
workers_dev = true

[build]
command = "worker-build --release crates/tokker-worker"
watch_dir = ["crates/tokker-worker/src"]

[observability]
enabled = true

# Hand-maintained until Cratefield's manifest cron field lands
# (plan.md §5.2 G7a, Cratefield/harness#774).
[triggers]
crons = ["*/30 * * * *"]

# Default (local/dev) bindings; every deploy environment re-declares its own.
[[d1_databases]]
binding = "DB"
database_name = "tokker"
# Local dev keys the database on the name; remote deploys use the env blocks
# below, which carry the real ids.
database_id = "local"
migrations_dir = "migrations"

[[kv_namespaces]]
binding = "KV"
id = "local"

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "tokker-local"

# Anonymous per-IP limit for the public read API (plan.md §5.1). Workers
# rate limiting binding; counters are per colo, per namespace.
[[ratelimits]]
name = "RATE_LIMITER"
namespace_id = "1001"
simple = { limit = 120, period = 60 }

# --- staging -------------------------------------------------------------

[env.staging]
name = "tokker-api-staging"
workers_dev = true

[env.staging.vars]
ENV = "staging"

[env.staging.triggers]
crons = ["*/30 * * * *"]

[[env.staging.d1_databases]]
binding = "DB"
database_name = "tokker-staging"
# Owner: create with `wrangler d1 create tokker-staging` and paste the id.
database_id = "REPLACE_WITH_STAGING_D1_ID"
migrations_dir = "migrations"

[[env.staging.kv_namespaces]]
binding = "KV"
# Owner: create with `wrangler kv namespace create KV --env staging`.
id = "REPLACE_WITH_STAGING_KV_ID"

[[env.staging.r2_buckets]]
binding = "BUCKET"
bucket_name = "tokker-staging"

[[env.staging.ratelimits]]
name = "RATE_LIMITER"
namespace_id = "2001"
simple = { limit = 120, period = 60 }

# --- production ----------------------------------------------------------

[env.production]
name = "tokker-api-production"
# No routes or custom domains yet; workers.dev only. Disable workers_dev
# when the custom domain lands.
workers_dev = true

[env.production.vars]
ENV = "production"

[env.production.triggers]
crons = ["*/30 * * * *"]

[[env.production.d1_databases]]
binding = "DB"
database_name = "tokker-production"
# Owner: create with `wrangler d1 create tokker-production` and paste the id.
database_id = "REPLACE_WITH_PRODUCTION_D1_ID"
migrations_dir = "migrations"

[[env.production.kv_namespaces]]
binding = "KV"
# Owner: create with `wrangler kv namespace create KV --env production`.
id = "REPLACE_WITH_PRODUCTION_KV_ID"

[[env.production.r2_buckets]]
binding = "BUCKET"
bucket_name = "tokker-production"

[[env.production.ratelimits]]
name = "RATE_LIMITER"
namespace_id = "3001"
simple = { limit = 120, period = 60 }
Loading