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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
This commit is contained in:
Claude committed 2026-10-12 18:46:56 +00:00
1 parent cb687873d3
commit c54cbf2596
11 files changed
+470 -11

No files matched your search

+187
View File
@@ -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");
+1 -1
View File
@@ -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);
+64 -5
View File
@@ -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);
}
};
})();
+197 -1
View File
@@ -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})$/);
+7
View File
@@ -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")
+7 -1
View File
@@ -458,9 +458,15 @@ already in your folder: load it with <script src="time.js"></script> 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.
"""
+3
View File
@@ -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
+1 -1
View File
@@ -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