diff --git a/CHECKLIST.md b/CHECKLIST.md index a9ac4d1..62d234e 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -40,7 +40,7 @@ Do section A, then: ## D. After changes to the timeline / protected pages -- [ ] [browser] Timeline loads fully: globe visible, 23 event cards, dot-nav at bottom, fonts render as true italic (not system oblique) +- [ ] [browser] Timeline loads fully: globe visible, 12 event cards, dot-nav at bottom, fonts render as true italic (not system oblique) - [ ] [browser] Scroll works smoothly, card reveal animations fire - [ ] [browser] Dot-nav switches between Timeline / Overview / Archive views - [ ] DevTools console: no CSP violations, no 404s for fonts/vendor files @@ -70,6 +70,16 @@ Do section A with extra attention to: - [ ] After reload, `curl -I https://project-bifrost.fenja.ai/` includes `X-Powered-By: Express` (proves Nginx is still proxying to Node, not serving static files) - [ ] Rate-limit zone still exists: `grep -r "limit_req_zone" /etc/nginx/` +## H1. After changes to the Join-CTA tracking (bifrost_joins) + +- [ ] [browser, logged in] Click the final "Join Project Bifrost" CTA → confirmation panel renders (staggered checkmarks appear) +- [ ] DevTools network tab: `POST /api/bifrost-join` returns 200 with `{clicked_at: }` +- [ ] `sudo -u fenja node /opt/fenja/bin/joins.js list` shows a new row with your email and a current timestamp +- [ ] Click the CTA a second time (refresh the page first): a second row appears — the log is per-click, not per-user +- [ ] `sudo -u fenja node /opt/fenja/bin/joins.js summary` groups by email with correct `click_count` +- [ ] `sudo -u fenja node /opt/fenja/bin/joins.js stats` totals match what you see in `list` +- [ ] Logged out: `curl -X POST https://project-bifrost.fenja.ai/api/bifrost-join` → 401 (auth gate holds) + ## H. After data/DB changes (schema, migrations) - [ ] Fresh boot creates schema cleanly: stop service, `mv data/fenja.sqlite data/fenja.sqlite.bak`, restart, verify it comes up healthy diff --git a/CLAUDE.md b/CLAUDE.md index d7bfbec..efd9b62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ npm install # install deps npm run dev # node --watch server.js, binds 127.0.0.1:3000 npm start # production start node bin/invite.js add # invite (also: remove, list) +node bin/joins.js list # read join-CTA click log + # (also: summary, for , stats) ``` There is no test suite, linter, or build step. Verification is the checklist in `CHECKLIST.md`, primarily by walking the entrance → code → timeline flow in a browser. @@ -36,7 +38,9 @@ Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public i **Auth flow** (see `src/auth.js`): email → 6-digit code (HMAC-SHA256 with `CODE_PEPPER`, 10-min TTL) → `/auth/verify-code` (constant-time compare, 5 wrong guesses nukes the code) → opaque 256-bit session ID stored in SQLite, set as `HttpOnly; Secure; SameSite=Lax` cookie. No JWTs; revocation is a DELETE. `/auth/request-code` always returns 200 regardless of invite status (email-enumeration defense). -**Storage** (`src/db.js`): `better-sqlite3` at `data/fenja.sqlite`, WAL mode, tables `invites` / `codes` / `sessions` / `rate_limits`. Prepared statements exported as `q.*`. A `setInterval().unref()` sweeps expired rows every 5 minutes. +**Storage** (`src/db.js`): `better-sqlite3` at `data/fenja.sqlite`, WAL mode, tables `invites` / `sessions` / `rate_limits` / `bifrost_joins`. Prepared statements exported as `q.*`. A `setInterval().unref()` sweeps expired rows every 5 minutes. + +`bifrost_joins` logs every click of the final "Join Project Bifrost" CTA — one row per click (auto-increment `id`, `email`, `clicked_at`, `session_id`). Writes come from `POST /api/bifrost-join` (behind `requireAuth`); reads come from `bin/joins.js`. See OPERATIONS.md for admin usage. **Rate limiting** (`src/middleware.js`) is a SQLite-backed sliding window keyed per-IP — 5 code requests/hour, 20 verify attempts/hour. Nginx adds another layer via a `limit_req_zone` declared in `/etc/nginx/nginx.conf`. @@ -61,4 +65,4 @@ These are from `PROJECT.md`. A change that breaks any of them is a security regr - ESM imports only, Node 20+. - File headers use the `// ─── ... ───` comment banner style. Match it when editing existing files. -- `bin/invite.js` is the only admin surface — there is no web UI for invite management by design. +- `bin/invite.js` and `bin/joins.js` are the admin CLIs — there is no web UI for either by design. `invite.js` manages the invite list; `joins.js` reads the CTA click log. diff --git a/OPERATIONS.md b/OPERATIONS.md index e42e4eb..cdabc4e 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -24,6 +24,31 @@ sudo sqlite3 /opt/fenja/data/fenja.sqlite \ "DELETE FROM sessions WHERE email = 'someone@example.com';" ``` +## Reading Join-CTA clicks + +Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Use `bin/joins.js` to read it: + +```bash +# Every click, newest first (id, email, session) +sudo -u fenja node /opt/fenja/bin/joins.js list + +# One row per user, with click count + first/last timestamps +sudo -u fenja node /opt/fenja/bin/joins.js summary + +# Full click history for a single user +sudo -u fenja node /opt/fenja/bin/joins.js for someone@example.com + +# Totals — clicks + unique users +sudo -u fenja node /opt/fenja/bin/joins.js stats +``` + +One row is written per click (the schema uses auto-increment `id`, not email-as-PK), so re-clicks are preserved. For ad-hoc SQL: + +```bash +sudo -u fenja sqlite3 /opt/fenja/data/fenja.sqlite \ + "SELECT email, datetime(clicked_at/1000,'unixepoch') FROM bifrost_joins ORDER BY clicked_at DESC;" +``` + ## Service control ```bash diff --git a/PROJECT.md b/PROJECT.md index 4f2062c..a224119 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -11,7 +11,7 @@ Invite-only frontdoor and editorial timeline for Fenja AI. Self-hosted on a sing Two surfaces on one domain (`project-bifrost.fenja.ai`): 1. **Entrance** — email → 6-digit code → session cookie. Shown to any logged-out visitor. -2. **Timeline** — an editorial scroll through 23 headlines about digital sovereignty, with a globe, archive, and overview. Shown to logged-in users at the same URL. +2. **Timeline** — an editorial scroll through 12 headlines about digital sovereignty, ending in a pivot to how Fenja AI addresses it. Shown to logged-in users at the same URL; includes a globe, an overview (Project Bifrost scenes), and an archive. The root URL `/` is context-aware: unauthenticated → entrance, authenticated → timeline. @@ -49,7 +49,8 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated │ │ └── fonts/ Manrope + Newsreader variable fonts │ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json ├── bin/ -│ └── invite.js CLI: add/remove/list invites +│ ├── invite.js CLI: add/remove/list invites +│ └── joins.js CLI: read the Join-CTA click log (list/summary/for/stats) ├── deploy/ │ ├── fenja.service systemd unit │ └── nginx.conf Nginx server block @@ -131,6 +132,19 @@ Good rules of thumb: Live at `https://project-bifrost.fenja.ai/`. Backups and uptime monitoring configured. Production running, invites being handled manually via SSH. +## Tracking: Join-CTA clicks + +The final "Join Project Bifrost" CTA records every press into `bifrost_joins`. Schema: + +``` +bifrost_joins(id INTEGER PK AUTOINCREMENT, + email TEXT NOT NULL, + clicked_at INTEGER NOT NULL, + session_id TEXT) +``` + +One row per click — if a user presses the button multiple times, you get multiple rows, so per-user history is preserved. Writes happen via `POST /api/bifrost-join` (behind `requireAuth`, reads `req.session.email`). Reads happen via `bin/joins.js` (see OPERATIONS.md). + ## Things not yet done Rough list of things that exist as possibilities, not commitments: diff --git a/README.md b/README.md index efa80b1..06cad62 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,17 @@ sudo -u fenja node /opt/fenja/bin/invite.js list sudo -u fenja node /opt/fenja/bin/invite.js remove someone@example.com ``` +### Reading Join-CTA clicks + +Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Read it with: + +```bash +sudo -u fenja node /opt/fenja/bin/joins.js list # every click, newest first +sudo -u fenja node /opt/fenja/bin/joins.js summary # one row per user, with counts +sudo -u fenja node /opt/fenja/bin/joins.js for someone@example.com +sudo -u fenja node /opt/fenja/bin/joins.js stats # totals + unique users +``` + ### Backups Add a cron job: @@ -138,7 +149,8 @@ project-bifrost/ ├── .env.example # copy to .env ├── .gitignore ├── bin/ -│ └── invite.js # CLI: add / remove / list invites +│ ├── invite.js # CLI: add / remove / list invites +│ └── joins.js # CLI: read the Join-CTA click log ├── src/ │ ├── db.js # SQLite schema + prepared statements │ ├── sessions.js # codes, cookies, HMAC diff --git a/SITE_STRUCTURE_REFERENCE.md b/SITE_STRUCTURE_REFERENCE.md index d187864..89236bc 100644 --- a/SITE_STRUCTURE_REFERENCE.md +++ b/SITE_STRUCTURE_REFERENCE.md @@ -26,17 +26,18 @@ Before reaching any of the three, users see the **entrance** at `/` (email form **Files:** `protected/index.html` (`#page-timeline`, lines ~739–758) + `protected/timeline.js` -A horizontal mousewheel-driven scroll through 23 editorial cards, set over a slowly rotating orthographic globe. Cards alternate above and below a central spine; each is accented by one of four colours (copper/ochre/terracotta/crimson) denoting register. +A horizontal mousewheel-driven scroll through 12 editorial cards, set over a slowly rotating orthographic globe. Cards alternate above and below a central spine; each is accented by one of four colours (copper/ochre/terracotta/crimson) denoting register. Card layout is a two-column grid: headline on the left (spanning both rows), body paragraph top-right, source + date bottom-right. Headlines' italic-bold emphasis uses the crimson accent across every card. | Label | What it is | Where to change | |---|---|---| | **P1 page title** | *"From the promise of AI to the loss of **sovereignty.**"* | `index.html` line 740 | | **P1 subtitle** | *"Twenty-three headlines, quietly laid across a tinted map. Scroll the wheel — the map turns with you."* | `index.html` line 741 | | **P1 globe** | Rotating orthographic globe that tracks scroll position from N. America → Europe | `timeline.js` — `buildGlobe()` + `setRotation` in `applyScroll()` | -| **P1 events** | The 23 headline cards. Each has `date`, `kind`, `accent`, `hed`, `body`, `source` | `timeline.js` — `EVENTS` array, lines 4–102 | -| **P1 card styles** | Card layout, above/below-spine positioning, colour accents per `accent` value | `index.html` CSS `.evt`, `.accent-*` | -| **P1 year ticks** | "2022", "2023", … on the spine between groups of cards | `timeline.js` — `buildTimeline()`, year-tick loop | -| **P1 "Read the editor's note" button** | Bottom-right, appears near the end of the catalog; fires dot-nav switch to Overview | `index.html` line 747 (text) + `.continue-btn` CSS + `timeline.js` click handler | +| **P1 events** | The 12 headline cards, from September 2024 to Q1 2026. Each has `date`, `kind`, `accent`, `hed`, `body`, `source`; the card builder concatenates source + date at render time | `timeline.js` — `EVENTS` array, lines 14–66 | +| **P1 card styles** | Two-column editorial grid (1.15fr / 0.85fr), 640px wide, crimson `` accent in headline | `index.html` CSS `.evt`, `.evt h3`, `.evt p`, `.evt .source` | +| **P1 year ticks** | "2024", "2025", "2026" on the spine between groups of cards | `timeline.js` — `buildTimeline()`, year-tick loop | +| **P1 "How Fenja AI addresses this" button** | Vertically centered on the right edge, circular SVG arrow icon + italic crimson emphasis on "addresses". Fires dot-nav switch to Overview | `index.html` around line 2197 (markup) + `.continue-btn` CSS + `timeline.js` click handler | +| **P1 spine vertical position** | `--spine-y: 54%` — raised from the original 64% so the taller below-spine cards don't clip off the bottom of the viewport | `index.html` `.timeline-track` | --- @@ -88,13 +89,13 @@ The map stays static behind all six scenes. It does not rotate with scroll; it o | Label | What it is | Where | |---|---|---| -| **S1 eyebrow** | *"For regulated environments"* | `index.html` line 2246 | -| **S1 headline** | *"Secure & **Sovereign** AI, hosted where it **belongs.**"* | `index.html` lines 2247–2250 | -| **S1 lede** | *"Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure."* | `index.html` lines 2251–2253 | -| **S1 supported-by** | Innovationsfonden placeholder logo + *"Supported by"* strip | `index.html` lines 2258–2267 | -| **S1 scroll hint** | "Scroll →" indicator bottom-right | `index.html` lines 2268–2271 | -| **S1 hero styles** | Type scale, colours, grid positioning, left-column anchoring | `index.html` CSS `#hero`, `.hero-title`, `.hero-lede`, `.hero-foot` | -| **S1 hero animation** | Word-by-word reveal of the headline on scene entry | `bifrost.js` — search `HERO — staggered intro` | +| **S1 eyebrow** | *"For regulated environments"* | `index.html` line 2229 | +| **S1 headline** | *"Fenja AI — Secure & **Sovereign,** hosted where it **belongs.**"* | `index.html` lines 2230–2233 | +| **S1 lede** | *"Fenja AI is a sovereign AI platform, enabling highly advanced AI capabilities hosted within the client's own secure infrastructure."* | `index.html` lines 2234–2236 | +| **S1 supported-by** | Innovationsfonden placeholder logo + *"Supported by"* strip | `index.html` lines 2244–2254 | +| **S1 scroll hint** | "Scroll →" indicator bottom-right | `index.html` lines 2255–2258 | +| **S1 hero styles** | Type scale, colours, grid positioning, left-column anchoring. The hero is hidden via `.js .hero-wrap { opacity: 0 }` until Bifrost boots, then fades in as a single block | `index.html` CSS `#hero`, `.hero-title`, `.hero-lede`, `.hero-foot`, `.hero-wrap` | +| **S1 hero animation** | Single 1.0s `power2.out` fade of the whole `.hero-wrap` on scene entry (replaces the earlier per-word slide-in) | `bifrost.js` — search `HERO — single overall fade-in` | ### S2 — Architecture stack @@ -110,7 +111,8 @@ Four cards fall into a stack one at a time, then rearrange into a 2x2 grid. As t | **S2 card 3 — Tools** | *"How the AIs **act** — not just what they **know.**"* | `index.html` lines 2322–2330 (`data-layer="2"`) | | **S2 card 4 — Agents** | *"**Specialists**, **collaborating** to solve distinct tasks."* | `index.html` lines 2332–2340 (`data-layer="3"`) | | **S2 stack animation** | The fall, stack, morph-to-grid scrub sequence | `bifrost.js` — search `ARCHITECTURE — two-phase scrubbed sequence` | -| **S2 card styles** | Shape, padding, "brain" illustration, eyebrow, grid-mode styles | `index.html` CSS `.layer-card`, `.card-box`, `.card-brain`, `.in-grid` | +| **S2 card styles** | Shape, padding, per-layer illustration, eyebrow, grid-mode styles | `index.html` CSS `.layer-card`, `.card-box`, `.card-brain`, `.in-grid` | +| **S2 card illustrations** | Per-layer PNGs set via `--card-illust` custom property on `.card-brain`. AI → `ai.png`, Knowledge → `lightbulb - knowledge.png`, Tools → `blocs tools.png`, Agents → `agents.png`. Rendered as `background-image` (not mask) so each PNG shows in its own colours | `protected/fenja/illustrations/*.png` + per-layer overrides in `index.html` CSS | ### S3 — Words fly in @@ -170,21 +172,22 @@ The final scene is a large call-to-action headline with a single button. Clickin | **S6 footer left — "Project Bifrost"** | Wordmark rendered in Newsreader with italic emphasis on "Bifrost" | `index.html` line 2593 | | **S6 footer center — Fenja logo** | `` pointing to `/fenja/fenja-wordmark-black.svg` | `index.html` lines 2595–2599 | | **S6 footer right — Innovationsfonden** | Placeholder slanted-I mark + "nnovationsfonden" text (to be swapped for real asset) | `index.html` lines 2601–2612 | -| **S6 click handler** | CTA → confirmation crossfade + staggered checkmarks on list items | `bifrost.js` — search `SCENE 6 — Join section` | +| **S6 click handler** | CTA → confirmation crossfade + staggered checkmarks on list items. Also fires `POST /api/bifrost-join` (fire-and-forget, credentials:same-origin) so the click is logged in `bifrost_joins` | `bifrost.js` — search `SCENE 6 — Join section` | +| **S6 click tracking** | Every click of the CTA is appended to the `bifrost_joins` table (id, email, clicked_at, session_id). Read via `bin/joins.js list | summary | for | stats` | `src/db.js` (schema + `q.recordJoin`), `server.js` (`POST /api/bifrost-join`), `bin/joins.js` | --- -## [P3] ARCHIVE — 23-row table +## [P3] ARCHIVE — tabular view **Files:** `protected/index.html` (`#page-archive`, lines ~2622–2651) + `protected/timeline.js` archive-builder IIFE -A tabular view of the same 23 events from the timeline, sorted chronologically. Each row fades its background on hover. +A tabular view of the same 12 events from the timeline, sorted chronologically. Each row fades its background on hover. | Label | What it is | Where | |---|---|---| -| **P3 headline** | *"All twenty-three entries, in order of **publication.**"* | `index.html` line 2625 | +| **P3 headline** | *"All twenty-three entries, in order of **publication.**"* (copy still references 23 — update if this becomes user-facing again) | `index.html` line 2625 | | **P3 sub** | *"Dates, sources and plate numbers for every card in the catalog. Hover a row to lift it from the paper."* | `index.html` lines 2626–2629 | -| **P3 table** | 5 columns: №, Date, Register, Headline, Source | `index.html` table markup + `timeline.js` archive-builder IIFE (lines 406–420) | +| **P3 table** | 5 columns: №, Date, Register, Headline, Source | `index.html` table markup + `timeline.js` archive-builder IIFE | | **P3 data** | Same `EVENTS` array as the timeline | `timeline.js` — `EVENTS` | | **P3 footer** | *"Fenja AI · Field Notes, No. IV / Catalog closed 14 April 2026 / Page III of III"* | `index.html` lines 2644–2648 | @@ -217,7 +220,7 @@ The only public-facing page. A single email field; on submit, if the email is on | **Entrance error messages** | *"Please enter a valid email address."* / *"This email is not on the invite list."* / *"Too many attempts…"* | `entrance.js` — inside the submit handler | | **Welcome title** | *"Thanks for your interest, **[Name].**"* (or *"Thank you for your **interest.**"* when no first name) — set by JS after a successful login | `entrance.js` — `setWelcomeTitle()` | | **Welcome body, paragraph 1** | *"Thank you for joining and for your interest in enabling sovereign AI in Denmark and Europe. Project Bifrost is a deliberate effort to advance it — the conviction that how we build these systems, and where, will shape the next decades."* | `entrance.html` step-welcome `.welcome-body` (first one) | -| **Welcome body, paragraph 2** | *"What follows is a timeline: twenty-three moments that explain why this matters now, and what the path looks like."* | `entrance.html` step-welcome `.welcome-body` (second one) | +| **Welcome body, paragraph 2** | *"What follows is a timeline: twelve moments that explain why this matters now, and — at the end — a note on how Fenja AI addresses it."* | `entrance.html` step-welcome `.welcome-body` (second one) | | **"Learn more" button** | The button that routes to `/timeline` | `entrance.html` step-welcome `#welcome-continue` | | **Welcome logo ghost** | Faint Fenja wordmark occupying the right half of the viewport | `entrance.html` `.welcome-logo` | @@ -236,7 +239,10 @@ For completeness — not UI, but labelled here so you can point at it when relev | **Logout endpoint** | `POST /auth/logout` — clears session cookie | `src/auth.js` | | **Me endpoint** | `GET /auth/me` — returns `{email, firstName}` for current session or 401 | `src/auth.js` | | **Invite CLI** | `node bin/invite.js add [FirstName]` / `remove` / `list` | `bin/invite.js` | +| **Joins CLI** | `node bin/joins.js list` / `summary` / `for ` / `stats` — reads the CTA click log | `bin/joins.js` | | **Invite schema** | `invites(email, first_name, invited_at, invited_by)` | `src/db.js` | +| **Bifrost joins schema** | `bifrost_joins(id, email, clicked_at, session_id)` — one row per CTA click, not per user | `src/db.js` | +| **Join tracking endpoint** | `POST /api/bifrost-join` (behind `requireAuth`) — called by S6 click handler | `server.js` | | **Session duration** | 30 days | `src/sessions.js` — `SESSION_TTL_MS` | --- @@ -321,7 +327,8 @@ src/ └── middleware.js rateLimit() + requireAuth() bin/ -└── invite.js CLI: add / remove / list invites +├── invite.js CLI: add / remove / list invites +└── joins.js CLI: read the Join-CTA click log server.js Entry point, CSP, routing ```