@alteran/astro 0.7.6 → 0.8.1

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 (75) hide show
  1. package/README.md +25 -25
  2. package/migrations/0010_eminent_klaw.sql +37 -0
  3. package/migrations/0011_chief_darwin.sql +31 -0
  4. package/migrations/0012_backfill_blob_usage.sql +39 -0
  5. package/migrations/meta/0010_snapshot.json +790 -0
  6. package/migrations/meta/0011_snapshot.json +813 -0
  7. package/migrations/meta/_journal.json +22 -1
  8. package/package.json +24 -41
  9. package/src/db/blob.ts +323 -0
  10. package/src/db/dal.ts +224 -78
  11. package/src/db/repo.ts +205 -25
  12. package/src/db/schema.ts +14 -5
  13. package/src/handlers/debug.ts +4 -3
  14. package/src/lib/appview/auth-policy.ts +7 -24
  15. package/src/lib/appview/proxy.ts +56 -23
  16. package/src/lib/appview/types.ts +1 -6
  17. package/src/lib/auth-scope.ts +399 -0
  18. package/src/lib/auth.ts +40 -39
  19. package/src/lib/commit.ts +37 -15
  20. package/src/lib/did-document.ts +4 -5
  21. package/src/lib/jwt.ts +3 -1
  22. package/src/lib/mime.ts +9 -0
  23. package/src/lib/oauth/observability.ts +53 -12
  24. package/src/lib/oauth/resource.ts +49 -0
  25. package/src/lib/preference-policy.ts +45 -0
  26. package/src/lib/preferences.ts +0 -4
  27. package/src/lib/public-host.ts +127 -0
  28. package/src/lib/ratelimit.ts +37 -12
  29. package/src/lib/relay.ts +7 -27
  30. package/src/lib/repo-write-blob-constraints.ts +141 -0
  31. package/src/lib/repo-write-data.ts +195 -0
  32. package/src/lib/repo-write-error.ts +46 -0
  33. package/src/lib/repo-write-validation.ts +463 -0
  34. package/src/lib/session-tokens.ts +22 -5
  35. package/src/lib/unsupported-routes.ts +32 -0
  36. package/src/lib/util.ts +57 -2
  37. package/src/pages/.well-known/atproto-did.ts +15 -3
  38. package/src/pages/.well-known/did.json.ts +13 -7
  39. package/src/pages/debug/db/bootstrap.ts +4 -3
  40. package/src/pages/debug/gc/blobs.ts +11 -8
  41. package/src/pages/debug/record.ts +11 -0
  42. package/src/pages/oauth/token.ts +78 -33
  43. package/src/pages/xrpc/[...nsid].ts +17 -9
  44. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  45. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  46. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  47. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  48. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  49. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  50. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  51. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  52. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  53. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  54. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  55. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  56. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  57. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  58. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  59. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  60. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  61. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  62. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  63. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  64. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  65. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  66. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  67. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  68. package/src/services/car.ts +13 -0
  69. package/src/services/repo/apply-prepared-writes.ts +185 -0
  70. package/src/services/repo/blob-refs.ts +48 -0
  71. package/src/services/repo/blockstore-ops.ts +59 -17
  72. package/src/services/repo/list-blobs.ts +43 -0
  73. package/src/services/repo-manager.ts +221 -78
  74. package/src/worker/runtime.ts +1 -1
  75. package/src/worker/sequencer/upgrade.ts +4 -1
@@ -2,8 +2,13 @@ import type { APIContext } from 'astro';
2
2
  import { errorMessage } from '../../lib/errors';
3
3
  import { withCache, CACHE_CONFIGS } from '../../lib/cache';
4
4
  import { resolveSecret } from '../../lib/secrets';
5
- import { Secp256k1Keypair } from '@atproto/crypto';
6
- import { formatMultikey } from '@atproto/crypto/dist/did';
5
+ import { Secp256k1Keypair, formatMultikey } from '@atproto/crypto';
6
+ import {
7
+ canonicalPdsOrigin,
8
+ configuredDid,
9
+ configuredHandle,
10
+ validAtprotoHandle,
11
+ } from '../../lib/public-host';
7
12
 
8
13
  export const prerender = false;
9
14
 
@@ -13,9 +18,10 @@ export async function GET({ locals, request }: APIContext) {
13
18
  return withCache(
14
19
  request,
15
20
  async () => {
16
- const did = env.PDS_DID ?? 'did:example:single-user';
17
- const handle = env.PDS_HANDLE ?? 'user.example.com';
18
- const hostname = env.PDS_HOSTNAME ?? new URL(request.url).hostname;
21
+ const did = await configuredDid(env);
22
+ const handle = await configuredHandle(env);
23
+ const claimedHandle = validAtprotoHandle(handle);
24
+ const serviceEndpoint = await canonicalPdsOrigin(env);
19
25
 
20
26
  let publicKeyMultibase: string | undefined;
21
27
  let signingKeyError: string | undefined;
@@ -59,13 +65,13 @@ export async function GET({ locals, request }: APIContext) {
59
65
  'https://w3id.org/security/multikey/v1',
60
66
  ],
61
67
  id: did,
62
- alsoKnownAs: [`at://${handle}`],
68
+ alsoKnownAs: claimedHandle ? [`at://${claimedHandle}`] : [],
63
69
  verificationMethod: verificationMethods,
64
70
  service: [
65
71
  {
66
72
  id: `${did}#atproto_pds`,
67
73
  type: 'AtprotoPersonalDataServer',
68
- serviceEndpoint: `https://${hostname}`,
74
+ serviceEndpoint,
69
75
  },
70
76
  ],
71
77
  };
@@ -13,11 +13,12 @@ export async function POST({ locals }: APIContext) {
13
13
  }
14
14
  const db = env.ALTERAN_DB;
15
15
  await db.exec("CREATE TABLE IF NOT EXISTS record (uri TEXT PRIMARY KEY, cid TEXT NOT NULL, json TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')));");
16
- await db.exec("CREATE TABLE IF NOT EXISTS blob (cid TEXT PRIMARY KEY, key TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL);");
17
- await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (record_uri TEXT NOT NULL, key TEXT NOT NULL);");
16
+ await db.exec("CREATE TABLE IF NOT EXISTS blob (cid TEXT NOT NULL, did TEXT NOT NULL, key TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, uploaded_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (did, cid));");
17
+ await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (did TEXT NOT NULL, record_uri TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY (did, record_uri, key));");
18
18
  await db.exec("CREATE TABLE IF NOT EXISTS repo_root (did TEXT PRIMARY KEY, commit_cid TEXT NOT NULL, rev INTEGER NOT NULL);");
19
19
  // Indexes
20
20
  await db.exec("CREATE INDEX IF NOT EXISTS record_cid_idx ON record(cid);");
21
- await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(record_uri);");
21
+ await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(did, record_uri);");
22
+ await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_did_key_idx ON blob_usage(did, key);");
22
23
  return new Response('ok');
23
24
  }
@@ -1,16 +1,19 @@
1
1
  import type { APIContext } from 'astro';
2
- import { listOrphanBlobKeys, deleteBlobByKey } from '../../../db/dal';
2
+ import type { Env } from '../../../env';
3
+ import { deleteUnreferencedBlobKeys, listOrphanBlobRefs } from '../../../db/dal';
3
4
 
4
5
  export const prerender = false;
5
6
 
7
+ function isLocalDebugAllowed(env: Env): boolean {
8
+ const envName = (env as any).ENVIRONMENT as string | undefined;
9
+ const host = env.PDS_HOSTNAME as string | undefined;
10
+ return envName !== 'production' && (!host || host.includes('localhost') || host.startsWith('127.') || host === '::1');
11
+ }
12
+
6
13
  export async function POST({ locals }: APIContext) {
7
14
  const { env } = locals.runtime;
8
- const keys = await listOrphanBlobKeys(env);
9
- let deleted = 0;
10
- for (const key of keys) {
11
- await env.ALTERAN_BLOBS.delete(key).catch(() => {});
12
- await deleteBlobByKey(env, key);
13
- deleted++;
14
- }
15
+ if (!isLocalDebugAllowed(env)) return new Response('Not Found', { status: 404 });
16
+
17
+ const deleted = await deleteUnreferencedBlobKeys(env, await listOrphanBlobRefs(env));
15
18
  return new Response(JSON.stringify({ deleted }), { headers: { 'Content-Type': 'application/json' } });
16
19
  }
@@ -1,10 +1,19 @@
1
1
  import type { APIContext } from 'astro';
2
+ import type { Env } from '../../env';
2
3
  import { getRecord as dalGetRecord, putRecord as dalPutRecord } from '../../db/dal';
3
4
 
4
5
  export const prerender = false;
5
6
 
7
+ function isLocalDebugAllowed(env: Env): boolean {
8
+ const envName = (env as any).ENVIRONMENT as string | undefined;
9
+ const host = env.PDS_HOSTNAME as string | undefined;
10
+ return envName !== 'production' && (!host || host.includes('localhost') || host.startsWith('127.') || host === '::1');
11
+ }
12
+
6
13
  export async function GET({ locals, request }: APIContext) {
7
14
  const { env } = locals.runtime;
15
+ if (!isLocalDebugAllowed(env)) return new Response('Not Found', { status: 404 });
16
+
8
17
  const url = new URL(request.url);
9
18
  const uri = url.searchParams.get('uri');
10
19
  if (!uri) return new Response('missing uri', { status: 400 });
@@ -17,6 +26,8 @@ export async function GET({ locals, request }: APIContext) {
17
26
 
18
27
  export async function POST({ locals, request }: APIContext) {
19
28
  const { env } = locals.runtime;
29
+ if (!isLocalDebugAllowed(env)) return new Response('Not Found', { status: 404 });
30
+
20
31
  const body = (await request.json()) as { uri?: string; json?: unknown };
21
32
  const uri = body.uri;
22
33
  if (!uri) return new Response('missing uri', { status: 400 });
@@ -15,15 +15,46 @@ import {
15
15
  storeRefreshToken,
16
16
  updateOAuthSessionCurrent,
17
17
  } from '../../db/account';
18
+ import {
19
+ logOauth,
20
+ summarizeTokenForm,
21
+ type OauthTokenFormSummary,
22
+ type OauthTokenStage,
23
+ } from '../../lib/oauth/observability';
18
24
 
19
25
  export const prerender = false;
20
26
 
21
27
  export async function POST({ locals, request }: APIContext) {
22
28
  const { env } = locals.runtime;
29
+ const requestId = (locals as { requestId?: string }).requestId ?? null;
30
+
31
+ let formSummary: OauthTokenFormSummary | null = null;
32
+ let clientId: string | null = null;
33
+
34
+ const log = (
35
+ stage: OauthTokenStage,
36
+ extra: { error?: unknown; outcome?: 'ok' | 'error' } = {},
37
+ ) =>
38
+ logOauth(request, {
39
+ endpoint: 'token',
40
+ stage,
41
+ outcome: extra.outcome ?? (extra.error !== undefined ? 'error' : 'ok'),
42
+ requestId,
43
+ error: extra.error,
44
+ clientId,
45
+ form: formSummary as Record<string, unknown> | null,
46
+ });
47
+
48
+ const fail = (stage: OauthTokenStage, code: string, desc: string): Response => {
49
+ log(stage, { error: new Error(desc) });
50
+ return jsonError(code, desc);
51
+ };
23
52
 
24
53
  try {
25
54
  const ver = await verifyDpop(env, request, { consumeJti: false, requireNonce: false });
26
55
  const form = new URLSearchParams(await request.text());
56
+ formSummary = summarizeTokenForm(form);
57
+ clientId = form.get('client_id') || null;
27
58
  const grant_type = form.get('grant_type') || '';
28
59
  const issuer = publicPdsOrigin(env, request);
29
60
 
@@ -33,24 +64,28 @@ export async function POST({ locals, request }: APIContext) {
33
64
  const redirect_uri = form.get('redirect_uri') || '';
34
65
  const code_verifier = form.get('code_verifier') || '';
35
66
  if (!code || !client_id || !redirect_uri || !code_verifier) {
36
- return jsonError('invalid_request', 'Missing parameters');
67
+ return fail('auth_code', 'invalid_request', 'Missing parameters');
37
68
  }
38
69
 
39
70
  const rec = await consumeCode(env, code);
40
- if (!rec) return jsonError('invalid_grant', 'Invalid or used code');
41
- if (rec.client_id !== client_id) return jsonError('invalid_grant', 'client_id mismatch');
42
- if (rec.redirect_uri !== redirect_uri) return jsonError('invalid_grant', 'redirect_uri mismatch');
71
+ if (!rec) return fail('auth_code', 'invalid_grant', 'Invalid or used code');
72
+ if (rec.client_id !== client_id) return fail('auth_code', 'invalid_grant', 'client_id mismatch');
73
+ if (rec.redirect_uri !== redirect_uri) return fail('auth_code', 'invalid_grant', 'redirect_uri mismatch');
43
74
 
44
75
  const expected = await sha256b64url(code_verifier);
45
- if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
46
- if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
47
-
48
- await requireStoredClientAuthentication(env, client_id, issuer, form, {
49
- method: rec.clientAuthMethod,
50
- keyId: rec.clientAuthKeyId ?? null,
51
- }).catch((error) => {
52
- throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
53
- });
76
+ if (expected !== rec.code_challenge) return fail('auth_code', 'invalid_grant', 'PKCE verification failed');
77
+ if (ver.jkt !== rec.dpopJkt) return fail('auth_code', 'invalid_dpop', 'DPoP key mismatch');
78
+
79
+ try {
80
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
81
+ method: rec.clientAuthMethod,
82
+ keyId: rec.clientAuthKeyId ?? null,
83
+ });
84
+ } catch (error) {
85
+ const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
86
+ log('client_auth', { error: wrapped });
87
+ throw wrapped;
88
+ }
54
89
  await consumeDpopVerificationJti(env, ver);
55
90
 
56
91
  const sessionId = crypto.randomUUID().replace(/-/g, '');
@@ -91,6 +126,7 @@ export async function POST({ locals, request }: APIContext) {
91
126
  });
92
127
 
93
128
  const expires_in = accessExpiresIn(accessPayload);
129
+ log('success', { outcome: 'ok' });
94
130
  return tokenResponse({
95
131
  access_token: accessJwt,
96
132
  token_type: 'DPoP',
@@ -105,41 +141,45 @@ export async function POST({ locals, request }: APIContext) {
105
141
  const refresh_token = form.get('refresh_token') || '';
106
142
  const client_id = form.get('client_id') || '';
107
143
  if (!refresh_token || !client_id) {
108
- return jsonError('invalid_request', 'Missing refresh_token or client_id');
144
+ return fail('refresh_token', 'invalid_request', 'Missing refresh_token or client_id');
109
145
  }
110
146
 
111
147
  const verification = await verifyRefreshToken(env, refresh_token).catch(() => null);
112
- if (!verification || !verification.decoded) return jsonError('invalid_grant', 'Invalid refresh token');
148
+ if (!verification || !verification.decoded) return fail('refresh_token', 'invalid_grant', 'Invalid refresh token');
113
149
  const nowSec = Math.floor(Date.now() / 1000);
114
- if (verification.decoded.exp <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
150
+ if (verification.decoded.exp <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
115
151
 
116
152
  const stored = await getRefreshToken(env, verification.decoded.jti);
117
153
  if (!stored || stored.tokenKind !== 'oauth' || !stored.oauthSessionId) {
118
- return jsonError('invalid_grant', 'Refresh token revoked');
154
+ return fail('refresh_token', 'invalid_grant', 'Refresh token revoked');
119
155
  }
120
156
  const session = await getOAuthSession(env, stored.oauthSessionId);
121
157
  if (!session || session.revokedAt || session.expiresAt <= nowSec) {
122
- return jsonError('invalid_grant', 'OAuth session revoked');
158
+ return fail('refresh_token', 'invalid_grant', 'OAuth session revoked');
123
159
  }
124
160
 
125
161
  if (stored.revokedAt || stored.nextId || stored.id !== session.currentRefreshTokenId) {
126
162
  await revokeOAuthSession(env, session.id, nowSec);
127
- return jsonError('invalid_grant', 'Refresh token replayed');
163
+ return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
128
164
  }
129
- if (stored.expiresAt <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
165
+ if (stored.expiresAt <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
130
166
  if (stored.did !== verification.decoded.sub || stored.did !== session.did) {
131
167
  await revokeOAuthSession(env, session.id, nowSec);
132
- return jsonError('invalid_grant', 'Subject mismatch');
168
+ return fail('refresh_token', 'invalid_grant', 'Subject mismatch');
169
+ }
170
+ if (client_id !== session.clientId) return fail('refresh_token', 'invalid_grant', 'client_id mismatch');
171
+ if (ver.jkt !== session.dpopJkt) return fail('refresh_token', 'invalid_dpop', 'DPoP key mismatch');
172
+
173
+ try {
174
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
175
+ method: session.clientAuthMethod,
176
+ keyId: session.clientAuthKeyId,
177
+ });
178
+ } catch (error) {
179
+ const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
180
+ log('client_auth', { error: wrapped });
181
+ throw wrapped;
133
182
  }
134
- if (client_id !== session.clientId) return jsonError('invalid_grant', 'client_id mismatch');
135
- if (ver.jkt !== session.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
136
-
137
- await requireStoredClientAuthentication(env, client_id, issuer, form, {
138
- method: session.clientAuthMethod,
139
- keyId: session.clientAuthKeyId,
140
- }).catch((error) => {
141
- throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
142
- });
143
183
  await consumeDpopVerificationJti(env, ver);
144
184
 
145
185
  const accessJti = crypto.randomUUID().replace(/-/g, '');
@@ -175,9 +215,10 @@ export async function POST({ locals, request }: APIContext) {
175
215
  });
176
216
  } catch {
177
217
  await revokeOAuthSession(env, session.id, nowSec);
178
- return jsonError('invalid_grant', 'Refresh token replayed');
218
+ return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
179
219
  }
180
220
 
221
+ log('success', { outcome: 'ok' });
181
222
  return tokenResponse({
182
223
  access_token: accessJwt,
183
224
  token_type: 'DPoP',
@@ -188,9 +229,13 @@ export async function POST({ locals, request }: APIContext) {
188
229
  }, await getAuthzNonce(env));
189
230
  }
190
231
 
191
- return jsonError('unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
232
+ return fail('unsupported_grant', 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
192
233
  } catch (e) {
193
- if (e instanceof DpopNonceError) return dpopErrorResponse(env, e);
234
+ if (e instanceof DpopNonceError) {
235
+ log('dpop', { error: e });
236
+ return dpopErrorResponse(env, e);
237
+ }
238
+ log('outer', { error: e });
194
239
  return jsonError('invalid_request', errorMessage(e) ?? 'Unknown error');
195
240
  }
196
241
  }
@@ -1,6 +1,10 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { proxyAppView } from '../../lib/appview';
3
3
  import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
4
+ import {
5
+ isSingleUserUnsupportedRoute,
6
+ unsupportedSingleUserRouteResponse,
7
+ } from '../../lib/unsupported-routes';
4
8
 
5
9
  export const prerender = false;
6
10
 
@@ -20,6 +24,19 @@ function nsidFromParams(params: Record<string, any>): string {
20
24
 
21
25
  async function handle({ locals, request, params }: APIContext): Promise<Response> {
22
26
  const { env } = locals.runtime;
27
+ const nsid = nsidFromParams(params).trim();
28
+ console.log('xrpc catchall invoked:', { nsid, url: request.url });
29
+ if (!nsid) {
30
+ return new Response(JSON.stringify({ error: 'NotFound' }), {
31
+ status: 404,
32
+ headers: { 'Content-Type': 'application/json' },
33
+ });
34
+ }
35
+
36
+ if (isSingleUserUnsupportedRoute(nsid)) {
37
+ return unsupportedSingleUserRouteResponse(nsid);
38
+ }
39
+
23
40
  let auth;
24
41
  try {
25
42
  auth = await authenticateRequest(request, env);
@@ -30,15 +47,6 @@ async function handle({ locals, request, params }: APIContext): Promise<Response
30
47
  throw error;
31
48
  }
32
49
 
33
- const nsid = nsidFromParams(params).trim();
34
- console.log('xrpc catchall invoked:', { nsid, url: request.url });
35
- if (!nsid) {
36
- return new Response(JSON.stringify({ error: 'NotFound' }), {
37
- status: 404,
38
- headers: { 'Content-Type': 'application/json' },
39
- });
40
- }
41
-
42
50
  if (!shouldProxy(nsid)) {
43
51
  return new Response(JSON.stringify({ error: 'NotImplemented' }), {
44
52
  status: 404,
@@ -1,13 +1,18 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessActorPreferences } from '../../lib/auth-scope';
4
+ import { preferencesForAccess } from '../../lib/preference-policy';
3
5
  import { getActorPreferences } from '../../lib/preferences';
4
6
 
5
7
  export const prerender = false;
6
8
 
7
9
  export async function GET({ locals, request }: APIContext) {
8
10
  const { env } = locals.runtime;
11
+ let auth: NonNullable<Awaited<ReturnType<typeof authenticateRequest>>>;
9
12
  try {
10
- if (!(await isAuthorized(request, env))) return unauthorized();
13
+ const verified = await authenticateRequest(request, env);
14
+ if (!verified || !canAccessActorPreferences(verified.access)) return unauthorized();
15
+ auth = verified;
11
16
  } catch (error) {
12
17
  const handled = await authErrorResponse(env, error);
13
18
  if (handled) return handled;
@@ -15,7 +20,8 @@ export async function GET({ locals, request }: APIContext) {
15
20
  }
16
21
 
17
22
  const { preferences } = await getActorPreferences(env);
18
- return new Response(JSON.stringify({ preferences: Array.isArray(preferences) ? preferences : [] }), {
23
+ const visible = preferencesForAccess(Array.isArray(preferences) ? preferences : [], auth.access);
24
+ return new Response(JSON.stringify({ preferences: visible }), {
19
25
  headers: { 'Content-Type': 'application/json' },
20
26
  });
21
27
  }
@@ -1,15 +1,20 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { errorCode, errorMessage } from '../../lib/errors';
3
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
4
+ import { canAccessActorPreferences } from '../../lib/auth-scope';
5
+ import { hasAppPasswordRestrictedPreferences, preferencesForWrite } from '../../lib/preference-policy';
4
6
  import { readJsonBounded } from '../../lib/util';
5
- import { setActorPreferences } from '../../lib/preferences';
7
+ import { getActorPreferences, setActorPreferences } from '../../lib/preferences';
6
8
 
7
9
  export const prerender = false;
8
10
 
9
11
  export async function POST({ locals, request }: APIContext) {
10
12
  const { env } = locals.runtime;
13
+ let auth: NonNullable<Awaited<ReturnType<typeof authenticateRequest>>>;
11
14
  try {
12
- if (!(await isAuthorized(request, env))) return unauthorized();
15
+ const verified = await authenticateRequest(request, env);
16
+ if (!verified || !canAccessActorPreferences(verified.access)) return unauthorized();
17
+ auth = verified;
13
18
  } catch (error) {
14
19
  const handled = await authErrorResponse(env, error);
15
20
  if (handled) return handled;
@@ -27,7 +32,15 @@ export async function POST({ locals, request }: APIContext) {
27
32
  }
28
33
 
29
34
  const preferences = Array.isArray(body?.preferences) ? body.preferences : [];
30
- await setActorPreferences(env, preferences);
35
+ if (hasAppPasswordRestrictedPreferences(preferences, auth.access)) {
36
+ return new Response(
37
+ JSON.stringify({ error: 'Forbidden', message: 'App passwords cannot update restricted preferences' }),
38
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
39
+ );
40
+ }
41
+ const existing = auth.access.isAppPassword ? (await getActorPreferences(env)).preferences : [];
42
+ const writable = preferencesForWrite(existing, preferences, auth.access);
43
+ await setActorPreferences(env, writable);
31
44
 
32
45
  return new Response(JSON.stringify({}), {
33
46
  headers: { 'Content-Type': 'application/json' },
@@ -1,12 +1,14 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canUseAppPasswordLevelAccess } from '../../lib/auth-scope';
3
4
 
4
5
  export const prerender = false;
5
6
 
6
7
  export async function GET({ locals, request }: APIContext) {
7
8
  const { env } = locals.runtime;
8
9
  try {
9
- if (!(await isAuthorized(request, env))) return unauthorized();
10
+ const auth = await authenticateRequest(request, env);
11
+ if (!auth || !canUseAppPasswordLevelAccess(auth.access)) return unauthorized();
10
12
  } catch (error) {
11
13
  const handled = await authErrorResponse(env, error);
12
14
  if (handled) return handled;
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessChat } from '../../lib/auth-scope';
3
4
  import { getPrimaryActor } from '../../lib/actor';
4
5
  import { listChatConvoLogs } from '../../lib/chat';
5
6
 
@@ -8,7 +9,8 @@ export const prerender = false;
8
9
  export async function GET({ locals, request }: APIContext) {
9
10
  const { env } = locals.runtime;
10
11
  try {
11
- if (!(await isAuthorized(request, env))) return unauthorized();
12
+ const auth = await authenticateRequest(request, env);
13
+ if (!auth || !canAccessChat(auth.access)) return unauthorized();
12
14
  } catch (error) {
13
15
  const handled = await authErrorResponse(env, error);
14
16
  if (handled) return handled;
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessChat } from '../../lib/auth-scope';
3
4
  import { listChatConvos } from '../../lib/chat';
4
5
  import { getPrimaryActor } from '../../lib/actor';
5
6
 
@@ -8,7 +9,8 @@ export const prerender = false;
8
9
  export async function GET({ locals, request }: APIContext) {
9
10
  const { env } = locals.runtime;
10
11
  try {
11
- if (!(await isAuthorized(request, env))) return unauthorized();
12
+ const auth = await authenticateRequest(request, env);
13
+ if (!auth || !canAccessChat(auth.access)) return unauthorized();
12
14
  } catch (error) {
13
15
  const handled = await authErrorResponse(env, error);
14
16
  if (handled) return handled;
@@ -1,6 +1,8 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessFullAccount } from '../../lib/auth-scope';
3
4
  import { resolveSecret } from '../../lib/secrets';
5
+ import { canonicalPdsOrigin, configuredHandle, validAtprotoHandle } from '../../lib/public-host';
4
6
 
5
7
  export const prerender = false;
6
8
 
@@ -15,7 +17,8 @@ export async function GET({ locals, request }: APIContext) {
15
17
  const { env } = locals.runtime;
16
18
 
17
19
  try {
18
- if (!(await isAuthorized(request, env))) return unauthorized();
20
+ const auth = await authenticateRequest(request, env);
21
+ if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
19
22
  } catch (error) {
20
23
  const handled = await authErrorResponse(env, error);
21
24
  if (handled) return handled;
@@ -23,8 +26,9 @@ export async function GET({ locals, request }: APIContext) {
23
26
  }
24
27
 
25
28
  try {
26
- const handle = (await resolveSecret(env.PDS_HANDLE)) ?? 'example.com';
27
- const hostname = env.PDS_HOSTNAME ?? handle;
29
+ const handle = await configuredHandle(env);
30
+ const claimedHandle = validAtprotoHandle(handle);
31
+ const serviceEndpoint = await canonicalPdsOrigin(env);
28
32
 
29
33
  // Always ES256K: derive did:key from the secp256k1 signing key
30
34
  let didKey: string | undefined;
@@ -70,12 +74,12 @@ export async function GET({ locals, request }: APIContext) {
70
74
 
71
75
  const credentials = {
72
76
  rotationKeys,
73
- alsoKnownAs: [`at://${handle}`],
77
+ alsoKnownAs: claimedHandle ? [`at://${claimedHandle}`] : [],
74
78
  verificationMethods: { atproto: didKey },
75
79
  services: {
76
80
  atproto_pds: {
77
81
  type: 'AtprotoPersonalDataServer',
78
- endpoint: `https://${hostname}`
82
+ endpoint: serviceEndpoint
79
83
  }
80
84
  }
81
85
  };
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessFullAccount } from '../../lib/auth-scope';
3
4
 
4
5
  export const prerender = false;
5
6
 
@@ -16,7 +17,8 @@ export async function POST({ locals, request }: APIContext) {
16
17
  const { env } = locals.runtime;
17
18
 
18
19
  try {
19
- if (!(await isAuthorized(request, env))) {
20
+ const auth = await authenticateRequest(request, env);
21
+ if (!auth || !canAccessFullAccount(auth.access)) {
20
22
  return unauthorized();
21
23
  }
22
24
  } catch (error) {
@@ -27,4 +29,3 @@ export async function POST({ locals, request }: APIContext) {
27
29
 
28
30
  return new Response(null, { status: 200 });
29
31
  }
30
-
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { getAppViewConfig } from '../../lib/appview';
3
+ import { configuredAtprotoHandle, configuredDid, validAtprotoHandle } from '../../lib/public-host';
3
4
 
4
5
  export const prerender = false;
5
6
 
@@ -11,8 +12,6 @@ export async function GET({ locals, url }: APIContext) {
11
12
  const { env } = locals.runtime;
12
13
 
13
14
  const handle = url.searchParams.get('handle');
14
- const configuredHandle = String(env.PDS_HANDLE || 'user.example.com');
15
- const did = String(env.PDS_DID || 'did:example:single-user');
16
15
 
17
16
  if (!handle) {
18
17
  return new Response(
@@ -21,10 +20,19 @@ export async function GET({ locals, url }: APIContext) {
21
20
  );
22
21
  }
23
22
 
23
+ const normalizedHandle = validAtprotoHandle(handle);
24
+ if (!normalizedHandle) {
25
+ return new Response(
26
+ JSON.stringify({ error: 'InvalidRequest', message: 'Unable to resolve handle' }),
27
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
28
+ );
29
+ }
30
+
24
31
  // Single-user PDS: resolve the local configured handle directly
25
- if (handle.toLowerCase() === configuredHandle.toLowerCase()) {
32
+ const configuredHandle = await configuredAtprotoHandle(env);
33
+ if (configuredHandle && normalizedHandle === configuredHandle) {
26
34
  return new Response(
27
- JSON.stringify({ did }),
35
+ JSON.stringify({ did: await configuredDid(env) }),
28
36
  {
29
37
  status: 200,
30
38
  headers: { 'Content-Type': 'application/json' },
@@ -38,7 +46,7 @@ export async function GET({ locals, url }: APIContext) {
38
46
  const app = getAppViewConfig(env);
39
47
  const base = app?.url || 'https://api.bsky.app';
40
48
  const upstream = new URL('/xrpc/com.atproto.identity.resolveHandle', base);
41
- upstream.searchParams.set('handle', handle);
49
+ upstream.searchParams.set('handle', normalizedHandle);
42
50
 
43
51
  const response = await fetch(upstream.toString(), {
44
52
  headers: { accept: 'application/json' },
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
+ import { canAccessFullAccount } from '../../lib/auth-scope';
3
4
  import { resolveSecret } from '../../lib/secrets';
4
5
 
5
6
  export const prerender = false;
@@ -16,7 +17,8 @@ export async function POST({ locals, request }: APIContext) {
16
17
  const { env } = locals.runtime;
17
18
 
18
19
  try {
19
- if (!(await isAuthorized(request, env))) return unauthorized();
20
+ const auth = await authenticateRequest(request, env);
21
+ if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
20
22
  } catch (error) {
21
23
  const handled = await authErrorResponse(env, error);
22
24
  if (handled) return handled;
@@ -1,6 +1,7 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { errorMessage } from '../../lib/errors';
3
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
4
+ import { canAccessFullAccount } from '../../lib/auth-scope';
4
5
  import { resolveSecret } from '../../lib/secrets';
5
6
 
6
7
  export const prerender = false;
@@ -16,7 +17,8 @@ export async function POST({ locals, request }: APIContext) {
16
17
  const { env } = locals.runtime;
17
18
 
18
19
  try {
19
- if (!(await isAuthorized(request, env))) return unauthorized();
20
+ const auth = await authenticateRequest(request, env);
21
+ if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
20
22
  } catch (error) {
21
23
  const handled = await authErrorResponse(env, error);
22
24
  if (handled) return handled;