@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
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
+ }
@@ -1,5 +1,7 @@
1
1
  import { errorMessage } from '../errors';
2
2
 
3
+ export type OauthEndpoint = 'par' | 'authorize' | 'consent' | 'token' | 'revoke';
4
+
3
5
  export type OauthParStage =
4
6
  | 'metadata_fetch'
5
7
  | 'metadata_shape'
@@ -9,6 +11,17 @@ export type OauthParStage =
9
11
  | 'outer'
10
12
  | 'success';
11
13
 
14
+ export type OauthTokenStage =
15
+ | 'dpop'
16
+ | 'parse'
17
+ | 'unsupported_grant'
18
+ | 'auth_code'
19
+ | 'refresh_token'
20
+ | 'client_auth'
21
+ | 'session_issue'
22
+ | 'outer'
23
+ | 'success';
24
+
12
25
  export type OauthParFormSummary = {
13
26
  redirectUri: string;
14
27
  responseType: string;
@@ -20,20 +33,30 @@ export type OauthParFormSummary = {
20
33
  hasClientAssertion: boolean;
21
34
  };
22
35
 
23
- export type OauthParLogDetails = {
36
+ export type OauthTokenFormSummary = {
37
+ grantType: string;
38
+ clientId: string;
39
+ redirectUri: string;
40
+ hasCode: boolean;
41
+ hasCodeVerifier: boolean;
42
+ hasRefreshToken: boolean;
43
+ hasClientAssertion: boolean;
44
+ clientAssertionType: string | null;
45
+ };
46
+
47
+ export type OauthLogDetails = {
48
+ endpoint: OauthEndpoint;
49
+ stage: string;
24
50
  outcome: 'ok' | 'error';
25
51
  requestId?: string | null;
26
52
  error?: unknown;
27
53
  clientId?: string | null;
28
- form?: OauthParFormSummary | null;
54
+ form?: Record<string, unknown> | null;
29
55
  metadataStatus?: number | null;
30
56
  metadataContentType?: string | null;
31
57
  metadataRedirected?: boolean | null;
32
58
  };
33
59
 
34
- // Read context off an Error attached by safeFetchJson when the metadata HTTP
35
- // roundtrip itself failed; absent for shape/validation errors that never made
36
- // a request.
37
60
  export type FetchAugmentedError = Error & {
38
61
  metadataStatus?: number;
39
62
  metadataContentType?: string | null;
@@ -69,16 +92,25 @@ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
69
92
  };
70
93
  }
71
94
 
72
- export function logOauthPar(
73
- stage: OauthParStage,
74
- request: Request,
75
- details: OauthParLogDetails,
76
- ): void {
95
+ export function summarizeTokenForm(form: URLSearchParams): OauthTokenFormSummary {
96
+ return {
97
+ grantType: form.get('grant_type') ?? '',
98
+ clientId: form.get('client_id') ?? '',
99
+ redirectUri: form.get('redirect_uri') ?? '',
100
+ hasCode: !!form.get('code'),
101
+ hasCodeVerifier: !!form.get('code_verifier'),
102
+ hasRefreshToken: !!form.get('refresh_token'),
103
+ hasClientAssertion: !!form.get('client_assertion'),
104
+ clientAssertionType: form.get('client_assertion_type'),
105
+ };
106
+ }
107
+
108
+ export function logOauth(request: Request, details: OauthLogDetails): void {
77
109
  const url = new URL(request.url);
78
110
  const record = {
79
111
  level: details.outcome === 'ok' ? 'info' : 'error',
80
- type: 'oauth_par',
81
- stage,
112
+ type: `oauth_${details.endpoint}`,
113
+ stage: details.stage,
82
114
  outcome: details.outcome,
83
115
  requestId: details.requestId ?? null,
84
116
  method: request.method,
@@ -97,3 +129,12 @@ export function logOauthPar(
97
129
  console.error(JSON.stringify(record));
98
130
  }
99
131
  }
132
+
133
+ // Backward-compatible alias for PAR call sites.
134
+ export function logOauthPar(
135
+ stage: OauthParStage,
136
+ request: Request,
137
+ details: Omit<OauthLogDetails, 'endpoint' | 'stage'>,
138
+ ): void {
139
+ logOauth(request, { ...details, endpoint: 'par', stage });
140
+ }
@@ -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
+ }