Skip to content

Commit 8c0de9f

Browse files
standard.site fixes
1 parent 8d4760d commit 8c0de9f

5 files changed

Lines changed: 184 additions & 6 deletions

File tree

.github/workflows/backfill-episodes.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,35 @@ on:
99
type: string
1010

1111
jobs:
12+
# Skip backfill entirely if standard.site/ATProto secrets aren't configured,
13+
# so forks without standard.site set up don't fail this workflow.
14+
check:
15+
runs-on: ubuntu-latest
16+
if: ${{ github.event.inputs.confirm == 'backfill' }}
17+
outputs:
18+
configured: ${{ steps.config.outputs.configured }}
19+
steps:
20+
- name: Check ATProto configuration
21+
id: config
22+
env:
23+
ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }}
24+
ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }}
25+
STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }}
26+
STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }}
27+
run: |
28+
if [ -n "$ATPROTO_HANDLE" ] && [ -n "$ATPROTO_APP_PASSWORD" ] && [ -n "$STANDARD_SITE_URL" ] && [ -n "$STANDARD_SITE_PUBLICATION_RKEY" ]; then
29+
echo "configured=true" >> "$GITHUB_OUTPUT"
30+
else
31+
echo "configured=false" >> "$GITHUB_OUTPUT"
32+
echo "::notice::standard.site/ATProto secrets not set — skipping episode backfill."
33+
fi
34+
1235
backfill:
36+
needs: check
37+
if: ${{ needs.check.outputs.configured == 'true' }}
1338
runs-on: ubuntu-latest
1439
permissions:
1540
contents: read
16-
if: ${{ github.event.inputs.confirm == 'backfill' }}
1741
steps:
1842
- uses: actions/checkout@v4
1943
- uses: actions/setup-node@v4

.github/workflows/publish-episodes.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,36 @@ on:
99
workflow_dispatch:
1010

1111
jobs:
12+
# Skip publishing entirely if standard.site/ATProto secrets aren't configured,
13+
# so forks without standard.site set up don't fail this workflow.
14+
check:
15+
runs-on: ubuntu-latest
16+
# Only run if the triggering workflow succeeded (or manual dispatch)
17+
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
18+
outputs:
19+
configured: ${{ steps.config.outputs.configured }}
20+
steps:
21+
- name: Check ATProto configuration
22+
id: config
23+
env:
24+
ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }}
25+
ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }}
26+
STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }}
27+
STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }}
28+
run: |
29+
if [ -n "$ATPROTO_HANDLE" ] && [ -n "$ATPROTO_APP_PASSWORD" ] && [ -n "$STANDARD_SITE_URL" ] && [ -n "$STANDARD_SITE_PUBLICATION_RKEY" ]; then
30+
echo "configured=true" >> "$GITHUB_OUTPUT"
31+
else
32+
echo "configured=false" >> "$GITHUB_OUTPUT"
33+
echo "::notice::standard.site/ATProto secrets not set — skipping episode publishing."
34+
fi
35+
1236
publish:
37+
needs: check
38+
if: ${{ needs.check.outputs.configured == 'true' }}
1339
runs-on: ubuntu-latest
1440
permissions:
1541
contents: read
16-
# Only run if the triggering workflow succeeded (or manual dispatch)
17-
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
1842
steps:
1943
- uses: actions/checkout@v4
2044
- uses: actions/setup-node@v4

scripts/publish-episodes.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ async function main() {
5959
const publicationRkey = process.env.STANDARD_SITE_PUBLICATION_RKEY;
6060

6161
if (!identifier || !password || !siteUrl || !publicationRkey) {
62-
console.error(
63-
'Missing required environment variables. Need: ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, STANDARD_SITE_URL, STANDARD_SITE_PUBLICATION_RKEY'
62+
console.log(
63+
'ℹ️ standard.site/ATProto not configured — skipping episode publishing.\n' +
64+
' To enable, set: ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, STANDARD_SITE_URL, STANDARD_SITE_PUBLICATION_RKEY'
6465
);
65-
process.exit(1);
66+
return;
6667
}
6768

6869
console.log(

src/lib/standardSite.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Build-time helpers for standard.site document verification.
3+
*
4+
* Episodes are published to ATProto as `site.standard.document` records by
5+
* `scripts/publish-episodes.ts`. Each record stores its `path` (matching an
6+
* episode's slug) but gets a random TID record key, so the only way to emit the
7+
* per-page `<link rel="site.standard.document">` verification tag is to look up
8+
* the published records and map their `path` back to their record key.
9+
*
10+
* This module resolves the configured DID's PDS, lists its document records
11+
* (unauthenticated — listRecords is public), and returns a `path -> rkey` map.
12+
* The result is memoized so the lookup runs once per build despite the page
13+
* being rendered for many episodes.
14+
*/
15+
16+
const DOCUMENT_COLLECTION = 'site.standard.document';
17+
18+
interface PlcService {
19+
id?: string;
20+
type?: string;
21+
serviceEndpoint?: string;
22+
}
23+
24+
async function resolvePds(did: string): Promise<string> {
25+
if (did.startsWith('did:plc:')) {
26+
const res = await fetch(`https://plc.directory/${did}`);
27+
if (!res.ok) throw new Error(`Failed to resolve DID: ${did}`);
28+
const doc = (await res.json()) as { service?: PlcService[] };
29+
const pds = doc.service?.find(
30+
(s) => s.type === 'AtprotoPersonalDataServer' || s.id === '#atproto_pds'
31+
);
32+
if (!pds?.serviceEndpoint) {
33+
throw new Error(`No PDS found in DID document for ${did}`);
34+
}
35+
return pds.serviceEndpoint;
36+
}
37+
38+
if (did.startsWith('did:web:')) {
39+
const domain = did.replace('did:web:', '');
40+
const res = await fetch(`https://${domain}/.well-known/did.json`);
41+
if (!res.ok) throw new Error(`Failed to resolve DID: ${did}`);
42+
const doc = (await res.json()) as { service?: PlcService[] };
43+
const pds = doc.service?.find(
44+
(s) => s.type === 'AtprotoPersonalDataServer' || s.id === '#atproto_pds'
45+
);
46+
if (!pds?.serviceEndpoint) {
47+
throw new Error(`No PDS found in DID document for ${did}`);
48+
}
49+
return pds.serviceEndpoint;
50+
}
51+
52+
throw new Error(`Unsupported DID method: ${did}`);
53+
}
54+
55+
async function buildDocumentRkeyMap(did: string): Promise<Map<string, string>> {
56+
const pds = await resolvePds(did);
57+
const map = new Map<string, string>();
58+
let cursor: string | undefined;
59+
60+
do {
61+
const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`);
62+
url.searchParams.set('repo', did);
63+
url.searchParams.set('collection', DOCUMENT_COLLECTION);
64+
url.searchParams.set('limit', '100');
65+
if (cursor) url.searchParams.set('cursor', cursor);
66+
67+
const res = await fetch(url);
68+
if (!res.ok) {
69+
throw new Error(`Failed to list standard.site documents: ${res.status}`);
70+
}
71+
72+
const data = (await res.json()) as {
73+
records: { uri: string; value: { path?: string } }[];
74+
cursor?: string;
75+
};
76+
77+
for (const record of data.records) {
78+
const path = record.value.path;
79+
const rkey = record.uri.split('/').pop();
80+
if (path && rkey) map.set(path, rkey);
81+
}
82+
83+
cursor = data.cursor;
84+
} while (cursor);
85+
86+
return map;
87+
}
88+
89+
let cache: Promise<Map<string, string>> | null = null;
90+
91+
/**
92+
* Returns a `path -> rkey` map of the published standard.site documents for the
93+
* configured DID, or an empty map if standard.site is not configured (or the
94+
* lookup fails). Memoized for the lifetime of the build.
95+
*/
96+
export function getDocumentRkeys(): Promise<Map<string, string>> {
97+
if (cache) return cache;
98+
99+
const did = import.meta.env.STANDARD_SITE_DID;
100+
if (!did) {
101+
cache = Promise.resolve(new Map());
102+
return cache;
103+
}
104+
105+
cache = buildDocumentRkeyMap(did).catch((err) => {
106+
console.warn(
107+
`[standard.site] Could not load document records for verification: ${
108+
err instanceof Error ? err.message : String(err)
109+
}`
110+
);
111+
return new Map<string, string>();
112+
});
113+
114+
return cache;
115+
}

src/pages/[episode].astro

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import Sponsors from '../components/episode/Sponsors.astro';
1919
import PlayButton from '../components/player/PlayButton';
2020
import FullPlayButton from '../components/FullPlayButton';
2121
import UFOIllustration from '../components/illustrations/UFOIllustration.astro';
22+
import { generateDocumentLinkTag } from '@bryanguffey/astro-standard-site';
23+
2224
import Layout from '../layouts/Layout.astro';
2325
import { getAllEpisodes, getShowInfo } from '../lib/rss';
26+
import { getDocumentRkeys } from '../lib/standardSite';
2427
import { dasherize } from '../utils/dasherize';
2528
2629
const show = await getShowInfo();
@@ -89,6 +92,15 @@ const sponsors = await db
8992
.where(eq(DbEpisode.episodeSlug, episode.episodeSlug));
9093
9194
const title = `${episode.title} - ${show.title} - Episode ${episode.episodeNumber}`;
95+
96+
// standard.site document verification: link this page to its published ATProto
97+
// `site.standard.document` record so shared links can be verified as ours.
98+
const standardSiteDid = import.meta.env.STANDARD_SITE_DID;
99+
const documentRkey = (await getDocumentRkeys()).get(`/${episode.episodeSlug}`);
100+
const documentLinkTag =
101+
standardSiteDid && documentRkey
102+
? generateDocumentLinkTag({ did: standardSiteDid, documentRkey })
103+
: null;
92104
---
93105

94106
<Layout
@@ -122,6 +134,8 @@ const title = `${episode.title} - ${show.title} - Episode ${episode.episodeNumbe
122134
}}
123135
/>
124136

137+
{documentLinkTag && <Fragment slot="head" set:html={documentLinkTag} />}
138+
125139
<!-- Enhanced Open Graph tags -->
126140
<meta slot="head" property="og:audio" content={episode.audio?.src} />
127141
<meta

0 commit comments

Comments
 (0)