Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
573621a
Read each poll page before applying the timestamp cutoff
Alitleis123 Nov 8, 2026
61a5608
Keep audio controls working when browser storage is blocked
Alitleis123 Nov 8, 2026
c4bdf2a
Update Next.js and compatible security fixes
Alitleis123 Nov 9, 2026
4ba505c
Check lint, backend tests and static builds on pull requests
Alitleis123 Nov 9, 2026
61379e3
Show an error when the panel cannot save the visit counting setting
Alitleis123 Nov 9, 2026
a2a248a
Open the gallery above the nav bar and floating buttons
Alitleis123 Nov 9, 2026
be13269
Keep keys typed in Ask from moving the gallery behind it
Alitleis123 Nov 9, 2026
2803474
Find passages for Ask questions sent before the search index loads
Alitleis123 Nov 9, 2026
b9ab7e2
Let keyboard selection in the studio stop the guided run
Alitleis123 Nov 9, 2026
7ada3f0
Leave browser shortcuts with modifier keys alone in the studio
Alitleis123 Nov 9, 2026
1575664
Stop smooth scrolling for visitors who ask for less motion
Alitleis123 Nov 9, 2026
6d17e90
Let share cards for the studio and reading view link to those pages
Alitleis123 Nov 9, 2026
fc762e5
Cut a visit's referring host to 253 characters before logging it
Alitleis123 Nov 9, 2026
8f01bfb
Spend a passkey challenge even when its origin or ceremony is wrong
Alitleis123 Nov 9, 2026
4e8f8b3
Step the panel timeline ruler by calendar days across clock changes
Alitleis123 Nov 9, 2026
8da9860
Let Tab reach expandable questions inside a focus-trapped sheet
Alitleis123 Nov 9, 2026
b2023a7
Time the panel's live list by each visitor's latest view or question
Alitleis123 Nov 9, 2026
98ec133
Apply only the newest panel refresh when two overlap
Alitleis123 Nov 9, 2026
16febff
Update the visit counting note after adding a passkey
Alitleis123 Nov 9, 2026
284c1a9
Name the panel's icon-only copy button for screen readers
Alitleis123 Nov 9, 2026
21ffd8e
Average the two middle answer times for an even count
Alitleis123 Nov 9, 2026
2a116b1
Stop the panel showing 60m, 24h or 7d ago at rounding edges
Alitleis123 Nov 9, 2026
52677f5
Run the panel's poll script against a real Redis in pull request checks
Alitleis123 Nov 9, 2026
0701d30
Keep the studio frame on screen when a visitor takes over the guided run
Alitleis123 Nov 9, 2026
e0e9e00
Let slow panel polls land, dropping only answers older than one shown
Alitleis123 Nov 9, 2026
36608f7
Send Upstash requests as JSON so the Redis check runs in CI
Alitleis123 Nov 9, 2026
471fe37
Close only Ask when Escape is pressed over an open gallery
Alitleis123 Nov 9, 2026
b86fd99
Spend a pairing code when its passkey is stored, not when the prompt …
Alitleis123 Nov 9, 2026
87812ee
Forget devices with long credential ids, and treat one already gone a…
Alitleis123 Nov 9, 2026
0ee2772
Keep the panel locked after a sign-out, and keep entries from overlap…
Alitleis123 Nov 9, 2026
64663d5
Store a paired passkey and spend its code in one Redis script
Alitleis123 Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Check pull request

on:
pull_request:
branches: ["main"]

permissions:
contents: read

concurrency:
group: checks-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
env:
NEXT_TELEMETRY_DISABLED: "1"
# A throwaway Redis behind Upstash's REST API, so ask/test/redis.test.js
# runs the panel's poll script for real.
services:
redis:
image: redis:7-alpine
srh:
image: hiett/serverless-redis-http:0.0.10
env:
SRH_MODE: env
SRH_TOKEN: test-token
SRH_CONNECTION_STRING: redis://redis:6379
ports:
- 8079:80
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test backend
env:
TEST_REDIS_REST_URL: http://localhost:8079
TEST_REDIS_REST_TOKEN: test-token
run: node --test ask/test/*.test.js

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Render resume preview
run: |
python -m pip install --quiet pymupdf==2.0 pillow==13.0.0
python scripts/render-resume-preview.py

- name: Build static site
run: npm run build
3 changes: 2 additions & 1 deletion ask/api/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export async function POST(request) {
return reply(request, { ok: true });

case "forget":
return (await forget(clip(body.id, 200)))
// WebAuthn allows a credential id of 1023 bytes, 1364 in base64url.
return (await forget(clip(body.id, 1400)))
? reply(request, { ok: true })
: reply(request, { error: "Keep at least one device, or the password opens setup again." }, 400);

Expand Down
3 changes: 2 additions & 1 deletion ask/api/visit.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const clip = (v, max) => (typeof v === "string" && v ? v.slice(0, max) : null);
function from(referrer) {
try {
const host = new URL(referrer).hostname.replace(/^www\./, "");
return host === "alitleis.dev" ? null : host;
// A URL's host has no length limit, and DNS stops at 253.
return host === "alitleis.dev" ? null : host.slice(0, 253);
} catch {
return null;
}
Expand Down
62 changes: 41 additions & 21 deletions ask/lib/owner.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const ALGS = { "-7": "ec", "-8": "ed25519", "-257": "rsa" };
export const passkeys = () => hash(PASSKEYS);

/**
* Writes that depend on what the hash holds, run inside Redis so a second
* Writes that depend on what Redis holds, run inside Redis so a second
* request cannot slip in between the check and the write. Each returns -1
* when its condition fails.
*/
Expand All @@ -65,8 +65,13 @@ export const passkeys = () => hash(PASSKEYS);
const SWAP = `if redis.call("HGET", KEYS[1], ARGV[1]) == ARGV[2] then return redis.call("HSET", KEYS[1], ARGV[1], ARGV[3]) end return -1`;
// Stores the first passkey only while there is none.
const FIRST = `if redis.call("HLEN", KEYS[1]) == 0 then return redis.call("HSET", KEYS[1], ARGV[1], ARGV[2]) end return -1`;
// Forgets a passkey only while another is left.
const DROP = `if redis.call("HLEN", KEYS[1]) > 1 then return redis.call("HDEL", KEYS[1], ARGV[1]) end return -1`;
// Forgets a passkey only while another is left. One already gone answers 0.
const DROP = `if redis.call("HEXISTS", KEYS[1], ARGV[1]) ~= 1 then return 0 end if redis.call("HLEN", KEYS[1]) > 1 then return redis.call("HDEL", KEYS[1], ARGV[1]) end return -1`;
// Stores a passkey made with a pairing code and spends the code, only while
// the code is the one the challenge was issued for. In one script, so a
// second device racing or a newer code cannot use it, and a write that fails
// leaves the code for another try.
const PAIRED = `if redis.call("GET", KEYS[2]) == ARGV[3] then redis.call("HSET", KEYS[1], ARGV[1], ARGV[2]) redis.call("DEL", KEYS[2]) return 1 end return -1`;

// ── Challenges ───────────────────────────────────────────────────────────────

Expand All @@ -93,9 +98,9 @@ async function client(json, type) {
} catch {
return null;
}
if (data?.type !== type || data.origin !== ORIGIN) return null;
if (typeof data.challenge !== "string" || !/^[\w-]{43}$/.test(data.challenge)) return null;
if (typeof data?.challenge !== "string" || !/^[\w-]{43}$/.test(data.challenge)) return null;
const [kind] = await must([["GETDEL", `${NS}challenge:${data.challenge}`]]);
if (data.type !== type || data.origin !== ORIGIN) return null;
return kind ?? null;
}

Expand All @@ -114,12 +119,16 @@ function authenticator(data) {

/**
* Whether this request may add a passkey, as the kind of challenge to issue:
* "enroll" from a signed-in device or with a pairing code one made, "first"
* with the password while there are none at all, null otherwise.
* "enroll" from a signed-in device, "pair:" and the code's hash with a pairing
* code one made, "first" with the password while there are none at all, null
* otherwise.
*/
export async function mayEnroll(request, { password, code } = {}) {
if (await signedIn(request)) return "enroll";
if (code) return (await redeemPair(code)) ? "enroll" : null;
if (code) {
const pair = await checkPair(code);
return pair ? `pair:${pair}` : null;
}
if (typeof password !== "string" || !password || !process.env.ADMIN_PASSWORD) return null;
const [count] = await must([["HLEN", PASSKEYS]]);
return count === 0 && same(password, process.env.ADMIN_PASSWORD) ? "first" : null;
Expand All @@ -146,7 +155,8 @@ const TRANSPORTS = new Set(["ble", "hybrid", "internal", "nfc", "usb"]);
/** Stores a new passkey from the browser's answer and signs that device in. */
export async function enroll(cred, name) {
const kind = await client(cred?.clientDataJSON, "webauthn.create");
if (kind !== "enroll" && kind !== "first") return null;
const paired = typeof kind === "string" && kind.startsWith("pair:");
if (kind !== "enroll" && kind !== "first" && !paired) return null;

const data = unb64(cred.authenticatorData);
const auth = authenticator(data);
Expand Down Expand Up @@ -179,9 +189,15 @@ export async function enroll(cred, name) {
used: new Date().toISOString(),
});
// Two challenges the password granted while the hash was empty must not
// both add a device. The second one finds the first already there.
// both add a device. The second one finds the first already there. A
// pairing code is spent here, with the passkey, rather than when the
// options went out. Spent then, a cancelled Face ID prompt killed the code.
const [stored] = await must([
kind === "first" ? ["EVAL", FIRST, 1, PASSKEYS, b64(id), record] : ["HSET", PASSKEYS, b64(id), record],
kind === "first"
? ["EVAL", FIRST, 1, PASSKEYS, b64(id), record]
: paired
? ["EVAL", PAIRED, 2, PASSKEYS, PAIR, b64(id), record, kind.slice(5)]
: ["HSET", PASSKEYS, b64(id), record],
]);
if (!(stored >= 0)) return null;
return startSession(b64(id));
Expand Down Expand Up @@ -272,10 +288,14 @@ export async function endSession(request) {
if (token) await must([["DEL", sessionKey(token)]]);
}

/** Forgets a device, unless it is the last one, which would reopen the password. */
/**
* Forgets a device, unless it is the last one, which would reopen the
* password. One already gone counts as forgotten, as when two tabs both press
* Forget, rather than reading as the last device.
*/
export async function forget(id) {
const [removed] = await must([["EVAL", DROP, 1, PASSKEYS, String(id)]]);
return removed === 1;
return removed !== -1;
}

// ── Pairing ──────────────────────────────────────────────────────────────────
Expand All @@ -301,20 +321,20 @@ export async function startPair() {
return { code: `${code.slice(0, 4)}-${code.slice(4)}`, expires: Date.now() + PAIR_SECONDS * 1000 };
}

async function redeemPair(code) {
/**
* The stored hash when the code matches, without spending it. enroll spends
* it, so only one device gets in on it however many prompts were started.
*/
async function checkPair(code) {
const [stored] = await must([["GET", PAIR]]);
if (!stored) return false;
if (same(sha(normal(code)).toString("hex"), stored)) {
// Two devices racing with the same code: only the one that deletes it wins.
const [deleted] = await must([["DEL", PAIR]]);
return deleted === 1;
}
if (!stored) return null;
if (same(sha(normal(code)).toString("hex"), stored)) return stored;
const [fails] = await must([
["INCR", PAIR_FAILS],
["EXPIRE", PAIR_FAILS, PAIR_SECONDS],
]);
if (fails >= 5) await must([["DEL", PAIR]]);
return false;
return null;
}

// ── Responses ────────────────────────────────────────────────────────────────
Expand Down
33 changes: 18 additions & 15 deletions ask/lib/track.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export async function redis(commands) {
if (!DB_URL || !DB_TOKEN) return null;
const res = await fetch(`${DB_URL}/pipeline`, {
method: "POST",
headers: { Authorization: `Bearer ${DB_TOKEN}` },
// Upstash reads the body either way. The Redis stand-in the pull request
// checks run answers 400 to the text/plain a bare string body gets.
headers: { Authorization: `Bearer ${DB_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(commands),
signal: AbortSignal.timeout(3000),
});
Expand Down Expand Up @@ -110,22 +112,23 @@ export async function recent(list) {
}

/**
* Entries logged after `since`, read from the head of the list a page at a
* time. The panel polls with this, so a poll moves a few new entries out of
* Upstash rather than the whole log, which on the free plan's 10GB a month
* would run out after a few days of leaving the panel open.
* Answers finish out of order, so a timestamp cannot end a list scan early.
* Filter the capped list inside Redis to keep old entries off the network
* and within the free plan's 10GB monthly transfer limit.
*/
const NEWER = `
local out = {}
for _, item in ipairs(redis.call("LRANGE", KEYS[1], 0, tonumber(ARGV[2]) - 1)) do
local ok, entry = pcall(cjson.decode, item)
if ok and type(entry) == "table" and type(entry.t) == "string" and entry.t > ARGV[1] then
table.insert(out, item)
end
end
return out`;

export async function newer(list, since) {
const out = [];
for (let start = 0; start < KEEP; start += 100) {
const [items] = await must([["LRANGE", list, start, start + 99]]);
for (const entry of parsed(items)) {
if (entry.t <= since) return out;
out.push(entry);
}
if (items.length < 100) break;
}
return out;
const [items] = await must([["EVAL", NEWER, 1, list, since, KEEP]]);
return parsed(items);
}

/** Crawlers and headless checks, which would otherwise read as visitors. */
Expand Down
Loading
Loading