Add on-the-record comments, publish the passkeys guide, open the book page
validate / validate (push) Successful in 24s

- Comments steal the giscus pattern with our own Gitea: each article maps
  to an issue titled with its content path in ana/trust-issues-discussion;
  comments are fetched at build time and baked into the static page —
  no JS, no third party, part of the record. Text is rendered escaped.
- 'Reply on the record' links to the thread; articles without one get a
  prefilled new-issue link. Lookup failures degrade to an empty thread.
- Passkeys field guide written and published (status: developing) —
  first real guide, so Guides enters the nav
- Book page ungated with honest pre-publication copy; commercial pieces
  still wait on site.book

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Compleet
2026-07-14 19:18:10 +01:00
parent 0e7871f395
commit 8269515904
7 changed files with 248 additions and 6 deletions
+137
View File
@@ -0,0 +1,137 @@
---
import { getDiscussion } from '../lib/discussion';
import { site } from '../site.config';
interface Props {
discussionKey: string;
}
const { discussionKey } = Astro.props;
const discussion = await getDiscussion(discussionKey);
---
{discussion && (
<section class="discussion" aria-label="Public discussion">
<div class="discussion-rule">
<h2>On the record</h2>
<span>{discussion.comments.length === 0 ? 'No comments yet' : `${discussion.comments.length} ${discussion.comments.length === 1 ? 'comment' : 'comments'}`}</span>
</div>
{discussion.comments.length > 0 && (
<ol>
{discussion.comments.map((comment) => (
<li>
<p class="comment-head">
<span class="comment-author">{comment.author}</span>
<time datetime={comment.date}>{comment.date}</time>
</p>
<p class="comment-body">{comment.body}</p>
</li>
))}
</ol>
)}
<div class="discussion-foot">
<a href={discussion.threadUrl ?? discussion.newThreadUrl}>
{discussion.threadUrl ? 'Reply on the record ↗' : 'Start the discussion ↗'}
</a>
<p>
Comments are issue comments in the <a href={`https://git.frrn.life/${site.discussionRepo}`}>public
discussion repository</a> and render here at each site rebuild — part of the record, not a widget.
</p>
</div>
</section>
)}
<style>
.discussion {
width: min(100%, var(--reading));
margin-top: clamp(3.5rem, 7vw, 6rem);
}
.discussion-rule {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.7rem;
border-bottom: 1px solid var(--ink);
}
.discussion-rule h2 {
margin: 0;
font-family: 'Newsreader Variable', Georgia, serif;
font-size: 1.7rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.discussion-rule span {
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
}
ol {
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 1.4rem 0;
border-bottom: 1px solid var(--rule);
}
.comment-head {
display: flex;
justify-content: space-between;
gap: 1rem;
margin: 0 0 0.5rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.comment-author {
color: var(--blue);
font-weight: 500;
}
.comment-head time {
color: var(--muted);
}
.comment-body {
margin: 0;
font-family: 'Newsreader Variable', Georgia, serif;
font-size: 1.08rem;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.discussion-foot {
margin-top: 1.4rem;
}
.discussion-foot > a {
display: inline-block;
padding: 0.65rem 1rem;
border: 1px solid var(--ink);
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
}
.discussion-foot > a:hover {
background: var(--ink);
color: var(--paper);
}
.discussion-foot p {
margin: 0.9rem 0 0;
color: var(--muted);
font-size: 0.78rem;
}
</style>
+1 -1
View File
@@ -13,7 +13,7 @@ const visibleNavigation = navigation.filter((item) => {
if (item.href === '/opine') return stories.some(({ data }) => data.type === 'essay');
if (item.href === '/podcast') return episodes.length > 0 || Boolean(site.podcastFeedUrl);
if (item.href === '/guides') return guides.length > 0;
if (item.href === '/book') return site.book.enabled;
if (item.href === '/book') return true;
if (item.href === '/forum') return Boolean(site.forumUrl);
return true;
});
+28 -3
View File
@@ -7,32 +7,57 @@ author: "Ana"
maintainers:
- "Ana"
status: developing
draft: true
draft: false
tags:
- identity
- authentication
- recovery
---
> Draft field-guide template. Do not publish until every section has been sourced and reviewed.
## What it is
A passkey is a cryptographic key pair that replaces a password. The private half stays on your device (or inside your platform account); the website keeps only the public half. Signing in means proving possession of the private key — nothing secret ever travels to the site or sits in its database. The underlying standards are WebAuthn and FIDO2, and every major browser and platform now ships them.
## Why people care
Passwords fail at industrial scale: they get phished, reused, guessed, and leaked by the billion. A passkey cannot be phished in the ordinary way because it is bound to the real website's origin — a convincing fake site receives nothing usable — and a breach of the site's database yields only public keys. This is not marginal improvement; it removes the two most common ways accounts actually get taken over.
## How it works, without the marketing
When you register, your device generates a fresh key pair for that one site and hands over the public key. When you sign in, the site sends a challenge; your device signs it after you unlock with a fingerprint, face, or PIN. The biometric never leaves your device — it only unlocks the local key.
The consequential design choice is what happens to the private key afterwards. **Device-bound** passkeys (security keys, some enterprise setups) live and die with one piece of hardware. **Synced** passkeys — the consumer default — are copied through your platform account: iCloud Keychain, Google Password Manager, or a third-party manager like Bitwarden or 1Password. Syncing is what makes passkeys survivable for ordinary people, and it is also where the trust moves.
## Who must trust whom
The password asked you to trust your own memory and every site's password handling. The passkey asks you to trust your platform vendor instead. Whoever operates your sync fabric — Apple, Google, Microsoft, or a password manager — can restore your keys, which means their account recovery process, their view of your identity, and their cooperation with whoever pressures them are all now part of your login. The site you sign into trusts the platform too: it never sees your key, only the platform's word that a valid signature arrived.
## What it knows about you
Better than the reputation of most authentication tech. Each site gets its own key pair, so passkeys do not create a cross-site identifier; two sites cannot correlate you by your credential. Consumer attestation is deliberately blurred, so a site generally cannot even tell which brand of authenticator you used. What your *platform* knows is another matter: your sync provider necessarily knows every site you hold a passkey for.
## What happens when you lose access
This is the honest centre of the guide. With synced passkeys, losing the phone is survivable: sign into your platform account on a new device and the keys return. What you have actually done is move the cliff — everything now hangs on recovering the *platform account*, and platform recovery is exactly where documented horror stories live: locked Google accounts with no human to appeal to, Apple accounts frozen by suspected fraud, and recovery flows that quietly fall back to SMS or security questions weaker than the passkey they protect.
Moving between ecosystems is the second cliff. Passkey export and cross-ecosystem transfer are only now being standardised; in practice, leaving a platform today usually means re-registering, site by site, while you still can. Registering passkeys on two independent ecosystems — or one platform plus a hardware key kept in a drawer — is the practical hedge.
## The strongest argument for it
Phishing resistance that does not depend on user vigilance. Every other mainstream defence — training, password managers, most one-time codes — still fails when a tired person meets a good fake. Origin-bound keys fail closed. For the attacks that actually empty accounts, passkeys are the largest real-world improvement since two-factor authentication, and they are easier to use, not harder.
## The strongest argument against it
Concentration. Passkeys convert millions of small, survivable password failures into a dependence on a handful of platform recovery systems whose decisions are opaque and mostly unappealable. If your platform account dies, everything attached to it dies at once — and unlike a forgotten password, there is no site-by-site way back that doesn't rely on each site's own (often weaker) fallback. The security is real; so is the new landlord.
## Known incidents and disputes
No mass compromise of the passkey protocol itself has been documented. The disputes are structural: security researchers have criticised the gap between device-bound FIDO's original threat model and the synced consumer reality; ecosystems have been criticised for making passkeys easy to create and hard to move; and sites that keep weaker fallback methods (SMS reset, security questions) undercut most of the benefit, since attackers simply use the fallback.
## What changed recently
The FIDO Alliance published draft specifications for secure credential exchange, which would make passkeys portable between password managers and platforms — the answer to the lock-in criticism, arriving slowly. Third-party password managers now register and sync passkeys on both major mobile platforms, which softens (without removing) single-vendor dependence.
## Maintainers and contributors
Drafted by the publication; maintained by Ana. Corrections and challenges are welcome through the public discussion thread or a pull request — the review date above means what the editorial commitments say it means.
+2
View File
@@ -1,4 +1,5 @@
---
import Discussion from '../components/Discussion.astro';
import MaintenanceMeta from '../components/MaintenanceMeta.astro';
import TrustPattern from '../components/TrustPattern.astro';
import BaseLayout from './BaseLayout.astro';
@@ -56,6 +57,7 @@ const props = Astro.props;
<div class="article-body prose">
<slot />
</div>
<Discussion discussionKey={props.sourcePath.replace(/^src\/content\//, '').replace(/\.md$/, '')} />
</article>
</BaseLayout>
+75
View File
@@ -0,0 +1,75 @@
import { site } from '../site.config';
export interface DiscussionComment {
author: string;
date: string;
body: string;
}
export interface Discussion {
threadUrl: string | null;
newThreadUrl: string;
comments: DiscussionComment[];
}
interface GiteaIssue {
number: number;
title: string;
comments: number;
html_url: string;
}
const API = 'https://git.frrn.life/api/v1';
const PAGE_SIZE = 50;
// One issue index shared across every page of a build.
let issueIndex: Promise<Map<string, GiteaIssue>> | null = null;
async function fetchIssueIndex(): Promise<Map<string, GiteaIssue>> {
const index = new Map<string, GiteaIssue>();
for (let page = 1; page <= 40; page++) {
const res = await fetch(
`${API}/repos/${site.discussionRepo}/issues?state=all&type=issues&limit=${PAGE_SIZE}&page=${page}`,
);
if (!res.ok) break;
const batch: GiteaIssue[] = await res.json();
for (const issue of batch) index.set(issue.title, issue);
if (batch.length < PAGE_SIZE) break;
}
return index;
}
/**
* Build-time lookup of the public discussion thread for an article.
* `key` is the content path, e.g. `stories/who-sent-this-agent` — it is
* also the exact title of the thread's issue in the discussion repo.
* Failures degrade to an empty thread; they never fail the build.
*/
export async function getDiscussion(key: string): Promise<Discussion | null> {
if (!site.discussionRepo) return null;
const newThreadUrl = `https://git.frrn.life/${site.discussionRepo}/issues/new?title=${encodeURIComponent(key)}`;
try {
issueIndex ??= fetchIssueIndex();
const issue = (await issueIndex).get(key);
if (!issue) return { threadUrl: null, newThreadUrl, comments: [] };
let comments: DiscussionComment[] = [];
if (issue.comments > 0) {
const res = await fetch(
`${API}/repos/${site.discussionRepo}/issues/${issue.number}/comments`,
);
if (res.ok) {
const raw: Array<{ user?: { login?: string }; created_at?: string; body?: string }> =
await res.json();
comments = raw.map((comment) => ({
author: comment.user?.login ?? 'unknown',
date: comment.created_at?.slice(0, 10) ?? '',
body: comment.body ?? '',
}));
}
}
return { threadUrl: issue.html_url, newThreadUrl, comments };
} catch {
return { threadUrl: null, newThreadUrl, comments: [] };
}
}
+4 -2
View File
@@ -10,8 +10,8 @@ import { site } from '../site.config';
</div>
<div>
<p class="eyebrow">The book</p>
<h1 class="display">{site.book.enabled ? site.book.title : 'The book belongs at the centre of the work.'}</h1>
<p class="lede">{site.book.enabled ? site.book.description : 'This surface is ready for the actual cover, title, argument, sample chapter, endorsements, and purchase route. Those details are intentionally not fabricated in the scaffold.'}</p>
<h1 class="display">{site.book.enabled ? site.book.title : 'A book is being written.'}</h1>
<p class="lede">{site.book.enabled ? site.book.description : 'The long-form version of this publications argument: where trust actually lives in the systems that claim to have removed it, and what that costs the people inside them. It gets a title, a cover, and a date when those things are real — the RSS feed will say so first.'}</p>
{site.book.enabled && (
<div class="actions">
{site.book.sampleUrl && <a class="button" href={site.book.sampleUrl}>Read a sample</a>}
@@ -19,6 +19,7 @@ import { site } from '../site.config';
</div>
)}
<div class="book-anatomy">
<p class="anatomy-label">What this page will hold</p>
<div><span>01</span><p>Why I wrote it</p></div>
<div><span>02</span><p>Read a real chapter</p></div>
<div><span>03</span><p>Choose a purchase route</p></div>
@@ -35,6 +36,7 @@ import { site } from '../site.config';
.lede { max-width: 58ch; color: var(--muted); font-size: 1.1rem; }
.actions { display: flex; gap: .7rem; margin: 2rem 0; }
.book-anatomy { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 3rem; border-top: 1px solid var(--rule); }
.anatomy-label { grid-column: 1 / -1; margin: 0.9rem 0 0; padding: 0 1rem; color: var(--muted); font-family: 'IBM Plex Mono', monospace; font-size: 0.62rem; letter-spacing: 0.07em; text-transform: uppercase; }
.book-anatomy div { padding: 1rem; border-right: 1px solid var(--rule); }
.book-anatomy div:last-child { border-right: 0; }
.book-anatomy span { color: var(--amend); font-family: 'IBM Plex Mono', monospace; font-size: .68rem; }
+1
View File
@@ -13,6 +13,7 @@ export const site = {
podcastFeedUrl: '/podcast/feed.xml',
forumUrl: '',
contactUrl: '',
discussionRepo: 'ana/trust-issues-discussion',
book: {
enabled: false,
title: '',