From c54cbf25960c8f81245d599635cff13925cf35ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:46:56 +0000 Subject: [PATCH] venture backends: accounts with passkeys Each venture's Worker can now sign people up and in with passkeys (WebAuthn): no passwords, no email, no OAuth app, nothing to configure. The relying party is the venture's own host and the session a __Host- cookie (HttpOnly, Secure, SameSite=Lax, 30 days), stored hashed in the venture's D1 database. ES256 and RS256 keys; challenges are single-use and expire in five minutes; origin, relying party, user presence and the signature counter are checked; auth endpoints refuse cross-origin calls. Items saved while signed in are recorded as that person's, and /api/me/items lists them. time.js gains Time.accounts, user, signUp, signIn, signOut and mine; builds are told to use them for what the paid tier promises and never to put sign-in in front of the free product. test_auth.mjs runs the whole ceremony against a real SQLite and a software authenticator, including replay, phishing origin, another site's passkey, a cloned counter, a tampered signature and a duplicate registration; CI pins node 22 so it runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr --- .claude/skills/time/SKILL.md | 2 +- .github/time/cloudflare/test_auth.mjs | 187 ++++++++++++++++++++++ .github/time/cloudflare/test_worker.mjs | 2 +- .github/time/cloudflare/time.js | 69 ++++++++- .github/time/cloudflare/worker.js | 198 +++++++++++++++++++++++- .github/time/test_time.py | 7 + .github/time/venture.py | 8 +- .github/workflows/ci.yml | 3 + .github/workflows/update.yml | 2 +- CHANGELOG.md | 1 + README.md | 2 +- 11 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 .github/time/cloudflare/test_auth.mjs diff --git a/.claude/skills/time/SKILL.md b/.claude/skills/time/SKILL.md index 8386400..a612519 100644 --- a/.claude/skills/time/SKILL.md +++ b/.claude/skills/time/SKILL.md @@ -122,7 +122,7 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily ### Backends on Cloudflare (optional) -With a Cloudflare API token, each venture also gets a backend: its own Worker (`time-`, code in `.github/time/cloudflare/worker.js`), its own D1 database and Workers AI, serving the venture's Pages folder under its own domain. The page reaches it through `time.js` (written next to `index.html`; the build never edits it): `Time.save` / `Time.load` for share links, `Time.ai` for a small model. Every call degrades to null, so a product still works on Pages alone and in the headless checks. +With a Cloudflare API token, each venture also gets a backend: its own Worker (`time-`, code in `.github/time/cloudflare/worker.js`), its own D1 database and Workers AI, serving the venture's Pages folder under its own domain. The page reaches it through `time.js` (written next to `index.html`; the build never edits it): `Time.save` / `Time.load` for share links, `Time.ai` for a small model, and accounts with passkeys (`Time.signUp`, `Time.signIn`, `Time.user`, `Time.mine` for what someone saved). Accounts work only on the venture's own domain: the passkey belongs to that host and the session is a `__Host-` cookie. Every call degrades to null, so a product still works on Pages alone and in the headless checks. - Token: Cloudflare → My Profile → API Tokens → Create Token (custom) with Account · Workers Scripts · Edit, Account · D1 · Edit, Account · Workers AI · Read, and, for a domain, Zone · Workers Routes · Edit and Zone · DNS · Edit on that zone. Then `gh secret set CLOUDFLARE_API_TOKEN` and `gh secret set CLOUDFLARE_ACCOUNT_ID` (the account id is in the dashboard URL). - Domain: `gh variable set TIME_VENTURE_DOMAIN --body example.com` (a zone on that account) puts each venture at `.example.com`. Without it, ventures live at `time-..workers.dev`. - Optional: `TIME_VENTURE_AI_MODEL` (default `@cf/google/gemma-4-26b-a4b-it`). diff --git a/.github/time/cloudflare/test_auth.mjs b/.github/time/cloudflare/test_auth.mjs new file mode 100644 index 0000000..abd20f3 --- /dev/null +++ b/.github/time/cloudflare/test_auth.mjs @@ -0,0 +1,187 @@ +// Offline test for passkey accounts in worker.js: a real SQLite standing in for D1 (node:sqlite) and a software +// authenticator with real P-256 and RSA keys. Run: node .github/time/cloudflare/test_auth.mjs +// Skips (and says so) when this node has no node:sqlite. +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +let DatabaseSync; +try { + process.removeAllListeners("warning"); + ({ DatabaseSync } = await import("node:sqlite")); +} catch { + console.log("AUTH SKIPPED: no node:sqlite"); + process.exit(0); +} +const here = dirname(fileURLToPath(import.meta.url)); +const worker = (await import(join(here, "worker.js"))).default; + +// D1's surface over SQLite: prepare/bind/run/first/all, and batch as one transaction. +class D1 { + constructor() { this.db = new DatabaseSync(":memory:"); } + prepare(sql) { + const db = this.db; + const make = (args) => ({ + bind: (...a) => make(a), + exec: () => ({ results: db.prepare(sql).all(...args) }), + run: async () => ({ results: db.prepare(sql).all(...args) }), + first: async () => db.prepare(sql).all(...args)[0] ?? null, + all: async () => ({ results: db.prepare(sql).all(...args) }), + }); + return make([]); + } + async batch(stmts) { + this.db.exec("BEGIN"); + try { + const out = stmts.map((s) => s.exec()); + this.db.exec("COMMIT"); + return out; + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } + } +} + +const HOST = "mdecks.yolo.lol", ORIGIN = `https://${HOST}`; +const env = { DB: new D1(), SLUG: "mdecks", REV: "r", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io" }; +const enc = new TextEncoder(); +const b64u = (buf) => Buffer.from(buf).toString("base64url"); +const unb64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); +const sha256 = async (d) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof d === "string" ? enc.encode(d) : d)); +let ip = 0; +const call = (path, { body, cookie, origin, host = HOST } = {}) => worker.fetch(new Request(`https://${host}${path}`, { + method: body === undefined ? "GET" : "POST", body: body === undefined ? undefined : JSON.stringify(body), + headers: { "content-type": "application/json", "cf-connecting-ip": `10.0.0.${++ip % 250}`, ...(cookie ? { cookie } : {}), + ...(origin ? { origin } : {}) } }), env); +const cookieOf = (r) => (r.headers.get("set-cookie") || "").split(";")[0]; + +// The software authenticator: what a phone or a laptop's passkey would hand the page. +function authData(rpHash, flags, counter) { + const a = new Uint8Array(37); + a.set(rpHash); + a[32] = flags; + new DataView(a.buffer).setUint32(33, counter); + return a; +} +function rawToDer(raw) { + const int = (b) => { + let v = [...b]; + while (v.length > 1 && v[0] === 0 && !(v[1] & 0x80)) v.shift(); + if (v[0] & 0x80) v = [0, ...v]; + return [0x02, v.length, ...v]; + }; + const body = [...int(raw.slice(0, 32)), ...int(raw.slice(32))]; + return new Uint8Array([0x30, body.length, ...body]); +} +class Authenticator { + static async make(kind = "ES256") { + const a = new Authenticator(); + a.kind = kind; + a.alg = kind === "ES256" ? -7 : -257; + a.keys = kind === "ES256" + ? await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + : await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256" }, true, ["sign", "verify"]); + a.id = b64u(crypto.getRandomValues(new Uint8Array(16))); + a.counter = 0; + return a; + } + async create(options, { origin = ORIGIN } = {}) { + const clientData = enc.encode(JSON.stringify({ type: "webauthn.create", challenge: options.publicKey.challenge, origin })); + return { challengeId: options.challengeId, id: this.id, alg: this.alg, clientDataJSON: b64u(clientData), + authenticatorData: b64u(authData(await sha256(options.publicKey.rp.id), 0x45, this.counter)), + publicKey: b64u(await crypto.subtle.exportKey("spki", this.keys.publicKey)) }; + } + async get(options, { origin = ORIGIN, counter = ++this.counter, rpId = options.publicKey.rpId } = {}) { + const clientData = enc.encode(JSON.stringify({ type: "webauthn.get", challenge: options.publicKey.challenge, origin })); + const ad = authData(await sha256(rpId), 0x05, counter); + const signed = new Uint8Array([...ad, ...(await sha256(clientData))]); + const algo = this.kind === "ES256" ? { name: "ECDSA", hash: "SHA-256" } : { name: "RSASSA-PKCS1-v1_5" }; + let sig = new Uint8Array(await crypto.subtle.sign(algo, this.keys.privateKey, signed)); + if (this.kind === "ES256") sig = rawToDer(sig); + return { challengeId: options.challengeId, id: this.id, clientDataJSON: b64u(clientData), authenticatorData: b64u(ad), + signature: b64u(sig) }; + } +} + +// sign up: options for this host, then the passkey's answer makes the account and a session +const key = await Authenticator.make(); +let r = await call("/api/auth/register/start", { body: { name: "Ada" } }); +let opts = await r.json(); +assert.equal(opts.publicKey.rp.id, HOST); +assert.equal(opts.publicKey.user.name, "Ada"); +assert.deepEqual(opts.publicKey.pubKeyCredParams.map((p) => p.alg), [-7, -257]); +r = await call("/api/auth/register/finish", { body: await key.create(opts) }); +assert.equal(r.status, 200, await r.clone().text()); +const ada = (await r.json()).user; +assert.equal(ada.name, "Ada"); +let cookie = cookieOf(r); +assert.match(r.headers.get("set-cookie"), /^__Host-time=[A-Za-z0-9_-]+; Path=\/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000$/); + +// the session knows who it is, and keeps what they save +assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada); +r = await call("/api/items", { body: { deck: "# mine" }, cookie }); +const mine = (await r.json()).id; +await call("/api/items", { body: { deck: "# someone else's" } }); +r = await call("/api/me/items", { cookie }); +assert.deepEqual((await r.json()).items.map((i) => i.id), [mine]); +assert.equal((await call("/api/me/items")).status, 401); + +// sign out, then sign back in with the same passkey (discoverable: no username asked) +r = await call("/api/auth/logout", { body: {}, cookie }); +assert.match(r.headers.get("set-cookie"), /Max-Age=0/); +assert.equal((await call("/api/auth/me", { cookie })).status, 401); +opts = await (await call("/api/auth/login/start", { body: {} })).json(); +assert.equal(opts.publicKey.rpId, HOST); +const answer = await key.get(opts); +r = await call("/api/auth/login/finish", { body: answer }); +assert.equal(r.status, 200, await r.clone().text()); +cookie = cookieOf(r); +assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada); +assert.deepEqual((await (await call("/api/me/items", { cookie })).json()).items.map((i) => i.id), [mine]); + +// what must fail +const fails = async (path, body, why) => { + const res = await call(path, { body }); + assert.equal(res.status, 400, `${why}: ${res.status}`); + return (await res.json()).error; +}; +assert.match(await fails("/api/auth/login/finish", answer, "replayed answer"), /expired/); +let o = await (await call("/api/auth/login/start", { body: {} })).json(); +assert.match(await fails("/api/auth/login/finish", await key.get(o, { origin: "https://evil.example" }), "phishing origin"), /origin/); +o = await (await call("/api/auth/login/start", { body: {} })).json(); +assert.match(await fails("/api/auth/login/finish", await key.get(o, { rpId: "evil.example" }), "other site's passkey"), /relying party/); +o = await (await call("/api/auth/login/start", { body: {} })).json(); +assert.match(await fails("/api/auth/login/finish", await key.get(o, { counter: 1 }), "counter went back"), /cloned/); +o = await (await call("/api/auth/login/start", { body: {} })).json(); +const forged = await key.get(o); +const bad = unb64u(forged.signature); +bad[bad.length - 1] ^= 1; +assert.match(await fails("/api/auth/login/finish", { ...forged, signature: b64u(bad) }, "tampered signature"), /signature/); +o = await (await call("/api/auth/login/start", { body: {} })).json(); +const stranger = await Authenticator.make(); +assert.match(await fails("/api/auth/login/finish", await stranger.get(o), "unregistered passkey"), /unknown passkey/); +o = await (await call("/api/auth/register/start", { body: { name: "Again" } })).json(); +assert.match(await fails("/api/auth/register/finish", await key.create(o), "same passkey twice"), /already registered/); +o = await (await call("/api/auth/register/start", { body: { name: "Eve" } })).json(); +assert.match(await fails("/api/auth/register/finish", await stranger.create(o, { origin: "https://o.github.io" }), "from Pages"), /origin/); +assert.equal((await call("/api/auth/login/start", { body: {}, origin: "https://o.github.io" })).status, 403); +assert.equal((await call("/api/auth/me", { cookie: "__Host-time=forged" })).status, 401); +// a failed sign-up left no half-made account behind +assert.equal(env.DB.db.prepare("SELECT COUNT(*) AS n FROM users").all()[0].n, 1); + +// RSA passkeys (RS256, some Windows Hello setups) work too +const rsa = await Authenticator.make("RS256"); +o = await (await call("/api/auth/register/start", { body: { name: "Grace" } })).json(); +r = await call("/api/auth/register/finish", { body: await rsa.create(o) }); +assert.equal(r.status, 200, await r.clone().text()); +o = await (await call("/api/auth/login/start", { body: {} })).json(); +r = await call("/api/auth/login/finish", { body: await rsa.get(o) }); +assert.equal((await r.json()).user.name, "Grace"); + +// the other endpoints still work on a database that has accounts +r = await call("/api/health"); +assert.equal((await r.json()).ok, true); + +console.log("AUTH OK"); diff --git a/.github/time/cloudflare/test_worker.mjs b/.github/time/cloudflare/test_worker.mjs index 09a3b98..2dba3ef 100644 --- a/.github/time/cloudflare/test_worker.mjs +++ b/.github/time/cloudflare/test_worker.mjs @@ -60,7 +60,7 @@ assert.equal(r.status, 201); const saved = await r.json(); assert.match(saved.id, /^[a-f0-9]{12}$/); assert.equal(saved.link, `https://mdecks.yolo.lol/?s=${saved.id}`); -assert.equal(env.DB.created, 2); +assert.equal(env.DB.created, 7); // every table, created once r = await call(`/api/items/${saved.id}`); assert.deepEqual(await r.json(), { slides: ["# Hi"] }); assert.equal((await call("/api/items/doesnotexist1")).status, 404); diff --git a/.github/time/cloudflare/time.js b/.github/time/cloudflare/time.js index 099c837..c49de41 100644 --- a/.github/time/cloudflare/time.js +++ b/.github/time/cloudflare/time.js @@ -1,24 +1,44 @@ /* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it. - Every call resolves to null when the backend can't help (no network, over the daily limit, or the page is being - checked locally), so a product uses these only for extras and works fully without them. - Time.save(obj) -> a share link (string) or null + Every call resolves to null (or false) when the backend can't help (no network, over the daily limit, or the page is + being checked locally), so a product uses these only for extras and works fully without them. + Time.save(obj) -> a share link (string) or null; when someone is signed in it is also theirs Time.load() -> the object a share link (?s=...) carried, or null Time.ai(prompt, {system}) -> text from a small model, or null - Time.ready -> a promise of true when the backend answers */ + Time.ready -> a promise of true when the backend answers + Accounts, with passkeys (Face ID, Touch ID, Windows Hello, a phone): no passwords, no email. + Time.accounts -> true when this page can sign people in (its own domain, a browser with passkeys) + Time.user() -> { id, name } of whoever is signed in, or null + Time.signUp(name) -> makes an account with a new passkey and signs in: { id, name } or null + Time.signIn() -> signs in with a passkey made here before: { id, name } or null + Time.signOut() -> true + Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */ (function () { var API = "__TIME_API__"; var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:"; + var home = API.replace(/\/api\/?$/, ""); + var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential; function call(path, init) { if (local) return Promise.resolve(null); return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } function post(path, body) { - return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); } + function bytes(s) { + s = s.replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(s + "===".slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0); }); + } + function text(buf) { + var b = new Uint8Array(buf), s = ""; + for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + function user(r) { return r && r.user ? r.user : null; } window.Time = { api: API, + accounts: accounts, ready: call("health").then(function (r) { return !!(r && r.ok); }), save: function (obj) { return post("items", obj).then(function (r) { return r && r.link ? r.link : null; }); @@ -30,6 +50,45 @@ ai: function (prompt, opts) { return post("ai", { prompt: String(prompt || ""), system: opts && opts.system }) .then(function (r) { return r && typeof r.text === "string" ? r.text : null; }); + }, + user: function () { + return accounts ? call("auth/me").then(user) : Promise.resolve(null); + }, + signUp: function (name) { + if (!accounts) return Promise.resolve(null); + return post("auth/register/start", { name: name }).then(function (o) { + if (!o) return null; + var pk = o.publicKey; + pk.challenge = bytes(pk.challenge); + pk.user.id = bytes(pk.user.id); + return navigator.credentials.create({ publicKey: pk }).then(function (c) { + var r = c.response; + return post("auth/register/finish", { + challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON), + authenticatorData: text(r.getAuthenticatorData()), publicKey: text(r.getPublicKey()), alg: r.getPublicKeyAlgorithm() + }).then(user); + }); + }).catch(function () { return null; }); + }, + signIn: function () { + if (!accounts) return Promise.resolve(null); + return post("auth/login/start").then(function (o) { + if (!o) return null; + o.publicKey.challenge = bytes(o.publicKey.challenge); + return navigator.credentials.get({ publicKey: o.publicKey }).then(function (c) { + var r = c.response; + return post("auth/login/finish", { + challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON), + authenticatorData: text(r.authenticatorData), signature: text(r.signature) + }).then(user); + }); + }).catch(function () { return null; }); + }, + signOut: function () { + return accounts ? post("auth/logout").then(function () { return true; }) : Promise.resolve(true); + }, + mine: function () { + return accounts ? call("me/items").then(function (r) { return r && r.items ? r.items : null; }) : Promise.resolve(null); } }; })(); diff --git a/.github/time/cloudflare/worker.js b/.github/time/cloudflare/worker.js index 13fe499..34d4c87 100644 --- a/.github/time/cloudflare/worker.js +++ b/.github/time/cloudflare/worker.js @@ -5,14 +5,34 @@ // POST /api/items -> { id, link } save a JSON creation (<= 64 KB), for share links // GET /api/items/:id -> the saved JSON // POST /api/ai -> { text } { prompt, system? } through Workers AI, rate limited +// accounts, with passkeys (WebAuthn): no passwords, no email, nothing to configure +// POST /api/auth/register/start { name } -> { challengeId, publicKey } options for navigator.credentials.create +// POST /api/auth/register/finish -> { user } sets the session cookie +// POST /api/auth/login/start -> { challengeId, publicKey } options for navigator.credentials.get +// POST /api/auth/login/finish -> { user } sets the session cookie +// GET /api/auth/me -> { user } or 401 POST /api/auth/logout +// GET /api/me/items -> { items } what the signed-in person saved (POST /api/items records the owner) // anything else -> the venture's page on GitHub Pages (ORIGIN), served under this domain // +// Accounts live on the venture's own domain: the passkey's relying party is this host, and the session is a +// __Host- cookie (HttpOnly, Secure, SameSite=Lax). On GitHub Pages or in the checks there is no session, by design. +// // Bindings: DB (D1), AI (Workers AI). Vars: SLUG, ORIGIN, PAGES_ORIGIN, REV, AI_MODEL, AI_DAILY, SAVE_DAILY. const SCHEMA = [ "CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT NOT NULL)", "CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS creds (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, spki TEXT NOT NULL, alg INTEGER NOT NULL, " + + "counter INTEGER NOT NULL, created TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS challenges (id TEXT PRIMARY KEY, challenge TEXT NOT NULL, kind TEXT NOT NULL, user_id TEXT, " + + "name TEXT, expires INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS owned (item_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, created TEXT NOT NULL)", ]; +const COOKIE = "__Host-time"; +const SESSION_DAYS = 30; +const CHALLENGE_MS = 5 * 60 * 1000; const MAX_ITEM = 65536; const MAX_PROMPT = 4000; let ready = null; @@ -48,11 +68,184 @@ async function spend(env, kind, person, perPerson, total) { return mine.results[0].n <= perPerson && all.results[0].n <= total; } +// ---------- passkeys (WebAuthn) ---------- + +const enc = new TextEncoder(); +const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); +const random = (n) => crypto.getRandomValues(new Uint8Array(n)); +const sha256 = async (data) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof data === "string" ? enc.encode(data) : data)); +const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); +const ALGS = { + "-7": { importAs: { name: "ECDSA", namedCurve: "P-256" }, verifyAs: { name: "ECDSA", hash: "SHA-256" }, der: true }, + "-257": { importAs: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verifyAs: { name: "RSASSA-PKCS1-v1_5" }, der: false }, +}; + +// Authenticators sign ES256 as DER (SEQUENCE of two INTEGERs); WebCrypto wants r || s, 32 bytes each. +function derToRaw(der) { + let i = 2; + if (der[1] & 0x80) i += der[1] & 0x7f; + const part = () => { + if (der[i++] !== 0x02) throw new Error("bad signature"); + const len = der[i++]; + let v = der.slice(i, i + len); + i += len; + while (v.length > 32 && v[0] === 0) v = v.slice(1); + const out = new Uint8Array(32); + out.set(v, 32 - v.length); + return out; + }; + const r = part(), s = part(); + const raw = new Uint8Array(64); + raw.set(r); + raw.set(s, 32); + return raw; +} + +async function challenge(env, kind, extra = {}) { + const id = b64u(random(12)), value = b64u(random(32)), now = Date.now(); + await env.DB.batch([ + env.DB.prepare("DELETE FROM challenges WHERE expires < ?").bind(now), + env.DB.prepare("INSERT INTO challenges (id, challenge, kind, user_id, name, expires) VALUES (?, ?, ?, ?, ?, ?)") + .bind(id, value, kind, extra.user ?? null, extra.name ?? null, now + CHALLENGE_MS), + ]); + return { id, value }; +} + +// One use only: the row is gone whether or not the answer checks out. +async function takeChallenge(env, id, kind) { + const row = await env.DB.prepare("DELETE FROM challenges WHERE id = ? RETURNING challenge, kind, user_id, name, expires") + .bind(String(id || "")).first(); + if (!row || row.kind !== kind || row.expires < Date.now()) throw new AuthError("that sign-in attempt expired; try again"); + return row; +} + +class AuthError extends Error {} + +// What the browser and the authenticator both vouched for: the ceremony, our challenge, our origin, our host. +async function checkCeremony(url, type, expected, clientDataB64, authDataB64) { + const clientRaw = unb64u(clientDataB64); + const client = JSON.parse(new TextDecoder().decode(clientRaw)); + if (client.type !== type) throw new AuthError("wrong ceremony"); + if (client.challenge !== expected) throw new AuthError("challenge mismatch"); + if (client.origin !== url.origin) throw new AuthError("wrong origin"); + const auth = unb64u(authDataB64); + if (auth.length < 37 || !same(auth.slice(0, 32), await sha256(url.hostname))) throw new AuthError("wrong relying party"); + const flags = auth[32]; + if (!(flags & 0x01)) throw new AuthError("no user presence"); + const counter = new DataView(auth.buffer, auth.byteOffset + 33, 4).getUint32(0); + return { clientRaw, auth, flags, counter }; +} + +async function startSession(env, url, userId, h) { + const token = b64u(random(32)); + const expires = Date.now() + SESSION_DAYS * 86400e3; + await env.DB.prepare("INSERT INTO sessions (hash, user_id, expires) VALUES (?, ?, ?)").bind(b64u(await sha256(token)), userId, expires).run(); + const user = await env.DB.prepare("SELECT id, name FROM users WHERE id = ?").bind(userId).first(); + return json({ user }, 200, { ...h, "set-cookie": `${COOKIE}=${token}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}` }); +} + +function cookieToken(req) { + const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`)); + return m ? m[1] : null; +} + +async function currentUser(req, env) { + const token = cookieToken(req); + if (!token) return null; + return env.DB.prepare("SELECT users.id, users.name FROM sessions JOIN users ON users.id = sessions.user_id " + + "WHERE sessions.hash = ? AND sessions.expires > ?").bind(b64u(await sha256(token)), Date.now()).first(); +} + +async function auth(req, env, url, path, h) { + // Accounts belong to this domain: refuse cross-origin calls outright. + const origin = req.headers.get("origin"); + if (origin && origin !== url.origin) return json({ error: "sign in on the venture's own domain" }, 403, h); + if (path === "/api/auth/me") { + const user = await currentUser(req, env); + return user ? json({ user }, 200, h) : json({ error: "not signed in" }, 401, h); + } + if (path === "/api/auth/logout" && req.method === "POST") { + const token = cookieToken(req); + if (token) await env.DB.prepare("DELETE FROM sessions WHERE hash = ?").bind(b64u(await sha256(token))).run(); + return json({ ok: true }, 200, { ...h, "set-cookie": `${COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0` }); + } + if (req.method !== "POST") return json({ error: "no such endpoint" }, 404, h); + let body; + try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); } + try { + if (path === "/api/auth/register/start") { + if (!(await spend(env, "register", await who(req, env), 20, 2000))) return json({ error: "over today's limit" }, 429, h); + const name = String(body.name || "").trim().slice(0, 40) || "Me"; + const user = b64u(random(16)); + const c = await challenge(env, "register", { user, name }); + return json({ challengeId: c.id, publicKey: { + challenge: c.value, rp: { name: env.SLUG, id: url.hostname }, user: { id: user, name, displayName: name }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }], + authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, attestation: "none", timeout: 60000, + } }, 200, h); + } + if (path === "/api/auth/register/finish") { + const c = await takeChallenge(env, body.challengeId, "register"); + const { flags, counter } = await checkCeremony(url, "webauthn.create", c.challenge, body.clientDataJSON, body.authenticatorData); + if (!(flags & 0x40)) throw new AuthError("no credential in the answer"); + const alg = ALGS[String(body.alg)]; + if (!alg || !body.publicKey || !/^[A-Za-z0-9_-]{16,1400}$/.test(String(body.id))) throw new AuthError("unsupported passkey"); + await crypto.subtle.importKey("spki", unb64u(body.publicKey), alg.importAs, false, ["verify"]); // throws if it isn't a key + const now = new Date().toISOString(); + try { + await env.DB.batch([ + env.DB.prepare("INSERT INTO users (id, name, created) VALUES (?, ?, ?)").bind(c.user_id, c.name, now), + env.DB.prepare("INSERT INTO creds (id, user_id, spki, alg, counter, created) VALUES (?, ?, ?, ?, ?, ?)") + .bind(body.id, c.user_id, body.publicKey, Number(body.alg), counter, now), + ]); + } catch { throw new AuthError("that passkey is already registered here"); } + return startSession(env, url, c.user_id, h); + } + if (path === "/api/auth/login/start") { + if (!(await spend(env, "login", await who(req, env), 60, 5000))) return json({ error: "over today's limit" }, 429, h); + const c = await challenge(env, "login"); + return json({ challengeId: c.id, publicKey: { challenge: c.value, rpId: url.hostname, userVerification: "preferred", + timeout: 60000 } }, 200, h); + } + if (path === "/api/auth/login/finish") { + const c = await takeChallenge(env, body.challengeId, "login"); + const { clientRaw, auth: authData, counter } = await checkCeremony(url, "webauthn.get", c.challenge, body.clientDataJSON, + body.authenticatorData); + const cred = await env.DB.prepare("SELECT user_id, spki, alg, counter FROM creds WHERE id = ?").bind(String(body.id || "")).first(); + if (!cred) throw new AuthError("unknown passkey for this site"); + const alg = ALGS[String(cred.alg)]; + const key = await crypto.subtle.importKey("spki", unb64u(cred.spki), alg.importAs, false, ["verify"]); + const signed = new Uint8Array(authData.length + 32); + signed.set(authData); + signed.set(await sha256(clientRaw), authData.length); + let sig = unb64u(body.signature); + if (alg.der) sig = derToRaw(sig); + if (!(await crypto.subtle.verify(alg.verifyAs, key, sig, signed))) throw new AuthError("signature does not check out"); + if ((counter || cred.counter) && counter <= cred.counter) throw new AuthError("this passkey looks cloned"); + await env.DB.prepare("UPDATE creds SET counter = ? WHERE id = ?").bind(counter, body.id).run(); + return startSession(env, url, cred.user_id, h); + } + } catch (e) { + if (e instanceof AuthError) return json({ error: e.message }, 400, h); + throw e; + } + return json({ error: "no such endpoint" }, 404, h); +} + async function api(req, env, url, h) { const path = url.pathname.replace(/\/+$/, ""); if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: h }); if (path === "/api/health") return json({ ok: true, slug: env.SLUG, rev: env.REV, ai: !!env.AI }, 200, h); await setup(env); + if (path.startsWith("/api/auth/")) return auth(req, env, url, path, h); + if (path === "/api/me/items") { + const user = await currentUser(req, env); + if (!user) return json({ error: "not signed in" }, 401, h); + const rows = await env.DB.prepare("SELECT item_id, created FROM owned WHERE user_id = ? ORDER BY created DESC LIMIT 50") + .bind(user.id).all(); + return json({ items: rows.results.map((r) => ({ id: r.item_id, link: `${url.origin}/?s=${r.item_id}`, created: r.created })) }, 200, h); + } if (path === "/api/items" && req.method === "POST") { const text = await req.text(); if (text.length > MAX_ITEM) return json({ error: "too big (64 KB max)" }, 413, h); @@ -60,7 +253,10 @@ async function api(req, env, url, h) { if (!(await spend(env, "save", await who(req, env), 50, Number(env.SAVE_DAILY || 2000)))) return json({ error: "over today's limit" }, 429, h); const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12); - await env.DB.prepare("INSERT INTO items (id, data, created) VALUES (?, ?, ?)").bind(id, text, new Date().toISOString()).run(); + const now = new Date().toISOString(); + const user = await currentUser(req, env); + await env.DB.batch([env.DB.prepare("INSERT INTO items (id, data, created) VALUES (?, ?, ?)").bind(id, text, now), + ...(user ? [env.DB.prepare("INSERT INTO owned (item_id, user_id, created) VALUES (?, ?, ?)").bind(id, user.id, now)] : [])]); return json({ id, link: `${url.origin}/?s=${id}` }, 201, h); } const item = path.match(/^\/api\/items\/([a-z0-9]{6,32})$/); diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 9ad7189..9798f27 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -945,6 +945,7 @@ assert w38["backend"]["worker"] == "time-mdecks" and "Backend: https://mdecks.yo assert "https://mdecks.yolo.lol/api/" in W.files["ventures/mdecks/time.js"] brief = vt._build_on_pages.briefs[0] assert "This venture has a backend at https://mdecks.yolo.lol/" in brief and "Time.save(obj)" in brief and "never write it" in brief +assert "Time.signIn()" in brief and "nobody has to sign in to use the free product" in brief reg38 = json.loads(W.files["ventures/ventures.json"])["ventures"][0] assert reg38["url"] == "https://mdecks.yolo.lol/" and reg38["pages"] == w38["pages"] and reg38["backend"]["rev"] == w38["backend"]["rev"] assert "https://mdecks.yolo.lol/" in W.files["ventures/index.html"] and "mdecks.yolo.lol" in W.files["ventures/mdecks/LAUNCH.md"] @@ -1033,3 +1034,9 @@ if _sh.which("node"): print("ALL OK 35") else: print("skipped the worker test: no node") + +# passkey accounts in worker.js, against a real SQLite and a software authenticator (needs node:sqlite) +if _sh.which("node"): + res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_auth.mjs")], capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and ("AUTH OK" in res.stdout or "AUTH SKIPPED" in res.stdout), res.stdout[-2000:] + res.stderr[-2000:] + print("ALL OK 36" if "AUTH OK" in res.stdout else "skipped the passkey test: no node:sqlite") diff --git a/.github/time/venture.py b/.github/time/venture.py index 57776c3..df170a9 100644 --- a/.github/time/venture.py +++ b/.github/time/venture.py @@ -458,9 +458,15 @@ already in your folder: load it with before app. - Time.save(obj) -> a share link to this page that brings obj back, or null - Time.load() -> the object the current share link carried, or null (call it on load and open that creation if present) - Time.ai(prompt, {{system}}) -> text from a small language model, or null (rate limited; a few hundred calls a day) +- Accounts with passkeys (no passwords, no email), only on the venture's own domain: Time.accounts is true when the page + can sign people in. Time.user() -> {{id, name}} or null; Time.signUp(name) and Time.signIn() -> the user or null; + Time.signOut(); Time.mine() -> the share links the signed-in person saved (Time.save records the owner by itself). + Use them for what the plan's paid tier promises, e.g. "Your saved decks": a small "Sign in" control in the header, + shown only when Time.accounts is true, never in front of the product and never before someone has used it. Every call can return null (offline, over the limit, and always during the checks, which run the page locally), so the product must work fully without them: use them for extras such as a "Share link" button that saves the user's creation, -or an AI assist, and hide or disable that extra when the call gives null. The preloaded sample never depends on them. +an AI assist, or saved work for signed-in people, and hide or disable that extra when the call gives null. The +preloaded sample never depends on them, and nobody has to sign in to use the free product. """ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 183a0c9..8309b07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,4 +12,7 @@ jobs: timeout-minutes: 3 steps: - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 # node 22+ has node:sqlite, which the passkey test runs on + with: + node-version: 22 - run: python3 .github/time/test_time.py diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 80d98b7..d7bda8a 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -24,7 +24,7 @@ jobs: - name: copy engine, open PR if changed run: | set -e - for f in .github/time/time.py .github/time/venture.py .github/time/cloudflare.py .github/time/cloudflare/worker.js .github/time/cloudflare/time.js .github/time/cloudflare/test_worker.mjs .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do + for f in .github/time/time.py .github/time/venture.py .github/time/cloudflare.py .github/time/cloudflare/worker.js .github/time/cloudflare/time.js .github/time/cloudflare/test_worker.mjs .github/time/cloudflare/test_auth.mjs .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f" done python3 .github/time/test_time.py > /dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cf9f2..180398f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials. ### Added +- Accounts for ventures, with passkeys: each venture's backend can sign people up and in (Face ID, Touch ID, Windows Hello, a phone), keep a 30-day session, and list what each person saved. No passwords, no email, nothing to configure; accounts live in the venture's own database on its own domain. `time.js` gains `Time.signUp`, `Time.signIn`, `Time.signOut`, `Time.user` and `Time.mine`, and builds are told to use them for the paid tier's promise without ever putting sign-in in front of the free product. - Venture backends use Gemma 4 (`@cf/google/gemma-4-26b-a4b-it`) by default and read both the older `{response}` and the OpenAI-style answer from Workers AI (the first default had been retired). - Ventures can be backed by Cloudflare: with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, each venture gets its own Worker, D1 database and Workers AI, and lives at `.` (or workers.dev) while its page stays on GitHub Pages. The page's `time.js` gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token. - Venture loop launches products, not pages: a launch round where board seats write plans and one synthesizes `LAUNCH.md` with attribution; launch pages where the working product fills the first screen under a slim header with one call to action, then pricing and honest limits, in a look that follows the product; early-access signups through a GitHub issue form, thanked on their issues; deterministic and headless-Chrome checks with fix rounds and an andon; a 7-day decision against success and kill lines; a studio index at `/ventures/`. diff --git a/README.md b/README.md index 0e6ae7a..477d294 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t - **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice). - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures//`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo. -- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `.`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`. +- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `.`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`. - **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys. - **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.