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`). 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: 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 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/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/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" + } + } + } + } +} diff --git a/packages/pds/src/middleware/auth.ts b/packages/pds/src/middleware/auth.ts index c7a34ac4..e38ab635 100644 --- a/packages/pds/src/middleware/auth.ts +++ b/packages/pds/src/middleware/auth.ts @@ -1,4 +1,5 @@ import type { Context, Next } from "hono"; +import { verifyServiceJwt } from "../service-auth"; import { verifyAccessToken } from "../session"; import type { PDSEnv } from "../types"; @@ -31,11 +32,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 +59,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/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/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( diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 43701c27..f3634750 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import type { AccountDurableObject } from "../account-do"; +import { createServiceJwt, getSigningKeypair } from "../service-auth"; import { createAccessToken, createRefreshToken, @@ -257,3 +258,35 @@ export async function getAccountStatus( }); } } + +/** + * 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 getSigningKeypair(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/service-auth.test.ts b/packages/pds/test/service-auth.test.ts index aab7d540..9a2303ce 100644 --- a/packages/pds/test/service-auth.test.ts +++ b/packages/pds/test/service-auth.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { Secp256k1Keypair } from "@atproto/crypto"; -import { createServiceJwt } from "../src/service-auth"; +import { createServiceJwt, verifyServiceJwt } from "../src/service-auth"; +import { env, worker } from "./helpers"; describe("Service Auth", () => { it("creates valid service JWT", async () => { @@ -77,4 +78,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(); + }); }); 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(