@alteran/astro 0.1.13 → 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 +28 -3
  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 +26 -3
  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 +144 -47
  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 +4 -1
  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 +10 -1
  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,26 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { getPrimaryActor } from '../../lib/actor';
4
+ import { listChatConvoLogs } from '../../lib/chat';
5
+
6
+ export const prerender = false;
7
+
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ if (!(await isAuthorized(request, env))) return unauthorized();
11
+
12
+ const url = new URL(request.url);
13
+ const cursorParam = url.searchParams.get('cursor');
14
+ const parsedCursor = Number.parseInt(cursorParam ?? '', 10);
15
+ const cursor = Number.isFinite(parsedCursor) ? parsedCursor : undefined;
16
+
17
+ const actor = await getPrimaryActor(env);
18
+ const { logs, cursor: nextCursor } = await listChatConvoLogs(env, actor.did, cursor);
19
+
20
+ const payload: Record<string, unknown> = { logs };
21
+ if (nextCursor) payload.cursor = nextCursor;
22
+
23
+ return new Response(JSON.stringify(payload), {
24
+ headers: { 'Content-Type': 'application/json' },
25
+ });
26
+ }
@@ -0,0 +1,37 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { listChatConvos } from '../../lib/chat';
4
+ import { getPrimaryActor } from '../../lib/actor';
5
+
6
+ export const prerender = false;
7
+
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ if (!(await isAuthorized(request, env))) return unauthorized();
11
+
12
+ const url = new URL(request.url);
13
+ const limitInput = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
14
+ const limit = Math.max(1, Math.min(Number.isFinite(limitInput) ? limitInput : 50, 100));
15
+ const cursorParam = url.searchParams.get('cursor');
16
+ const cursor = cursorParam ? Number.parseInt(cursorParam, 10) : undefined;
17
+ const readStateParam = url.searchParams.get('readState');
18
+ const statusParam = url.searchParams.get('status');
19
+
20
+ const filters = {
21
+ readState: readStateParam === 'unread' ? 'unread' : null,
22
+ status:
23
+ statusParam === 'request' || statusParam === 'accepted' ? statusParam : null,
24
+ } as const;
25
+
26
+ const actor = await getPrimaryActor(env);
27
+ const { convos, cursor: nextCursor } = await listChatConvos(env, actor.did, limit, cursor, filters);
28
+
29
+ const payload: Record<string, unknown> = {
30
+ convos,
31
+ };
32
+ if (nextCursor) payload.cursor = nextCursor;
33
+
34
+ return new Response(JSON.stringify(payload), {
35
+ headers: { 'Content-Type': 'application/json' },
36
+ });
37
+ }
@@ -1,13 +1,16 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { resolveSecret } from '../../lib/secrets';
4
+ import * as uint8arrays from 'uint8arrays';
3
5
 
4
6
  export const prerender = false;
5
7
 
6
8
  /**
7
9
  * com.atproto.identity.getRecommendedDidCredentials
8
10
  *
9
- * Returns recommended DID credentials for this PDS.
10
- * Used during migration to update identity documents.
11
+ * Returns the recommended DID credentials for the current account.
12
+ * This includes the handle, signing key, and PDS endpoint that should be
13
+ * used when updating the PLC identity.
11
14
  */
12
15
  export async function GET({ locals, request }: APIContext) {
13
16
  const { env } = locals.runtime;
@@ -15,85 +18,80 @@ export async function GET({ locals, request }: APIContext) {
15
18
  if (!(await isAuthorized(request, env))) return unauthorized();
16
19
 
17
20
  try {
18
- const did = env.PDS_DID ?? 'did:example:single-user';
19
- const handle = env.PDS_HANDLE ?? 'example.com';
21
+ const handle = (await resolveSecret(env.PDS_HANDLE)) ?? 'example.com';
20
22
  const hostname = env.PDS_HOSTNAME ?? handle;
21
23
 
22
- // Get signing key if available
23
- let signingKey: string | undefined;
24
- if (env.REPO_SIGNING_PUBLIC_KEY) {
25
- // Convert raw public key to multibase format
26
- const pubKeyStr = String(env.REPO_SIGNING_PUBLIC_KEY);
27
- const pubKeyBytes = Uint8Array.from(atob(pubKeyStr), c => c.charCodeAt(0));
24
+ // Load signing key (Ed25519 PKCS#8 base64)
25
+ const signingKeyBase64 = await resolveSecret(env.REPO_SIGNING_KEY);
26
+ if (!signingKeyBase64) {
27
+ return new Response(
28
+ JSON.stringify({
29
+ error: 'InvalidRequest',
30
+ message: 'Signing key not configured'
31
+ }),
32
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
33
+ );
34
+ }
35
+
36
+ // Import Ed25519 private key from PKCS#8 base64
37
+ const b64 = signingKeyBase64.replace(/\s+/g, '');
38
+ const bin = atob(b64);
39
+ const pkcs8 = new Uint8Array(bin.length);
40
+ for (let i = 0; i < bin.length; i++) pkcs8[i] = bin.charCodeAt(i);
41
+
42
+ // Ed25519 PKCS#8 format: the public key is the last 32 bytes of the private key section
43
+ // PKCS#8 structure for Ed25519:
44
+ // - Header (16 bytes)
45
+ // - Private key (32 bytes)
46
+ // - Public key (32 bytes)
47
+ // Total: 80 bytes for unencrypted PKCS#8
48
+ const publicKeyBytes = pkcs8.slice(-32);
28
49
 
29
- // Ed25519 multicodec prefix (0xed01) + public key
30
- const multicodecBytes = new Uint8Array(2 + pubKeyBytes.length);
31
- multicodecBytes[0] = 0xed;
32
- multicodecBytes[1] = 0x01;
33
- multicodecBytes.set(pubKeyBytes, 2);
50
+ // Create did:key from public key
51
+ // Ed25519 multicodec prefix is 0xed01
52
+ const multicodecPrefix = new Uint8Array([0xed, 0x01]);
53
+ const multicodecKey = new Uint8Array(multicodecPrefix.length + publicKeyBytes.length);
54
+ multicodecKey.set(multicodecPrefix);
55
+ multicodecKey.set(publicKeyBytes, multicodecPrefix.length);
34
56
 
35
- // Base58 encode with 'z' prefix for multibase
36
- signingKey = 'z' + base58Encode(multicodecBytes);
57
+ const didKey = 'did:key:z' + uint8arrays.toString(multicodecKey, 'base58btc');
58
+
59
+ // Get current PLC data to preserve rotation keys
60
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
61
+ const plcResponse = await fetch(`https://plc.directory/${did}/data`);
62
+
63
+ let rotationKeys: string[] = [];
64
+ if (plcResponse.ok) {
65
+ const plcData = await plcResponse.json() as { rotationKeys?: string[] };
66
+ rotationKeys = plcData.rotationKeys || [];
37
67
  }
38
68
 
39
- return new Response(
40
- JSON.stringify({
41
- did,
42
- handle,
43
- pds: `https://${hostname}`,
44
- signingKey,
45
- alsoKnownAs: [`at://${handle}`],
46
- verificationMethods: signingKey ? {
47
- atproto: signingKey
48
- } : undefined,
49
- services: {
50
- atproto_pds: {
51
- type: 'AtprotoPersonalDataServer',
52
- endpoint: `https://${hostname}`
53
- }
69
+ const credentials = {
70
+ rotationKeys,
71
+ alsoKnownAs: [`at://${handle}`],
72
+ verificationMethods: {
73
+ atproto: didKey
74
+ },
75
+ services: {
76
+ atproto_pds: {
77
+ type: 'AtprotoPersonalDataServer',
78
+ endpoint: `https://${hostname}`
54
79
  }
55
- }),
80
+ }
81
+ };
82
+
83
+ return new Response(
84
+ JSON.stringify(credentials),
56
85
  { status: 200, headers: { 'Content-Type': 'application/json' } }
57
86
  );
58
87
  } catch (error: any) {
88
+ console.error('Get recommended credentials error:', error);
59
89
  return new Response(
60
90
  JSON.stringify({
61
91
  error: 'InternalServerError',
62
- message: error.message || 'Failed to get DID credentials'
92
+ message: error.message || 'Failed to get recommended credentials'
63
93
  }),
64
94
  { status: 500, headers: { 'Content-Type': 'application/json' } }
65
95
  );
66
96
  }
67
- }
68
-
69
- /**
70
- * Base58 encode (Bitcoin alphabet)
71
- */
72
- function base58Encode(bytes: Uint8Array): string {
73
- const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
74
-
75
- // Convert bytes to bigint
76
- let num = 0n;
77
- for (const byte of bytes) {
78
- num = num * 256n + BigInt(byte);
79
- }
80
-
81
- // Convert to base58
82
- let result = '';
83
- while (num > 0n) {
84
- const remainder = Number(num % 58n);
85
- result = ALPHABET[remainder] + result;
86
- num = num / 58n;
87
- }
88
-
89
- // Add leading '1's for leading zero bytes
90
- for (const byte of bytes) {
91
- if (byte === 0) {
92
- result = '1' + result;
93
- } else {
94
- break;
95
- }
96
- }
97
-
98
- return result;
99
97
  }
@@ -0,0 +1,24 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.identity.requestPlcOperationSignature
8
+ *
9
+ * Single-user PDS instances typically control the PLC rotation key directly,
10
+ * so the email-based 2FA flow used by the public PDS is unnecessary. Clients
11
+ * (like the Indigo goat CLI) still invoke this endpoint prior to signing a PLC
12
+ * operation, so we acknowledge the request and report success without
13
+ * triggering any side effects.
14
+ */
15
+ export async function POST({ locals, request }: APIContext) {
16
+ const { env } = locals.runtime;
17
+
18
+ if (!(await isAuthorized(request, env))) {
19
+ return unauthorized();
20
+ }
21
+
22
+ return new Response(null, { status: 200 });
23
+ }
24
+
@@ -0,0 +1,127 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { resolveSecret } from '../../lib/secrets';
4
+
5
+ export const prerender = false;
6
+
7
+ /**
8
+ * com.atproto.identity.signPlcOperation
9
+ *
10
+ * Signs a PLC update operation with the server's PLC rotation key and returns it
11
+ * to the caller. This endpoint mirrors the behavior of a PDS that controls the
12
+ * PLC rotation key directly (single-user deployments), so the email challenge
13
+ * token is accepted but not enforced here.
14
+ */
15
+ export async function POST({ locals, request }: APIContext) {
16
+ const { env } = locals.runtime;
17
+
18
+ if (!(await isAuthorized(request, env))) return unauthorized();
19
+
20
+ try {
21
+ const body = await request.json() as {
22
+ token?: string;
23
+ rotationKeys?: string[];
24
+ alsoKnownAs?: string[];
25
+ verificationMethods?: Record<string, string>;
26
+ services?: Record<string, { type: string; endpoint: string }>;
27
+ };
28
+
29
+ // NOTE: For single-user PDS we don't enforce email token checks.
30
+ if (!body || typeof body !== 'object') {
31
+ return jsonErr(400, 'InvalidRequest', 'Malformed JSON body');
32
+ }
33
+
34
+ const did = (await resolveSecret(env.PDS_DID)) ?? '';
35
+ if (!did || !did.startsWith('did:')) {
36
+ return jsonErr(400, 'InvalidRequest', 'PDS_DID is not configured');
37
+ }
38
+
39
+ // Load PLC rotation key (hex-encoded secp256k1 private key).
40
+ // MUST be the rotation key currently present in the PLC document.
41
+ const privHex = ((await resolveSecret(env.PDS_PLC_ROTATION_KEY as any)) || '').trim();
42
+ if (!privHex) {
43
+ return jsonErr(500, 'ServerMisconfigured', 'PDS_PLC_ROTATION_KEY is not configured');
44
+ }
45
+ // Lazy-load deps compatible with Workers runtime
46
+ const { Secp256k1Keypair } = await import('@atproto/crypto');
47
+ const dagCbor: any = await import('@ipld/dag-cbor');
48
+ const { sha256 } = await import('multiformats/hashes/sha2');
49
+ const { CID } = await import('multiformats/cid');
50
+ const u8a: any = await import('uint8arrays');
51
+
52
+ const signer = await Secp256k1Keypair.import(privHex);
53
+
54
+ // Fetch last op for prev CID
55
+ const lastRes = await fetch(`https://plc.directory/${encodeURIComponent(did)}/log/last`);
56
+ if (!lastRes.ok) {
57
+ const text = await lastRes.text();
58
+ return jsonErr(lastRes.status, 'PlcFetchFailed', `Failed to fetch last op: ${text}`);
59
+ }
60
+ const lastOp = await lastRes.json();
61
+ if ((lastOp as any)?.type === 'plc_tombstone') {
62
+ return jsonErr(400, 'DidTombstoned', 'DID is tombstoned');
63
+ }
64
+ const lastOpCbor = dagCbor.encode(lastOp);
65
+ const mh = await sha256.digest(lastOpCbor);
66
+ const prevCid = CID.createV1(dagCbor.code, mh);
67
+
68
+ // Fetch current document data as defaults and to verify rotation key
69
+ const dataRes = await fetch(`https://plc.directory/${encodeURIComponent(did)}/data`);
70
+ if (!dataRes.ok) {
71
+ const text = await dataRes.text();
72
+ return jsonErr(dataRes.status, 'PlcFetchFailed', `Failed to fetch document data: ${text}`);
73
+ }
74
+ const doc = (await dataRes.json()) as any;
75
+
76
+ const rotationKeys = body.rotationKeys ?? doc.rotationKeys ?? [];
77
+ const alsoKnownAs = body.alsoKnownAs ?? doc.alsoKnownAs ?? [];
78
+ const verificationMethods = body.verificationMethods ?? doc.verificationMethods ?? {};
79
+ const services = body.services ?? doc.services ?? {};
80
+
81
+ if (!services.atproto_pds || typeof services.atproto_pds !== 'object') {
82
+ return jsonErr(400, 'InvalidRequest', 'Missing atproto_pds service in PLC operation');
83
+ }
84
+ if (!services.atproto_pds.type) {
85
+ services.atproto_pds.type = 'AtprotoPersonalDataServer';
86
+ }
87
+
88
+ const unsignedOp = {
89
+ type: 'plc_operation',
90
+ rotationKeys,
91
+ verificationMethods,
92
+ alsoKnownAs,
93
+ services,
94
+ prev: prevCid.toString(),
95
+ } as Record<string, unknown>;
96
+
97
+ const bytes = dagCbor.encode(unsignedOp);
98
+ const sig = await signer.sign(bytes);
99
+ const sigB64 = (u8a.toString as any)(sig, 'base64url');
100
+ const operation = { ...unsignedOp, sig: sigB64 };
101
+
102
+ // sanity: ensure our configured rotation key is included
103
+ const signerDid = (await (await import('@atproto/crypto')).Secp256k1Keypair.import(privHex)).did();
104
+ if (!rotationKeys.includes(signerDid)) {
105
+ return jsonErr(
106
+ 400,
107
+ 'RotationKeyMismatch',
108
+ `Configured PDS_PLC_ROTATION_KEY (${signerDid}) is not present in PLC rotationKeys. Update PLC or your configuration.`,
109
+ );
110
+ }
111
+
112
+ return new Response(JSON.stringify({ operation }), {
113
+ status: 200,
114
+ headers: { 'Content-Type': 'application/json' },
115
+ });
116
+ } catch (error: any) {
117
+ console.error('signPlcOperation error:', error);
118
+ return jsonErr(500, 'InternalServerError', error?.message || 'Failed to sign PLC operation');
119
+ }
120
+ }
121
+
122
+ function jsonErr(status: number, error: string, message: string) {
123
+ return new Response(
124
+ JSON.stringify({ error, message }),
125
+ { status, headers: { 'Content-Type': 'application/json' } }
126
+ );
127
+ }
@@ -0,0 +1,91 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { resolveSecret } from '../../lib/secrets';
4
+
5
+ export const prerender = false;
6
+
7
+ /**
8
+ * com.atproto.identity.submitPlcOperation
9
+ *
10
+ * Submits a signed PLC operation to the PLC directory.
11
+ * This is a proxy endpoint that validates the operation is for the current account
12
+ * before submitting it to plc.directory.
13
+ */
14
+ export async function POST({ locals, request }: APIContext) {
15
+ const { env } = locals.runtime;
16
+
17
+ if (!(await isAuthorized(request, env))) return unauthorized();
18
+
19
+ try {
20
+ const body = await request.json() as { operation?: any };
21
+ const { operation } = body;
22
+
23
+ if (!operation) {
24
+ return new Response(
25
+ JSON.stringify({
26
+ error: 'InvalidRequest',
27
+ message: 'Missing operation in request body'
28
+ }),
29
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
30
+ );
31
+ }
32
+
33
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
34
+
35
+ console.log('Submitting PLC operation:', {
36
+ did,
37
+ operationType: operation.type,
38
+ hasSig: !!operation.sig,
39
+ prev: operation.prev
40
+ });
41
+
42
+ // Submit to PLC directory
43
+ const plcResponse = await fetch(`https://plc.directory/${did}`, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/json'
47
+ },
48
+ body: JSON.stringify(operation)
49
+ });
50
+
51
+ const responseHeaders: Record<string, string> = {};
52
+ plcResponse.headers.forEach((value, key) => {
53
+ responseHeaders[key] = value;
54
+ });
55
+
56
+ console.log('PLC response:', {
57
+ status: plcResponse.status,
58
+ statusText: plcResponse.statusText,
59
+ headers: responseHeaders
60
+ });
61
+
62
+ if (!plcResponse.ok) {
63
+ const errorText = await plcResponse.text();
64
+ console.error('PLC directory error:', errorText);
65
+ return new Response(
66
+ JSON.stringify({
67
+ error: 'PlcOperationFailed',
68
+ message: `PLC directory rejected operation (${plcResponse.status}): ${errorText}`
69
+ }),
70
+ { status: plcResponse.status, headers: { 'Content-Type': 'application/json' } }
71
+ );
72
+ }
73
+
74
+ const plcResult = await plcResponse.text();
75
+ console.log('PLC submission successful:', plcResult);
76
+
77
+ return new Response(
78
+ JSON.stringify({ success: true }),
79
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
80
+ );
81
+ } catch (error: any) {
82
+ console.error('Submit PLC operation error:', error);
83
+ return new Response(
84
+ JSON.stringify({
85
+ error: 'InternalServerError',
86
+ message: error.message || 'Failed to submit PLC operation'
87
+ }),
88
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
89
+ );
90
+ }
91
+ }
@@ -4,6 +4,7 @@ import { checkRate } from '../../lib/ratelimit';
4
4
  import { isAllowedMime } from '../../lib/util';
5
5
  import { R2BlobStore } from '../../services/r2-blob-store';
6
6
  import { putBlobRef, checkBlobQuota, updateBlobQuota, isAccountActive } from '../../db/dal';
7
+ import { resolveSecret } from '../../lib/secrets';
7
8
 
8
9
  export const prerender = false;
9
10
 
@@ -12,7 +13,7 @@ export async function POST({ locals, request }: APIContext) {
12
13
  if (!(await isAuthorized(request, env))) return unauthorized();
13
14
 
14
15
  // Get DID from environment (single-user PDS)
15
- const did = env.PDS_DID ?? 'did:example:single-user';
16
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
16
17
 
17
18
  // Check if account is active
18
19
  const active = await isAccountActive(env, did);
@@ -31,7 +32,10 @@ export async function POST({ locals, request }: APIContext) {
31
32
 
32
33
  const buf = await request.arrayBuffer();
33
34
  const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
34
- if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
35
+
36
+ // Skip MIME type validation during migration - accept all types
37
+ // Uncomment the line below to re-enable MIME type restrictions after migration
38
+ // if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
35
39
 
36
40
  // Check quota before upload
37
41
  const canUpload = await checkBlobQuota(env, did, buf.byteLength);
@@ -1,9 +1,12 @@
1
1
  import type { APIContext } from 'astro';
2
- import { signJwt } from '../../lib/jwt';
3
2
  import { readJson } from '../../lib/util';
4
3
  import { drizzle } from 'drizzle-orm/d1';
5
4
  import { login_attempts } from '../../db/schema';
6
5
  import { eq } from 'drizzle-orm';
6
+ import { createAccount, getAccountByIdentifier, storeRefreshToken } from '../../db/account';
7
+ import { hashPassword, verifyPassword } from '../../lib/password';
8
+ import { issueSessionTokens } from '../../lib/session-tokens';
9
+ import { getRuntimeString } from '../../lib/secrets';
7
10
 
8
11
  export const prerender = false;
9
12
 
@@ -30,8 +33,27 @@ export async function POST({ locals, request }: APIContext) {
30
33
  );
31
34
  }
32
35
 
33
- const { identifier, password } = await readJson(request).catch(() => ({ identifier: '', password: '' }));
34
- const ok = !!password && password === (env.USER_PASSWORD ?? 'changeme');
36
+ const body = await readJson(request).catch(() => ({ identifier: '', password: '' }));
37
+ const identifier = typeof body.identifier === 'string' && body.identifier ? body.identifier : (await getRuntimeString(env, 'PDS_HANDLE', 'user.example'));
38
+ const password = typeof body.password === 'string' ? body.password : '';
39
+
40
+ let account = await getAccountByIdentifier(env, identifier ?? '');
41
+ if (!account) {
42
+ const fallbackPassword = await getRuntimeString(env, 'USER_PASSWORD', '');
43
+ if (fallbackPassword) {
44
+ const fallbackDid = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
45
+ const fallbackHandle = await getRuntimeString(env, 'PDS_HANDLE', identifier);
46
+ const hashed = await hashPassword(fallbackPassword);
47
+ await createAccount(env, {
48
+ did: fallbackDid,
49
+ handle: fallbackHandle,
50
+ passwordScrypt: hashed,
51
+ });
52
+ account = await getAccountByIdentifier(env, identifier ?? '');
53
+ }
54
+ }
55
+ const passwordHash = account?.passwordScrypt ?? null;
56
+ const ok = !!password && !!account && (await verifyPassword(password, passwordHash));
35
57
 
36
58
  if (!ok) {
37
59
  // Track failed attempt
@@ -80,11 +102,17 @@ export async function POST({ locals, request }: APIContext) {
80
102
  await db.delete(login_attempts).where(eq(login_attempts.ip, clientIp)).run();
81
103
  }
82
104
 
83
- const did = env.PDS_DID ?? 'did:example:single-user';
84
- const handle = env.PDS_HANDLE ?? identifier ?? 'user.example';
85
- const jti = crypto.randomUUID();
86
- const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
87
- const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti }, 'refresh');
105
+ const did = account?.did ?? (await getRuntimeString(env, 'PDS_DID', 'did:example:single-user'));
106
+ const handle = account?.handle ?? (await getRuntimeString(env, 'PDS_HANDLE', identifier ?? 'user.example'));
107
+
108
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, did);
109
+
110
+ await storeRefreshToken(env, {
111
+ id: refreshPayload.jti,
112
+ did,
113
+ expiresAt: refreshExpiry,
114
+ appPasswordName: null,
115
+ });
88
116
 
89
117
  return new Response(JSON.stringify({ did, handle, accessJwt, refreshJwt }), {
90
118
  headers: { 'Content-Type': 'application/json' },
@@ -1,15 +1,48 @@
1
1
  import type { APIContext } from 'astro';
2
+ import { getAppViewConfig } from '../../lib/appview';
2
3
 
3
4
  export const prerender = false;
4
5
 
5
6
  export function GET({ locals }: APIContext) {
6
7
  const { env } = locals.runtime;
8
+ const did = typeof env.PDS_DID === 'string' ? env.PDS_DID : 'did:example:single-user';
9
+ const availableUserDomains: string[] = [];
10
+
11
+ const links = typeof env.PDS_LINK_PRIVACY === 'string' || typeof env.PDS_LINK_TOS === 'string'
12
+ ? {
13
+ $type: 'com.atproto.server.describeServer#links' as const,
14
+ ...(typeof env.PDS_LINK_PRIVACY === 'string' ? { privacyPolicy: env.PDS_LINK_PRIVACY } : {}),
15
+ ...(typeof env.PDS_LINK_TOS === 'string' ? { termsOfService: env.PDS_LINK_TOS } : {}),
16
+ }
17
+ : undefined;
18
+
19
+ const contact = typeof env.PDS_CONTACT_EMAIL === 'string'
20
+ ? {
21
+ $type: 'com.atproto.server.describeServer#contact' as const,
22
+ email: env.PDS_CONTACT_EMAIL,
23
+ }
24
+ : undefined;
25
+
26
+ const appView = getAppViewConfig(env);
27
+
7
28
  const body = {
8
- version: 'experimental',
9
- did: env.PDS_DID ?? null,
10
- handle: env.PDS_HANDLE ?? null,
29
+ did,
30
+ availableUserDomains,
11
31
  inviteCodeRequired: false,
12
- links: {},
32
+ phoneVerificationRequired: false,
33
+ ...(links ? { links } : {}),
34
+ ...(contact ? { contact } : {}),
35
+ ...(appView
36
+ ? {
37
+ services: {
38
+ appview: {
39
+ $type: 'com.atproto.server.describeServer#service' as const,
40
+ serviceEndpoint: appView.url,
41
+ did: appView.did,
42
+ },
43
+ },
44
+ }
45
+ : {}),
13
46
  };
14
47
  return new Response(JSON.stringify(body), {
15
48
  headers: { 'Content-Type': 'application/json' },