+235
-189
src/pds.js
+235
-189
src/pds.js
···
814
814
return car
815
815
}
816
816
817
+
/**
818
+
* Route handler function type
819
+
* @callback RouteHandler
820
+
* @param {PersonalDataServer} pds - PDS instance
821
+
* @param {Request} request - HTTP request
822
+
* @param {URL} url - Parsed URL
823
+
* @returns {Promise<Response>} HTTP response
824
+
*/
825
+
826
+
/**
827
+
* @typedef {Object} Route
828
+
* @property {string} [method] - Required HTTP method (default: any)
829
+
* @property {RouteHandler} handler - Handler function
830
+
*/
831
+
832
+
/** @type {Record<string, Route>} */
833
+
const pdsRoutes = {
834
+
'/.well-known/atproto-did': {
835
+
handler: (pds, req, url) => pds.handleAtprotoDid()
836
+
},
837
+
'/init': {
838
+
method: 'POST',
839
+
handler: (pds, req, url) => pds.handleInit(req)
840
+
},
841
+
'/status': {
842
+
handler: (pds, req, url) => pds.handleStatus()
843
+
},
844
+
'/reset-repo': {
845
+
handler: (pds, req, url) => pds.handleResetRepo()
846
+
},
847
+
'/forward-event': {
848
+
handler: (pds, req, url) => pds.handleForwardEvent(req)
849
+
},
850
+
'/register-did': {
851
+
handler: (pds, req, url) => pds.handleRegisterDid(req)
852
+
},
853
+
'/get-registered-dids': {
854
+
handler: (pds, req, url) => pds.handleGetRegisteredDids()
855
+
},
856
+
'/repo-info': {
857
+
handler: (pds, req, url) => pds.handleRepoInfo()
858
+
},
859
+
'/xrpc/com.atproto.server.describeServer': {
860
+
handler: (pds, req, url) => pds.handleDescribeServer(req)
861
+
},
862
+
'/xrpc/com.atproto.sync.listRepos': {
863
+
handler: (pds, req, url) => pds.handleListRepos()
864
+
},
865
+
'/xrpc/com.atproto.repo.createRecord': {
866
+
method: 'POST',
867
+
handler: (pds, req, url) => pds.handleCreateRecord(req)
868
+
},
869
+
'/xrpc/com.atproto.repo.getRecord': {
870
+
handler: (pds, req, url) => pds.handleGetRecord(url)
871
+
},
872
+
'/xrpc/com.atproto.sync.getLatestCommit': {
873
+
handler: (pds, req, url) => pds.handleGetLatestCommit()
874
+
},
875
+
'/xrpc/com.atproto.sync.getRepoStatus': {
876
+
handler: (pds, req, url) => pds.handleGetRepoStatus()
877
+
},
878
+
'/xrpc/com.atproto.sync.getRepo': {
879
+
handler: (pds, req, url) => pds.handleGetRepo()
880
+
},
881
+
'/xrpc/com.atproto.sync.subscribeRepos': {
882
+
handler: (pds, req, url) => pds.handleSubscribeRepos(req, url)
883
+
}
884
+
}
885
+
817
886
export class PersonalDataServer {
818
887
constructor(state, env) {
819
888
this.state = state
···
1107
1176
}
1108
1177
}
1109
1178
1110
-
async fetch(request) {
1111
-
const url = new URL(request.url)
1112
-
1113
-
// Handle resolution - doesn't require ?did= param
1114
-
if (url.pathname === '/.well-known/atproto-did') {
1115
-
let did = await this.getDid()
1116
-
// If no DID on this instance, check registered DIDs (default instance)
1117
-
if (!did) {
1118
-
const registeredDids = await this.state.storage.get('registeredDids') || []
1119
-
did = registeredDids[0]
1120
-
}
1121
-
if (!did) {
1122
-
return new Response('User not found', { status: 404 })
1123
-
}
1124
-
return new Response(did, {
1125
-
headers: { 'Content-Type': 'text/plain' }
1126
-
})
1127
-
}
1128
-
1129
-
if (url.pathname === '/init') {
1130
-
const body = await request.json()
1131
-
if (!body.did || !body.privateKey) {
1132
-
return Response.json({ error: 'missing did or privateKey' }, { status: 400 })
1133
-
}
1134
-
await this.initIdentity(body.did, body.privateKey, body.handle || null)
1135
-
return Response.json({ ok: true, did: body.did, handle: body.handle || null })
1136
-
}
1137
-
if (url.pathname === '/status') {
1138
-
const did = await this.getDid()
1139
-
return Response.json({
1140
-
initialized: !!did,
1141
-
did: did || null
1142
-
})
1143
-
}
1144
-
// Reset endpoint - clears all repo data but keeps identity
1145
-
if (url.pathname === '/reset-repo') {
1146
-
this.sql.exec(`DELETE FROM blocks`)
1147
-
this.sql.exec(`DELETE FROM records`)
1148
-
this.sql.exec(`DELETE FROM commits`)
1149
-
this.sql.exec(`DELETE FROM seq_events`)
1150
-
await this.state.storage.delete('head')
1151
-
await this.state.storage.delete('rev')
1152
-
return Response.json({ ok: true, message: 'repo data cleared' })
1153
-
}
1154
-
// Internal endpoint to forward events for relay broadcasting
1155
-
if (url.pathname === '/forward-event') {
1156
-
const evt = await request.json()
1157
-
// Convert evt back to proper format and broadcast
1158
-
const numSockets = [...this.state.getWebSockets()].length
1159
-
console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`)
1160
-
this.broadcastEvent({
1161
-
seq: evt.seq,
1162
-
did: evt.did,
1163
-
commit_cid: evt.commit_cid,
1164
-
evt: new Uint8Array(Object.values(evt.evt))
1165
-
})
1166
-
return Response.json({ ok: true, sockets: numSockets })
1167
-
}
1168
-
// Internal endpoint to register DIDs for discovery
1169
-
if (url.pathname === '/register-did') {
1170
-
const body = await request.json()
1179
+
async handleAtprotoDid() {
1180
+
let did = await this.getDid()
1181
+
if (!did) {
1171
1182
const registeredDids = await this.state.storage.get('registeredDids') || []
1172
-
if (!registeredDids.includes(body.did)) {
1173
-
registeredDids.push(body.did)
1174
-
await this.state.storage.put('registeredDids', registeredDids)
1175
-
}
1176
-
return Response.json({ ok: true })
1177
-
}
1178
-
// Internal endpoint to get registered DIDs
1179
-
if (url.pathname === '/get-registered-dids') {
1180
-
const registeredDids = await this.state.storage.get('registeredDids') || []
1181
-
return Response.json({ dids: registeredDids })
1182
-
}
1183
-
// Internal endpoint to get repo info (head/rev)
1184
-
if (url.pathname === '/repo-info') {
1185
-
const head = await this.state.storage.get('head')
1186
-
const rev = await this.state.storage.get('rev')
1187
-
return Response.json({ head: head || null, rev: rev || null })
1183
+
did = registeredDids[0]
1188
1184
}
1189
-
if (url.pathname === '/xrpc/com.atproto.server.describeServer') {
1190
-
// Server DID should be did:web based on hostname, passed via header
1191
-
const hostname = request.headers.get('x-hostname') || 'localhost'
1192
-
return Response.json({
1193
-
did: `did:web:${hostname}`,
1194
-
availableUserDomains: [`.${hostname}`],
1195
-
inviteCodeRequired: false,
1196
-
phoneVerificationRequired: false,
1197
-
links: {},
1198
-
contact: {}
1199
-
})
1185
+
if (!did) {
1186
+
return new Response('User not found', { status: 404 })
1200
1187
}
1201
-
if (url.pathname === '/xrpc/com.atproto.sync.listRepos') {
1202
-
const registeredDids = await this.state.storage.get('registeredDids') || []
1203
-
// If this is the default instance, return registered DIDs
1204
-
// If this is a user instance, return its own DID
1205
-
const did = await this.getDid()
1206
-
const repos = did ? [{ did, head: null, rev: null }] :
1207
-
registeredDids.map(d => ({ did: d, head: null, rev: null }))
1208
-
return Response.json({ repos })
1188
+
return new Response(did, { headers: { 'Content-Type': 'text/plain' } })
1189
+
}
1190
+
1191
+
async handleInit(request) {
1192
+
const body = await request.json()
1193
+
if (!body.did || !body.privateKey) {
1194
+
return Response.json({ error: 'missing did or privateKey' }, { status: 400 })
1209
1195
}
1210
-
if (url.pathname === '/xrpc/com.atproto.repo.createRecord') {
1211
-
if (request.method !== 'POST') {
1212
-
return Response.json({ error: 'method not allowed' }, { status: 405 })
1213
-
}
1196
+
await this.initIdentity(body.did, body.privateKey, body.handle || null)
1197
+
return Response.json({ ok: true, did: body.did, handle: body.handle || null })
1198
+
}
1199
+
1200
+
async handleStatus() {
1201
+
const did = await this.getDid()
1202
+
return Response.json({ initialized: !!did, did: did || null })
1203
+
}
1214
1204
1215
-
const body = await request.json()
1216
-
if (!body.collection || !body.record) {
1217
-
return Response.json({ error: 'missing collection or record' }, { status: 400 })
1218
-
}
1205
+
async handleResetRepo() {
1206
+
this.sql.exec(`DELETE FROM blocks`)
1207
+
this.sql.exec(`DELETE FROM records`)
1208
+
this.sql.exec(`DELETE FROM commits`)
1209
+
this.sql.exec(`DELETE FROM seq_events`)
1210
+
await this.state.storage.delete('head')
1211
+
await this.state.storage.delete('rev')
1212
+
return Response.json({ ok: true, message: 'repo data cleared' })
1213
+
}
1214
+
1215
+
async handleForwardEvent(request) {
1216
+
const evt = await request.json()
1217
+
const numSockets = [...this.state.getWebSockets()].length
1218
+
console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`)
1219
+
this.broadcastEvent({
1220
+
seq: evt.seq,
1221
+
did: evt.did,
1222
+
commit_cid: evt.commit_cid,
1223
+
evt: new Uint8Array(Object.values(evt.evt))
1224
+
})
1225
+
return Response.json({ ok: true, sockets: numSockets })
1226
+
}
1219
1227
1220
-
try {
1221
-
const result = await this.createRecord(body.collection, body.record, body.rkey)
1222
-
return Response.json(result)
1223
-
} catch (err) {
1224
-
return Response.json({ error: err.message }, { status: 500 })
1225
-
}
1228
+
async handleRegisterDid(request) {
1229
+
const body = await request.json()
1230
+
const registeredDids = await this.state.storage.get('registeredDids') || []
1231
+
if (!registeredDids.includes(body.did)) {
1232
+
registeredDids.push(body.did)
1233
+
await this.state.storage.put('registeredDids', registeredDids)
1226
1234
}
1227
-
if (url.pathname === '/xrpc/com.atproto.repo.getRecord') {
1228
-
const collection = url.searchParams.get('collection')
1229
-
const rkey = url.searchParams.get('rkey')
1235
+
return Response.json({ ok: true })
1236
+
}
1230
1237
1231
-
if (!collection || !rkey) {
1232
-
return Response.json({ error: 'missing collection or rkey' }, { status: 400 })
1233
-
}
1238
+
async handleGetRegisteredDids() {
1239
+
const registeredDids = await this.state.storage.get('registeredDids') || []
1240
+
return Response.json({ dids: registeredDids })
1241
+
}
1234
1242
1235
-
const did = await this.getDid()
1236
-
const uri = `at://${did}/${collection}/${rkey}`
1243
+
async handleRepoInfo() {
1244
+
const head = await this.state.storage.get('head')
1245
+
const rev = await this.state.storage.get('rev')
1246
+
return Response.json({ head: head || null, rev: rev || null })
1247
+
}
1237
1248
1238
-
const rows = this.sql.exec(
1239
-
`SELECT cid, value FROM records WHERE uri = ?`, uri
1240
-
).toArray()
1249
+
handleDescribeServer(request) {
1250
+
const hostname = request.headers.get('x-hostname') || 'localhost'
1251
+
return Response.json({
1252
+
did: `did:web:${hostname}`,
1253
+
availableUserDomains: [`.${hostname}`],
1254
+
inviteCodeRequired: false,
1255
+
phoneVerificationRequired: false,
1256
+
links: {},
1257
+
contact: {}
1258
+
})
1259
+
}
1241
1260
1242
-
if (rows.length === 0) {
1243
-
return Response.json({ error: 'record not found' }, { status: 404 })
1244
-
}
1261
+
async handleListRepos() {
1262
+
const registeredDids = await this.state.storage.get('registeredDids') || []
1263
+
const did = await this.getDid()
1264
+
const repos = did ? [{ did, head: null, rev: null }] :
1265
+
registeredDids.map(d => ({ did: d, head: null, rev: null }))
1266
+
return Response.json({ repos })
1267
+
}
1245
1268
1246
-
const row = rows[0]
1247
-
// Decode CBOR for response (convert ArrayBuffer to Uint8Array)
1248
-
const value = cborDecode(new Uint8Array(row.value))
1269
+
async handleCreateRecord(request) {
1270
+
const body = await request.json()
1271
+
if (!body.collection || !body.record) {
1272
+
return Response.json({ error: 'missing collection or record' }, { status: 400 })
1273
+
}
1274
+
try {
1275
+
const result = await this.createRecord(body.collection, body.record, body.rkey)
1276
+
return Response.json(result)
1277
+
} catch (err) {
1278
+
return Response.json({ error: err.message }, { status: 500 })
1279
+
}
1280
+
}
1249
1281
1250
-
return Response.json({ uri, cid: row.cid, value })
1282
+
async handleGetRecord(url) {
1283
+
const collection = url.searchParams.get('collection')
1284
+
const rkey = url.searchParams.get('rkey')
1285
+
if (!collection || !rkey) {
1286
+
return Response.json({ error: 'missing collection or rkey' }, { status: 400 })
1287
+
}
1288
+
const did = await this.getDid()
1289
+
const uri = `at://${did}/${collection}/${rkey}`
1290
+
const rows = this.sql.exec(
1291
+
`SELECT cid, value FROM records WHERE uri = ?`, uri
1292
+
).toArray()
1293
+
if (rows.length === 0) {
1294
+
return Response.json({ error: 'record not found' }, { status: 404 })
1251
1295
}
1252
-
if (url.pathname === '/xrpc/com.atproto.sync.getLatestCommit') {
1253
-
const commits = this.sql.exec(
1254
-
`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1255
-
).toArray()
1296
+
const row = rows[0]
1297
+
const value = cborDecode(new Uint8Array(row.value))
1298
+
return Response.json({ uri, cid: row.cid, value })
1299
+
}
1256
1300
1257
-
if (commits.length === 0) {
1258
-
return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1259
-
}
1301
+
handleGetLatestCommit() {
1302
+
const commits = this.sql.exec(
1303
+
`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1304
+
).toArray()
1305
+
if (commits.length === 0) {
1306
+
return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1307
+
}
1308
+
return Response.json({ cid: commits[0].cid, rev: commits[0].rev })
1309
+
}
1260
1310
1261
-
return Response.json({ cid: commits[0].cid, rev: commits[0].rev })
1311
+
async handleGetRepoStatus() {
1312
+
const did = await this.getDid()
1313
+
const commits = this.sql.exec(
1314
+
`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1315
+
).toArray()
1316
+
if (commits.length === 0 || !did) {
1317
+
return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1262
1318
}
1263
-
if (url.pathname === '/xrpc/com.atproto.sync.getRepoStatus') {
1264
-
const did = await this.getDid()
1265
-
const commits = this.sql.exec(
1266
-
`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1267
-
).toArray()
1319
+
return Response.json({ did, active: true, status: 'active', rev: commits[0].rev })
1320
+
}
1268
1321
1269
-
if (commits.length === 0 || !did) {
1270
-
return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1271
-
}
1322
+
handleGetRepo() {
1323
+
const commits = this.sql.exec(
1324
+
`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`
1325
+
).toArray()
1326
+
if (commits.length === 0) {
1327
+
return Response.json({ error: 'repo not found' }, { status: 404 })
1328
+
}
1329
+
const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray()
1330
+
const blocksForCar = blocks.map(b => ({
1331
+
cid: b.cid,
1332
+
data: new Uint8Array(b.data)
1333
+
}))
1334
+
const car = buildCarFile(commits[0].cid, blocksForCar)
1335
+
return new Response(car, {
1336
+
headers: { 'content-type': 'application/vnd.ipld.car' }
1337
+
})
1338
+
}
1272
1339
1273
-
return Response.json({
1274
-
did,
1275
-
active: true,
1276
-
status: 'active',
1277
-
rev: commits[0].rev
1278
-
})
1340
+
handleSubscribeRepos(request, url) {
1341
+
const upgradeHeader = request.headers.get('Upgrade')
1342
+
if (upgradeHeader !== 'websocket') {
1343
+
return new Response('expected websocket', { status: 426 })
1279
1344
}
1280
-
if (url.pathname === '/xrpc/com.atproto.sync.getRepo') {
1281
-
const commits = this.sql.exec(
1282
-
`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`
1345
+
const { 0: client, 1: server } = new WebSocketPair()
1346
+
this.state.acceptWebSocket(server)
1347
+
const cursor = url.searchParams.get('cursor')
1348
+
if (cursor) {
1349
+
const events = this.sql.exec(
1350
+
`SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
1351
+
parseInt(cursor)
1283
1352
).toArray()
1284
-
1285
-
if (commits.length === 0) {
1286
-
return Response.json({ error: 'repo not found' }, { status: 404 })
1353
+
for (const evt of events) {
1354
+
server.send(this.formatEvent(evt))
1287
1355
}
1288
-
1289
-
const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray()
1290
-
const blocksForCar = blocks.map(b => ({
1291
-
cid: b.cid,
1292
-
data: new Uint8Array(b.data)
1293
-
}))
1294
-
const car = buildCarFile(commits[0].cid, blocksForCar)
1295
-
1296
-
return new Response(car, {
1297
-
headers: { 'content-type': 'application/vnd.ipld.car' }
1298
-
})
1299
1356
}
1300
-
if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
1301
-
const upgradeHeader = request.headers.get('Upgrade')
1302
-
if (upgradeHeader !== 'websocket') {
1303
-
return new Response('expected websocket', { status: 426 })
1304
-
}
1305
-
1306
-
const { 0: client, 1: server } = new WebSocketPair()
1307
-
this.state.acceptWebSocket(server)
1308
-
1309
-
// Send backlog if cursor provided
1310
-
const cursor = url.searchParams.get('cursor')
1311
-
if (cursor) {
1312
-
const events = this.sql.exec(
1313
-
`SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
1314
-
parseInt(cursor)
1315
-
).toArray()
1357
+
return new Response(null, { status: 101, webSocket: client })
1358
+
}
1316
1359
1317
-
for (const evt of events) {
1318
-
server.send(this.formatEvent(evt))
1319
-
}
1320
-
}
1360
+
async fetch(request) {
1361
+
const url = new URL(request.url)
1362
+
const route = pdsRoutes[url.pathname]
1321
1363
1322
-
return new Response(null, { status: 101, webSocket: client })
1364
+
if (!route) {
1365
+
return Response.json({ error: 'not found' }, { status: 404 })
1366
+
}
1367
+
if (route.method && request.method !== route.method) {
1368
+
return Response.json({ error: 'method not allowed' }, { status: 405 })
1323
1369
}
1324
-
return Response.json({ error: 'not found' }, { status: 404 })
1370
+
return route.handler(this, request, url)
1325
1371
}
1326
1372
}
1327
1373