Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
6
fork

Configure Feed

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

feat: dynamically generate the lexicon types

Hugo e4936130 51cf37f5

+258 -511
+1 -1
ARCHITECTURE.md
··· 123 123 - The backend indexes relevant data from PDS for fast querying 124 124 - Sphere configuration and membership are published on PDS for interoperability (see below) 125 125 - The local SQLite database caches/indexes all PDS data for fast querying 126 - - Every record type written to a user's PDS has a formal Lexicon schema definition in `packages/core/src/lexicons/` 126 + - Every record type written to a user's PDS has a formal Lexicon schema definition hosted at `landing/.well-known/` 127 127 128 128 ### Lexicon schemas 129 129
+1
package.json
··· 15 15 "pds:down": "docker compose -f docker-compose.dev.yml down", 16 16 "pds:logs": "docker compose -f docker-compose.dev.yml logs -f pds", 17 17 "pds:account": "bun run scripts/pds-account.ts", 18 + "generate:lexicons": "bun scripts/generate-lexicon-types.ts", 18 19 "db:generate": "drizzle-kit generate", 19 20 "db:migrate": "bun run packages/core/src/db/migrate.ts", 20 21 "build": "bun run --filter '@exosphere/app' build",
+99
packages/core/src/generated/lexicon-records.ts
··· 1 + // AUTO-GENERATED from landing/.well-known/site.exosphere.*.json 2 + // Do not edit manually. Run: bun run generate:lexicons 3 + 4 + export interface FeatureRequestRecord { 5 + title: string; 6 + description?: string; 7 + category?: string; 8 + /** did — DID of the Sphere owner (the identity hosting the site.exosphere.sphere record). */ 9 + subject: string; 10 + /** datetime */ 11 + createdAt: string; 12 + } 13 + 14 + export interface FeatureRequestCommentRecord { 15 + /** at-uri — AT URI of the feature request being commented on. */ 16 + subject: string; 17 + content: string; 18 + /** datetime */ 19 + createdAt: string; 20 + /** datetime */ 21 + updatedAt?: string; 22 + } 23 + 24 + export interface FeatureRequestCommentVoteRecord { 25 + /** at-uri — AT URI of the comment being voted on. */ 26 + subject: string; 27 + /** datetime */ 28 + createdAt: string; 29 + } 30 + 31 + export interface FeatureRequestStatusRecord { 32 + /** at-uri — AT URI of the feature request whose status is being changed. */ 33 + subject: string; 34 + status: string; 35 + /** datetime */ 36 + createdAt: string; 37 + } 38 + 39 + export interface FeatureRequestVoteRecord { 40 + /** at-uri — AT URI of the feature request being voted on. */ 41 + subject: string; 42 + /** datetime */ 43 + createdAt: string; 44 + } 45 + 46 + export interface ModerationRecord { 47 + /** at-uri — AT URI of the Sphere. */ 48 + sphere: string; 49 + /** at-uri — AT URI of the content being moderated. */ 50 + subject: string; 51 + action: string; 52 + /** datetime */ 53 + createdAt: string; 54 + } 55 + 56 + export interface SphereRecord { 57 + /** Human-readable display name. */ 58 + name: string; 59 + /** Short description of the Sphere's purpose. */ 60 + description?: string; 61 + /** Whether the Sphere's content is publicly readable. */ 62 + visibility: string; 63 + /** Who can create content in this Sphere. */ 64 + writeAccess: string; 65 + /** Module names enabled for this Sphere. */ 66 + modules?: string[]; 67 + /** datetime */ 68 + createdAt: string; 69 + } 70 + 71 + export interface SphereMemberRecord { 72 + /** at-uri — AT URI of the Sphere record (site.exosphere.sphere) on the owner's PDS. */ 73 + sphere: string; 74 + /** datetime */ 75 + createdAt: string; 76 + } 77 + 78 + export interface SphereMemberApprovalRecord { 79 + /** at-uri — AT URI of the Sphere record (site.exosphere.sphere). */ 80 + sphere: string; 81 + /** did — DID of the member being approved. */ 82 + subject: string; 83 + /** Role granted to the member. */ 84 + role: string; 85 + /** datetime */ 86 + createdAt: string; 87 + } 88 + 89 + export interface PdsRecordMap { 90 + "site.exosphere.featureRequest": FeatureRequestRecord; 91 + "site.exosphere.featureRequestComment": FeatureRequestCommentRecord; 92 + "site.exosphere.featureRequestCommentVote": FeatureRequestCommentVoteRecord; 93 + "site.exosphere.featureRequestStatus": FeatureRequestStatusRecord; 94 + "site.exosphere.featureRequestVote": FeatureRequestVoteRecord; 95 + "site.exosphere.moderation": ModerationRecord; 96 + "site.exosphere.sphere": SphereRecord; 97 + "site.exosphere.sphereMember": SphereMemberRecord; 98 + "site.exosphere.sphereMemberApproval": SphereMemberApprovalRecord; 99 + }
-37
packages/core/src/lexicons/site.exosphere.featureRequest.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.featureRequest", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A feature request submitted to a Sphere. Published on the author's PDS for public Spheres.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["title", "sphereHandle", "createdAt"], 12 - "properties": { 13 - "title": { 14 - "type": "string", 15 - "maxLength": 512 16 - }, 17 - "description": { 18 - "type": "string", 19 - "maxLength": 10000 20 - }, 21 - "category": { 22 - "type": "string", 23 - "knownValues": ["general", "enhancement", "bug", "integration", "ui-ux"] 24 - }, 25 - "sphereHandle": { 26 - "type": "string", 27 - "description": "Handle of the Sphere this request belongs to." 28 - }, 29 - "createdAt": { 30 - "type": "string", 31 - "format": "datetime" 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }
-34
packages/core/src/lexicons/site.exosphere.featureRequestComment.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.featureRequestComment", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A comment on a feature request. Published on the commenter's PDS.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "content", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the feature request being commented on." 17 - }, 18 - "content": { 19 - "type": "string", 20 - "maxLength": 10000 21 - }, 22 - "createdAt": { 23 - "type": "string", 24 - "format": "datetime" 25 - }, 26 - "updatedAt": { 27 - "type": "string", 28 - "format": "datetime" 29 - } 30 - } 31 - } 32 - } 33 - } 34 - }
-26
packages/core/src/lexicons/site.exosphere.featureRequestCommentVote.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.featureRequestCommentVote", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "An upvote on a feature request comment. Published on the voter's PDS.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the comment being voted on." 17 - }, 18 - "createdAt": { 19 - "type": "string", 20 - "format": "datetime" 21 - } 22 - } 23 - } 24 - } 25 - } 26 - }
-34
packages/core/src/lexicons/site.exosphere.featureRequestStatus.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.featureRequestStatus", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A status change on a feature request, published by an admin/owner on their PDS. Third-party indexers can replay these to derive the current status of a request.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "status", "sphereHandle", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the feature request whose status is being changed." 17 - }, 18 - "status": { 19 - "type": "string", 20 - "knownValues": ["requested", "not-planned", "approved", "in-progress", "done"] 21 - }, 22 - "sphereHandle": { 23 - "type": "string", 24 - "description": "Handle of the Sphere (used by indexers to scope the status change)." 25 - }, 26 - "createdAt": { 27 - "type": "string", 28 - "format": "datetime" 29 - } 30 - } 31 - } 32 - } 33 - } 34 - }
-26
packages/core/src/lexicons/site.exosphere.featureRequestVote.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.featureRequestVote", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "An upvote on a feature request. Published on the voter's PDS.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the feature request being voted on." 17 - }, 18 - "createdAt": { 19 - "type": "string", 20 - "format": "datetime" 21 - } 22 - } 23 - } 24 - } 25 - } 26 - }
-35
packages/core/src/lexicons/site.exosphere.moderation.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.moderation", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A moderation action taken by a Sphere admin/owner. Published on the moderator's PDS.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["sphere", "subject", "action", "createdAt"], 12 - "properties": { 13 - "sphere": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the Sphere." 17 - }, 18 - "subject": { 19 - "type": "string", 20 - "format": "at-uri", 21 - "description": "AT URI of the content being moderated." 22 - }, 23 - "action": { 24 - "type": "string", 25 - "knownValues": ["remove"] 26 - }, 27 - "createdAt": { 28 - "type": "string", 29 - "format": "datetime" 30 - } 31 - } 32 - } 33 - } 34 - } 35 - }
-51
packages/core/src/lexicons/site.exosphere.sphere.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.sphere", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Declaration of a Exosphere Sphere, published on the owner's PDS. Enables third-party discovery and indexing of Spheres across the AT Protocol network.", 8 - "key": "self", 9 - "record": { 10 - "type": "object", 11 - "required": ["handle", "name", "visibility", "writeAccess", "createdAt"], 12 - "properties": { 13 - "handle": { 14 - "type": "string", 15 - "description": "Bluesky handle of the Sphere owner, used as the Sphere's unique identifier.", 16 - "maxLength": 253 17 - }, 18 - "name": { 19 - "type": "string", 20 - "description": "Human-readable display name.", 21 - "maxLength": 256 22 - }, 23 - "description": { 24 - "type": "string", 25 - "description": "Short description of the Sphere's purpose.", 26 - "maxLength": 1024 27 - }, 28 - "visibility": { 29 - "type": "string", 30 - "description": "Whether the Sphere's content is publicly readable.", 31 - "knownValues": ["public", "private"] 32 - }, 33 - "writeAccess": { 34 - "type": "string", 35 - "description": "Who can create content in this Sphere.", 36 - "knownValues": ["open", "members"] 37 - }, 38 - "modules": { 39 - "type": "array", 40 - "description": "Module names enabled for this Sphere.", 41 - "items": { "type": "string" } 42 - }, 43 - "createdAt": { 44 - "type": "string", 45 - "format": "datetime" 46 - } 47 - } 48 - } 49 - } 50 - } 51 - }
-26
packages/core/src/lexicons/site.exosphere.sphereMember.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.sphereMember", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A user's declaration that they are a member of a Sphere. Published on the member's own PDS. The Sphere owner/admin publishes a corresponding sphereMemberApproval record to confirm the membership. Together, the two records form a bilateral proof of membership — similar to how AT Protocol follows work.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["sphere", "createdAt"], 12 - "properties": { 13 - "sphere": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the Sphere record (site.exosphere.sphere) on the owner's PDS." 17 - }, 18 - "createdAt": { 19 - "type": "string", 20 - "format": "datetime" 21 - } 22 - } 23 - } 24 - } 25 - } 26 - }
-36
packages/core/src/lexicons/site.exosphere.sphereMemberApproval.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "site.exosphere.sphereMemberApproval", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Published by a Sphere owner or admin on their own PDS to confirm a user's membership. References the member's sphereMember record. The role field indicates the member's permissions within the Sphere.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["sphere", "subject", "role", "createdAt"], 12 - "properties": { 13 - "sphere": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT URI of the Sphere record (site.exosphere.sphere)." 17 - }, 18 - "subject": { 19 - "type": "string", 20 - "format": "did", 21 - "description": "DID of the member being approved." 22 - }, 23 - "role": { 24 - "type": "string", 25 - "description": "Role granted to the member.", 26 - "knownValues": ["admin", "member"] 27 - }, 28 - "createdAt": { 29 - "type": "string", 30 - "format": "datetime" 31 - } 32 - } 33 - } 34 - } 35 - } 36 - }
+6 -3
packages/core/src/pds.ts
··· 1 1 import type { OAuthSession } from "@atproto/oauth-client-node"; 2 2 import { TID } from "@atproto/common-web"; 3 3 4 + import type { PdsRecordMap } from "./generated/lexicon-records.ts"; 5 + export type { PdsRecordMap }; 6 + 4 7 export function generateRkey(): string { 5 8 return TID.nextStr(); 6 9 } 7 10 8 - export async function putPdsRecord( 11 + export async function putPdsRecord<C extends keyof PdsRecordMap>( 9 12 session: OAuthSession, 10 - collection: string, 13 + collection: C, 11 14 rkey: string, 12 - record: Record<string, unknown>, 15 + record: PdsRecordMap[C], 13 16 ): Promise<string | null> { 14 17 const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 15 18 method: "POST",
-2
packages/core/src/sphere/api/spheres.ts
··· 88 88 // Write Sphere declaration to owner's PDS 89 89 const now = new Date().toISOString(); 90 90 const pdsUri = await putPdsRecord(session, SPHERE_COLLECTION, "self", { 91 - handle, 92 91 name, 93 92 description: description ?? undefined, 94 93 visibility, ··· 227 226 const session = c.var.session; 228 227 const modules = getEnabledModules(sphere.id).map((m) => m.moduleName); 229 228 const pdsUri = await putPdsRecord(session, SPHERE_COLLECTION, "self", { 230 - handle: sphere.handle, 231 229 name: updates.name ?? sphere.name, 232 230 description: updates.description ?? sphere.description ?? undefined, 233 231 visibility: updates.visibility ?? sphere.visibility,
+3 -8
packages/core/src/sphere/operations.ts
··· 58 58 } 59 59 60 60 export function upsertSphereFromRecord({ did, rkey, record, pdsUri }: UpsertSphereParams): void { 61 - const handle = record.handle as string; 62 - if (!handle) return; 63 - 64 61 const db = getDb(); 65 62 const existing = db 66 - .select({ id: spheres.id, ownerDid: spheres.ownerDid }) 63 + .select({ id: spheres.id }) 67 64 .from(spheres) 68 - .where(eq(spheres.handle, handle)) 65 + .where(eq(spheres.ownerDid, did)) 69 66 .get(); 70 67 71 68 if (existing) { 72 - if (existing.ownerDid !== did) return; 73 - 74 69 const set: Record<string, unknown> = { pdsUri }; 75 70 if (record.name) set.name = record.name; 76 71 if (record.description !== undefined) set.description = record.description; ··· 80 75 81 76 db.update(spheres).set(set).where(eq(spheres.id, existing.id)).run(); 82 77 } else { 83 - // Ignore sphere records for handles not on this instance — 78 + // Ignore sphere records for DIDs not on this instance — 84 79 // spheres are created locally, not via Jetstream from other instances. 85 80 return; 86 81 }
+12 -75
packages/feature-requests/src/api/comments.ts
··· 180 180 // Write to PDS for public spheres 181 181 if (sphereVisibility === "public") { 182 182 const session = c.var.session; 183 - const now = new Date().toISOString(); 184 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 185 - method: "POST", 186 - headers: { "Content-Type": "application/json" }, 187 - body: JSON.stringify({ 188 - repo: session.did, 189 - collection: COMMENT_COLLECTION, 190 - rkey: id, 191 - record: { 192 - $type: COMMENT_COLLECTION, 193 - subject: existing.pdsUri, 194 - content, 195 - createdAt: now, 196 - }, 197 - }), 183 + pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { 184 + subject: existing.pdsUri!, 185 + content, 186 + createdAt: new Date().toISOString(), 198 187 }); 199 - 200 - if (res.ok) { 201 - const data = (await res.json()) as { uri: string }; 202 - pdsUri = data.uri; 203 - } else { 204 - console.error( 205 - "[feature-requests] PDS comment write failed:", 206 - res.status, 207 - await res.text().catch(() => ""), 208 - ); 209 - } 210 188 } 211 189 212 190 insertComment({ id, requestId, authorDid: did, content, pdsUri }); ··· 246 224 // Update on PDS if the comment was written there 247 225 if (comment.pdsUri) { 248 226 const session = c.var.session; 249 - const now = new Date().toISOString(); 250 227 251 228 // Look up the parent feature request's pdsUri for the subject field 252 229 const parent = db ··· 255 232 .where(eq(featureRequests.id, comment.requestId)) 256 233 .get(); 257 234 258 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 259 - method: "POST", 260 - headers: { "Content-Type": "application/json" }, 261 - body: JSON.stringify({ 262 - repo: session.did, 263 - collection: COMMENT_COLLECTION, 264 - rkey: id, 265 - record: { 266 - $type: COMMENT_COLLECTION, 267 - subject: parent?.pdsUri, 268 - content, 269 - createdAt: comment.createdAt, 270 - updatedAt: now, 271 - }, 272 - }), 235 + await putPdsRecord(session, COMMENT_COLLECTION, id, { 236 + subject: parent!.pdsUri!, 237 + content, 238 + createdAt: comment.createdAt, 239 + updatedAt: new Date().toISOString(), 273 240 }); 274 - 275 - if (!res.ok) { 276 - console.error( 277 - "[feature-requests] PDS comment update failed:", 278 - res.status, 279 - await res.text().catch(() => ""), 280 - ); 281 - } 282 241 } 283 242 284 243 updateComment(id, content); ··· 458 417 459 418 if (sphereVisibility === "public" && comment.pdsUri) { 460 419 const session = c.var.session; 461 - const now = new Date().toISOString(); 462 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 463 - method: "POST", 464 - headers: { "Content-Type": "application/json" }, 465 - body: JSON.stringify({ 466 - repo: session.did, 467 - collection: COMMENT_VOTE_COLLECTION, 468 - rkey: id, 469 - record: { 470 - $type: COMMENT_VOTE_COLLECTION, 471 - subject: comment.pdsUri, 472 - createdAt: now, 473 - }, 474 - }), 420 + votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, { 421 + subject: comment.pdsUri, 422 + createdAt: new Date().toISOString(), 475 423 }); 476 - 477 - if (res.ok) { 478 - const data = (await res.json()) as { uri: string }; 479 - votePdsUri = data.uri; 480 - } else { 481 - console.error( 482 - "[feature-requests] PDS comment vote write failed:", 483 - res.status, 484 - await res.text().catch(() => ""), 485 - ); 486 - } 487 424 } 488 425 489 426 insertCommentVote(id, did, votePdsUri);
+7 -29
packages/feature-requests/src/api/requests.ts
··· 150 150 151 151 const { title, description, category } = result.data; 152 152 const sphereId = c.var.sphereId; 153 - const sphereHandle = c.var.sphereHandle; 153 + const sphereOwnerDid = c.var.sphereOwnerDid; 154 154 const sphereVisibility = c.var.sphereVisibility; 155 155 const id = generateRkey(); 156 156 const did = c.var.did; ··· 160 160 // Write to PDS for public spheres 161 161 if (sphereVisibility === "public") { 162 162 const session = c.var.session; 163 - const now = new Date().toISOString(); 164 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 165 - method: "POST", 166 - headers: { "Content-Type": "application/json" }, 167 - body: JSON.stringify({ 168 - repo: session.did, 169 - collection: COLLECTION, 170 - rkey: id, 171 - record: { 172 - $type: COLLECTION, 173 - title, 174 - description, 175 - category, 176 - sphereHandle, 177 - createdAt: now, 178 - }, 179 - }), 163 + pdsUri = await putPdsRecord(session, COLLECTION, id, { 164 + title, 165 + description, 166 + category, 167 + subject: sphereOwnerDid, 168 + createdAt: new Date().toISOString(), 180 169 }); 181 - 182 - if (res.ok) { 183 - const data = (await res.json()) as { uri: string }; 184 - pdsUri = data.uri; 185 - } else { 186 - console.error( 187 - "[feature-requests] PDS write failed:", 188 - res.status, 189 - await res.text().catch(() => ""), 190 - ); 191 - } 192 170 } 193 171 194 172 const row = insertFeatureRequest({
+10 -58
packages/feature-requests/src/api/statuses.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { z } from "zod"; 3 3 import { getDb } from "@exosphere/core/db"; 4 - import { eq, and, count, sql } from "@exosphere/core/db/drizzle"; 4 + import { eq, and, sql } from "@exosphere/core/db/drizzle"; 5 5 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 6 import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 7 - import { generateRkey } from "@exosphere/core/pds"; 7 + import { putPdsRecord, generateRkey } from "@exosphere/core/pds"; 8 8 import { resolveDidHandles } from "@exosphere/core/identity"; 9 9 import type { SphereEnv } from "@exosphere/core/types"; 10 10 import { featureRequests, featureRequestStatuses } from "../db/schema.ts"; ··· 30 30 const db = getDb(); 31 31 const did = c.var.did; 32 32 const sphereId = c.var.sphereId; 33 - const sphereHandle = c.var.sphereHandle; 34 33 const sphereVisibility = c.var.sphereVisibility; 35 34 36 35 // Check FR exists, belongs to this sphere, is "requested", and not hidden ··· 89 88 let pdsUri: string | null = null; 90 89 if (sphereVisibility === "public" && fr.pdsUri) { 91 90 const session = c.var.session; 92 - const now = new Date().toISOString(); 93 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 94 - method: "POST", 95 - headers: { "Content-Type": "application/json" }, 96 - body: JSON.stringify({ 97 - repo: session.did, 98 - collection: STATUS_COLLECTION, 99 - rkey: id, 100 - record: { 101 - $type: STATUS_COLLECTION, 102 - subject: fr.pdsUri, 103 - status: "duplicate", 104 - sphereHandle, 105 - createdAt: now, 106 - }, 107 - }), 91 + pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, { 92 + subject: fr.pdsUri, 93 + status: "duplicate", 94 + createdAt: new Date().toISOString(), 108 95 }); 109 - 110 - if (res.ok) { 111 - const data = (await res.json()) as { uri: string }; 112 - pdsUri = data.uri; 113 - } else { 114 - console.error( 115 - "[feature-requests] PDS duplicate status write failed:", 116 - res.status, 117 - await res.text().catch(() => ""), 118 - ); 119 - } 120 96 } 121 97 122 98 const statusId = generateRkey(); ··· 165 141 const db = getDb(); 166 142 const did = c.var.did; 167 143 const sphereId = c.var.sphereId; 168 - const sphereHandle = c.var.sphereHandle; 169 144 const sphereVisibility = c.var.sphereVisibility; 170 145 171 146 const existing = db ··· 191 166 // Write to PDS for public spheres 192 167 if (sphereVisibility === "public" && existing.pdsUri) { 193 168 const session = c.var.session; 194 - const now = new Date().toISOString(); 195 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 196 - method: "POST", 197 - headers: { "Content-Type": "application/json" }, 198 - body: JSON.stringify({ 199 - repo: session.did, 200 - collection: STATUS_COLLECTION, 201 - rkey: id, 202 - record: { 203 - $type: STATUS_COLLECTION, 204 - subject: existing.pdsUri, 205 - status, 206 - sphereHandle, 207 - createdAt: now, 208 - }, 209 - }), 169 + pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, { 170 + subject: existing.pdsUri, 171 + status, 172 + createdAt: new Date().toISOString(), 210 173 }); 211 - 212 - if (res.ok) { 213 - const data = (await res.json()) as { uri: string }; 214 - pdsUri = data.uri; 215 - } else { 216 - console.error( 217 - "[feature-requests] PDS status write failed:", 218 - res.status, 219 - await res.text().catch(() => ""), 220 - ); 221 - } 222 174 } 223 175 224 176 const statusId = generateRkey();
+4 -25
packages/feature-requests/src/api/votes.ts
··· 2 2 import { getDb } from "@exosphere/core/db"; 3 3 import { eq, and, count, sql } from "@exosphere/core/db/drizzle"; 4 4 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 5 + import { putPdsRecord } from "@exosphere/core/pds"; 5 6 import type { SphereEnv } from "@exosphere/core/types"; 6 7 import { featureRequests, featureRequestVotes } from "../db/schema.ts"; 7 8 import { insertVote, deleteVoteByAuthor } from "../db/operations.ts"; ··· 66 67 // Write to PDS for public spheres 67 68 if (sphereVisibility === "public" && existing.pdsUri) { 68 69 const session = c.var.session; 69 - const now = new Date().toISOString(); 70 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 71 - method: "POST", 72 - headers: { "Content-Type": "application/json" }, 73 - body: JSON.stringify({ 74 - repo: session.did, 75 - collection: VOTE_COLLECTION, 76 - rkey: id, 77 - record: { 78 - $type: VOTE_COLLECTION, 79 - subject: existing.pdsUri, 80 - createdAt: now, 81 - }, 82 - }), 70 + votePdsUri = await putPdsRecord(session, VOTE_COLLECTION, id, { 71 + subject: existing.pdsUri, 72 + createdAt: new Date().toISOString(), 83 73 }); 84 - 85 - if (res.ok) { 86 - const data = (await res.json()) as { uri: string }; 87 - votePdsUri = data.uri; 88 - } else { 89 - console.error( 90 - "[feature-requests] PDS vote write failed:", 91 - res.status, 92 - await res.text().catch(() => ""), 93 - ); 94 - } 95 74 } 96 75 97 76 insertVote(id, did, votePdsUri);
+5 -5
packages/feature-requests/src/indexer.ts
··· 30 30 const STATUS_COLLECTION = "site.exosphere.featureRequestStatus"; 31 31 32 32 function findSphereForAccess( 33 - sphereHandle: string, 33 + sphereOwnerDid: string, 34 34 did: string, 35 35 ): { allowed: boolean; sphereId: string | null } { 36 36 const db = getDb(); 37 37 const sphere = db 38 38 .select({ id: spheres.id, writeAccess: spheres.writeAccess }) 39 39 .from(spheres) 40 - .where(eq(spheres.handle, sphereHandle)) 40 + .where(eq(spheres.ownerDid, sphereOwnerDid)) 41 41 .get(); 42 42 if (!sphere) return { allowed: false, sphereId: null }; 43 43 if (sphere.writeAccess === "open") return { allowed: true, sphereId: sphere.id }; ··· 62 62 const pdsUri = buildAtUri(did, collection, rkey); 63 63 64 64 if (collection === COLLECTION) { 65 - const sphereHandle = record.sphereHandle as string; 66 - if (!sphereHandle) return; // Reject records without a sphere 65 + const subject = record.subject as string; 66 + if (!subject || !subject.startsWith("did:")) return; 67 67 68 - const access = findSphereForAccess(sphereHandle, did); 68 + const access = findSphereForAccess(subject, did); 69 69 if (!access.allowed || !access.sphereId) return; 70 70 const sphereId = access.sphereId; 71 71
+110
scripts/generate-lexicon-types.ts
··· 1 + /** 2 + * Reads AT Protocol lexicon schemas from landing/.well-known/site.exosphere.*.json 3 + * and generates TypeScript interfaces + PdsRecordMap. 4 + * 5 + * Usage: bun run generate:lexicons 6 + */ 7 + 8 + import { resolve } from "node:path"; 9 + import { readdir } from "node:fs/promises"; 10 + 11 + const LEXICON_DIR = resolve(import.meta.dirname!, "../../landing/.well-known"); 12 + const OUTPUT_FILE = resolve( 13 + import.meta.dirname!, 14 + "../packages/core/src/generated/lexicon-records.ts", 15 + ); 16 + 17 + const PREFIX = "site.exosphere."; 18 + 19 + interface LexiconProperty { 20 + type: string; 21 + format?: string; 22 + description?: string; 23 + items?: { type: string }; 24 + knownValues?: string[]; 25 + maxLength?: number; 26 + } 27 + 28 + interface LexiconSchema { 29 + lexicon: number; 30 + id: string; 31 + defs: { 32 + main: { 33 + type: string; 34 + record: { 35 + type: string; 36 + required: string[]; 37 + properties: Record<string, LexiconProperty>; 38 + }; 39 + }; 40 + }; 41 + } 42 + 43 + function toInterfaceName(lexiconId: string): string { 44 + const name = lexiconId.slice(PREFIX.length); 45 + return name.charAt(0).toUpperCase() + name.slice(1) + "Record"; 46 + } 47 + 48 + function tsType(prop: LexiconProperty): string { 49 + if (prop.type === "array" && prop.items?.type === "string") return "string[]"; 50 + if (prop.type === "string") return "string"; 51 + throw new Error(`Unsupported lexicon property type: ${JSON.stringify(prop)}`); 52 + } 53 + 54 + function formatComment(prop: LexiconProperty): string | null { 55 + const parts: string[] = []; 56 + if (prop.format) parts.push(prop.format); 57 + if (prop.description) parts.push(prop.description); 58 + return parts.length > 0 ? parts.join(" — ") : null; 59 + } 60 + 61 + async function main() { 62 + const files = (await readdir(LEXICON_DIR)) 63 + .filter((f) => f.startsWith("site.exosphere.") && f.endsWith(".json")) 64 + .sort(); 65 + 66 + const schemas: LexiconSchema[] = []; 67 + for (const file of files) { 68 + const content = await Bun.file(resolve(LEXICON_DIR, file)).json(); 69 + schemas.push(content as LexiconSchema); 70 + } 71 + 72 + const lines: string[] = [ 73 + "// AUTO-GENERATED from landing/.well-known/site.exosphere.*.json", 74 + "// Do not edit manually. Run: bun run generate:lexicons", 75 + "", 76 + ]; 77 + 78 + for (const schema of schemas) { 79 + const name = toInterfaceName(schema.id); 80 + const record = schema.defs.main.record; 81 + const required = new Set(record.required); 82 + 83 + lines.push(`export interface ${name} {`); 84 + for (const [key, prop] of Object.entries(record.properties)) { 85 + const comment = formatComment(prop); 86 + if (comment) { 87 + lines.push(` /** ${comment} */`); 88 + } 89 + const optional = required.has(key) ? "" : "?"; 90 + lines.push(` ${key}${optional}: ${tsType(prop)};`); 91 + } 92 + lines.push("}"); 93 + lines.push(""); 94 + } 95 + 96 + // Generate PdsRecordMap 97 + lines.push("export interface PdsRecordMap {"); 98 + for (const schema of schemas) { 99 + const name = toInterfaceName(schema.id); 100 + lines.push(` "${schema.id}": ${name};`); 101 + } 102 + lines.push("}"); 103 + lines.push(""); 104 + 105 + await Bun.write(OUTPUT_FILE, lines.join("\n")); 106 + 107 + console.log(`Generated ${schemas.length} interfaces → ${OUTPUT_FILE}`); 108 + } 109 + 110 + main();