Skip to content

Commit e496e9f

Browse files
committed
at intents with xrpc and jwt auth
1 parent 3772d21 commit e496e9f

18 files changed

Lines changed: 1302 additions & 21 deletions

at-intent/capabilities.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"capabilities": [
3+
{
4+
"name": "Save to Skyreader",
5+
"icon": "https://skyreader.app/icons/icon-512.png",
6+
"description": "Save an article to your Skyreader reading list (read-it-later). Accepts any web URL. Not for following a source over time; use subscribe for that.",
7+
"verb": "save",
8+
"subject": [{ "kind": "uri" }],
9+
"input": [{ "name": "title", "kind": "string", "required": false }],
10+
"delivery": "service",
11+
"endpoint": "https://api.skyreader.app/xrpc/app.skyreader.feed.save",
12+
"serviceId": "skyreader_api"
13+
},
14+
{
15+
"name": "Subscribe to feed in Skyreader",
16+
"icon": "https://skyreader.app/icons/icon-512.png",
17+
"description": "Subscribe to an RSS/Atom feed by URL so new items appear in your Skyreader reading list. For following an atproto account or publication, this maps to the account-follow capability instead.",
18+
"verb": "subscribe",
19+
"subject": [{ "kind": "uri" }],
20+
"input": [{ "name": "category", "kind": "string", "required": false }],
21+
"delivery": "service",
22+
"endpoint": "https://api.skyreader.app/xrpc/app.skyreader.feed.subscribe",
23+
"serviceId": "skyreader_api"
24+
},
25+
{
26+
"name": "Follow in Skyreader",
27+
"icon": "https://skyreader.app/icons/icon-512.png",
28+
"description": "Follow an atproto account or standard.site publication so its new posts appear in Skyreader. Requires Atmospheric sync to be enabled on the account.",
29+
"verb": "subscribe",
30+
"subject": [
31+
{ "kind": "did", "as": "account" },
32+
{ "kind": "at-uri", "of": "site.standard.publication" }
33+
],
34+
"produces": ["site.standard.graph.subscription"],
35+
"delivery": "repo",
36+
"scope": "repo:site.standard.graph.subscription"
37+
},
38+
{
39+
"name": "Share to Skyreader linkblog",
40+
"icon": "https://skyreader.app/icons/icon-512.png",
41+
"description": "Share a link to your public linkblog with an optional note. The post appears in your portable standard.site linkblog, visible across the Atmosphere.",
42+
"verb": "share",
43+
"subject": [{ "kind": "uri" }],
44+
"input": [
45+
{ "name": "title", "kind": "string", "required": false },
46+
{ "name": "note", "kind": "string", "required": false },
47+
{ "name": "tags", "kind": "string", "required": false }
48+
],
49+
"delivery": "service",
50+
"endpoint": "https://api.skyreader.app/xrpc/app.skyreader.linkblog.share",
51+
"serviceId": "skyreader_api"
52+
}
53+
]
54+
}

backend/package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"format:check": "prettier --check ."
1717
},
1818
"dependencies": {
19+
"@noble/curves": "^2.2.0",
20+
"@scure/base": "^2.2.0",
1921
"fast-xml-parser": "^5.2.0"
2022
},
2123
"devDependencies": {

backend/src/config/identity.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Skyreader's own atproto identity (handle skyreader.app). This is the repo where
2+
// Skyreader publishes its dev.at-intent.capability records, the `app` value written into
3+
// users' dev.at-intent.usage footprints, AND the expected `aud` of an atproto service-auth
4+
// JWT presented to Skyreader's XRPC endpoints. Resolve with:
5+
// curl https://skyreader.app/.well-known/atproto-did
6+
export const SKYREADER_APP_DID = 'did:plc:ra4jsemddo2ii4pn5jaf6x4v';

backend/src/config/scopes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,21 @@ export const LINKBLOG_SCOPES = ['repo:site.standard.publication', 'repo:site.sta
3838
// check for sharing) so adding it doesn't retroactively over-restrict shares.
3939
export const ATMOSPHERE_SCOPES = ['repo:site.standard.graph.subscription'];
4040

41+
// AT Intents discovery footprint — lets Skyreader write a dev.at-intent.usage record
42+
// into the user's OWN repo so other Atmosphere apps/agents can discover that the user
43+
// uses Skyreader and resolve the capabilities it publishes. Deliberately kept OUT of
44+
// GRANULAR_SCOPES (it's not required for any core Skyreader action), so requesting it
45+
// doesn't push existing users through a hasRequiredScopes re-auth. It's only added to
46+
// ALL_POSSIBLE_SCOPES, which is what the login/callback flow actually requests, so new
47+
// logins pick it up and the usage write is skipped for sessions that lack it.
48+
export const AT_INTENT_SCOPES = ['repo:dev.at-intent.usage'];
49+
4150
// All possible scopes (base + all integrations) — used in client metadata
4251
export const ALL_POSSIBLE_SCOPES = [
4352
GRANULAR_SCOPES,
4453
...SEMBLE_SCOPES,
4554
...MARGIN_SCOPES,
4655
...LINKBLOG_SCOPES,
4756
...ATMOSPHERE_SCOPES,
57+
...AT_INTENT_SCOPES,
4858
].join(' ');

backend/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
handleDeleteLabel,
5252
handleBulkAddLabels,
5353
} from './routes/labels';
54+
import { handleXrpcSave, handleXrpcSubscribe, handleXrpcLinkblogShare } from './routes/xrpc';
5455
import {
5556
handleCreateSaved,
5657
handleGetSaved,
@@ -527,6 +528,18 @@ export default {
527528
response = await handleSyncStatus(request, env);
528529
break;
529530

531+
// AT Intents service endpoints (XRPC procedures). No top-level session guard:
532+
// the handlers return XRPC-shaped `{ error, message }` auth errors themselves.
533+
case url.pathname === '/xrpc/app.skyreader.feed.save':
534+
response = await handleXrpcSave(request, env, ctx);
535+
break;
536+
case url.pathname === '/xrpc/app.skyreader.feed.subscribe':
537+
response = await handleXrpcSubscribe(request, env, ctx);
538+
break;
539+
case url.pathname === '/xrpc/app.skyreader.linkblog.share':
540+
response = await handleXrpcLinkblogShare(request, env);
541+
break;
542+
530543
default:
531544
response = new Response(JSON.stringify({ error: 'Not found' }), {
532545
status: 404,

backend/src/routes/auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
ALL_POSSIBLE_SCOPES,
3030
} from '../config/scopes';
3131
import { getUserTier } from '../services/user-tier';
32+
import { writeUsageRecord } from '../services/at-intent-usage';
3233
import { getLimitsForTier } from '../config/tier-limits';
3334
import {
3435
buildSetCookieHeader,
@@ -749,6 +750,10 @@ export async function handleAuthCallback(
749750
// Now store session (after user exists in DB due to FK constraint)
750751
await storeSession(env, sessionId, session);
751752

753+
// Write the AT Intents discovery footprint into the user's repo (best-effort,
754+
// skipped if the usage scope wasn't granted). Never block the login redirect on it.
755+
ctx.waitUntil(writeUsageRecord(session));
756+
752757
// Build the session cookie to set during redirect
753758
const cookieDomain = getCookieDomain(env, request);
754759
const isSecure = isSecureContext(request);

0 commit comments

Comments
 (0)