From b9164b619a1b4e797f2d48d1f8d96058a3044e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 22:40:23 +0000 Subject: [PATCH 01/10] feat: add com.atproto.server.getServiceAuth endpoint This endpoint is required for video uploads. Clients call it to get a service JWT to authenticate with external services like the video service (did:web:video.bsky.app). The endpoint: - Requires authentication - Takes 'aud' (required) and 'lxm' (optional) query params - Returns a signed service JWT with the requested audience and lxm claims Adds 4 new tests for the endpoint. --- packages/pds/src/index.ts | 7 +++ packages/pds/src/xrpc/server.ts | 43 ++++++++++++++++++ packages/pds/test/xrpc.test.ts | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index d46c7eac..627eeef6 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -215,6 +215,13 @@ app.get("/xrpc/com.atproto.server.getAccountStatus", requireAuth, (c) => server.getAccountStatus(c, getAccountDO(c.env)), ); +// Service auth - used by clients to get JWTs for external services (video, etc.) +app.get( + "/xrpc/com.atproto.server.getServiceAuth", + requireAuth, + server.getServiceAuth, +); + // Actor preferences (stub - returns empty preferences) app.get("/xrpc/app.bsky.actor.getPreferences", requireAuth, (c) => { return c.json({ preferences: [] }); diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 43701c27..f5aba0b4 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,4 +1,5 @@ import type { Context } from "hono"; +import { Secp256k1Keypair } from "@atproto/crypto"; import type { AccountDurableObject } from "../account-do"; import { createAccessToken, @@ -7,6 +8,7 @@ import { verifyAccessToken, verifyRefreshToken, } from "../session"; +import { createServiceJwt } from "../service-auth"; import type { AppEnv, AuthedAppEnv } from "../types"; export async function describeServer(c: Context): Promise { @@ -257,3 +259,44 @@ export async function getAccountStatus( }); } } + +// Lazy-loaded keypair for service auth +let keypairPromise: Promise | null = null; +function getKeypair(signingKey: string): Promise { + if (!keypairPromise) { + keypairPromise = Secp256k1Keypair.import(signingKey); + } + return keypairPromise; +} + +/** + * Get a service auth token for communicating with external services. + * Used by clients to get JWTs for services like video.bsky.app. + */ +export async function getServiceAuth( + c: Context, +): Promise { + const aud = c.req.query("aud"); + const lxm = c.req.query("lxm") || null; + + if (!aud) { + return c.json( + { + error: "InvalidRequest", + message: "Missing required parameter: aud", + }, + 400, + ); + } + + // Create service JWT for the requested audience + const keypair = await getKeypair(c.env.SIGNING_KEY); + const token = await createServiceJwt({ + iss: c.env.DID, + aud, + lxm, + keypair, + }); + + return c.json({ token }); +} diff --git a/packages/pds/test/xrpc.test.ts b/packages/pds/test/xrpc.test.ts index e8646f28..6f7b64cb 100644 --- a/packages/pds/test/xrpc.test.ts +++ b/packages/pds/test/xrpc.test.ts @@ -968,6 +968,86 @@ describe("XRPC Endpoints", () => { }); }); + describe("Service Auth", () => { + it("should return service JWT for video upload", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.getServiceAuth?aud=did:web:video.bsky.app&lxm=app.bsky.video.getUploadLimits", + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { token: string }; + expect(data.token).toBeDefined(); + + // Verify JWT structure + const parts = data.token.split("."); + expect(parts).toHaveLength(3); + + // Decode and verify payload + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + expect(payload.iss).toBe(env.DID); + expect(payload.aud).toBe("did:web:video.bsky.app"); + expect(payload.lxm).toBe("app.bsky.video.getUploadLimits"); + expect(payload.iat).toBeTypeOf("number"); + expect(payload.exp).toBeTypeOf("number"); + }); + + it("should return service JWT without lxm", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app", + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { token: string }; + const parts = data.token.split("."); + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + expect(payload.lxm).toBeUndefined(); + }); + + it("should require authentication", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.getServiceAuth?aud=did:web:video.bsky.app", + ), + env, + ); + expect(response.status).toBe(401); + }); + + it("should require aud parameter", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.getServiceAuth", + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }, + ), + env, + ); + expect(response.status).toBe(400); + + const data = (await response.json()) as { error: string }; + expect(data.error).toBe("InvalidRequest"); + }); + }); + describe("Sync Endpoints", () => { it("should get repo status", async () => { const response = await worker.fetch( From eba9c373249b7452d3205e2f7f1e767055c3eaea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 22:43:01 +0000 Subject: [PATCH 02/10] chore: add changeset for getServiceAuth endpoint --- .changeset/video-upload-auth.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/video-upload-auth.md diff --git a/.changeset/video-upload-auth.md b/.changeset/video-upload-auth.md new file mode 100644 index 00000000..45fe637e --- /dev/null +++ b/.changeset/video-upload-auth.md @@ -0,0 +1,7 @@ +--- +"@ascorbic/pds": minor +--- + +Add `com.atproto.server.getServiceAuth` endpoint for video upload authentication + +This endpoint is required for video uploads. Clients call it to get a service JWT to authenticate with external services like the video service (`did:web:video.bsky.app`). From 65750f8e941f89df48f00e6a43b8e18ef0b9be6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 08:28:54 +0000 Subject: [PATCH 03/10] fix: accept service JWTs for video upload auth The video service (video.bsky.app) calls uploadBlob on the PDS using a service JWT issued by getServiceAuth. The auth middleware now accepts these ES256K-signed service JWTs in addition to HS256 session JWTs. Auth flow: 1. Client gets service JWT via getServiceAuth(aud=PDS, lxm=uploadBlob) 2. Client sends video to video.bsky.app with this token 3. Video service calls uploadBlob on PDS using the same token 4. PDS verifies the service JWT signature and allows the upload Adds verifyServiceJwt() function and integration test for the flow. --- packages/pds/src/middleware/auth.ts | 40 +++++++++--- packages/pds/src/session.ts | 77 ++++++++++++++++++++++ packages/pds/test/service-auth.test.ts | 89 ++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/packages/pds/src/middleware/auth.ts b/packages/pds/src/middleware/auth.ts index c7a34ac4..6056c6b5 100644 --- a/packages/pds/src/middleware/auth.ts +++ b/packages/pds/src/middleware/auth.ts @@ -1,5 +1,5 @@ import type { Context, Next } from "hono"; -import { verifyAccessToken } from "../session"; +import { verifyAccessToken, verifyServiceJwt } from "../session"; import type { PDSEnv } from "../types"; export interface AuthInfo { @@ -31,11 +31,14 @@ export async function requireAuth( // Try static token first (backwards compatibility) if (token === c.env.AUTH_TOKEN) { + c.set("auth", { did: c.env.DID, scope: "atproto" }); return next(); } - // Try JWT verification const serviceDid = `did:web:${c.env.PDS_HOSTNAME}`; + + // Try session JWT verification (HS256, signed with JWT_SECRET) + // Used by Bluesky app for normal operations (posts, likes, etc.) try { const payload = await verifyAccessToken( token, @@ -55,15 +58,34 @@ export async function requireAuth( } // Store auth info in context for downstream use - c.set("auth", { did: payload.sub, scope: payload.scope }); + c.set("auth", { did: payload.sub, scope: payload.scope as string }); return next(); } catch { - return c.json( - { - error: "AuthenticationRequired", - message: "Invalid authentication token", - }, - 401, + // Session JWT verification failed, try service JWT + } + + // Try service JWT verification (ES256K, signed with our signing key) + // Used by external services (like video.bsky.app) calling back to our PDS + try { + const payload = await verifyServiceJwt( + token, + c.env.SIGNING_KEY, + serviceDid, // audience should be our PDS + c.env.DID, // issuer should be the user's DID ); + + // Store auth info in context + c.set("auth", { did: payload.iss, scope: payload.lxm || "atproto" }); + return next(); + } catch { + // Service JWT verification also failed } + + return c.json( + { + error: "AuthenticationRequired", + message: "Invalid authentication token", + }, + 401, + ); } diff --git a/packages/pds/src/session.ts b/packages/pds/src/session.ts index 3c819871..041a8ab3 100644 --- a/packages/pds/src/session.ts +++ b/packages/pds/src/session.ts @@ -1,5 +1,6 @@ import { SignJWT, jwtVerify, type JWTPayload } from "jose"; import { compare } from "bcryptjs"; +import { Secp256k1Keypair, verifySignature } from "@atproto/crypto"; const ACCESS_TOKEN_LIFETIME = "2h"; const REFRESH_TOKEN_LIFETIME = "90d"; @@ -117,3 +118,79 @@ export async function verifyRefreshToken( * Verify a password against a bcrypt hash */ export { compare as verifyPassword }; + +/** + * Service JWT payload structure + */ +export interface ServiceJwtPayload { + iss: string; // Issuer (user's DID) + aud: string; // Audience (PDS DID) + exp: number; // Expiration timestamp + iat?: number; // Issued at timestamp + lxm?: string; // Lexicon method (optional) + jti?: string; // Token ID (optional) +} + +/** + * Verify a service JWT signed with our signing key. + * These are issued by getServiceAuth and used by external services + * (like video.bsky.app) to call back to our PDS. + */ +export async function verifyServiceJwt( + token: string, + signingKey: string, + expectedAudience: string, + expectedIssuer: string, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + + const [headerB64, payloadB64, signatureB64] = parts; + + // Decode header + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + if (header.alg !== "ES256K") { + throw new Error(`Unsupported algorithm: ${header.alg}`); + } + + // Decode payload + const payload: ServiceJwtPayload = JSON.parse( + Buffer.from(payloadB64, "base64url").toString(), + ); + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) { + throw new Error("Token expired"); + } + + // Check audience (should be our PDS) + if (payload.aud !== expectedAudience) { + throw new Error(`Invalid audience: expected ${expectedAudience}`); + } + + // Check issuer (should be the user's DID) + if (payload.iss !== expectedIssuer) { + throw new Error(`Invalid issuer: expected ${expectedIssuer}`); + } + + // Verify signature using our signing key + // Import keypair fresh each time to avoid module-scope caching issues + const keypair = await Secp256k1Keypair.import(signingKey); + const msgBytes = new Uint8Array( + Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), + ); + const sigBytes = new Uint8Array(Buffer.from(signatureB64, "base64url")); + + const isValid = await verifySignature(keypair.did(), msgBytes, sigBytes, { + allowMalleableSig: true, + }); + + if (!isValid) { + throw new Error("Invalid signature"); + } + + return payload; +} diff --git a/packages/pds/test/service-auth.test.ts b/packages/pds/test/service-auth.test.ts index aab7d540..755cd09f 100644 --- a/packages/pds/test/service-auth.test.ts +++ b/packages/pds/test/service-auth.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { Secp256k1Keypair } from "@atproto/crypto"; import { createServiceJwt } from "../src/service-auth"; +import { verifyServiceJwt } from "../src/session"; +import { env, worker } from "./helpers"; describe("Service Auth", () => { it("creates valid service JWT", async () => { @@ -77,4 +79,91 @@ describe("Service Auth", () => { expect(isValid).toBe(true); }); + + it("verifyServiceJwt validates correctly signed token", async () => { + const keypair = await Secp256k1Keypair.create({ exportable: true }); + const signingKey = await keypair.export(); + + const jwt = await createServiceJwt({ + iss: "did:web:alice.test", + aud: "did:web:pds.test", + lxm: "com.atproto.repo.uploadBlob", + keypair, + }); + + const payload = await verifyServiceJwt( + jwt, + signingKey, + "did:web:pds.test", + "did:web:alice.test", + ); + + expect(payload.iss).toBe("did:web:alice.test"); + expect(payload.aud).toBe("did:web:pds.test"); + expect(payload.lxm).toBe("com.atproto.repo.uploadBlob"); + }); + + it("verifyServiceJwt rejects wrong audience", async () => { + const keypair = await Secp256k1Keypair.create({ exportable: true }); + const signingKey = await keypair.export(); + + const jwt = await createServiceJwt({ + iss: "did:web:alice.test", + aud: "did:web:other.test", + lxm: "com.atproto.repo.uploadBlob", + keypair, + }); + + await expect( + verifyServiceJwt( + jwt, + signingKey, + "did:web:pds.test", // wrong audience + "did:web:alice.test", + ), + ).rejects.toThrow("Invalid audience"); + }); + + it("uploadBlob accepts service JWT auth (video upload flow)", async () => { + // First get a service JWT for uploadBlob + // This mimics what happens when a client uploads a video: + // 1. Client calls getServiceAuth with aud=PDS and lxm=uploadBlob + // 2. Client sends video to video.bsky.app with this token + // 3. Video service calls uploadBlob on our PDS using the same token + const getAuthResponse = await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.server.getServiceAuth?aud=did:web:${env.PDS_HOSTNAME}&lxm=com.atproto.repo.uploadBlob`, + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }, + ), + env, + ); + expect(getAuthResponse.status).toBe(200); + + const { token } = (await getAuthResponse.json()) as { token: string }; + + // Now use that service JWT to call uploadBlob + // This simulates what video.bsky.app does after processing a video + const blobData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG header + const uploadResponse = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.repo.uploadBlob", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "image/png", + }, + body: blobData, + }), + env, + ); + + expect(uploadResponse.status).toBe(200); + const blob = (await uploadResponse.json()) as { + blob: { ref: { $link: string } }; + }; + expect(blob.blob.ref.$link).toBeDefined(); + }); }); From 368e6e7c2c14499b0043e84cc693b3d219410421 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 08:36:53 +0000 Subject: [PATCH 04/10] refactor: restore keypair caching for service JWT verification The actual fix was wrapping Buffer with Uint8Array, not removing caching. Cloudflare Workers' Buffer polyfill doesn't work correctly with @atproto/crypto's verifySignature() - it needs true Uint8Array instances. Restores caching for better performance while keeping the Uint8Array fix. --- packages/pds/src/session.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/pds/src/session.ts b/packages/pds/src/session.ts index 041a8ab3..b3f6e048 100644 --- a/packages/pds/src/session.ts +++ b/packages/pds/src/session.ts @@ -131,6 +131,21 @@ export interface ServiceJwtPayload { jti?: string; // Token ID (optional) } +// Cached keypair for service JWT verification +let cachedKeypair: Secp256k1Keypair | null = null; +let cachedSigningKey: string | null = null; + +async function getVerificationKeypair( + signingKey: string, +): Promise { + if (cachedKeypair && cachedSigningKey === signingKey) { + return cachedKeypair; + } + cachedKeypair = await Secp256k1Keypair.import(signingKey); + cachedSigningKey = signingKey; + return cachedKeypair; +} + /** * Verify a service JWT signed with our signing key. * These are issued by getServiceAuth and used by external services @@ -176,9 +191,9 @@ export async function verifyServiceJwt( throw new Error(`Invalid issuer: expected ${expectedIssuer}`); } - // Verify signature using our signing key - // Import keypair fresh each time to avoid module-scope caching issues - const keypair = await Secp256k1Keypair.import(signingKey); + // Verify signature using our signing key (with caching) + const keypair = await getVerificationKeypair(signingKey); + // Uint8Array wrapper is required - Buffer polyfill doesn't work with @atproto/crypto const msgBytes = new Uint8Array( Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), ); From 3100e6f8f698c7e58cce5f6fabd95d334eeb5c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 08:43:18 +0000 Subject: [PATCH 05/10] refactor: share keypair caching between modules Extract keypair caching to a shared module (keypair.ts) used by both service-auth.ts (for creating service JWTs) and session.ts (for verifying them). This ensures consistent behavior and reduces code duplication. --- packages/pds/src/keypair.ts | 23 +++++++++++++++++++++++ packages/pds/src/session.ts | 22 ++++------------------ packages/pds/src/xrpc/server.ts | 13 ++----------- 3 files changed, 29 insertions(+), 29 deletions(-) create mode 100644 packages/pds/src/keypair.ts diff --git a/packages/pds/src/keypair.ts b/packages/pds/src/keypair.ts new file mode 100644 index 00000000..65c39a77 --- /dev/null +++ b/packages/pds/src/keypair.ts @@ -0,0 +1,23 @@ +import { Secp256k1Keypair } from "@atproto/crypto"; + +/** + * Shared keypair cache for signing and verification. + * Both service auth creation and verification use this. + */ +let cachedKeypair: Secp256k1Keypair | null = null; +let cachedSigningKey: string | null = null; + +/** + * Get the signing keypair, with caching. + * Used for creating service JWTs and verifying them. + */ +export async function getSigningKeypair( + signingKey: string, +): Promise { + if (cachedKeypair && cachedSigningKey === signingKey) { + return cachedKeypair; + } + cachedKeypair = await Secp256k1Keypair.import(signingKey); + cachedSigningKey = signingKey; + return cachedKeypair; +} diff --git a/packages/pds/src/session.ts b/packages/pds/src/session.ts index b3f6e048..6086c2ce 100644 --- a/packages/pds/src/session.ts +++ b/packages/pds/src/session.ts @@ -1,6 +1,7 @@ import { SignJWT, jwtVerify, type JWTPayload } from "jose"; import { compare } from "bcryptjs"; -import { Secp256k1Keypair, verifySignature } from "@atproto/crypto"; +import { verifySignature } from "@atproto/crypto"; +import { getSigningKeypair } from "./keypair"; const ACCESS_TOKEN_LIFETIME = "2h"; const REFRESH_TOKEN_LIFETIME = "90d"; @@ -131,21 +132,6 @@ export interface ServiceJwtPayload { jti?: string; // Token ID (optional) } -// Cached keypair for service JWT verification -let cachedKeypair: Secp256k1Keypair | null = null; -let cachedSigningKey: string | null = null; - -async function getVerificationKeypair( - signingKey: string, -): Promise { - if (cachedKeypair && cachedSigningKey === signingKey) { - return cachedKeypair; - } - cachedKeypair = await Secp256k1Keypair.import(signingKey); - cachedSigningKey = signingKey; - return cachedKeypair; -} - /** * Verify a service JWT signed with our signing key. * These are issued by getServiceAuth and used by external services @@ -191,8 +177,8 @@ export async function verifyServiceJwt( throw new Error(`Invalid issuer: expected ${expectedIssuer}`); } - // Verify signature using our signing key (with caching) - const keypair = await getVerificationKeypair(signingKey); + // Verify signature using shared keypair + const keypair = await getSigningKeypair(signingKey); // Uint8Array wrapper is required - Buffer polyfill doesn't work with @atproto/crypto const msgBytes = new Uint8Array( Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index f5aba0b4..2c289013 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; -import { Secp256k1Keypair } from "@atproto/crypto"; import type { AccountDurableObject } from "../account-do"; +import { getSigningKeypair } from "../keypair"; import { createAccessToken, createRefreshToken, @@ -260,15 +260,6 @@ export async function getAccountStatus( } } -// Lazy-loaded keypair for service auth -let keypairPromise: Promise | null = null; -function getKeypair(signingKey: string): Promise { - if (!keypairPromise) { - keypairPromise = Secp256k1Keypair.import(signingKey); - } - return keypairPromise; -} - /** * Get a service auth token for communicating with external services. * Used by clients to get JWTs for services like video.bsky.app. @@ -290,7 +281,7 @@ export async function getServiceAuth( } // Create service JWT for the requested audience - const keypair = await getKeypair(c.env.SIGNING_KEY); + const keypair = await getSigningKeypair(c.env.SIGNING_KEY); const token = await createServiceJwt({ iss: c.env.DID, aud, From d83bfba2a04ccc7bb0c6f012223b3e06faa64d43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 08:49:34 +0000 Subject: [PATCH 06/10] refactor: consolidate service auth code in service-auth.ts Move keypair caching, verifyServiceJwt, and ServiceJwtPayload from separate modules into service-auth.ts where they logically belong alongside createServiceJwt. --- packages/pds/src/keypair.ts | 23 ------ packages/pds/src/middleware/auth.ts | 3 +- packages/pds/src/service-auth.ts | 99 +++++++++++++++++++++++++- packages/pds/src/session.ts | 78 -------------------- packages/pds/src/xrpc/server.ts | 3 +- packages/pds/test/service-auth.test.ts | 3 +- 6 files changed, 102 insertions(+), 107 deletions(-) delete mode 100644 packages/pds/src/keypair.ts diff --git a/packages/pds/src/keypair.ts b/packages/pds/src/keypair.ts deleted file mode 100644 index 65c39a77..00000000 --- a/packages/pds/src/keypair.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Secp256k1Keypair } from "@atproto/crypto"; - -/** - * Shared keypair cache for signing and verification. - * Both service auth creation and verification use this. - */ -let cachedKeypair: Secp256k1Keypair | null = null; -let cachedSigningKey: string | null = null; - -/** - * Get the signing keypair, with caching. - * Used for creating service JWTs and verifying them. - */ -export async function getSigningKeypair( - signingKey: string, -): Promise { - if (cachedKeypair && cachedSigningKey === signingKey) { - return cachedKeypair; - } - cachedKeypair = await Secp256k1Keypair.import(signingKey); - cachedSigningKey = signingKey; - return cachedKeypair; -} diff --git a/packages/pds/src/middleware/auth.ts b/packages/pds/src/middleware/auth.ts index 6056c6b5..e38ab635 100644 --- a/packages/pds/src/middleware/auth.ts +++ b/packages/pds/src/middleware/auth.ts @@ -1,5 +1,6 @@ import type { Context, Next } from "hono"; -import { verifyAccessToken, verifyServiceJwt } from "../session"; +import { verifyServiceJwt } from "../service-auth"; +import { verifyAccessToken } from "../session"; import type { PDSEnv } from "../types"; export interface AuthInfo { diff --git a/packages/pds/src/service-auth.ts b/packages/pds/src/service-auth.ts index 697293b9..a92ec4b8 100644 --- a/packages/pds/src/service-auth.ts +++ b/packages/pds/src/service-auth.ts @@ -1,7 +1,40 @@ -import { Secp256k1Keypair, randomStr } from "@atproto/crypto"; +import { Secp256k1Keypair, randomStr, verifySignature } from "@atproto/crypto"; const MINUTE = 60 * 1000; +/** + * Shared keypair cache for signing and verification. + */ +let cachedKeypair: Secp256k1Keypair | null = null; +let cachedSigningKey: string | null = null; + +/** + * Get the signing keypair, with caching. + * Used for creating service JWTs and verifying them. + */ +export async function getSigningKeypair( + signingKey: string, +): Promise { + if (cachedKeypair && cachedSigningKey === signingKey) { + return cachedKeypair; + } + cachedKeypair = await Secp256k1Keypair.import(signingKey); + cachedSigningKey = signingKey; + return cachedKeypair; +} + +/** + * Service JWT payload structure + */ +export interface ServiceJwtPayload { + iss: string; // Issuer (user's DID) + aud: string; // Audience (PDS DID) + exp: number; // Expiration timestamp + iat?: number; // Issued at timestamp + lxm?: string; // Lexicon method (optional) + jti?: string; // Token ID (optional) +} + type ServiceJwtParams = { iss: string; aud: string; @@ -58,3 +91,67 @@ export async function createServiceJwt( return `${toSignStr}.${sig.toString("base64url")}`; } + +/** + * Verify a service JWT signed with our signing key. + * These are issued by getServiceAuth and used by external services + * (like video.bsky.app) to call back to our PDS. + */ +export async function verifyServiceJwt( + token: string, + signingKey: string, + expectedAudience: string, + expectedIssuer: string, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + + const [headerB64, payloadB64, signatureB64] = parts; + + // Decode header + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + if (header.alg !== "ES256K") { + throw new Error(`Unsupported algorithm: ${header.alg}`); + } + + // Decode payload + const payload: ServiceJwtPayload = JSON.parse( + Buffer.from(payloadB64, "base64url").toString(), + ); + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) { + throw new Error("Token expired"); + } + + // Check audience (should be our PDS) + if (payload.aud !== expectedAudience) { + throw new Error(`Invalid audience: expected ${expectedAudience}`); + } + + // Check issuer (should be the user's DID) + if (payload.iss !== expectedIssuer) { + throw new Error(`Invalid issuer: expected ${expectedIssuer}`); + } + + // Verify signature using shared keypair + const keypair = await getSigningKeypair(signingKey); + // Uint8Array wrapper is required - Buffer polyfill doesn't work with @atproto/crypto + const msgBytes = new Uint8Array( + Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), + ); + const sigBytes = new Uint8Array(Buffer.from(signatureB64, "base64url")); + + const isValid = await verifySignature(keypair.did(), msgBytes, sigBytes, { + allowMalleableSig: true, + }); + + if (!isValid) { + throw new Error("Invalid signature"); + } + + return payload; +} diff --git a/packages/pds/src/session.ts b/packages/pds/src/session.ts index 6086c2ce..3c819871 100644 --- a/packages/pds/src/session.ts +++ b/packages/pds/src/session.ts @@ -1,7 +1,5 @@ import { SignJWT, jwtVerify, type JWTPayload } from "jose"; import { compare } from "bcryptjs"; -import { verifySignature } from "@atproto/crypto"; -import { getSigningKeypair } from "./keypair"; const ACCESS_TOKEN_LIFETIME = "2h"; const REFRESH_TOKEN_LIFETIME = "90d"; @@ -119,79 +117,3 @@ export async function verifyRefreshToken( * Verify a password against a bcrypt hash */ export { compare as verifyPassword }; - -/** - * Service JWT payload structure - */ -export interface ServiceJwtPayload { - iss: string; // Issuer (user's DID) - aud: string; // Audience (PDS DID) - exp: number; // Expiration timestamp - iat?: number; // Issued at timestamp - lxm?: string; // Lexicon method (optional) - jti?: string; // Token ID (optional) -} - -/** - * Verify a service JWT signed with our signing key. - * These are issued by getServiceAuth and used by external services - * (like video.bsky.app) to call back to our PDS. - */ -export async function verifyServiceJwt( - token: string, - signingKey: string, - expectedAudience: string, - expectedIssuer: string, -): Promise { - const parts = token.split("."); - if (parts.length !== 3) { - throw new Error("Invalid JWT format"); - } - - const [headerB64, payloadB64, signatureB64] = parts; - - // Decode header - const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); - if (header.alg !== "ES256K") { - throw new Error(`Unsupported algorithm: ${header.alg}`); - } - - // Decode payload - const payload: ServiceJwtPayload = JSON.parse( - Buffer.from(payloadB64, "base64url").toString(), - ); - - // Check expiration - const now = Math.floor(Date.now() / 1000); - if (payload.exp && payload.exp < now) { - throw new Error("Token expired"); - } - - // Check audience (should be our PDS) - if (payload.aud !== expectedAudience) { - throw new Error(`Invalid audience: expected ${expectedAudience}`); - } - - // Check issuer (should be the user's DID) - if (payload.iss !== expectedIssuer) { - throw new Error(`Invalid issuer: expected ${expectedIssuer}`); - } - - // Verify signature using shared keypair - const keypair = await getSigningKeypair(signingKey); - // Uint8Array wrapper is required - Buffer polyfill doesn't work with @atproto/crypto - const msgBytes = new Uint8Array( - Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), - ); - const sigBytes = new Uint8Array(Buffer.from(signatureB64, "base64url")); - - const isValid = await verifySignature(keypair.did(), msgBytes, sigBytes, { - allowMalleableSig: true, - }); - - if (!isValid) { - throw new Error("Invalid signature"); - } - - return payload; -} diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 2c289013..f3634750 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; import type { AccountDurableObject } from "../account-do"; -import { getSigningKeypair } from "../keypair"; +import { createServiceJwt, getSigningKeypair } from "../service-auth"; import { createAccessToken, createRefreshToken, @@ -8,7 +8,6 @@ import { verifyAccessToken, verifyRefreshToken, } from "../session"; -import { createServiceJwt } from "../service-auth"; import type { AppEnv, AuthedAppEnv } from "../types"; export async function describeServer(c: Context): Promise { diff --git a/packages/pds/test/service-auth.test.ts b/packages/pds/test/service-auth.test.ts index 755cd09f..9a2303ce 100644 --- a/packages/pds/test/service-auth.test.ts +++ b/packages/pds/test/service-auth.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { Secp256k1Keypair } from "@atproto/crypto"; -import { createServiceJwt } from "../src/service-auth"; -import { verifyServiceJwt } from "../src/session"; +import { createServiceJwt, verifyServiceJwt } from "../src/service-auth"; import { env, worker } from "./helpers"; describe("Service Auth", () => { From 4d420cb8c375c6d8eb28c9822a5b26bc25e615e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 09:44:19 +0000 Subject: [PATCH 07/10] feat: add video embed and missing defs lexicons Add app.bsky.embed.video schema for video post support, along with all dependent defs schemas (embed.defs, actor.defs, feed.defs, graph.defs, notification.defs) that are referenced by other lexicons. --- packages/pds/scripts/update-lexicons.sh | 8 + .../pds/src/lexicons/app.bsky.actor.defs.json | 666 ++++++++++++++++++ .../pds/src/lexicons/app.bsky.embed.defs.json | 15 + .../src/lexicons/app.bsky.embed.video.json | 67 ++ .../pds/src/lexicons/app.bsky.feed.defs.json | 331 +++++++++ .../pds/src/lexicons/app.bsky.graph.defs.json | 186 +++++ .../lexicons/app.bsky.notification.defs.json | 88 +++ 7 files changed, 1361 insertions(+) create mode 100644 packages/pds/src/lexicons/app.bsky.actor.defs.json create mode 100644 packages/pds/src/lexicons/app.bsky.embed.defs.json create mode 100644 packages/pds/src/lexicons/app.bsky.embed.video.json create mode 100644 packages/pds/src/lexicons/app.bsky.feed.defs.json create mode 100644 packages/pds/src/lexicons/app.bsky.graph.defs.json create mode 100644 packages/pds/src/lexicons/app.bsky.notification.defs.json diff --git a/packages/pds/scripts/update-lexicons.sh b/packages/pds/scripts/update-lexicons.sh index 5bac1fe0..f7f7470c 100755 --- a/packages/pds/scripts/update-lexicons.sh +++ b/packages/pds/scripts/update-lexicons.sh @@ -23,15 +23,18 @@ schemas=( "app/bsky/feed/like" "app/bsky/feed/repost" "app/bsky/feed/threadgate" + "app/bsky/feed/defs" # Actor schemas "app/bsky/actor/profile" + "app/bsky/actor/defs" # Graph schemas "app/bsky/graph/follow" "app/bsky/graph/block" "app/bsky/graph/list" "app/bsky/graph/listitem" + "app/bsky/graph/defs" # Richtext schemas "app/bsky/richtext/facet" @@ -41,6 +44,11 @@ schemas=( "app/bsky/embed/external" "app/bsky/embed/record" "app/bsky/embed/recordWithMedia" + "app/bsky/embed/video" + "app/bsky/embed/defs" + + # Notification schemas (referenced by actor.defs) + "app/bsky/notification/defs" ) # Fetch each schema diff --git a/packages/pds/src/lexicons/app.bsky.actor.defs.json b/packages/pds/src/lexicons/app.bsky.actor.defs.json new file mode 100644 index 00000000..13ea29ec --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.actor.defs.json @@ -0,0 +1,666 @@ +{ + "lexicon": 1, + "id": "app.bsky.actor.defs", + "defs": { + "profileViewBasic": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "pronouns": { "type": "string" }, + "avatar": { "type": "string", "format": "uri" }, + "associated": { + "type": "ref", + "ref": "#profileAssociated" + }, + "viewer": { "type": "ref", "ref": "#viewerState" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "createdAt": { "type": "string", "format": "datetime" }, + "verification": { + "type": "ref", + "ref": "#verificationState" + }, + "status": { + "type": "ref", + "ref": "#statusView" + }, + "debug": { + "type": "unknown", + "description": "Debug information for internal development" + } + } + }, + "profileView": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "pronouns": { "type": "string" }, + "description": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "avatar": { "type": "string", "format": "uri" }, + "associated": { + "type": "ref", + "ref": "#profileAssociated" + }, + "indexedAt": { "type": "string", "format": "datetime" }, + "createdAt": { "type": "string", "format": "datetime" }, + "viewer": { "type": "ref", "ref": "#viewerState" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "verification": { + "type": "ref", + "ref": "#verificationState" + }, + "status": { + "type": "ref", + "ref": "#statusView" + }, + "debug": { + "type": "unknown", + "description": "Debug information for internal development" + } + } + }, + "profileViewDetailed": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "pronouns": { "type": "string" }, + "website": { "type": "string", "format": "uri" }, + "avatar": { "type": "string", "format": "uri" }, + "banner": { "type": "string", "format": "uri" }, + "followersCount": { "type": "integer" }, + "followsCount": { "type": "integer" }, + "postsCount": { "type": "integer" }, + "associated": { + "type": "ref", + "ref": "#profileAssociated" + }, + "joinedViaStarterPack": { + "type": "ref", + "ref": "app.bsky.graph.defs#starterPackViewBasic" + }, + "indexedAt": { "type": "string", "format": "datetime" }, + "createdAt": { "type": "string", "format": "datetime" }, + "viewer": { "type": "ref", "ref": "#viewerState" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "pinnedPost": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "verification": { + "type": "ref", + "ref": "#verificationState" + }, + "status": { + "type": "ref", + "ref": "#statusView" + }, + "debug": { + "type": "unknown", + "description": "Debug information for internal development" + } + } + }, + "profileAssociated": { + "type": "object", + "properties": { + "lists": { "type": "integer" }, + "feedgens": { "type": "integer" }, + "starterPacks": { "type": "integer" }, + "labeler": { "type": "boolean" }, + "chat": { "type": "ref", "ref": "#profileAssociatedChat" }, + "activitySubscription": { + "type": "ref", + "ref": "#profileAssociatedActivitySubscription" + } + } + }, + "profileAssociatedChat": { + "type": "object", + "required": ["allowIncoming"], + "properties": { + "allowIncoming": { + "type": "string", + "knownValues": ["all", "none", "following"] + } + } + }, + "profileAssociatedActivitySubscription": { + "type": "object", + "required": ["allowSubscriptions"], + "properties": { + "allowSubscriptions": { + "type": "string", + "knownValues": ["followers", "mutuals", "none"] + } + } + }, + "viewerState": { + "type": "object", + "description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.", + "properties": { + "muted": { "type": "boolean" }, + "mutedByList": { + "type": "ref", + "ref": "app.bsky.graph.defs#listViewBasic" + }, + "blockedBy": { "type": "boolean" }, + "blocking": { "type": "string", "format": "at-uri" }, + "blockingByList": { + "type": "ref", + "ref": "app.bsky.graph.defs#listViewBasic" + }, + "following": { "type": "string", "format": "at-uri" }, + "followedBy": { "type": "string", "format": "at-uri" }, + "knownFollowers": { + "description": "This property is present only in selected cases, as an optimization.", + "type": "ref", + "ref": "#knownFollowers" + }, + "activitySubscription": { + "description": "This property is present only in selected cases, as an optimization.", + "type": "ref", + "ref": "app.bsky.notification.defs#activitySubscription" + } + } + }, + "knownFollowers": { + "type": "object", + "description": "The subject's followers whom you also follow", + "required": ["count", "followers"], + "properties": { + "count": { "type": "integer" }, + "followers": { + "type": "array", + "minLength": 0, + "maxLength": 5, + "items": { + "type": "ref", + "ref": "#profileViewBasic" + } + } + } + }, + "verificationState": { + "type": "object", + "description": "Represents the verification information about the user this object is attached to.", + "required": ["verifications", "verifiedStatus", "trustedVerifierStatus"], + "properties": { + "verifications": { + "type": "array", + "description": "All verifications issued by trusted verifiers on behalf of this user. Verifications by untrusted verifiers are not included.", + "items": { "type": "ref", "ref": "#verificationView" } + }, + "verifiedStatus": { + "type": "string", + "description": "The user's status as a verified account.", + "knownValues": ["valid", "invalid", "none"] + }, + "trustedVerifierStatus": { + "type": "string", + "description": "The user's status as a trusted verifier.", + "knownValues": ["valid", "invalid", "none"] + } + } + }, + "verificationView": { + "type": "object", + "description": "An individual verification for an associated subject.", + "required": ["issuer", "uri", "isValid", "createdAt"], + "properties": { + "issuer": { + "type": "string", + "description": "The user who issued this verification.", + "format": "did" + }, + "uri": { + "type": "string", + "description": "The AT-URI of the verification record.", + "format": "at-uri" + }, + "isValid": { + "type": "boolean", + "description": "True if the verification passes validation, otherwise false." + }, + "createdAt": { + "type": "string", + "description": "Timestamp when the verification was created.", + "format": "datetime" + } + } + }, + "preferences": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "#adultContentPref", + "#contentLabelPref", + "#savedFeedsPref", + "#savedFeedsPrefV2", + "#personalDetailsPref", + "#declaredAgePref", + "#feedViewPref", + "#threadViewPref", + "#interestsPref", + "#mutedWordsPref", + "#hiddenPostsPref", + "#bskyAppStatePref", + "#labelersPref", + "#postInteractionSettingsPref", + "#verificationPrefs" + ] + } + }, + "adultContentPref": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean", "default": false } + } + }, + "contentLabelPref": { + "type": "object", + "required": ["label", "visibility"], + "properties": { + "labelerDid": { + "type": "string", + "description": "Which labeler does this preference apply to? If undefined, applies globally.", + "format": "did" + }, + "label": { "type": "string" }, + "visibility": { + "type": "string", + "knownValues": ["ignore", "show", "warn", "hide"] + } + } + }, + "savedFeed": { + "type": "object", + "required": ["id", "type", "value", "pinned"], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "knownValues": ["feed", "list", "timeline"] + }, + "value": { + "type": "string" + }, + "pinned": { + "type": "boolean" + } + } + }, + "savedFeedsPrefV2": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#savedFeed" + } + } + } + }, + "savedFeedsPref": { + "type": "object", + "required": ["pinned", "saved"], + "properties": { + "pinned": { + "type": "array", + "items": { + "type": "string", + "format": "at-uri" + } + }, + "saved": { + "type": "array", + "items": { + "type": "string", + "format": "at-uri" + } + }, + "timelineIndex": { + "type": "integer" + } + } + }, + "personalDetailsPref": { + "type": "object", + "properties": { + "birthDate": { + "type": "string", + "format": "datetime", + "description": "The birth date of account owner." + } + } + }, + "declaredAgePref": { + "type": "object", + "description": "Read-only preference containing value(s) inferred from the user's declared birthdate. Absence of this preference object in the response indicates that the user has not made a declaration.", + "properties": { + "isOverAge13": { + "type": "boolean", + "description": "Indicates if the user has declared that they are over 13 years of age." + }, + "isOverAge16": { + "type": "boolean", + "description": "Indicates if the user has declared that they are over 16 years of age." + }, + "isOverAge18": { + "type": "boolean", + "description": "Indicates if the user has declared that they are over 18 years of age." + } + } + }, + "feedViewPref": { + "type": "object", + "required": ["feed"], + "properties": { + "feed": { + "type": "string", + "description": "The URI of the feed, or an identifier which describes the feed." + }, + "hideReplies": { + "type": "boolean", + "description": "Hide replies in the feed." + }, + "hideRepliesByUnfollowed": { + "type": "boolean", + "description": "Hide replies in the feed if they are not by followed users.", + "default": true + }, + "hideRepliesByLikeCount": { + "type": "integer", + "description": "Hide replies in the feed if they do not have this number of likes." + }, + "hideReposts": { + "type": "boolean", + "description": "Hide reposts in the feed." + }, + "hideQuotePosts": { + "type": "boolean", + "description": "Hide quote posts in the feed." + } + } + }, + "threadViewPref": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "description": "Sorting mode for threads.", + "knownValues": ["oldest", "newest", "most-likes", "random", "hotness"] + } + } + }, + "interestsPref": { + "type": "object", + "required": ["tags"], + "properties": { + "tags": { + "type": "array", + "maxLength": 100, + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }, + "description": "A list of tags which describe the account owner's interests gathered during onboarding." + } + } + }, + "mutedWordTarget": { + "type": "string", + "knownValues": ["content", "tag"], + "maxLength": 640, + "maxGraphemes": 64 + }, + "mutedWord": { + "type": "object", + "description": "A word that the account owner has muted.", + "required": ["value", "targets"], + "properties": { + "id": { "type": "string" }, + "value": { + "type": "string", + "description": "The muted word itself.", + "maxLength": 10000, + "maxGraphemes": 1000 + }, + "targets": { + "type": "array", + "description": "The intended targets of the muted word.", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#mutedWordTarget" + } + }, + "actorTarget": { + "type": "string", + "description": "Groups of users to apply the muted word to. If undefined, applies to all users.", + "knownValues": ["all", "exclude-following"], + "default": "all" + }, + "expiresAt": { + "type": "string", + "format": "datetime", + "description": "The date and time at which the muted word will expire and no longer be applied." + } + } + }, + "mutedWordsPref": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#mutedWord" + }, + "description": "A list of words the account owner has muted." + } + } + }, + "hiddenPostsPref": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "string", "format": "at-uri" }, + "description": "A list of URIs of posts the account owner has hidden." + } + } + }, + "labelersPref": { + "type": "object", + "required": ["labelers"], + "properties": { + "labelers": { + "type": "array", + "items": { + "type": "ref", + "ref": "#labelerPrefItem" + } + } + } + }, + "labelerPrefItem": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } + }, + "bskyAppStatePref": { + "description": "A grab bag of state that's specific to the bsky.app program. Third-party apps shouldn't use this.", + "type": "object", + "properties": { + "activeProgressGuide": { + "type": "ref", + "ref": "#bskyAppProgressGuide" + }, + "queuedNudges": { + "description": "An array of tokens which identify nudges (modals, popups, tours, highlight dots) that should be shown to the user.", + "type": "array", + "maxLength": 1000, + "items": { "type": "string", "maxLength": 100 } + }, + "nuxs": { + "description": "Storage for NUXs the user has encountered.", + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#nux" + } + } + } + }, + "bskyAppProgressGuide": { + "description": "If set, an active progress guide. Once completed, can be set to undefined. Should have unspecced fields tracking progress.", + "type": "object", + "required": ["guide"], + "properties": { + "guide": { "type": "string", "maxLength": 100 } + } + }, + "nux": { + "type": "object", + "description": "A new user experiences (NUX) storage object", + "required": ["id", "completed"], + "properties": { + "id": { + "type": "string", + "maxLength": 100 + }, + "completed": { + "type": "boolean", + "default": false + }, + "data": { + "description": "Arbitrary data for the NUX. The structure is defined by the NUX itself. Limited to 300 characters.", + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300 + }, + "expiresAt": { + "type": "string", + "format": "datetime", + "description": "The date and time at which the NUX will expire and should be considered completed." + } + } + }, + "verificationPrefs": { + "type": "object", + "description": "Preferences for how verified accounts appear in the app.", + "required": [], + "properties": { + "hideBadges": { + "description": "Hide the blue check badges for verified accounts and trusted verifiers.", + "type": "boolean", + "default": false + } + } + }, + "postInteractionSettingsPref": { + "type": "object", + "description": "Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly.", + "required": [], + "properties": { + "threadgateAllowRules": { + "description": "Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply.", + "type": "array", + "maxLength": 5, + "items": { + "type": "union", + "refs": [ + "app.bsky.feed.threadgate#mentionRule", + "app.bsky.feed.threadgate#followerRule", + "app.bsky.feed.threadgate#followingRule", + "app.bsky.feed.threadgate#listRule" + ] + } + }, + "postgateEmbeddingRules": { + "description": "Matches postgate record. List of rules defining who can embed this users posts. If value is an empty array or is undefined, no particular rules apply and anyone can embed.", + "type": "array", + "maxLength": 5, + "items": { + "type": "union", + "refs": ["app.bsky.feed.postgate#disableRule"] + } + } + } + }, + "statusView": { + "type": "object", + "required": ["status", "record"], + "properties": { + "status": { + "type": "string", + "description": "The status for the account.", + "knownValues": ["app.bsky.actor.status#live"] + }, + "record": { "type": "unknown" }, + "embed": { + "type": "union", + "description": "An optional embed associated with the status.", + "refs": ["app.bsky.embed.external#view"] + }, + "expiresAt": { + "type": "string", + "description": "The date when this status will expire. The application might choose to no longer return the status after expiration.", + "format": "datetime" + }, + "isActive": { + "type": "boolean", + "description": "True if the status is not expired, false if it is expired. Only present if expiration was set." + } + } + } + } +} diff --git a/packages/pds/src/lexicons/app.bsky.embed.defs.json b/packages/pds/src/lexicons/app.bsky.embed.defs.json new file mode 100644 index 00000000..57ffc03a --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.embed.defs.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.defs", + "defs": { + "aspectRatio": { + "type": "object", + "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/packages/pds/src/lexicons/app.bsky.embed.video.json b/packages/pds/src/lexicons/app.bsky.embed.video.json new file mode 100644 index 00000000..92511af1 --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.embed.video.json @@ -0,0 +1,67 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.video", + "description": "A video embedded in a Bluesky record (eg, a post).", + "defs": { + "main": { + "type": "object", + "required": ["video"], + "properties": { + "video": { + "type": "blob", + "description": "The mp4 video file. May be up to 100mb, formerly limited to 50mb.", + "accept": ["video/mp4"], + "maxSize": 100000000 + }, + "captions": { + "type": "array", + "items": { "type": "ref", "ref": "#caption" }, + "maxLength": 20 + }, + "alt": { + "type": "string", + "description": "Alt text description of the video, for accessibility.", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + }, + "caption": { + "type": "object", + "required": ["lang", "file"], + "properties": { + "lang": { + "type": "string", + "format": "language" + }, + "file": { + "type": "blob", + "accept": ["text/vtt"], + "maxSize": 20000 + } + } + }, + "view": { + "type": "object", + "required": ["cid", "playlist"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "playlist": { "type": "string", "format": "uri" }, + "thumbnail": { "type": "string", "format": "uri" }, + "alt": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + } + } +} diff --git a/packages/pds/src/lexicons/app.bsky.feed.defs.json b/packages/pds/src/lexicons/app.bsky.feed.defs.json new file mode 100644 index 00000000..0a19cf31 --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.feed.defs.json @@ -0,0 +1,331 @@ +{ + "lexicon": 1, + "id": "app.bsky.feed.defs", + "defs": { + "postView": { + "type": "object", + "required": ["uri", "cid", "author", "record", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "author": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic" + }, + "record": { "type": "unknown" }, + "embed": { + "type": "union", + "refs": [ + "app.bsky.embed.images#view", + "app.bsky.embed.video#view", + "app.bsky.embed.external#view", + "app.bsky.embed.record#view", + "app.bsky.embed.recordWithMedia#view" + ] + }, + "bookmarkCount": { "type": "integer" }, + "replyCount": { "type": "integer" }, + "repostCount": { "type": "integer" }, + "likeCount": { "type": "integer" }, + "quoteCount": { "type": "integer" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "viewer": { "type": "ref", "ref": "#viewerState" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "threadgate": { "type": "ref", "ref": "#threadgateView" }, + "debug": { + "type": "unknown", + "description": "Debug information for internal development" + } + } + }, + "viewerState": { + "type": "object", + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", + "properties": { + "repost": { "type": "string", "format": "at-uri" }, + "like": { "type": "string", "format": "at-uri" }, + "bookmarked": { "type": "boolean" }, + "threadMuted": { "type": "boolean" }, + "replyDisabled": { "type": "boolean" }, + "embeddingDisabled": { "type": "boolean" }, + "pinned": { "type": "boolean" } + } + }, + "threadContext": { + "type": "object", + "description": "Metadata about this post within the context of the thread it is in.", + "properties": { + "rootAuthorLike": { "type": "string", "format": "at-uri" } + } + }, + "feedViewPost": { + "type": "object", + "required": ["post"], + "properties": { + "post": { "type": "ref", "ref": "#postView" }, + "reply": { "type": "ref", "ref": "#replyRef" }, + "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] }, + "feedContext": { + "type": "string", + "description": "Context provided by feed generator that may be passed back alongside interactions.", + "maxLength": 2000 + }, + "reqId": { + "type": "string", + "description": "Unique identifier per request that may be passed back alongside interactions.", + "maxLength": 100 + } + } + }, + "replyRef": { + "type": "object", + "required": ["root", "parent"], + "properties": { + "root": { + "type": "union", + "refs": ["#postView", "#notFoundPost", "#blockedPost"] + }, + "parent": { + "type": "union", + "refs": ["#postView", "#notFoundPost", "#blockedPost"] + }, + "grandparentAuthor": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic", + "description": "When parent is a reply to another post, this is the author of that post." + } + } + }, + "reasonRepost": { + "type": "object", + "required": ["by", "indexedAt"], + "properties": { + "by": { "type": "ref", "ref": "app.bsky.actor.defs#profileViewBasic" }, + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "reasonPin": { + "type": "object", + "properties": {} + }, + "threadViewPost": { + "type": "object", + "required": ["post"], + "properties": { + "post": { "type": "ref", "ref": "#postView" }, + "parent": { + "type": "union", + "refs": ["#threadViewPost", "#notFoundPost", "#blockedPost"] + }, + "replies": { + "type": "array", + "items": { + "type": "union", + "refs": ["#threadViewPost", "#notFoundPost", "#blockedPost"] + } + }, + "threadContext": { "type": "ref", "ref": "#threadContext" } + } + }, + "notFoundPost": { + "type": "object", + "required": ["uri", "notFound"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "notFound": { "type": "boolean", "const": true } + } + }, + "blockedPost": { + "type": "object", + "required": ["uri", "blocked", "author"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "blocked": { "type": "boolean", "const": true }, + "author": { "type": "ref", "ref": "#blockedAuthor" } + } + }, + "blockedAuthor": { + "type": "object", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" }, + "viewer": { "type": "ref", "ref": "app.bsky.actor.defs#viewerState" } + } + }, + "generatorView": { + "type": "object", + "required": ["uri", "cid", "did", "creator", "displayName", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "did": { "type": "string", "format": "did" }, + "creator": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" }, + "displayName": { "type": "string" }, + "description": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000 + }, + "descriptionFacets": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "avatar": { "type": "string", "format": "uri" }, + "likeCount": { "type": "integer", "minimum": 0 }, + "acceptsInteractions": { "type": "boolean" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "viewer": { "type": "ref", "ref": "#generatorViewerState" }, + "contentMode": { + "type": "string", + "knownValues": [ + "app.bsky.feed.defs#contentModeUnspecified", + "app.bsky.feed.defs#contentModeVideo" + ] + }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "generatorViewerState": { + "type": "object", + "properties": { + "like": { "type": "string", "format": "at-uri" } + } + }, + "skeletonFeedPost": { + "type": "object", + "required": ["post"], + "properties": { + "post": { "type": "string", "format": "at-uri" }, + "reason": { + "type": "union", + "refs": ["#skeletonReasonRepost", "#skeletonReasonPin"] + }, + "feedContext": { + "type": "string", + "description": "Context that will be passed through to client and may be passed to feed generator back alongside interactions.", + "maxLength": 2000 + } + } + }, + "skeletonReasonRepost": { + "type": "object", + "required": ["repost"], + "properties": { + "repost": { "type": "string", "format": "at-uri" } + } + }, + "skeletonReasonPin": { + "type": "object", + "properties": {} + }, + "threadgateView": { + "type": "object", + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "record": { "type": "unknown" }, + "lists": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.graph.defs#listViewBasic" } + } + } + }, + "interaction": { + "type": "object", + "properties": { + "item": { "type": "string", "format": "at-uri" }, + "event": { + "type": "string", + "knownValues": [ + "app.bsky.feed.defs#requestLess", + "app.bsky.feed.defs#requestMore", + "app.bsky.feed.defs#clickthroughItem", + "app.bsky.feed.defs#clickthroughAuthor", + "app.bsky.feed.defs#clickthroughReposter", + "app.bsky.feed.defs#clickthroughEmbed", + "app.bsky.feed.defs#interactionSeen", + "app.bsky.feed.defs#interactionLike", + "app.bsky.feed.defs#interactionRepost", + "app.bsky.feed.defs#interactionReply", + "app.bsky.feed.defs#interactionQuote", + "app.bsky.feed.defs#interactionShare" + ] + }, + "feedContext": { + "type": "string", + "description": "Context on a feed item that was originally supplied by the feed generator on getFeedSkeleton.", + "maxLength": 2000 + }, + "reqId": { + "type": "string", + "description": "Unique identifier per request that may be passed back alongside interactions.", + "maxLength": 100 + } + } + }, + "requestLess": { + "type": "token", + "description": "Request that less content like the given feed item be shown in the feed" + }, + "requestMore": { + "type": "token", + "description": "Request that more content like the given feed item be shown in the feed" + }, + "clickthroughItem": { + "type": "token", + "description": "User clicked through to the feed item" + }, + "clickthroughAuthor": { + "type": "token", + "description": "User clicked through to the author of the feed item" + }, + "clickthroughReposter": { + "type": "token", + "description": "User clicked through to the reposter of the feed item" + }, + "clickthroughEmbed": { + "type": "token", + "description": "User clicked through to the embedded content of the feed item" + }, + "contentModeUnspecified": { + "type": "token", + "description": "Declares the feed generator returns any types of posts." + }, + "contentModeVideo": { + "type": "token", + "description": "Declares the feed generator returns posts containing app.bsky.embed.video embeds." + }, + "interactionSeen": { + "type": "token", + "description": "Feed item was seen by user" + }, + "interactionLike": { + "type": "token", + "description": "User liked the feed item" + }, + "interactionRepost": { + "type": "token", + "description": "User reposted the feed item" + }, + "interactionReply": { + "type": "token", + "description": "User replied to the feed item" + }, + "interactionQuote": { + "type": "token", + "description": "User quoted the feed item" + }, + "interactionShare": { + "type": "token", + "description": "User shared the feed item" + } + } +} diff --git a/packages/pds/src/lexicons/app.bsky.graph.defs.json b/packages/pds/src/lexicons/app.bsky.graph.defs.json new file mode 100644 index 00000000..5e753315 --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.graph.defs.json @@ -0,0 +1,186 @@ +{ + "lexicon": 1, + "id": "app.bsky.graph.defs", + "defs": { + "listViewBasic": { + "type": "object", + "required": ["uri", "cid", "name", "purpose"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "name": { "type": "string", "maxLength": 64, "minLength": 1 }, + "purpose": { "type": "ref", "ref": "#listPurpose" }, + "avatar": { "type": "string", "format": "uri" }, + "listItemCount": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "viewer": { "type": "ref", "ref": "#listViewerState" }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "listView": { + "type": "object", + "required": ["uri", "cid", "creator", "name", "purpose", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "creator": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" }, + "name": { "type": "string", "maxLength": 64, "minLength": 1 }, + "purpose": { "type": "ref", "ref": "#listPurpose" }, + "description": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000 + }, + "descriptionFacets": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "avatar": { "type": "string", "format": "uri" }, + "listItemCount": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "viewer": { "type": "ref", "ref": "#listViewerState" }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "listItemView": { + "type": "object", + "required": ["uri", "subject"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "subject": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" } + } + }, + "starterPackView": { + "type": "object", + "required": ["uri", "cid", "record", "creator", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "record": { "type": "unknown" }, + "creator": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic" + }, + "list": { "type": "ref", "ref": "#listViewBasic" }, + "listItemsSample": { + "type": "array", + "maxLength": 12, + "items": { "type": "ref", "ref": "#listItemView" } + }, + "feeds": { + "type": "array", + "maxLength": 3, + "items": { "type": "ref", "ref": "app.bsky.feed.defs#generatorView" } + }, + "joinedWeekCount": { "type": "integer", "minimum": 0 }, + "joinedAllTimeCount": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "starterPackViewBasic": { + "type": "object", + "required": ["uri", "cid", "record", "creator", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "record": { "type": "unknown" }, + "creator": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic" + }, + "listItemCount": { "type": "integer", "minimum": 0 }, + "joinedWeekCount": { "type": "integer", "minimum": 0 }, + "joinedAllTimeCount": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "listPurpose": { + "type": "string", + "knownValues": [ + "app.bsky.graph.defs#modlist", + "app.bsky.graph.defs#curatelist", + "app.bsky.graph.defs#referencelist" + ] + }, + "modlist": { + "type": "token", + "description": "A list of actors to apply an aggregate moderation action (mute/block) on." + }, + "curatelist": { + "type": "token", + "description": "A list of actors used for curation purposes such as list feeds or interaction gating." + }, + "referencelist": { + "type": "token", + "description": "A list of actors used for only for reference purposes such as within a starter pack." + }, + "listViewerState": { + "type": "object", + "properties": { + "muted": { "type": "boolean" }, + "blocked": { "type": "string", "format": "at-uri" } + } + }, + "notFoundActor": { + "type": "object", + "description": "indicates that a handle or DID could not be resolved", + "required": ["actor", "notFound"], + "properties": { + "actor": { "type": "string", "format": "at-identifier" }, + "notFound": { "type": "boolean", "const": true } + } + }, + "relationship": { + "type": "object", + "description": "lists the bi-directional graph relationships between one actor (not indicated in the object), and the target actors (the DID included in the object)", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" }, + "following": { + "type": "string", + "format": "at-uri", + "description": "if the actor follows this DID, this is the AT-URI of the follow record" + }, + "followedBy": { + "type": "string", + "format": "at-uri", + "description": "if the actor is followed by this DID, contains the AT-URI of the follow record" + }, + "blocking": { + "type": "string", + "format": "at-uri", + "description": "if the actor blocks this DID, this is the AT-URI of the block record" + }, + "blockedBy": { + "type": "string", + "format": "at-uri", + "description": "if the actor is blocked by this DID, contains the AT-URI of the block record" + }, + "blockingByList": { + "type": "string", + "format": "at-uri", + "description": "if the actor blocks this DID via a block list, this is the AT-URI of the listblock record" + }, + "blockedByList": { + "type": "string", + "format": "at-uri", + "description": "if the actor is blocked by this DID via a block list, contains the AT-URI of the listblock record" + } + } + } + } +} diff --git a/packages/pds/src/lexicons/app.bsky.notification.defs.json b/packages/pds/src/lexicons/app.bsky.notification.defs.json new file mode 100644 index 00000000..eb077119 --- /dev/null +++ b/packages/pds/src/lexicons/app.bsky.notification.defs.json @@ -0,0 +1,88 @@ +{ + "lexicon": 1, + "id": "app.bsky.notification.defs", + "defs": { + "recordDeleted": { + "type": "object", + "properties": {} + }, + "chatPreference": { + "type": "object", + "required": ["include", "push"], + "properties": { + "include": { "type": "string", "knownValues": ["all", "accepted"] }, + "push": { "type": "boolean" } + } + }, + "filterablePreference": { + "type": "object", + "required": ["include", "list", "push"], + "properties": { + "include": { "type": "string", "knownValues": ["all", "follows"] }, + "list": { "type": "boolean" }, + "push": { "type": "boolean" } + } + }, + "preference": { + "type": "object", + "required": ["list", "push"], + "properties": { + "list": { "type": "boolean" }, + "push": { "type": "boolean" } + } + }, + "preferences": { + "type": "object", + "required": [ + "chat", + "follow", + "like", + "likeViaRepost", + "mention", + "quote", + "reply", + "repost", + "repostViaRepost", + "starterpackJoined", + "subscribedPost", + "unverified", + "verified" + ], + "properties": { + "chat": { "type": "ref", "ref": "#chatPreference" }, + "follow": { "type": "ref", "ref": "#filterablePreference" }, + "like": { "type": "ref", "ref": "#filterablePreference" }, + "likeViaRepost": { "type": "ref", "ref": "#filterablePreference" }, + "mention": { "type": "ref", "ref": "#filterablePreference" }, + "quote": { "type": "ref", "ref": "#filterablePreference" }, + "reply": { "type": "ref", "ref": "#filterablePreference" }, + "repost": { "type": "ref", "ref": "#filterablePreference" }, + "repostViaRepost": { "type": "ref", "ref": "#filterablePreference" }, + "starterpackJoined": { "type": "ref", "ref": "#preference" }, + "subscribedPost": { "type": "ref", "ref": "#preference" }, + "unverified": { "type": "ref", "ref": "#preference" }, + "verified": { "type": "ref", "ref": "#preference" } + } + }, + "activitySubscription": { + "type": "object", + "required": ["post", "reply"], + "properties": { + "post": { "type": "boolean" }, + "reply": { "type": "boolean" } + } + }, + "subjectActivitySubscription": { + "description": "Object used to store activity subscription data in stash.", + "type": "object", + "required": ["subject", "activitySubscription"], + "properties": { + "subject": { "type": "string", "format": "did" }, + "activitySubscription": { + "type": "ref", + "ref": "#activitySubscription" + } + } + } + } +} From 0599593af5826587393b6e12b22a85be33b87422 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 09:44:58 +0000 Subject: [PATCH 08/10] chore: add script to check for missing lexicon refs Scans all lexicon JSON files for external references and verifies that corresponding lexicon files exist. Useful for ensuring all dependencies are satisfied when adding new schemas. --- packages/pds/scripts/check-lexicon-refs.sh | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100755 packages/pds/scripts/check-lexicon-refs.sh diff --git a/packages/pds/scripts/check-lexicon-refs.sh b/packages/pds/scripts/check-lexicon-refs.sh new file mode 100755 index 00000000..03845a04 --- /dev/null +++ b/packages/pds/scripts/check-lexicon-refs.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Check for missing lexicon references +# This script scans all lexicon JSON files and reports any external refs +# that don't have corresponding lexicon files. +# + +set -e + +LEXICONS_DIR="$(cd "$(dirname "$0")/../src/lexicons" && pwd)" + +echo "Checking lexicon references in: $LEXICONS_DIR" +echo "" + +# Extract all external refs (those with a namespace, not just #fragment) +# Format: "app.bsky.foo.bar#baz" -> we need "app.bsky.foo.bar" +refs=$(grep -roh '"ref": "[^#"]*#[^"]*"' "$LEXICONS_DIR"/*.json 2>/dev/null | \ + grep -v '^"ref": "#' | \ + sed 's/"ref": "\([^#]*\)#.*/\1/' | \ + sort -u) + +missing=() + +for ref in $refs; do + file="$LEXICONS_DIR/${ref}.json" + if [ ! -f "$file" ]; then + missing+=("$ref") + fi +done + +if [ ${#missing[@]} -eq 0 ]; then + echo "✓ All lexicon references are satisfied!" + echo "" + exit 0 +else + echo "✗ Missing lexicon files for the following refs:" + echo "" + for ref in "${missing[@]}"; do + echo " - $ref" + done + echo "" + echo "Add these to scripts/update-lexicons.sh and run it to fetch them." + exit 1 +fi From 4a15aaa24dd7e3a465f7477c5793dda75396a40b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 09:55:56 +0000 Subject: [PATCH 09/10] ci: run lexicon ref check in update workflow --- .github/workflows/update-lexicons.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/update-lexicons.yml b/.github/workflows/update-lexicons.yml index e84781e4..3930a1ab 100644 --- a/.github/workflows/update-lexicons.yml +++ b/.github/workflows/update-lexicons.yml @@ -15,6 +15,8 @@ jobs: uses: actions/checkout@v5 - name: Update lexicon schemas run: ./packages/pds/scripts/update-lexicons.sh + - name: Check for missing references + run: ./packages/pds/scripts/check-lexicon-refs.sh - name: Create Pull Request uses: peter-evans/create-pull-request@v8 with: From 832559f040980ee9f8cf340f70439165bc9f5fad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 10:02:42 +0000 Subject: [PATCH 10/10] fix: convert JSON to lexicon format before validating records Use jsonToLex() to convert incoming JSON records to proper lexicon format before validation. This handles $link -> CID conversion and blob object -> BlobRef conversion, fixing video embed validation. --- packages/pds/src/validation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/validation.ts b/packages/pds/src/validation.ts index 6ec14f0c..fce4c916 100644 --- a/packages/pds/src/validation.ts +++ b/packages/pds/src/validation.ts @@ -1,4 +1,4 @@ -import { Lexicons, type LexiconDoc } from "@atproto/lexicon"; +import { Lexicons, jsonToLex, type LexiconDoc } from "@atproto/lexicon"; /** * Record validator for AT Protocol records. @@ -62,9 +62,12 @@ export class RecordValidator { return; } + // Convert JSON to lexicon format (handles $link -> CID, blob -> BlobRef) + const lexRecord = jsonToLex(record); + // We have a schema, so validate against it try { - this.lex.assertValidRecord(collection, record); + this.lex.assertValidRecord(collection, lexRecord); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(