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:
11 files changed
+470
-11
No files matched your search
@@ -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-<slug>`, 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-<slug>`, 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 `<slug>.example.com`. Without it, ventures live at `time-<slug>.<account>.workers.dev`.
|
||||
- Optional: `TIME_VENTURE_AI_MODEL` (default `@cf/google/gemma-4-26b-a4b-it`).
|
||||
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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})$/);
|
||||
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<slug>.<TIME_VENTURE_DOMAIN>` (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/`.
|
||||
|
||||
@@ -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/<slug>/`. 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 `<slug>.<TIME_VENTURE_DOMAIN>`. 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 `<slug>.<TIME_VENTURE_DOMAIN>`. 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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user