@alteran/astro 0.1.14 → 0.3.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 (62) hide show
  1. package/README.md +35 -10
  2. package/index.js +2 -4
  3. package/migrations/0006_adorable_spectrum.sql +11 -0
  4. package/migrations/meta/0006_snapshot.json +429 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +7 -3
  7. package/src/db/account.ts +145 -0
  8. package/src/db/dal.ts +27 -9
  9. package/src/db/repo.ts +9 -8
  10. package/src/db/schema.ts +29 -11
  11. package/src/lib/actor.ts +133 -0
  12. package/src/lib/appview.ts +508 -0
  13. package/src/lib/auth.ts +22 -2
  14. package/src/lib/blob-refs.ts +9 -13
  15. package/src/lib/chat.ts +238 -0
  16. package/src/lib/config.ts +15 -7
  17. package/src/lib/feed.ts +165 -0
  18. package/src/lib/jwt.ts +231 -79
  19. package/src/lib/labeler.ts +91 -0
  20. package/src/lib/mst/blockstore.ts +98 -14
  21. package/src/lib/password.ts +40 -0
  22. package/src/lib/preferences.ts +73 -0
  23. package/src/lib/relay.ts +101 -0
  24. package/src/lib/secrets.ts +29 -21
  25. package/src/lib/session-tokens.ts +202 -0
  26. package/src/lib/token-cleanup.ts +3 -12
  27. package/src/lib/util.ts +17 -2
  28. package/src/middleware.ts +20 -21
  29. package/src/pages/.well-known/did.json.ts +45 -32
  30. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +23 -0
  31. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +34 -0
  32. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +42 -0
  33. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +36 -0
  34. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +42 -0
  35. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +37 -0
  36. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +26 -0
  37. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +35 -0
  38. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +29 -0
  39. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +29 -0
  40. package/src/pages/xrpc/app.bsky.labeler.getServices.ts +29 -0
  41. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +20 -0
  42. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +27 -0
  43. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +19 -0
  44. package/src/pages/xrpc/app.bsky.unspecced.getConfig.ts +15 -0
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +26 -0
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +37 -0
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +64 -66
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +24 -0
  49. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +127 -0
  50. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +91 -0
  51. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +6 -2
  52. package/src/pages/xrpc/com.atproto.server.createSession.ts +36 -8
  53. package/src/pages/xrpc/com.atproto.server.describeServer.ts +37 -4
  54. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +64 -0
  55. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +55 -32
  56. package/src/services/repo-manager.ts +15 -6
  57. package/src/worker/runtime.ts +21 -0
  58. package/types/env.d.ts +11 -2
  59. package/src/pages/xrpc/com.atproto.repo.importRepo.ts +0 -142
  60. package/src/pages/xrpc/com.atproto.server.activateAccount.ts +0 -53
  61. package/src/pages/xrpc/com.atproto.server.createAccount.ts +0 -99
  62. package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +0 -53
@@ -0,0 +1,73 @@
1
+ import type { Env } from '../env';
2
+ import { resolveSecret } from './secrets';
3
+
4
+ let tableEnsured = false;
5
+
6
+ async function ensureTable(env: Env) {
7
+ if (tableEnsured) return;
8
+ await env.DB.exec(
9
+ 'CREATE TABLE IF NOT EXISTS actor_preferences (did TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at INTEGER NOT NULL)'
10
+ );
11
+ tableEnsured = true;
12
+ }
13
+
14
+ const DEFAULT_PREFERENCES = [
15
+ {
16
+ $type: 'app.bsky.actor.defs#savedFeedsPrefV2',
17
+ items: [],
18
+ },
19
+ {
20
+ $type: 'app.bsky.actor.defs#feedViewPref',
21
+ feed: 'home',
22
+ hideReplies: false,
23
+ hideRepliesByUnfollowed: false,
24
+ hideRepliesByLikeCount: 0,
25
+ hideReposts: false,
26
+ hideQuotePosts: false,
27
+ },
28
+ {
29
+ $type: 'app.bsky.actor.defs#threadViewPref',
30
+ sort: 'oldest',
31
+ prioritizeFollowedUsers: true,
32
+ },
33
+ {
34
+ $type: 'app.bsky.actor.defs#labelersPref',
35
+ labelers: [
36
+ {
37
+ did: 'did:plc:ar7c4by46qjdydhdevvrndac',
38
+ },
39
+ ],
40
+ },
41
+ ];
42
+
43
+ export async function getActorPreferences(env: Env): Promise<{ did: string; preferences: any[] }> {
44
+ await ensureTable(env);
45
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
46
+ const row = await env.DB.prepare('SELECT json FROM actor_preferences WHERE did = ?')
47
+ .bind(did)
48
+ .first<{ json: string }>();
49
+
50
+ if (!row?.json) {
51
+ return { did, preferences: DEFAULT_PREFERENCES };
52
+ }
53
+
54
+ try {
55
+ const parsed = JSON.parse(row.json);
56
+ const preferences = Array.isArray(parsed) ? parsed : [];
57
+ // If preferences exist but are empty, return defaults
58
+ return { did, preferences: preferences.length > 0 ? preferences : DEFAULT_PREFERENCES };
59
+ } catch {
60
+ return { did, preferences: DEFAULT_PREFERENCES };
61
+ }
62
+ }
63
+
64
+ export async function setActorPreferences(env: Env, preferences: any[]): Promise<void> {
65
+ await ensureTable(env);
66
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
67
+ const now = Date.now();
68
+ await env.DB.prepare(
69
+ 'INSERT INTO actor_preferences (did, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(did) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at'
70
+ )
71
+ .bind(did, JSON.stringify(preferences ?? []), now)
72
+ .run();
73
+ }
@@ -0,0 +1,101 @@
1
+ import type { Env } from '../env';
2
+
3
+ /**
4
+ * Resolve the public hostname for this PDS.
5
+ * Priority: env.PDS_HOSTNAME -> request URL host.
6
+ * Ensures the value is a bare hostname (no scheme/port/path).
7
+ */
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
+ }
17
+
18
+ if (!host) return null;
19
+
20
+ // Normalize: strip protocol/port if somehow present
21
+ host = host.replace(/^https?:\/\//i, '').replace(/:\d+$/, '').trim();
22
+
23
+ // Skip obvious local hosts to avoid spamming relays from dev
24
+ const lower = host.toLowerCase();
25
+ if (
26
+ lower === 'localhost' ||
27
+ lower.endsWith('.localhost') ||
28
+ lower === '127.0.0.1' ||
29
+ lower === '0.0.0.0' ||
30
+ lower === '::1'
31
+ ) {
32
+ return null;
33
+ }
34
+
35
+ return host;
36
+ }
37
+
38
+ /**
39
+ * Parse relay hosts from env or return default list.
40
+ * CSV of bare hostnames (e.g. "bsky.network,relay.example.org").
41
+ */
42
+ export function getRelayHosts(env: Env): string[] {
43
+ const csv = (env.PDS_RELAY_HOSTS as string | undefined)?.trim();
44
+ const hosts = csv && csv.length > 0 ? csv.split(',') : ['bsky.network'];
45
+ return hosts
46
+ .map((h) => h.trim())
47
+ .filter((h) => h && !/^https?:\/\//i.test(h))
48
+ .filter((h, i, arr) => arr.indexOf(h) === i);
49
+ }
50
+
51
+ /**
52
+ * Notify a single relay host using com.atproto.sync.requestCrawl
53
+ */
54
+ export async function requestCrawl(relayHost: string, pdsHostname: string): Promise<Response> {
55
+ const url = `https://${relayHost}/xrpc/com.atproto.sync.requestCrawl`;
56
+ const res = await fetch(url, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({ hostname: pdsHostname }),
60
+ });
61
+ return res;
62
+ }
63
+
64
+ // In-memory isolation-scoped throttle to avoid spamming relays on every request.
65
+ let lastNotifyTs = 0;
66
+
67
+ /**
68
+ * Best-effort: notify relays that our PDS is available.
69
+ * - No throw on failure; logs to console only.
70
+ * - Throttled per isolate to at most once every 12h.
71
+ */
72
+ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promise<void> {
73
+ // Allow disabling via flag
74
+ const disabled = String(env.PDS_RELAY_NOTIFY || '').toLowerCase() === 'false';
75
+ if (disabled) return;
76
+
77
+ const now = Date.now();
78
+ if (now - lastNotifyTs < 12 * 60 * 60 * 1000) {
79
+ return;
80
+ }
81
+
82
+ const hostname = resolvePdsHostname(env, requestUrl);
83
+ if (!hostname) return;
84
+
85
+ const relays = getRelayHosts(env);
86
+ lastNotifyTs = now; // set early to avoid stampedes
87
+
88
+ await Promise.allSettled(
89
+ relays.map(async (relay) => {
90
+ try {
91
+ const res = await requestCrawl(relay, hostname);
92
+ if (!res.ok) {
93
+ console.warn('requestCrawl failed', { relay, status: res.status });
94
+ }
95
+ } catch (err) {
96
+ console.warn('requestCrawl error', { relay, error: String(err) });
97
+ }
98
+ }),
99
+ );
100
+ }
101
+
@@ -1,26 +1,33 @@
1
- import { setGetEnv } from 'astro/env/setup';
2
- import type { Env } from '../env';
3
- import type { SecretsStoreSecret } from '../../types/env';
1
+ import { setGetEnv } from "astro/env/setup";
2
+ import type { Env } from "../env";
3
+ import type { SecretsStoreSecret } from "../../types/env";
4
4
 
5
5
  const SECRET_KEYS = [
6
- 'PDS_DID',
7
- 'PDS_HANDLE',
8
- 'USER_PASSWORD',
9
- 'ACCESS_TOKEN_SECRET',
10
- 'REFRESH_TOKEN_SECRET',
11
- 'REPO_SIGNING_KEY',
12
- 'REPO_SIGNING_KEY_PUBLIC',
6
+ "PDS_DID",
7
+ "PDS_HANDLE",
8
+ "USER_PASSWORD",
9
+ "REFRESH_TOKEN",
10
+ "REFRESH_TOKEN_SECRET",
11
+ "SESSION_JWT_SECRET",
12
+ "REPO_SIGNING_KEY",
13
+ "REPO_SIGNING_KEY_PUBLIC",
14
+ "PDS_PLC_ROTATION_KEY",
15
+ "PDS_SERVICE_SIGNING_KEY_HEX",
13
16
  ] as const satisfies readonly (keyof Env)[];
14
17
 
15
18
  function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
16
- return !!value && typeof value === 'object' && typeof (value as any).get === 'function';
19
+ return (
20
+ !!value &&
21
+ typeof value === "object" &&
22
+ typeof (value as any).get === "function"
23
+ );
17
24
  }
18
25
 
19
26
  export async function resolveSecret(
20
- value: string | SecretsStoreSecret | undefined
27
+ value: string | SecretsStoreSecret | undefined,
21
28
  ): Promise<string | undefined> {
22
29
  if (value === undefined) return undefined;
23
- if (typeof value === 'string') return value;
30
+ if (typeof value === "string") return value;
24
31
  if (isSecretStoreBinding(value)) return value.get();
25
32
  return undefined;
26
33
  }
@@ -38,15 +45,16 @@ export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
38
45
  if (val !== undefined) {
39
46
  resolved[key as string] = val;
40
47
  }
41
- })
48
+ }),
42
49
  );
43
50
 
44
51
  setGetEnv((key) => {
45
52
  const local = resolved[key];
46
- if (typeof local === 'string') return local;
47
- if (typeof local === 'number' || typeof local === 'boolean') return String(local);
53
+ if (typeof local === "string") return local;
54
+ if (typeof local === "number" || typeof local === "boolean")
55
+ return String(local);
48
56
  const fallback = process.env[key];
49
- return typeof fallback === 'string' ? fallback : undefined;
57
+ return typeof fallback === "string" ? fallback : undefined;
50
58
  });
51
59
 
52
60
  return resolved as E;
@@ -59,7 +67,7 @@ let astroGetSecret: AstroGetSecret | null | undefined;
59
67
  async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
60
68
  if (astroGetSecret !== undefined) return astroGetSecret;
61
69
  try {
62
- const mod = await import('astro:env/server');
70
+ const mod = await import("astro:env/server");
63
71
  astroGetSecret = mod.getSecret as AstroGetSecret;
64
72
  } catch {
65
73
  astroGetSecret = null;
@@ -70,10 +78,10 @@ async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
70
78
  export async function getRuntimeString<K extends keyof Env>(
71
79
  env: Env,
72
80
  key: K,
73
- fallback?: string
81
+ fallback?: string,
74
82
  ): Promise<string | undefined> {
75
83
  const current = env[key];
76
- if (typeof current === 'string' && current !== '') {
84
+ if (typeof current === "string" && current !== "") {
77
85
  return current;
78
86
  }
79
87
 
@@ -81,7 +89,7 @@ export async function getRuntimeString<K extends keyof Env>(
81
89
  if (secretFn) {
82
90
  try {
83
91
  const value = secretFn(String(key));
84
- if (typeof value === 'string' && value !== '') {
92
+ if (typeof value === "string" && value !== "") {
85
93
  return value;
86
94
  }
87
95
  } catch (error) {
@@ -0,0 +1,202 @@
1
+ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
2
+ import type { Env } from '../env';
3
+ import { getRuntimeString } from './secrets';
4
+ import { getOrCreateSecret } from '../db/account';
5
+
6
+ const SESSION_SECRET_KEY = 'session_jwt_secret';
7
+ const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
8
+ const ACCESS_TTL_SECONDS = 120 * 60; // 120 minutes
9
+ const REFRESH_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
10
+
11
+ async function loadSecret(env: Env): Promise<string> {
12
+ const fromEnv = await getRuntimeString(env, 'SESSION_JWT_SECRET' as keyof Env, '');
13
+ if (fromEnv) {
14
+ // Mirror into D1 so Workers without env access can retrieve it
15
+ await getOrCreateSecret(env, SESSION_SECRET_KEY, async () => fromEnv);
16
+ return fromEnv;
17
+ }
18
+
19
+ return getOrCreateSecret(env, SESSION_SECRET_KEY, async () => bytesToHex(randomBytes(32)));
20
+ }
21
+
22
+ async function getJwtKey(env: Env): Promise<Uint8Array> {
23
+ const secret = await loadSecret(env);
24
+ return new TextEncoder().encode(secret);
25
+ }
26
+
27
+ async function getServiceDid(env: Env): Promise<string> {
28
+ const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
29
+ if (!did) {
30
+ throw new Error('PDS_DID is not configured');
31
+ }
32
+ return did;
33
+ }
34
+
35
+ export async function issueSessionTokens(env: Env, did: string, opts: { jti?: string } = {}) {
36
+ const jwtKey = await getJwtKey(env);
37
+ const serviceDid = await getServiceDid(env);
38
+ const now = Math.floor(Date.now() / 1000);
39
+
40
+ const accessExp = now + ACCESS_TTL_SECONDS;
41
+ const accessPayload: TokenPayload = {
42
+ scope: 'access',
43
+ aud: serviceDid,
44
+ sub: did,
45
+ iat: now,
46
+ exp: accessExp,
47
+ };
48
+ const accessJwt = await signJwt(jwtKey, 'at+jwt', accessPayload);
49
+
50
+ const jti = opts.jti ?? generateTokenId();
51
+ const refreshExp = now + REFRESH_TTL_SECONDS;
52
+ const refreshPayload: RefreshTokenPayload = {
53
+ scope: 'refresh',
54
+ aud: serviceDid,
55
+ sub: did,
56
+ iat: now,
57
+ exp: refreshExp,
58
+ jti,
59
+ };
60
+ const refreshJwt = await signJwt(jwtKey, 'refresh+jwt', refreshPayload);
61
+
62
+ return {
63
+ accessJwt,
64
+ refreshJwt,
65
+ refreshPayload,
66
+ refreshExpiry: refreshPayload.exp,
67
+ } as const;
68
+ }
69
+
70
+ export async function verifyRefreshToken(env: Env, token: string) {
71
+ const key = await getJwtKey(env);
72
+ const serviceDid = await getServiceDid(env);
73
+ const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
74
+ if (header.typ !== 'refresh+jwt') {
75
+ throw new Error('Invalid token type');
76
+ }
77
+ if (payload.scope !== 'refresh') {
78
+ throw new Error('Invalid refresh token scope');
79
+ }
80
+ return {
81
+ payload,
82
+ decoded: {
83
+ scope: payload.scope,
84
+ sub: payload.sub,
85
+ exp: payload.exp,
86
+ jti: payload.jti,
87
+ } as RefreshTokenPayload,
88
+ } as const;
89
+ }
90
+
91
+ export async function verifyAccessToken(env: Env, token: string) {
92
+ const key = await getJwtKey(env);
93
+ const serviceDid = await getServiceDid(env);
94
+ const { header, payload } = await decodeAndVerifyJwt(key, token, 'at+jwt', serviceDid);
95
+ if (header.typ !== 'at+jwt') {
96
+ throw new Error('Invalid token type');
97
+ }
98
+ if (payload.scope === 'refresh') {
99
+ throw new Error('Unexpected scope for access token');
100
+ }
101
+ return payload;
102
+ }
103
+
104
+ export function computeGraceExpiry(previousExpiry: number, nowSeconds: number): number {
105
+ const candidate = nowSeconds + GRACE_PERIOD_SECONDS;
106
+ return Math.min(previousExpiry, candidate);
107
+ }
108
+
109
+ type TokenPayload = {
110
+ scope: string;
111
+ aud: string;
112
+ sub: string;
113
+ iat: number;
114
+ exp: number;
115
+ jti?: string;
116
+ [key: string]: unknown;
117
+ };
118
+
119
+ type RefreshTokenPayload = TokenPayload & { jti: string };
120
+
121
+ type TokenHeader = { alg: 'HS256'; typ: 'at+jwt' | 'refresh+jwt' };
122
+
123
+ async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenPayload): Promise<string> {
124
+ const header: TokenHeader = { alg: 'HS256', typ };
125
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
126
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
127
+ const data = `${encodedHeader}.${encodedPayload}`;
128
+ const signature = await hmacSign(key, data);
129
+ return `${data}.${signature}`;
130
+ }
131
+
132
+ async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
133
+ const parts = token.split('.');
134
+ if (parts.length !== 3) {
135
+ throw new Error('Invalid token format');
136
+ }
137
+ const header = JSON.parse(base64UrlDecode(parts[0])) as TokenHeader;
138
+ const payload = JSON.parse(base64UrlDecode(parts[1])) as TokenPayload;
139
+
140
+ if (header.alg !== 'HS256' || header.typ !== expectedTyp) {
141
+ throw new Error('Unexpected token header');
142
+ }
143
+ if (payload.aud !== audience) {
144
+ throw new Error('Token audience mismatch');
145
+ }
146
+ if (!payload.sub) {
147
+ throw new Error('Token missing subject');
148
+ }
149
+ if (typeof payload.exp !== 'number') {
150
+ throw new Error('Token missing expiry');
151
+ }
152
+
153
+ const data = `${parts[0]}.${parts[1]}`;
154
+ const ok = await hmacVerify(key, data, parts[2]);
155
+ if (!ok) {
156
+ throw new Error('Invalid token signature');
157
+ }
158
+
159
+ return { header, payload };
160
+ }
161
+
162
+ function generateTokenId(): string {
163
+ const bytes = randomBytes(32);
164
+ return base64UrlEncode(bytes);
165
+ }
166
+
167
+ async function hmacSign(keyBytes: Uint8Array, data: string): Promise<string> {
168
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
169
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, textEncoder.encode(data));
170
+ return base64UrlEncode(new Uint8Array(signature));
171
+ }
172
+
173
+ async function hmacVerify(keyBytes: Uint8Array, data: string, signatureB64: string): Promise<boolean> {
174
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
175
+ return crypto.subtle.verify('HMAC', cryptoKey, base64UrlDecodeToBytes(signatureB64), textEncoder.encode(data));
176
+ }
177
+
178
+ function base64UrlEncode(value: string | Uint8Array): string {
179
+ const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
180
+ let binary = '';
181
+ for (let i = 0; i < bytes.length; i++) {
182
+ binary += String.fromCharCode(bytes[i]);
183
+ }
184
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
185
+ }
186
+
187
+ function base64UrlDecode(encoded: string): string {
188
+ const pad = encoded.length % 4 === 2 ? '==' : encoded.length % 4 === 3 ? '=' : '';
189
+ const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/') + pad);
190
+ return binary;
191
+ }
192
+
193
+ function base64UrlDecodeToBytes(encoded: string): Uint8Array {
194
+ const binary = base64UrlDecode(encoded);
195
+ const bytes = new Uint8Array(binary.length);
196
+ for (let i = 0; i < binary.length; i++) {
197
+ bytes[i] = binary.charCodeAt(i);
198
+ }
199
+ return bytes;
200
+ }
201
+
202
+ const textEncoder = new TextEncoder();
@@ -1,22 +1,13 @@
1
1
  import type { Env } from '../env';
2
- import { drizzle } from 'drizzle-orm/d1';
3
- import { token_revocation } from '../db/schema';
4
- import { lt } from 'drizzle-orm';
2
+ import { cleanupExpiredRefreshTokens } from '../db/account';
5
3
 
6
4
  /**
7
5
  * Clean up expired tokens from the revocation table
8
6
  * This prevents the table from growing indefinitely
9
7
  */
10
8
  export async function cleanupExpiredTokens(env: Env): Promise<number> {
11
- const db = drizzle(env.DB);
12
9
  const now = Math.floor(Date.now() / 1000);
13
-
14
- // Delete tokens where expiry is in the past
15
- const result = await db.delete(token_revocation)
16
- .where(lt(token_revocation.exp, now))
17
- .run();
18
-
19
- return result.meta.changes || 0;
10
+ return cleanupExpiredRefreshTokens(env, now);
20
11
  }
21
12
 
22
13
  /**
@@ -35,4 +26,4 @@ export async function lazyCleanupExpiredTokens(env: Env): Promise<void> {
35
26
  } catch (error) {
36
27
  console.error('Failed to cleanup expired tokens:', error);
37
28
  }
38
- }
29
+ }
package/src/lib/util.ts CHANGED
@@ -38,10 +38,25 @@ export function bearerToken(request: Request): string | null {
38
38
  }
39
39
 
40
40
  export function isAllowedMime(env: any, mime: string): boolean {
41
- const def = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
41
+ const def = [
42
+ // Images
43
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif',
44
+ // Videos
45
+ 'video/mp4', 'video/mpeg', 'video/webm', 'video/quicktime',
46
+ // Audio
47
+ 'audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/webm',
48
+ // JSON (for some Bluesky data)
49
+ 'application/json',
50
+ // Generic fallback
51
+ 'application/octet-stream'
52
+ ];
42
53
  const raw = (env.PDS_ALLOWED_MIME as string | undefined) ?? def.join(',');
43
54
  const set = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
44
- return set.has(mime.toLowerCase());
55
+
56
+ // Extract base MIME type (remove charset and other parameters)
57
+ const baseMime = mime.toLowerCase().split(';')[0].trim();
58
+
59
+ return set.has(baseMime);
45
60
  }
46
61
 
47
62
  export function randomRkey(): string {
package/src/middleware.ts CHANGED
@@ -1,36 +1,35 @@
1
1
  import { defineMiddleware, sequence } from 'astro:middleware';
2
2
 
3
3
  const cors = defineMiddleware(async ({ locals, request }, next) => {
4
- const { env } = (locals as any).runtime ?? (locals as any);
5
- const corsOrigins = (env.PDS_CORS_ORIGIN ?? '*').split(',').map((s: string) => s.trim()).filter(Boolean);
6
- const origin = request.headers.get('origin') ?? '';
7
-
8
- // In production, never allow wildcard - require explicit origins
9
- const isProduction = env.PDS_HOSTNAME && !env.PDS_HOSTNAME.includes('localhost');
10
- const allowWildcard = !isProduction && corsOrigins.includes('*');
11
-
12
- // Check if origin is in allowlist
13
- const isAllowed = allowWildcard || corsOrigins.includes(origin);
4
+ // Match atproto CORS implementation: use wildcard for public endpoints
5
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
6
+ // For requests without credentials, "*" can be specified as a wildcard
7
+ // This is safer than reflecting the request origin and matches atproto standard
14
8
 
15
9
  if (request.method === 'OPTIONS') {
16
- if (!isAllowed) {
17
- return new Response('CORS origin not allowed', { status: 403 });
18
- }
19
-
10
+ // CORS preflight - match atproto PDS implementation
20
11
  const headers = new Headers({
21
- 'Access-Control-Allow-Origin': allowWildcard ? '*' : origin,
22
- 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
23
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
24
- 'Access-Control-Max-Age': '86400', // 24 hours
12
+ 'Access-Control-Allow-Origin': '*',
13
+ // Use wildcard for methods (atproto standard)
14
+ 'Access-Control-Allow-Methods': '*',
15
+ // Use wildcard for headers to allow atproto-accept-labelers and other custom headers
16
+ 'Access-Control-Allow-Headers': '*',
17
+ // Match atproto: 1 day max-age for CORS preflight cache
18
+ 'Access-Control-Max-Age': '86400',
25
19
  });
26
20
  return new Response(null, { status: 204, headers });
27
21
  }
28
22
 
29
23
  const response = await next();
30
24
 
31
- if (isAllowed) {
32
- response.headers.set('Access-Control-Allow-Origin', allowWildcard ? '*' : origin);
33
- response.headers.set('Vary', 'Origin');
25
+ // Set CORS headers on all responses (atproto standard)
26
+ response.headers.set('Access-Control-Allow-Origin', '*');
27
+
28
+ // Expose DPoP-Nonce header for OAuth clients (atproto standard)
29
+ // This allows clients to read the DPoP-Nonce header from responses
30
+ const dpopNonce = response.headers.get('DPoP-Nonce');
31
+ if (dpopNonce) {
32
+ response.headers.set('Access-Control-Expose-Headers', 'DPoP-Nonce');
34
33
  }
35
34
 
36
35
  return response;