@alteran/astro 0.7.7 → 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 (73) 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/resource.ts +49 -0
  24. package/src/lib/preference-policy.ts +45 -0
  25. package/src/lib/preferences.ts +0 -4
  26. package/src/lib/public-host.ts +127 -0
  27. package/src/lib/ratelimit.ts +37 -12
  28. package/src/lib/relay.ts +7 -27
  29. package/src/lib/repo-write-blob-constraints.ts +141 -0
  30. package/src/lib/repo-write-data.ts +195 -0
  31. package/src/lib/repo-write-error.ts +46 -0
  32. package/src/lib/repo-write-validation.ts +463 -0
  33. package/src/lib/session-tokens.ts +22 -5
  34. package/src/lib/unsupported-routes.ts +32 -0
  35. package/src/lib/util.ts +57 -2
  36. package/src/pages/.well-known/atproto-did.ts +15 -3
  37. package/src/pages/.well-known/did.json.ts +13 -7
  38. package/src/pages/debug/db/bootstrap.ts +4 -3
  39. package/src/pages/debug/gc/blobs.ts +11 -8
  40. package/src/pages/debug/record.ts +11 -0
  41. package/src/pages/xrpc/[...nsid].ts +17 -9
  42. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  43. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  44. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  49. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  50. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  51. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  52. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  53. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  54. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  55. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  56. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  57. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  58. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  59. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  60. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  61. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  62. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  63. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  64. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  65. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  66. package/src/services/car.ts +13 -0
  67. package/src/services/repo/apply-prepared-writes.ts +185 -0
  68. package/src/services/repo/blob-refs.ts +48 -0
  69. package/src/services/repo/blockstore-ops.ts +59 -17
  70. package/src/services/repo/list-blobs.ts +43 -0
  71. package/src/services/repo-manager.ts +221 -78
  72. package/src/worker/runtime.ts +1 -1
  73. package/src/worker/sequencer/upgrade.ts +4 -1
package/src/lib/auth.ts CHANGED
@@ -3,10 +3,21 @@ import { AuthTokenExpiredError, expiredToken } from './auth-errors';
3
3
  import { verifyJwt, type JwtClaims } from './jwt';
4
4
  import { handleResourceAuthError, verifyResourceRequestHybrid } from './oauth/resource';
5
5
  import { bearerToken } from './util';
6
+ import { getAccountState } from '../db/dal';
7
+ import {
8
+ bearerAccessContext,
9
+ canAccessFullAccount,
10
+ isBearerAccessScope,
11
+ oauthAccessContext,
12
+ withAccountStatus,
13
+ type AuthAccessContext,
14
+ type AuthAccountStatus,
15
+ } from './auth-scope';
6
16
 
7
17
  export interface AuthContext {
8
18
  token: string;
9
19
  claims: JwtClaims;
20
+ access: AuthAccessContext;
10
21
  }
11
22
 
12
23
  function authScheme(request: Request): string | null {
@@ -16,63 +27,31 @@ function authScheme(request: Request): string | null {
16
27
  }
17
28
 
18
29
  export async function isAuthorized(request: Request, env: Env): Promise<boolean> {
19
- const auth = request.headers.get('authorization');
20
-
21
- console.error('=== AUTH DEBUG START ===');
22
- console.error('URL:', request.url);
23
- console.error('Has Auth Header:', !!auth);
24
- console.error('Auth Prefix:', auth?.substring(0, 30));
25
- console.error('=== AUTH DEBUG END ===');
26
-
27
- if (authScheme(request) === 'dpop') {
28
- const result = await verifyResourceRequestHybrid(env, request);
29
- return !!result;
30
- }
31
-
32
- const token = bearerToken(request);
33
- if (!token) {
34
- console.error('RESULT: No Bearer token found');
35
- return false;
36
- }
37
-
38
- console.error('Token Length:', token.length);
39
- console.error('Token Prefix:', token.substring(0, 30));
40
-
41
- // Prefer JWT
42
- let ver;
43
30
  try {
44
- ver = await verifyJwt(env, token);
31
+ const auth = await authenticateRequest(request, env);
32
+ if (auth) return canAccessFullAccount(auth.access);
45
33
  } catch (error) {
46
34
  if (error instanceof AuthTokenExpiredError) {
47
35
  throw error;
48
36
  }
49
- console.error('JWT VERIFICATION ERROR:', error instanceof Error ? error.message : String(error));
50
- return false;
37
+ throw error;
51
38
  }
52
39
 
53
- console.error('JWT Valid:', ver?.valid);
54
- console.error('JWT Type:', ver?.payload?.t);
55
- console.error('JWT Sub:', ver?.payload?.sub);
56
-
57
- if (ver && ver.valid && ver.payload.t === 'access') {
58
- console.error('RESULT: JWT Success');
59
- return true;
40
+ const token = bearerToken(request);
41
+ if (!token) {
42
+ return false;
60
43
  }
61
44
 
62
45
  // Back-compat local escape hatch if explicitly enabled
63
46
  const allowDev = (env as any).PDS_ALLOW_DEV_TOKEN === '1';
64
- console.error('Allow Dev Token:', allowDev);
65
47
 
66
48
  if (allowDev && token === 'dev-access-token') {
67
- console.error('RESULT: Dev token accepted');
68
49
  return true;
69
50
  }
70
51
  if (allowDev && env.USER_PASSWORD && token === env.USER_PASSWORD) {
71
- console.error('RESULT: User password accepted');
72
52
  return true;
73
53
  }
74
54
 
75
- console.error('RESULT: Unauthorized');
76
55
  return false;
77
56
  }
78
57
 
@@ -91,6 +70,11 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
91
70
  if (authScheme(request) === 'dpop') {
92
71
  const result = await verifyResourceRequestHybrid(env, request);
93
72
  if (!result) return null;
73
+ const access = await withResolvedAccountStatus(
74
+ env,
75
+ result.did,
76
+ oauthAccessContext(result.scope ?? 'atproto'),
77
+ );
94
78
  return {
95
79
  token: result.token,
96
80
  claims: {
@@ -98,6 +82,7 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
98
82
  scope: result.scope,
99
83
  t: 'access',
100
84
  } as JwtClaims,
85
+ access,
101
86
  };
102
87
  }
103
88
 
@@ -116,7 +101,23 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
116
101
  if (!ver || !ver.valid) return null;
117
102
  const claims = ver.payload as JwtClaims;
118
103
  if (claims.t !== 'access') return null;
119
- return { token, claims };
104
+ if (!isBearerAccessScope(claims.scope)) return null;
105
+ const access = await withResolvedAccountStatus(
106
+ env,
107
+ claims.sub,
108
+ bearerAccessContext(claims.scope),
109
+ );
110
+ return { token, claims, access };
120
111
  }
121
112
 
122
113
  export { AuthTokenExpiredError, expiredToken } from './auth-errors';
114
+
115
+ async function withResolvedAccountStatus(
116
+ env: Env,
117
+ did: string,
118
+ access: AuthAccessContext,
119
+ ): Promise<AuthAccessContext> {
120
+ const state = await getAccountState(env, did);
121
+ const accountStatus: AuthAccountStatus = state?.tag ?? 'active';
122
+ return withAccountStatus(access, accountStatus);
123
+ }
package/src/lib/commit.ts CHANGED
@@ -123,30 +123,52 @@ export function deserializeCommit(bytes: Uint8Array): SignedCommit {
123
123
  return dagCbor.decode(bytes) as SignedCommit;
124
124
  }
125
125
 
126
- /**
127
- * Generate a TID (Timestamp Identifier) for use as revision
128
- * TIDs are lexicographically sortable timestamps
129
- */
130
- export function generateTid(): string {
131
- const now = Date.now();
132
- const timestamp = now * 1000; // microseconds
126
+ const SORTABLE_BASE32_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
127
+ let lastTidTimestamp = 0;
128
+ let tidClockId: number | undefined;
133
129
 
134
- // Convert to base32 (simplified version)
135
- const chars = '234567abcdefghijklmnopqrstuvwxyz';
136
- let tid = '';
137
- let remaining = timestamp;
130
+ function sortableBase32Encode(value: number): string {
131
+ let encoded = '';
132
+ let remaining = value;
138
133
 
139
- for (let i = 0; i < 13; i++) {
140
- tid = chars[remaining % 32] + tid;
134
+ while (remaining > 0) {
135
+ encoded = SORTABLE_BASE32_CHARS[remaining % 32] + encoded;
141
136
  remaining = Math.floor(remaining / 32);
142
137
  }
143
138
 
144
- return tid;
139
+ return encoded;
140
+ }
141
+
142
+ function getTidClockId(): number {
143
+ tidClockId ??= Math.floor(Math.random() * 1024);
144
+ return tidClockId;
145
+ }
146
+
147
+ export function resetTidStateForTests(): void {
148
+ lastTidTimestamp = 0;
149
+ tidClockId = undefined;
150
+ }
151
+
152
+ /**
153
+ * Generate an ATProto Timestamp Identifier for use as a record key or revision.
154
+ *
155
+ * The timestamp portion is microsecond-precision and monotonically increases
156
+ * even when JavaScript only exposes millisecond time or the system clock moves
157
+ * backwards.
158
+ */
159
+ export function generateTid(): string {
160
+ const nowMicros = Date.now() * 1000;
161
+ const timestamp = Math.max(nowMicros, lastTidTimestamp + 1);
162
+ lastTidTimestamp = timestamp;
163
+
164
+ const timestampPart = sortableBase32Encode(timestamp).padStart(11, '2');
165
+ const clockPart = sortableBase32Encode(getTidClockId()).padStart(2, '2');
166
+ return `${timestampPart}${clockPart}`;
145
167
  }
146
168
 
147
169
  /**
148
170
  * Validate TID format
149
171
  */
150
172
  export function isValidTid(tid: string): boolean {
151
- return /^[234567abcdefghijklmnopqrstuvwxyz]{13}$/.test(tid);
173
+ return /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/.test(tid);
152
174
  }
@@ -1,5 +1,5 @@
1
1
  import type { Env } from '../env';
2
- import { getRuntimeString } from './secrets';
2
+ import { canonicalPdsOrigin, validAtprotoHandle } from './public-host';
3
3
 
4
4
  export interface DidDocument {
5
5
  '@context': string[];
@@ -14,18 +14,17 @@ export interface DidDocument {
14
14
  }
15
15
 
16
16
  export async function buildDidDocument(env: Env, did: string, handle: string): Promise<DidDocument> {
17
- const hostname = await getRuntimeString(env, 'PDS_HOSTNAME', handle);
18
-
17
+ const claimedHandle = validAtprotoHandle(handle);
19
18
  return {
20
19
  '@context': ['https://www.w3.org/ns/did/v1'],
21
20
  id: did,
22
- alsoKnownAs: [`at://${handle}`],
21
+ alsoKnownAs: claimedHandle ? [`at://${claimedHandle}`] : [],
23
22
  verificationMethod: [],
24
23
  service: [
25
24
  {
26
25
  id: '#atproto_pds',
27
26
  type: 'AtprotoPersonalDataServer',
28
- serviceEndpoint: `https://${hostname}`,
27
+ serviceEndpoint: await canonicalPdsOrigin(env),
29
28
  },
30
29
  ],
31
30
  };
package/src/lib/jwt.ts CHANGED
@@ -30,7 +30,9 @@ export async function signJwt(
30
30
  throw new Error("Cannot sign JWT without subject");
31
31
  }
32
32
  const { accessJwt, refreshJwt } = await issueSessionTokens(env, claims.sub, {
33
- jti: claims.jti,
33
+ jti: kind === "refresh" ? claims.jti : undefined,
34
+ accessJti: kind === "access" ? claims.jti : undefined,
35
+ scope: kind === "access" ? claims.scope : undefined,
34
36
  });
35
37
  return kind === "access" ? accessJwt : refreshJwt;
36
38
  }
@@ -0,0 +1,9 @@
1
+ export function mimeMatches(accept: string, mimeType: string): boolean {
2
+ const normalizedAccept = accept.toLowerCase();
3
+ const normalizedMime = mimeType.toLowerCase();
4
+ if (normalizedAccept === '*/*') return true;
5
+ if (normalizedAccept.endsWith('/*')) {
6
+ return normalizedMime.startsWith(normalizedAccept.slice(0, -1));
7
+ }
8
+ return normalizedAccept === normalizedMime;
9
+ }
@@ -3,7 +3,17 @@ import { errorCode, errorMessage } from '../errors';
3
3
  import { verifyAccessToken } from '../session-tokens';
4
4
  import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
5
5
  import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOAuthSession, getSecret, setSecret } from '../../db/account';
6
+ import { getAccountState } from '../../db/dal';
6
7
  import { jwkThumbprint } from './dpop';
8
+ import {
9
+ bearerAccessContext,
10
+ isBearerAccessScope,
11
+ isOAuthPermissionScope,
12
+ oauthAccessContext,
13
+ withAccountStatus,
14
+ type AuthAccessContext,
15
+ type AuthAccountStatus,
16
+ } from '../auth-scope';
7
17
 
8
18
  const NONCE_PDS_KEY = 'oauth_dpop_nonce_pds';
9
19
 
@@ -66,6 +76,7 @@ export type ResourceAuthContext = {
66
76
  token: string;
67
77
  scope?: string;
68
78
  authType: 'bearer' | 'oauth-dpop';
79
+ access: AuthAccessContext;
69
80
  };
70
81
 
71
82
  export async function verifyResourceRequest(env: Env, request: Request): Promise<ResourceAuthContext | null> {
@@ -86,11 +97,19 @@ export async function verifyResourceRequest(env: Env, request: Request): Promise
86
97
 
87
98
  if (scheme === 'bearer') {
88
99
  const payload = await verifyAccessTokenOrThrow(env, token, { allowOAuth: false });
100
+ if (!isBearerAccessScope(payload.scope)) {
101
+ throw new ResourceAuthError('invalid_token');
102
+ }
89
103
  return {
90
104
  did: payload.sub as string,
91
105
  token,
92
106
  scope: typeof payload.scope === 'string' ? payload.scope : undefined,
93
107
  authType: 'bearer',
108
+ access: await withResolvedResourceAccountStatus(
109
+ env,
110
+ payload.sub as string,
111
+ bearerAccessContext(payload.scope),
112
+ ),
94
113
  };
95
114
  }
96
115
 
@@ -128,6 +147,9 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
128
147
  await importJWK(header.jwk as JoseJWK, 'ES256');
129
148
 
130
149
  const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken, { allowOAuth: true });
150
+ if (!isOAuthPermissionScope(tokenPayload.scope)) {
151
+ throw new ResourceAuthError('invalid_token', { message: 'OAuth token has no PDS resource permissions' });
152
+ }
131
153
  const tokenJkt = (tokenPayload.cnf as any)?.jkt;
132
154
  if (typeof tokenJkt !== 'string') {
133
155
  throw new ResourceAuthError('invalid_token', { message: 'DPoP access token missing cnf.jkt' });
@@ -142,6 +164,11 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
142
164
  token: accessToken,
143
165
  scope: typeof tokenPayload.scope === 'string' ? tokenPayload.scope : undefined,
144
166
  authType: 'oauth-dpop' as const,
167
+ access: await withResolvedResourceAccountStatus(
168
+ env,
169
+ tokenPayload.sub as string,
170
+ oauthAccessContext(String(tokenPayload.scope)),
171
+ ),
145
172
  };
146
173
  }
147
174
 
@@ -238,11 +265,19 @@ export async function verifyResourceRequestHybrid(
238
265
 
239
266
  if (scheme === 'bearer') {
240
267
  const payloadJwt = await deps.verifyAccessToken(env, token, { allowOAuth: false });
268
+ if (!isBearerAccessScope(payloadJwt.scope)) {
269
+ throw new ResourceAuthError('invalid_token');
270
+ }
241
271
  return {
242
272
  did: payloadJwt.sub as string,
243
273
  token,
244
274
  scope: typeof payloadJwt.scope === 'string' ? payloadJwt.scope : undefined,
245
275
  authType: 'bearer',
276
+ access: await withResolvedResourceAccountStatus(
277
+ env,
278
+ payloadJwt.sub as string,
279
+ bearerAccessContext(payloadJwt.scope),
280
+ ),
246
281
  };
247
282
  }
248
283
 
@@ -256,6 +291,20 @@ function jsonError(error: string, message: string, status: number): Response {
256
291
  });
257
292
  }
258
293
 
294
+ export function insufficientScopeResponse(): Response {
295
+ return jsonError('InvalidToken', 'token does not grant access to this resource', 401);
296
+ }
297
+
298
+ async function withResolvedResourceAccountStatus(
299
+ env: Env,
300
+ did: string,
301
+ access: AuthAccessContext,
302
+ ): Promise<AuthAccessContext> {
303
+ const state = await getAccountState(env, did);
304
+ const accountStatus: AuthAccountStatus = state?.tag ?? 'active';
305
+ return withAccountStatus(access, accountStatus);
306
+ }
307
+
259
308
  export async function handleResourceAuthError(env: Env, error: unknown): Promise<Response | null> {
260
309
  if (!(error instanceof ResourceAuthError)) {
261
310
  return null;
@@ -0,0 +1,45 @@
1
+ import type { AuthAccessContext } from './auth-scope';
2
+
3
+ export const APP_PASSWORD_RESTRICTED_PREFERENCE_TYPES = new Set([
4
+ 'app.bsky.actor.defs#personalDetailsPref',
5
+ 'app.bsky.actor.defs#bskyAppStatePref',
6
+ ]);
7
+
8
+ function preferenceType(value: unknown): string | null {
9
+ if (!value || typeof value !== 'object') return null;
10
+ const type = (value as { $type?: unknown }).$type;
11
+ return typeof type === 'string' ? type : null;
12
+ }
13
+
14
+ export function isAppPasswordRestrictedPreference(value: unknown): boolean {
15
+ const type = preferenceType(value);
16
+ return !!type && APP_PASSWORD_RESTRICTED_PREFERENCE_TYPES.has(type);
17
+ }
18
+
19
+ export function preferencesForAccess(
20
+ preferences: unknown[],
21
+ access: AuthAccessContext,
22
+ ): unknown[] {
23
+ if (!access.isAppPassword) return preferences;
24
+ return preferences.filter((pref) => !isAppPasswordRestrictedPreference(pref));
25
+ }
26
+
27
+ export function hasAppPasswordRestrictedPreferences(
28
+ preferences: unknown[],
29
+ access: AuthAccessContext,
30
+ ): boolean {
31
+ return access.isAppPassword && preferences.some(isAppPasswordRestrictedPreference);
32
+ }
33
+
34
+ export function preferencesForWrite(
35
+ existingPreferences: unknown[],
36
+ nextPreferences: unknown[],
37
+ access: AuthAccessContext,
38
+ ): unknown[] {
39
+ if (!access.isAppPassword) return nextPreferences;
40
+ const preserved = existingPreferences.filter(isAppPasswordRestrictedPreference);
41
+ return [
42
+ ...nextPreferences.filter((pref) => !isAppPasswordRestrictedPreference(pref)),
43
+ ...preserved,
44
+ ];
45
+ }
@@ -2,14 +2,10 @@ import type { Env } from '../env';
2
2
  import { resolveSecret } from './secrets';
3
3
  import { ServerMisconfigured } from './errors';
4
4
 
5
- let tableEnsured = false;
6
-
7
5
  async function ensureTable(env: Env) {
8
- if (tableEnsured) return;
9
6
  await env.ALTERAN_DB.exec(
10
7
  'CREATE TABLE IF NOT EXISTS actor_preferences (did TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at INTEGER NOT NULL)'
11
8
  );
12
- tableEnsured = true;
13
9
  }
14
10
 
15
11
  // No defaults — return empty when nothing stored to avoid local fallbacks
@@ -0,0 +1,127 @@
1
+ import type { Env } from '../env';
2
+ import { getRuntimeString } from './secrets';
3
+
4
+ export type PublicOrigin = {
5
+ origin: string;
6
+ hostname: string;
7
+ host: string;
8
+ };
9
+
10
+ const HANDLE_SYNTAX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/i;
11
+ const DISALLOWED_RESOLUTION_TLDS = new Set([
12
+ 'alt',
13
+ 'arpa',
14
+ 'example',
15
+ 'internal',
16
+ 'invalid',
17
+ 'local',
18
+ 'localhost',
19
+ 'onion',
20
+ ]);
21
+
22
+ export async function configuredDid(env: Env): Promise<string> {
23
+ return (await getRuntimeString(env, 'PDS_DID', 'did:example:single-user')) ?? 'did:example:single-user';
24
+ }
25
+
26
+ async function configuredHandleValue(env: Env): Promise<string> {
27
+ return (await getRuntimeString(env, 'PDS_HANDLE', 'user.example.com')) ?? 'user.example.com';
28
+ }
29
+
30
+ export async function configuredHandle(env: Env): Promise<string> {
31
+ const value = await configuredHandleValue(env);
32
+ return validAtprotoHandle(value) ?? value.trim().toLowerCase();
33
+ }
34
+
35
+ export function validAtprotoHandle(handle: string | undefined | null): string | null {
36
+ if (typeof handle !== 'string') return null;
37
+ if (handle === '' || handle.length > 253) return null;
38
+ const normalized = handle.toLowerCase();
39
+ if (!HANDLE_SYNTAX.test(normalized)) return null;
40
+
41
+ const tld = normalized.slice(normalized.lastIndexOf('.') + 1);
42
+ if (DISALLOWED_RESOLUTION_TLDS.has(tld)) return null;
43
+
44
+ return normalized;
45
+ }
46
+
47
+ export async function configuredAtprotoHandle(env: Env): Promise<string | null> {
48
+ return validAtprotoHandle(await configuredHandleValue(env));
49
+ }
50
+
51
+ export async function canonicalPdsOrigin(env: Env): Promise<string> {
52
+ const configuredHost = await getRuntimeString(env, 'PDS_HOSTNAME', '');
53
+ const handle = await configuredHandle(env);
54
+ const parsed = parsePublicOrigin(configuredHost || handle);
55
+ return parsed?.origin ?? `https://${handle}`;
56
+ }
57
+
58
+ export async function canonicalPdsHost(env: Env): Promise<string | null> {
59
+ const configuredHost = await getRuntimeString(env, 'PDS_HOSTNAME', '');
60
+ const handle = await configuredHandle(env);
61
+ return parsePublicOrigin(configuredHost || handle)?.hostname ?? null;
62
+ }
63
+
64
+ export async function configuredHandleHost(env: Env): Promise<string | null> {
65
+ return configuredAtprotoHandle(env);
66
+ }
67
+
68
+ export function requestHostname(request: Request): string | null {
69
+ try {
70
+ return normalizeHostname(new URL(request.url).hostname);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export async function requestMatchesConfiguredHandle(request: Request, env: Env): Promise<boolean> {
77
+ const actual = requestHostname(request);
78
+ const expected = await configuredHandleHost(env);
79
+ return !!actual && !!expected && actual === expected;
80
+ }
81
+
82
+ export async function handleResolvesToDid(env: Env, handle: string, did: string): Promise<boolean> {
83
+ const normalizedHandle = validAtprotoHandle(handle);
84
+ const expectedHandle = await configuredHandle(env);
85
+ const expectedDid = await configuredDid(env);
86
+ return !!normalizedHandle &&
87
+ normalizedHandle === expectedHandle &&
88
+ did === expectedDid &&
89
+ !!(await configuredAtprotoHandle(env));
90
+ }
91
+
92
+ export function didDocClaimsHandle(didDoc: { alsoKnownAs?: unknown }, handle: string): boolean {
93
+ const normalizedHandle = validAtprotoHandle(handle);
94
+ if (!normalizedHandle) return false;
95
+ return Array.isArray(didDoc.alsoKnownAs) &&
96
+ didDoc.alsoKnownAs.includes(`at://${normalizedHandle}`);
97
+ }
98
+
99
+ export function isLocalHostname(hostname: string): boolean {
100
+ const lower = normalizeHostname(hostname);
101
+ return lower === 'localhost' ||
102
+ lower.endsWith('.localhost') ||
103
+ lower === '127.0.0.1' ||
104
+ lower === '0.0.0.0' ||
105
+ lower === '::1';
106
+ }
107
+
108
+ function normalizeHostname(hostname: string): string {
109
+ return hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1');
110
+ }
111
+
112
+ export function parsePublicOrigin(raw: string | undefined | null): PublicOrigin | null {
113
+ const value = raw?.trim();
114
+ if (!value) return null;
115
+
116
+ try {
117
+ const url = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
118
+ if (!url.hostname) return null;
119
+ return {
120
+ origin: `https://${url.host}`,
121
+ hostname: normalizeHostname(url.hostname),
122
+ host: url.host.toLowerCase(),
123
+ };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
@@ -1,26 +1,42 @@
1
1
  import type { Env } from '../env';
2
2
 
3
3
  // Rate limiting (best-effort, D1 based)
4
- export async function checkRate(env: Env, request: Request, bucket: 'writes' | 'blob'): Promise<Response | null> {
4
+ export async function checkRate(
5
+ env: Env,
6
+ request: Request,
7
+ bucket: 'writes' | 'blob',
8
+ options: { key?: string; cost?: number } = {},
9
+ ): Promise<Response | null> {
5
10
  try {
6
11
  const limit = Number((env.PDS_RATE_LIMIT_PER_MIN as string | undefined) ?? (bucket === 'blob' ? 30 : 60));
12
+ const cost = Math.max(1, Math.floor(options.cost ?? 1));
7
13
  const now = Date.now();
8
14
  const windowMs = 60_000;
9
15
  const win = Math.floor(now / windowMs);
10
- const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
16
+ const key = options.key
17
+ ?? request.headers.get('cf-connecting-ip')
18
+ ?? request.headers.get('x-forwarded-for')
19
+ ?? '127.0.0.1';
11
20
  await env.ALTERAN_DB.exec("CREATE TABLE IF NOT EXISTS rate_limit (ip TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (ip,bucket,window))");
12
- const row: any = await env.ALTERAN_DB.prepare('SELECT count FROM rate_limit WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).first();
13
- const count = row?.count ? Number(row.count) : 0;
14
- if (count >= limit) return rateLimited();
15
- if (count === 0) {
16
- await env.ALTERAN_DB.prepare('INSERT OR REPLACE INTO rate_limit (ip,bucket,window,count) VALUES (?,?,?,1)').bind(ip, bucket, win).run();
17
- } else {
18
- await env.ALTERAN_DB.prepare('UPDATE rate_limit SET count=count+1 WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).run();
19
- }
21
+ const results = await env.ALTERAN_DB.batch([
22
+ env.ALTERAN_DB.prepare(
23
+ 'INSERT OR IGNORE INTO rate_limit (ip,bucket,window,count) VALUES (?,?,?,0)',
24
+ ).bind(key, bucket, win),
25
+ env.ALTERAN_DB.prepare(
26
+ `UPDATE rate_limit
27
+ SET count = count + ?
28
+ WHERE ip = ? AND bucket = ? AND window = ? AND count + ? <= ?`,
29
+ ).bind(cost, key, bucket, win, cost, limit),
30
+ ]);
31
+ if (changedRows(results[1]) !== 1) return rateLimited();
32
+ const row = await env.ALTERAN_DB.prepare(
33
+ 'SELECT count FROM rate_limit WHERE ip=? AND bucket=? AND window=?',
34
+ ).bind(key, bucket, win).first<{ count: number }>();
35
+ const count = row?.count ? Number(row.count) : cost;
20
36
 
21
37
  const headers = new Headers();
22
38
  headers.set('x-ratelimit-limit', String(limit));
23
- headers.set('x-ratelimit-remaining', String(Math.max(0, limit - count - 1)));
39
+ headers.set('x-ratelimit-remaining', String(Math.max(0, limit - count)));
24
40
  headers.set('x-ratelimit-window', '60s');
25
41
 
26
42
  return null;
@@ -29,6 +45,15 @@ export async function checkRate(env: Env, request: Request, bucket: 'writes' | '
29
45
  }
30
46
  }
31
47
 
48
+ function changedRows(result: unknown): number {
49
+ const meta = (result as { meta?: Record<string, unknown> } | undefined)?.meta;
50
+ const changes = meta?.changes ?? meta?.rows_written ?? meta?.rowsWritten;
51
+ return typeof changes === 'number' ? changes : 0;
52
+ }
53
+
32
54
  function rateLimited() {
33
- return new Response(JSON.stringify({ error: 'RateLimited' }), { status: 429 });
55
+ return new Response(JSON.stringify({ error: 'RateLimited' }), {
56
+ status: 429,
57
+ headers: { 'Content-Type': 'application/json' },
58
+ });
34
59
  }
package/src/lib/relay.ts CHANGED
@@ -1,36 +1,17 @@
1
1
  import type { Env } from '../env';
2
+ import { canonicalPdsHost, isLocalHostname } from './public-host';
2
3
 
3
4
  /**
4
5
  * Resolve the public hostname for this PDS.
5
- * Priority: env.PDS_HOSTNAME -> request URL host.
6
+ * Priority: env.PDS_HOSTNAME -> env.PDS_HANDLE.
6
7
  * Ensures the value is a bare hostname (no scheme/port/path).
7
8
  */
8
- export function resolvePdsHostname(env: Env, requestUrl?: string): string | null {
9
- let host = (env.PDS_HOSTNAME as string | undefined)?.trim() || '';
10
-
11
- if (!host && requestUrl) {
12
- try {
13
- const url = new URL(requestUrl);
14
- host = url.hostname;
15
- } catch {
16
- // Malformed request URL: leave host unset so the caller can choose a fallback.
17
- }
18
- }
19
-
9
+ export async function resolvePdsHostname(env: Env, requestUrl?: string): Promise<string | null> {
10
+ void requestUrl;
11
+ const host = await canonicalPdsHost(env);
20
12
  if (!host) return null;
21
13
 
22
- // Normalize: strip protocol/port if somehow present
23
- host = host.replace(/^https?:\/\//i, '').replace(/:\d+$/, '').trim();
24
-
25
- // Skip obvious local hosts to avoid spamming relays from dev
26
- const lower = host.toLowerCase();
27
- if (
28
- lower === 'localhost' ||
29
- lower.endsWith('.localhost') ||
30
- lower === '127.0.0.1' ||
31
- lower === '0.0.0.0' ||
32
- lower === '::1'
33
- ) {
14
+ if (isLocalHostname(host)) {
34
15
  return null;
35
16
  }
36
17
 
@@ -81,7 +62,7 @@ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promi
81
62
  return;
82
63
  }
83
64
 
84
- const hostname = resolvePdsHostname(env, requestUrl);
65
+ const hostname = await resolvePdsHostname(env, requestUrl);
85
66
  if (!hostname) return;
86
67
 
87
68
  const relays = getRelayHosts(env);
@@ -100,4 +81,3 @@ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promi
100
81
  }),
101
82
  );
102
83
  }
103
-