@alteran/astro 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +25 -0
  2. package/index.js +2 -4
  3. package/migrations/0006_adorable_spectrum.sql +11 -0
  4. package/migrations/meta/0006_snapshot.json +429 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +6 -3
  7. package/src/db/account.ts +145 -0
  8. package/src/db/dal.ts +27 -9
  9. package/src/db/repo.ts +9 -8
  10. package/src/db/schema.ts +29 -11
  11. package/src/lib/actor.ts +133 -0
  12. package/src/lib/appview.ts +508 -0
  13. package/src/lib/auth.ts +22 -2
  14. package/src/lib/blob-refs.ts +9 -13
  15. package/src/lib/chat.ts +238 -0
  16. package/src/lib/config.ts +15 -7
  17. package/src/lib/feed.ts +165 -0
  18. package/src/lib/jwt.ts +135 -44
  19. package/src/lib/labeler.ts +91 -0
  20. package/src/lib/mst/blockstore.ts +98 -14
  21. package/src/lib/password.ts +40 -0
  22. package/src/lib/preferences.ts +73 -0
  23. package/src/lib/relay.ts +101 -0
  24. package/src/lib/secrets.ts +3 -0
  25. package/src/lib/session-tokens.ts +202 -0
  26. package/src/lib/token-cleanup.ts +3 -12
  27. package/src/lib/util.ts +17 -2
  28. package/src/middleware.ts +20 -21
  29. package/src/pages/.well-known/did.json.ts +45 -32
  30. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +23 -0
  31. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +34 -0
  32. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +42 -0
  33. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +36 -0
  34. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +42 -0
  35. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +37 -0
  36. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +26 -0
  37. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +35 -0
  38. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +29 -0
  39. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +29 -0
  40. package/src/pages/xrpc/app.bsky.labeler.getServices.ts +29 -0
  41. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +20 -0
  42. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +27 -0
  43. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +19 -0
  44. package/src/pages/xrpc/app.bsky.unspecced.getConfig.ts +15 -0
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +26 -0
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +37 -0
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +64 -66
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +24 -0
  49. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +127 -0
  50. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +91 -0
  51. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +6 -2
  52. package/src/pages/xrpc/com.atproto.server.createSession.ts +36 -8
  53. package/src/pages/xrpc/com.atproto.server.describeServer.ts +37 -4
  54. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +64 -0
  55. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +55 -32
  56. package/src/services/repo-manager.ts +15 -6
  57. package/src/worker/runtime.ts +9 -0
  58. package/types/env.d.ts +9 -0
  59. package/src/pages/xrpc/com.atproto.repo.importRepo.ts +0 -142
  60. package/src/pages/xrpc/com.atproto.server.activateAccount.ts +0 -53
  61. package/src/pages/xrpc/com.atproto.server.createAccount.ts +0 -99
  62. package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +0 -53
@@ -0,0 +1,64 @@
1
+ import type { APIContext } from 'astro';
2
+ import { authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { createServiceAuthToken } from '../../lib/appview';
4
+
5
+ export const prerender = false;
6
+
7
+ export async function GET({ locals, request }: APIContext) {
8
+ const { env } = locals.runtime;
9
+ const auth = await authenticateRequest(request, env);
10
+ if (!auth) return unauthorized();
11
+
12
+ const url = new URL(request.url);
13
+ const audienceParam = url.searchParams.get('aud');
14
+ const lexParam = url.searchParams.get('lxm');
15
+ const expParam = url.searchParams.get('exp');
16
+
17
+ const audience = audienceParam?.trim();
18
+ if (!audience) {
19
+ return new Response(JSON.stringify({ error: 'MissingAudience' }), {
20
+ status: 400,
21
+ headers: { 'Content-Type': 'application/json' },
22
+ });
23
+ }
24
+
25
+ const lexiconMethod = lexParam && lexParam.trim() !== '' ? lexParam.trim() : null;
26
+
27
+ let expiresIn = 60;
28
+ const now = Math.floor(Date.now() / 1000);
29
+ if (expParam !== null) {
30
+ if (!/^-?\d+$/.test(expParam)) {
31
+ return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration must be an integer timestamp' }), {
32
+ status: 400,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ });
35
+ }
36
+ const exp = Number(expParam);
37
+ if (exp <= now) {
38
+ return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration is in the past' }), {
39
+ status: 400,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ if (exp - now > 3600) {
44
+ return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration too far in future' }), {
45
+ status: 400,
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+ }
49
+ expiresIn = Math.max(1, exp - now);
50
+ }
51
+
52
+ try {
53
+ const token = await createServiceAuthToken(env, auth.claims.sub, audience, lexiconMethod, expiresIn);
54
+ return new Response(JSON.stringify({ token }), {
55
+ headers: { 'Content-Type': 'application/json' },
56
+ });
57
+ } catch (error: any) {
58
+ console.error('service auth error:', error);
59
+ return new Response(JSON.stringify({ error: 'InternalServerError' }), {
60
+ status: 500,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ }
64
+ }
@@ -1,10 +1,9 @@
1
1
  import type { APIContext } from 'astro';
2
- import { signJwt, verifyJwt } from '../../lib/jwt';
3
2
  import { bearerToken } from '../../lib/util';
4
3
  import { lazyCleanupExpiredTokens } from '../../lib/token-cleanup';
5
- import { drizzle } from 'drizzle-orm/d1';
6
- import { token_revocation } from '../../db/schema';
7
- import { eq } from 'drizzle-orm';
4
+ import { getRuntimeString } from '../../lib/secrets';
5
+ import { getAccountByIdentifier, getRefreshToken, markRefreshTokenRotated, storeRefreshToken } from '../../db/account';
6
+ import { verifyRefreshToken, issueSessionTokens, computeGraceExpiry } from '../../lib/session-tokens';
8
7
 
9
8
  export const prerender = false;
10
9
 
@@ -18,46 +17,70 @@ export async function POST({ locals, request }: APIContext) {
18
17
  );
19
18
  }
20
19
 
21
- const ver = await verifyJwt(env, token).catch(() => null);
22
- if (!ver || ver.payload.t !== 'refresh') {
20
+ const verification = await verifyRefreshToken(env, token).catch(() => null);
21
+ if (!verification) {
23
22
  return new Response(
24
23
  JSON.stringify({ error: 'InvalidToken', message: 'Invalid or expired refresh token' }),
25
24
  { status: 401, headers: { 'Content-Type': 'application/json' } }
26
25
  );
27
26
  }
28
27
 
29
- // Reject if JTI is revoked (single-use refresh tokens)
30
- const jtiOld = String(ver.payload.jti || '');
31
- if (jtiOld) {
32
- const db = drizzle(env.DB);
33
- const revoked = await db.select().from(token_revocation).where(eq(token_revocation.jti, jtiOld)).get();
34
- if (revoked) {
35
- return new Response(
36
- JSON.stringify({ error: 'InvalidToken', message: 'Refresh token has already been used' }),
37
- { status: 401, headers: { 'Content-Type': 'application/json' } }
38
- );
39
- }
28
+ const nowSec = Math.floor(Date.now() / 1000);
29
+ const { decoded } = verification;
30
+
31
+ if (!decoded || typeof decoded.jti !== 'string') {
32
+ return new Response(
33
+ JSON.stringify({ error: 'InvalidToken', message: 'Malformed refresh token' }),
34
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
35
+ );
40
36
  }
41
37
 
42
- const did = String(ver.payload.sub || (env.PDS_DID ?? 'did:example:single-user'));
43
- const handle = String(ver.payload.handle || env.PDS_HANDLE || 'user.example');
38
+ if (typeof decoded.exp !== 'number' || decoded.exp <= nowSec) {
39
+ return new Response(
40
+ JSON.stringify({ error: 'ExpiredToken', message: 'Refresh token expired' }),
41
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
42
+ );
43
+ }
44
44
 
45
- // Rotate: generate new token pair with new JTI
46
- const jtiNew = crypto.randomUUID();
47
- const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
48
- const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
45
+ const stored = await getRefreshToken(env, decoded.jti);
46
+ if (!stored) {
47
+ return new Response(
48
+ JSON.stringify({ error: 'InvalidToken', message: 'Refresh token has been revoked' }),
49
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
50
+ );
51
+ }
49
52
 
50
- // Revoke old refresh token by inserting into revocation table
51
- if (jtiOld && ver.payload.exp) {
52
- const db = drizzle(env.DB);
53
- const now = Math.floor(Date.now() / 1000);
54
- await db.insert(token_revocation).values({
55
- jti: jtiOld,
56
- exp: Number(ver.payload.exp),
57
- revoked_at: now,
58
- }).run();
53
+ if (stored.expiresAt <= nowSec) {
54
+ return new Response(
55
+ JSON.stringify({ error: 'ExpiredToken', message: 'Refresh token expired' }),
56
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
57
+ );
58
+ }
59
+
60
+ if (stored.did !== decoded.sub) {
61
+ return new Response(
62
+ JSON.stringify({ error: 'InvalidToken', message: 'Token subject mismatch' }),
63
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
64
+ );
59
65
  }
60
66
 
67
+ const account = await getAccountByIdentifier(env, stored.did);
68
+ const did = stored.did;
69
+ const handle = account?.handle ?? (await getRuntimeString(env, 'PDS_HANDLE', 'user.example'));
70
+
71
+ // Rotate: generate new token pair with new JTI
72
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, did, { jti: stored.nextId ?? undefined });
73
+
74
+ await storeRefreshToken(env, {
75
+ id: refreshPayload.jti,
76
+ did,
77
+ expiresAt: refreshExpiry,
78
+ appPasswordName: stored.appPasswordName ?? null,
79
+ });
80
+
81
+ const graceExpiry = computeGraceExpiry(stored.expiresAt, nowSec);
82
+ await markRefreshTokenRotated(env, decoded.jti, refreshPayload.jti, graceExpiry);
83
+
61
84
  // Lazy cleanup of expired tokens (runs 1% of the time)
62
85
  lazyCleanupExpiredTokens(env).catch(console.error);
63
86
 
@@ -3,13 +3,14 @@ import type { Env } from '../env';
3
3
  import { MST, D1Blockstore, Leaf } from '../lib/mst';
4
4
  import { drizzle } from 'drizzle-orm/d1';
5
5
  import { repo_root } from '../db/schema';
6
- import { eq } from 'drizzle-orm';
6
+ import { eq, sql } from 'drizzle-orm';
7
7
  import type { RepoOp } from '../lib/firehose/frames';
8
8
  import * as dagCbor from '@ipld/dag-cbor';
9
9
  import { cidForCbor } from '../lib/mst/util';
10
10
  import { putRecord as dalPutRecord, deleteRecord as dalDeleteRecord } from '../db/dal';
11
11
  import { bumpRoot } from '../db/repo';
12
12
  import { generateTid } from '../lib/commit';
13
+ import { resolveSecret } from '../lib/secrets';
13
14
 
14
15
  /**
15
16
  * Repository Manager
@@ -21,7 +22,12 @@ export class RepoManager {
21
22
 
22
23
  constructor(private env: Env) {
23
24
  this.blockstore = new D1Blockstore(env);
24
- this.did = env.PDS_DID ?? 'did:example:single-user';
25
+ // Note: this.did will be set asynchronously, but it's only used in async methods
26
+ this.did = 'did:example:single-user'; // Default, will be resolved in async methods
27
+ }
28
+
29
+ private async getDid(): Promise<string> {
30
+ return (await resolveSecret(this.env.PDS_DID)) ?? 'did:example:single-user';
25
31
  }
26
32
 
27
33
  /**
@@ -217,19 +223,22 @@ export class RepoManager {
217
223
  async updateRoot(mst: MST, rev: number): Promise<void> {
218
224
  const db = drizzle(this.env.DB);
219
225
  const rootCid = await mst.getPointer();
226
+ const did = await this.getDid();
227
+ const revStr = String(rev);
220
228
 
229
+ // Use sql.raw with excluded to properly reference INSERT values
221
230
  await db
222
231
  .insert(repo_root)
223
232
  .values({
224
- did: this.did,
233
+ did,
225
234
  commitCid: rootCid.toString(),
226
- rev,
235
+ rev: revStr,
227
236
  })
228
237
  .onConflictDoUpdate({
229
238
  target: repo_root.did,
230
239
  set: {
231
- commitCid: rootCid.toString(),
232
- rev,
240
+ commitCid: sql.raw('excluded.commit_cid'),
241
+ rev: sql.raw('excluded.rev'),
233
242
  },
234
243
  })
235
244
  .run();
@@ -1,6 +1,7 @@
1
1
  import { seed } from '../db/seed';
2
2
  import { validateConfigOrThrow } from '../lib/config';
3
3
  import { resolveEnvSecrets } from '../lib/secrets';
4
+ import { notifyRelaysIfNeeded } from '../lib/relay';
4
5
  import type { Env } from '../env';
5
6
  import type { SSRManifest } from 'astro';
6
7
  import type {
@@ -64,6 +65,14 @@ export function createPdsFetchHandler(options?: CreatePdsFetchHandlerOptions): P
64
65
 
65
66
  await seed(resolvedEnv.DB, (resolvedEnv.PDS_DID as string | undefined) ?? 'did:example:single-user');
66
67
 
68
+ // Fire-and-forget: let relays know this PDS exists and is reachable.
69
+ // Throttled per isolate and safe to call frequently.
70
+ try {
71
+ ctx.waitUntil(notifyRelaysIfNeeded(resolvedEnv as any, request.url));
72
+ } catch (err) {
73
+ // Never block on relay notification
74
+ }
75
+
67
76
  const url = new URL(request.url);
68
77
  if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
69
78
  const upgrade = request.headers.get('upgrade');
package/types/env.d.ts CHANGED
@@ -29,16 +29,25 @@ declare global {
29
29
  PDS_MAX_BLOB_SIZE?: string;
30
30
  ACCESS_TOKEN_SECRET?: string | SecretsStoreSecret;
31
31
  REFRESH_TOKEN_SECRET?: string | SecretsStoreSecret;
32
+ SESSION_JWT_SECRET?: string | SecretsStoreSecret;
32
33
  PDS_ACCESS_TTL_SEC?: string;
33
34
  PDS_REFRESH_TTL_SEC?: string;
34
35
  JWT_ALGORITHM?: string;
35
36
  REPO_SIGNING_KEY?: string | SecretsStoreSecret;
36
37
  REPO_SIGNING_KEY_PUBLIC?: string | SecretsStoreSecret;
38
+ PDS_PLC_ROTATION_KEY?: string | SecretsStoreSecret;
37
39
  PDS_RATE_LIMIT_PER_MIN?: string;
38
40
  PDS_MAX_JSON_BYTES?: string;
39
41
  PDS_CORS_ORIGIN?: string;
40
42
  PDS_SEQ_WINDOW?: string;
41
43
  ENVIRONMENT?: string;
44
+ PDS_BSKY_APP_VIEW_URL?: string;
45
+ PDS_BSKY_APP_VIEW_DID?: string;
46
+ PDS_BSKY_APP_VIEW_CDN_URL_PATTERN?: string;
47
+ PDS_SERVICE_SIGNING_KEY_HEX?: string | SecretsStoreSecret;
48
+ // Relay crawl configuration
49
+ PDS_RELAY_HOSTS?: string; // CSV of relay hostnames (no scheme). Default: bsky.network
50
+ PDS_RELAY_NOTIFY?: string; // 'false' to disable auto notify
42
51
  }
43
52
 
44
53
  namespace App {
@@ -1,142 +0,0 @@
1
- import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
3
- import { parseCarFile } from '../../lib/car-reader';
4
- import { D1Blockstore } from '../../lib/mst';
5
- import { getDb } from '../../db/client';
6
- import { repo_root, commit_log } from '../../db/schema';
7
- import { putRecord } from '../../db/dal';
8
- import * as dagCbor from '@ipld/dag-cbor';
9
- import { CID } from 'multiformats/cid';
10
-
11
- export const prerender = false;
12
-
13
- /**
14
- * com.atproto.repo.importRepo
15
- *
16
- * Imports a repository from a CAR (Content Addressable aRchive) file.
17
- * This is used during account migration to transfer the complete repo history.
18
- */
19
- export async function POST({ locals, request }: APIContext) {
20
- const { env } = locals.runtime;
21
-
22
- if (!(await isAuthorized(request, env))) return unauthorized();
23
-
24
- try {
25
- const contentType = request.headers.get('content-type');
26
- if (contentType !== 'application/vnd.ipld.car') {
27
- return new Response(
28
- JSON.stringify({
29
- error: 'InvalidRequest',
30
- message: 'Content-Type must be application/vnd.ipld.car'
31
- }),
32
- { status: 400, headers: { 'Content-Type': 'application/json' } }
33
- );
34
- }
35
-
36
- const did = env.PDS_DID ?? 'did:example:single-user';
37
- const carBytes = new Uint8Array(await request.arrayBuffer());
38
-
39
- // Parse CAR file
40
- const { header, blocks } = parseCarFile(carBytes);
41
-
42
- if (blocks.length === 0) {
43
- return new Response(
44
- JSON.stringify({
45
- error: 'InvalidRequest',
46
- message: 'CAR file contains no blocks'
47
- }),
48
- { status: 400, headers: { 'Content-Type': 'application/json' } }
49
- );
50
- }
51
-
52
- // Store all blocks in blockstore
53
- const blockstore = new D1Blockstore(env);
54
- for (const block of blocks) {
55
- await blockstore.put(block.cid, block.bytes);
56
- }
57
-
58
- // Find the commit block (root of the CAR)
59
- const rootCid = header.roots[0];
60
- if (!rootCid) {
61
- return new Response(
62
- JSON.stringify({
63
- error: 'InvalidRequest',
64
- message: 'CAR file has no root CID'
65
- }),
66
- { status: 400, headers: { 'Content-Type': 'application/json' } }
67
- );
68
- }
69
-
70
- // Decode the commit to get repo details
71
- const commitBlock = blocks.find(b => b.cid.equals(rootCid));
72
- if (!commitBlock) {
73
- return new Response(
74
- JSON.stringify({
75
- error: 'InvalidRequest',
76
- message: 'Root commit block not found in CAR'
77
- }),
78
- { status: 400, headers: { 'Content-Type': 'application/json' } }
79
- );
80
- }
81
-
82
- const commit = dagCbor.decode(commitBlock.bytes) as any;
83
- const rev = commit.rev || commit.version || 1;
84
-
85
- // Update repo root
86
- const db = getDb(env);
87
- await db
88
- .insert(repo_root)
89
- .values({
90
- did,
91
- commitCid: rootCid.toString(),
92
- rev: typeof rev === 'string' ? parseInt(rev) : rev,
93
- })
94
- .onConflictDoUpdate({
95
- target: repo_root.did,
96
- set: {
97
- commitCid: rootCid.toString(),
98
- rev: typeof rev === 'string' ? parseInt(rev) : rev,
99
- },
100
- })
101
- .run();
102
-
103
- // Index records from MST
104
- // Note: This is a simplified implementation
105
- // A full implementation would walk the MST tree and index all records
106
- let recordCount = 0;
107
- for (const block of blocks) {
108
- try {
109
- const obj = dagCbor.decode(block.bytes) as any;
110
-
111
- // Check if this looks like a record (has $type)
112
- if (obj && typeof obj === 'object' && obj.$type) {
113
- // This is a record, we should index it
114
- // For now, we'll skip detailed indexing and let it be done lazily
115
- recordCount++;
116
- }
117
- } catch {
118
- // Not a valid CBOR object or not a record, skip
119
- }
120
- }
121
-
122
- return new Response(
123
- JSON.stringify({
124
- did,
125
- commitCid: rootCid.toString(),
126
- rev,
127
- blocksImported: blocks.length,
128
- recordsFound: recordCount,
129
- message: 'Repository imported successfully'
130
- }),
131
- { status: 200, headers: { 'Content-Type': 'application/json' } }
132
- );
133
- } catch (error: any) {
134
- return new Response(
135
- JSON.stringify({
136
- error: 'InternalServerError',
137
- message: error.message || 'Failed to import repository'
138
- }),
139
- { status: 500, headers: { 'Content-Type': 'application/json' } }
140
- );
141
- }
142
- }
@@ -1,53 +0,0 @@
1
- import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
3
- import { setAccountActive, getAccountState } from '../../db/dal';
4
-
5
- export const prerender = false;
6
-
7
- /**
8
- * com.atproto.server.activateAccount
9
- *
10
- * Activates a deactivated account after successful migration.
11
- * This enables write operations on the PDS.
12
- */
13
- export async function POST({ locals, request }: APIContext) {
14
- const { env } = locals.runtime;
15
-
16
- if (!(await isAuthorized(request, env))) return unauthorized();
17
-
18
- try {
19
- const did = env.PDS_DID ?? 'did:example:single-user';
20
-
21
- // Check if account exists
22
- const accountState = await getAccountState(env, did);
23
- if (!accountState) {
24
- return new Response(
25
- JSON.stringify({
26
- error: 'AccountNotFound',
27
- message: 'Account does not exist'
28
- }),
29
- { status: 404, headers: { 'Content-Type': 'application/json' } }
30
- );
31
- }
32
-
33
- // Activate the account
34
- await setAccountActive(env, did, true);
35
-
36
- return new Response(
37
- JSON.stringify({
38
- did,
39
- active: true,
40
- message: 'Account activated successfully'
41
- }),
42
- { status: 200, headers: { 'Content-Type': 'application/json' } }
43
- );
44
- } catch (error: any) {
45
- return new Response(
46
- JSON.stringify({
47
- error: 'InternalServerError',
48
- message: error.message || 'Failed to activate account'
49
- }),
50
- { status: 500, headers: { 'Content-Type': 'application/json' } }
51
- );
52
- }
53
- }
@@ -1,99 +0,0 @@
1
- import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
3
- import { createAccountState, getAccountState } from '../../db/dal';
4
-
5
- export const prerender = false;
6
-
7
- function methodNotAllowedResponse(): Response {
8
- return new Response(null, {
9
- status: 405,
10
- headers: {
11
- Allow: 'POST',
12
- },
13
- });
14
- }
15
-
16
- /**
17
- * com.atproto.server.createAccount
18
- *
19
- * Single-user PDS implementation:
20
- * - Only allows creating account for the configured PDS_DID
21
- * - Creates account in deactivated state for migration
22
- * - Optionally validates serviceAuth JWT from old PDS
23
- */
24
- export async function POST({ locals, request }: APIContext) {
25
- const { env } = locals.runtime;
26
-
27
- // Require authentication for account creation
28
- if (!(await isAuthorized(request, env))) {
29
- console.log(JSON.stringify({ level: 'warn', type: 'createAccount', message: 'Unauthorized', method: request.method, url: request.url }));
30
- return unauthorized();
31
- }
32
-
33
- try {
34
- const body = await request.json() as { did?: string; handle?: string; deactivated?: boolean };
35
- const { did, handle, deactivated } = body;
36
-
37
- // Validate required fields
38
- if (!did) {
39
- return new Response(
40
- JSON.stringify({ error: 'InvalidRequest', message: 'did is required' }),
41
- { status: 400, headers: { 'Content-Type': 'application/json' } }
42
- );
43
- }
44
-
45
- // Single-user enforcement: only allow configured DID
46
- const configuredDid = env.PDS_DID;
47
- if (did !== configuredDid) {
48
- return new Response(
49
- JSON.stringify({
50
- error: 'InvalidRequest',
51
- message: `This is a single-user PDS. Only ${configuredDid} is allowed.`
52
- }),
53
- { status: 400, headers: { 'Content-Type': 'application/json' } }
54
- );
55
- }
56
-
57
- // Check if account already exists
58
- const existing = await getAccountState(env, did);
59
- if (existing) {
60
- return new Response(
61
- JSON.stringify({
62
- error: 'AccountAlreadyExists',
63
- message: 'Account already exists for this DID'
64
- }),
65
- { status: 400, headers: { 'Content-Type': 'application/json' } }
66
- );
67
- }
68
-
69
- // Create account in deactivated state (for migration)
70
- const active = deactivated === true ? false : true;
71
- await createAccountState(env, did, active);
72
-
73
- return new Response(
74
- JSON.stringify({
75
- did,
76
- handle: handle || env.PDS_HANDLE,
77
- active,
78
- createdAt: new Date().toISOString()
79
- }),
80
- { status: 200, headers: { 'Content-Type': 'application/json' } }
81
- );
82
- } catch (error: any) {
83
- return new Response(
84
- JSON.stringify({
85
- error: 'InternalServerError',
86
- message: error.message || 'Failed to create account'
87
- }),
88
- { status: 500, headers: { 'Content-Type': 'application/json' } }
89
- );
90
- }
91
- }
92
-
93
- export async function HEAD(): Promise<Response> {
94
- return methodNotAllowedResponse();
95
- }
96
-
97
- export async function GET(): Promise<Response> {
98
- return methodNotAllowedResponse();
99
- }
@@ -1,53 +0,0 @@
1
- import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
3
- import { setAccountActive, getAccountState } from '../../db/dal';
4
-
5
- export const prerender = false;
6
-
7
- /**
8
- * com.atproto.server.deactivateAccount
9
- *
10
- * Deactivates an account, preventing write operations.
11
- * Used when migrating away from this PDS.
12
- */
13
- export async function POST({ locals, request }: APIContext) {
14
- const { env } = locals.runtime;
15
-
16
- if (!(await isAuthorized(request, env))) return unauthorized();
17
-
18
- try {
19
- const did = env.PDS_DID ?? 'did:example:single-user';
20
-
21
- // Check if account exists
22
- const accountState = await getAccountState(env, did);
23
- if (!accountState) {
24
- return new Response(
25
- JSON.stringify({
26
- error: 'AccountNotFound',
27
- message: 'Account does not exist'
28
- }),
29
- { status: 404, headers: { 'Content-Type': 'application/json' } }
30
- );
31
- }
32
-
33
- // Deactivate the account
34
- await setAccountActive(env, did, false);
35
-
36
- return new Response(
37
- JSON.stringify({
38
- did,
39
- active: false,
40
- message: 'Account deactivated successfully'
41
- }),
42
- { status: 200, headers: { 'Content-Type': 'application/json' } }
43
- );
44
- } catch (error: any) {
45
- return new Response(
46
- JSON.stringify({
47
- error: 'InternalServerError',
48
- message: error.message || 'Failed to deactivate account'
49
- }),
50
- { status: 500, headers: { 'Content-Type': 'application/json' } }
51
- );
52
- }
53
- }