grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add deleteAccount xrpc

Adds social.grain.unspecced.deleteAccount procedure so clients can
satisfy App Store guideline 5.1.1(v) with a single call. Deletes every
Grain record owned by the viewer across all ten grain.* collections
(photos, exif, gallery.items, galleries, stories, favorites, comments,
follows, blocks, actor profile), then clears appview-side state the user
created (mutes, preferences, push tokens) and revokes their OAuth
session. Reports and labels are moderation records and are preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+83 -2
+2 -2
hatk.generated.client.ts
··· 3 3 // to avoid pulling in server-only dependencies. 4 4 export type { XrpcSchema } from './hatk.generated.ts' 5 5 import type { XrpcSchema } from './hatk.generated.ts' 6 - export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, ApplyWrites, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetCommentThread, GetFollowers, GetFollowing, GetGallery, GetKnownFollowers, GetLocations, GetMutes, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchActorsTypeahead, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, DeclarationMessageMe, Create, Update, Delete, CreateResult, UpdateResult, DeleteResult, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainCommentDefsViewerState, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, GrainStoryDefsViewerState, BlockItem, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, MuteItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, ApplyWrites, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteAccount, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetCommentThread, GetFollowers, GetFollowing, GetGallery, GetKnownFollowers, GetLocations, GetMutes, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchActorsTypeahead, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, DeclarationMessageMe, Create, Update, Delete, CreateResult, UpdateResult, DeleteResult, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainCommentDefsViewerState, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, GrainStoryDefsViewerState, BlockItem, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, MuteItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 - const _procedures = new Set(['dev.hatk.applyWrites', 'dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.graph.muteActor', 'social.grain.graph.unmuteActor', 'social.grain.unspecced.deleteGallery']) 8 + const _procedures = new Set(['dev.hatk.applyWrites', 'dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.graph.muteActor', 'social.grain.graph.unmuteActor', 'social.grain.unspecced.deleteAccount', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob']) 10 10 11 11 type CallArg<K extends keyof XrpcSchema> =
+4
hatk.generated.ts
··· 65 65 const exifLex = {"lexicon":1,"id":"social.grain.photo.exif","defs":{"main":{"type":"record","description":"Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.","key":"tid","record":{"type":"object","required":["photo","createdAt"],"properties":{"photo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string","format":"datetime"},"exposureTime":{"type":"integer"},"fNumber":{"type":"integer"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"integer"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}}}}} as const 66 66 const storyLex = {"lexicon":1,"id":"social.grain.story","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["media","aspectRatio","createdAt"],"properties":{"media":{"type":"blob","accept":["image/*","video/*"],"maxSize":5000000},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"labels":{"type":"union","description":"Self-label values for this story. Effectively content warnings.","refs":["com.atproto.label.defs#selfLabels"]},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 67 67 const grainStoryDefsLex = {"lexicon":1,"id":"social.grain.story.defs","defs":{"storyView":{"type":"object","required":["uri","cid","creator","thumb","fullsize","aspectRatio","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"creator":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"thumb":{"type":"string","format":"uri","description":"Thumbnail URL for the story image."},"fullsize":{"type":"string","format":"uri","description":"Full-size URL for the story image."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"locationDisplay":{"type":"string","description":"Formatted location label for display."},"createdAt":{"type":"string","format":"datetime"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}},"expired":{"type":"boolean","description":"Whether the story has passed its 24-hour window."},"commentCount":{"type":"integer"},"viewer":{"type":"ref","ref":"#viewerState"},"crossPost":{"type":"ref","ref":"social.grain.gallery.defs#crossPostInfo"}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the story.","properties":{"fav":{"type":"string","format":"at-uri"}}}}} as const 68 + const deleteAccountLex = {"lexicon":1,"id":"social.grain.unspecced.deleteAccount","defs":{"main":{"type":"procedure","description":"Delete the authenticated user's Grain account. Removes all of their Grain records from their PDS and clears supporting server-side state (mutes, preferences, push tokens, OAuth session). Reports and labels are preserved.","output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 68 69 const deleteGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.deleteGallery","defs":{"main":{"type":"procedure","description":"Delete a gallery and all associated records (items, photos, EXIF, favorites, comments).","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey"],"properties":{"rkey":{"type":"string","description":"Record key of the gallery to delete."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 69 70 const getActorFavoritesLex = {"lexicon":1,"id":"social.grain.unspecced.getActorFavorites","defs":{"main":{"type":"query","description":"Get galleries favorited by the authenticated actor. Only the actor themselves can view their favorites.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}},"cursor":{"type":"string"}}}}}}} as const 70 71 const getActorProfileLex = {"lexicon":1,"id":"social.grain.unspecced.getActorProfile","defs":{"main":{"type":"query","description":"Get an actor's profile with gallery stats and follow relationships.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"social.grain.actor.defs#profileViewDetailed"}}}}} as const ··· 148 149 'social.grain.photo.exif': typeof exifLex 149 150 'social.grain.story': typeof storyLex 150 151 'social.grain.story.defs': typeof grainStoryDefsLex 152 + 'social.grain.unspecced.deleteAccount': typeof deleteAccountLex 151 153 'social.grain.unspecced.deleteGallery': typeof deleteGalleryLex 152 154 'social.grain.unspecced.getActorFavorites': typeof getActorFavoritesLex 153 155 'social.grain.unspecced.getActorProfile': typeof getActorProfileLex ··· 204 206 export type Photo = Prettify<LexRecord<typeof photoLex, Registry>> 205 207 export type Exif = Prettify<LexRecord<typeof exifLex, Registry>> 206 208 export type Story = Prettify<LexRecord<typeof storyLex, Registry>> 209 + export type DeleteAccount = Prettify<LexProcedure<typeof deleteAccountLex, Registry>> 207 210 export type DeleteGallery = Prettify<LexProcedure<typeof deleteGalleryLex, Registry>> 208 211 export type GetActorFavorites = Prettify<LexQuery<typeof getActorFavoritesLex, Registry>> 209 212 export type GetActorProfile = Prettify<LexQuery<typeof getActorProfileLex, Registry>> ··· 432 435 'parts.page.mention.search': Search 433 436 'social.grain.graph.muteActor': MuteActor 434 437 'social.grain.graph.unmuteActor': UnmuteActor 438 + 'social.grain.unspecced.deleteAccount': DeleteAccount 435 439 'social.grain.unspecced.deleteGallery': DeleteGallery 436 440 'social.grain.unspecced.getActorFavorites': GetActorFavorites 437 441 'social.grain.unspecced.getActorProfile': GetActorProfile
+17
lexicons/social/grain/unspecced/deleteAccount.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.deleteAccount", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete the authenticated user's Grain account. Removes all of their Grain records from their PDS and clears supporting server-side state (mutes, preferences, push tokens, OAuth session). Reports and labels are preserved.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": {} 13 + } 14 + } 15 + } 16 + } 17 + }
+60
server/xrpc/deleteAccount.ts
··· 1 + // Deletes the authenticated user's Grain account. 2 + // Removes all of the user's Grain records from their PDS and clears supporting 3 + // server-side state (mutes, preferences, push tokens, OAuth session). 4 + // Reports and labels are moderation records and are preserved. 5 + // POST /xrpc/social.grain.unspecced.deleteAccount 6 + 7 + import { defineProcedure } from "$hatk"; 8 + 9 + // Ordered children-first for cleanliness. atproto deleteRecord doesn't require 10 + // a specific order, but this avoids leaving orphaned children visible in the 11 + // appview between individual deletes. 12 + const GRAIN_COLLECTIONS = [ 13 + "social.grain.photo.exif", 14 + "social.grain.gallery.item", 15 + "social.grain.photo", 16 + "social.grain.gallery", 17 + "social.grain.story", 18 + "social.grain.favorite", 19 + "social.grain.comment", 20 + "social.grain.graph.follow", 21 + "social.grain.graph.block", 22 + "social.grain.actor.profile", 23 + ] as const; 24 + 25 + export default defineProcedure("social.grain.unspecced.deleteAccount", async (ctx) => { 26 + const { db, ok, viewer, deleteRecord } = ctx; 27 + if (!viewer) throw new Error("Authentication required"); 28 + const did = viewer.did; 29 + 30 + // 1. Delete every Grain record owned by the viewer from their PDS. 31 + // Best-effort: log and continue on per-record failures so a single 32 + // stuck record can't block the rest of the deletion. 33 + for (const collection of GRAIN_COLLECTIONS) { 34 + const rows = (await db.query( 35 + `SELECT uri FROM "${collection}" WHERE did = $1`, 36 + [did], 37 + )) as { uri: string }[]; 38 + for (const row of rows) { 39 + const rkey = row.uri.split("/").pop()!; 40 + try { 41 + await deleteRecord(collection, rkey); 42 + } catch (err) { 43 + console.warn(`deleteAccount: failed to delete ${row.uri}:`, err); 44 + } 45 + } 46 + } 47 + 48 + // 2. Clear appview-side state keyed by the viewer's did. Reports and labels 49 + // are moderation records and are intentionally preserved. 50 + await db.run(`DELETE FROM _mutes WHERE did = $1`, [did]); 51 + await db.run(`DELETE FROM _preferences WHERE did = $1`, [did]); 52 + await db.run(`DELETE FROM _push_tokens WHERE did = $1`, [did]); 53 + 54 + // 3. Revoke OAuth state. This ends the server-side session; the client 55 + // should also clear local tokens and drop the user back to the login. 56 + await db.run(`DELETE FROM _oauth_refresh_tokens WHERE did = $1`, [did]); 57 + await db.run(`DELETE FROM _oauth_sessions WHERE did = $1`, [did]); 58 + 59 + return ok({}); 60 + });