@alteran/astro 0.7.7 → 0.8.2

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
@@ -71,6 +71,27 @@
71
71
  "when": 1778624701795,
72
72
  "tag": "0009_oauth_session_state",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1778710862250,
79
+ "tag": "0010_eminent_klaw",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1778754994653,
86
+ "tag": "0011_chief_darwin",
87
+ "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "6",
92
+ "when": 1778790000000,
93
+ "tag": "0012_backfill_blob_usage",
94
+ "breakpoints": true
74
95
  }
75
96
  ]
76
- }
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.7.7",
3
+ "version": "0.8.2",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,54 +25,37 @@
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },
28
- "scripts": {
29
- "dev": "astro dev",
30
- "build": "astro build",
31
- "preview": "astro preview",
32
- "test:oauth": "bun test tests/oauth-*.test.ts tests/resource-auth.test.ts",
33
- "deploy": "astro build && bunx wrangler deploy --env production",
34
- "iac:deploy": "bunx alchemy deploy iac/alchemy.run.ts",
35
- "iac:destroy": "bunx alchemy destroy iac/alchemy.run.ts",
36
- "iac:plan": "bunx alchemy plan iac/alchemy.run.ts",
37
- "db:generate": "bunx drizzle-kit generate",
38
- "db:apply": "bunx wrangler d1 migrations apply pds",
39
- "db:apply:local": "bunx wrangler d1 migrations apply pds --local",
40
- "db:apply:local:direct": "wrangler d1 migrations apply pds --local",
41
- "db:reset:local": "rm -rf .wrangler/state && rm -rf migrations && bun run db:generate && bun run db:apply:local",
42
- "secrets:setup": "bun run scripts/setup-secrets.ts",
43
- "relay:request-crawl": "bun run scripts/request-crawl.ts",
44
- "pds:test-create-session": "bun run scripts/test-create-session.ts"
45
- },
28
+ "scripts": {},
46
29
  "devDependencies": {
47
- "@astrojs/cloudflare": "^12.6.9",
48
- "@atproto/api": "^0.17.0",
49
- "@cloudflare/vite-plugin": "^1.13.8",
50
- "@types/bun": "latest",
51
- "drizzle-kit": "^0.31.5",
52
- "miniflare": "3",
53
- "vite": "^6.3.6",
54
- "wrangler": "^4.40.3"
30
+ "@astrojs/cloudflare": "^12.6.13",
31
+ "@cloudflare/vite-plugin": "^1.37.0",
32
+ "drizzle-kit": "^0.31.10",
33
+ "miniflare": "^3.20250718.3",
34
+ "vite": "^6.4.2",
35
+ "wrangler": "^4.91.0"
55
36
  },
56
37
  "peerDependencies": {
57
38
  "typescript": "^5"
58
39
  },
59
40
  "dependencies": {
60
- "@atproto/crypto": "^0.4.4",
61
- "@cloudflare/workers-types": "^4.20251001.0",
41
+ "@atproto/api": "^0.17.7",
42
+ "@atproto/crypto": "^0.4.5",
43
+ "@atproto/syntax": "^0.4.3",
44
+ "@cloudflare/workers-types": "^4.20260511.1",
62
45
  "@did-plc/lib": "^0.0.4",
63
- "@iconify-json/simple-icons": "^1.2.53",
64
- "@ipld/car": "^5.4.2",
65
- "@ipld/dag-cbor": "^9.2.5",
66
- "@noble/hashes": "^2.0.1",
67
- "astro": "^6.1.5",
46
+ "@iconify-json/simple-icons": "^1.2.82",
47
+ "@ipld/car": "^5.4.6",
48
+ "@ipld/dag-cbor": "^9.2.7",
49
+ "@noble/hashes": "^2.2.0",
50
+ "astro": "^6.3.3",
68
51
  "astro-icon": "^1.1.5",
69
- "dotenv": "^17.2.3",
70
- "drizzle-orm": "^0.44.6",
71
- "get-port": "^7.1.0",
72
- "hono": "^4.9.9",
73
- "multiformats": "^13.4.1",
74
- "uint8arrays": "^5.1.0",
75
- "jose": "^5.9.6"
52
+ "dotenv": "^17.4.2",
53
+ "drizzle-orm": "^0.44.7",
54
+ "get-port": "^7.2.0",
55
+ "hono": "^4.12.18",
56
+ "multiformats": "^13.4.2",
57
+ "uint8arrays": "^5.1.1",
58
+ "jose": "^5.10.0"
76
59
  },
77
60
  "repository": {
78
61
  "type": "git",
package/src/db/blob.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { eq, sql } from 'drizzle-orm';
2
+ import { getDb } from './client';
3
+ import { blob_ref, blob_quota } from './schema';
4
+ import type { Env } from '../env';
5
+
6
+ export type BlobKeyRef = {
7
+ did: string;
8
+ key: string;
9
+ };
10
+
11
+ export type BlobRefMetadata = {
12
+ did: string;
13
+ cid: string;
14
+ key: string;
15
+ mime: string;
16
+ size: number;
17
+ uploadedAt: number;
18
+ };
19
+
20
+ export type BlobRegistrationResult =
21
+ | { tag: 'registered'; blob: BlobRefMetadata }
22
+ | { tag: 'alreadyExists'; blob: BlobRefMetadata }
23
+ | { tag: 'quotaExceeded' };
24
+
25
+ export async function putBlobRef(env: Env, did: string, cid: string, key: string, mime: string, size: number) {
26
+ const db = getDb(env);
27
+ const uploadedAt = Date.now();
28
+ await db
29
+ .insert(blob_ref)
30
+ .values({ did, cid, key, mime, size, uploadedAt })
31
+ .onConflictDoUpdate({
32
+ target: [blob_ref.did, blob_ref.cid],
33
+ set: {
34
+ key: sql.raw(`excluded.${blob_ref.key.name}`),
35
+ mime: sql.raw(`excluded.${blob_ref.mime.name}`),
36
+ size: sql.raw(`excluded.${blob_ref.size.name}`),
37
+ uploadedAt: sql.raw(`excluded.${blob_ref.uploadedAt.name}`),
38
+ },
39
+ });
40
+ }
41
+
42
+ export async function registerBlobRefWithQuota(
43
+ env: Env,
44
+ did: string,
45
+ cid: string,
46
+ key: string,
47
+ mime: string,
48
+ size: number,
49
+ ): Promise<BlobRegistrationResult> {
50
+ const existing = await getBlobRef(env, did, cid);
51
+ if (existing) {
52
+ const uploadedAt = Date.now();
53
+ await env.ALTERAN_DB.batch([
54
+ env.ALTERAN_DB.prepare(
55
+ `UPDATE blob
56
+ SET uploaded_at = ?
57
+ WHERE did = ? AND cid = ?`,
58
+ ).bind(uploadedAt, did, cid),
59
+ env.ALTERAN_DB.prepare(
60
+ `UPDATE blob_quota
61
+ SET updated_at = ?
62
+ WHERE did = ?`,
63
+ ).bind(uploadedAt, did),
64
+ ]);
65
+ return { tag: 'alreadyExists', blob: { ...existing, uploadedAt } };
66
+ }
67
+
68
+ const quotaBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
69
+ const maxBytes = Number.isFinite(quotaBytes) && quotaBytes > 0 ? quotaBytes : 10737418240;
70
+ const uploadedAt = Date.now();
71
+ const results = await env.ALTERAN_DB.batch([
72
+ env.ALTERAN_DB.prepare(
73
+ `INSERT OR IGNORE INTO blob_quota (did, total_bytes, blob_count, updated_at)
74
+ VALUES (?, 0, 0, ?)`,
75
+ ).bind(did, uploadedAt),
76
+ env.ALTERAN_DB.prepare(
77
+ `UPDATE blob_quota
78
+ SET updated_at = ?
79
+ WHERE did = ?`,
80
+ ).bind(uploadedAt, did),
81
+ env.ALTERAN_DB.prepare(
82
+ `INSERT OR IGNORE INTO blob (did, cid, key, mime, size, uploaded_at)
83
+ SELECT ?, ?, ?, ?, ?, ?
84
+ FROM blob_quota
85
+ WHERE did = ?
86
+ AND total_bytes + ? <= ?`,
87
+ ).bind(did, cid, key, mime, size, uploadedAt, did, size, maxBytes),
88
+ env.ALTERAN_DB.prepare(
89
+ `UPDATE blob_quota
90
+ SET total_bytes = (
91
+ SELECT COALESCE(SUM(size), 0) FROM blob WHERE did = ?
92
+ ),
93
+ blob_count = (
94
+ SELECT COUNT(*) FROM blob WHERE did = ?
95
+ ),
96
+ updated_at = ?
97
+ WHERE did = ?`,
98
+ ).bind(did, did, Date.now(), did),
99
+ ]);
100
+
101
+ const blob = await getBlobRef(env, did, cid);
102
+ if (blob && changedRows(results[2]) === 1) return { tag: 'registered', blob };
103
+ if (blob) return { tag: 'alreadyExists', blob };
104
+ return { tag: 'quotaExceeded' };
105
+ }
106
+
107
+ export async function getRecordBlobKeys(env: Env, did: string, uri: string): Promise<string[]> {
108
+ const result = await env.ALTERAN_DB.prepare(
109
+ 'SELECT key FROM blob_usage WHERE did = ? AND record_uri = ?',
110
+ )
111
+ .bind(did, uri)
112
+ .all<{ key: string }>();
113
+
114
+ return result.results?.map((row: { key: string }) => row.key) ?? [];
115
+ }
116
+
117
+ export async function blobKeyHasUsage(env: Env, did: string, key: string): Promise<boolean> {
118
+ const row = await env.ALTERAN_DB.prepare(
119
+ 'SELECT 1 FROM blob_usage WHERE did = ? AND key = ? LIMIT 1',
120
+ )
121
+ .bind(did, key)
122
+ .first();
123
+
124
+ return row !== null;
125
+ }
126
+
127
+ export async function deleteUnreferencedBlobKeys(
128
+ env: Env,
129
+ refs: readonly BlobKeyRef[],
130
+ ): Promise<number> {
131
+ let deleted = 0;
132
+ for (const { did, key } of uniqueBlobKeyRefs(refs)) {
133
+ const blob = await env.ALTERAN_DB.prepare(
134
+ 'SELECT 1 FROM blob WHERE did = ? AND key = ? LIMIT 1',
135
+ )
136
+ .bind(did, key)
137
+ .first();
138
+ if (!blob) continue;
139
+
140
+ const cutoff = blobDeletionCutoff();
141
+ const object = await env.ALTERAN_BLOBS.head(key);
142
+ if (object && !uploadedBeforeCutoff(object, cutoff)) continue;
143
+
144
+ const [deletion] = await env.ALTERAN_DB.batch([
145
+ env.ALTERAN_DB.prepare(
146
+ `DELETE FROM blob
147
+ WHERE did = ?
148
+ AND key = ?
149
+ AND uploaded_at <= ?
150
+ AND NOT EXISTS (
151
+ SELECT 1 FROM blob_usage WHERE did = ? AND key = ?
152
+ )`,
153
+ ).bind(did, key, cutoff, did, key),
154
+ recomputeQuotaStatement(env, did),
155
+ ]);
156
+ if (changedRows(deletion) !== 1) continue;
157
+
158
+ // R2 deletes cannot share D1's commit guard. Metadata is the visibility
159
+ // gate; object cleanup needs a separate lease/tombstone flow.
160
+ deleted++;
161
+ }
162
+
163
+ return deleted;
164
+ }
165
+
166
+ export async function sweepEligibleUnreferencedBlobKeys(
167
+ env: Env,
168
+ options: { did?: string; limit?: number } = {},
169
+ ): Promise<number> {
170
+ return deleteUnreferencedBlobKeys(env, await listOrphanBlobRefs(env, {
171
+ did: options.did,
172
+ limit: options.limit ?? 100,
173
+ }));
174
+ }
175
+
176
+ export async function listOrphanBlobKeys(env: Env): Promise<string[]> {
177
+ return (await listOrphanBlobRefs(env)).map((ref) => ref.key);
178
+ }
179
+
180
+ export async function listOrphanBlobRefs(
181
+ env: Env,
182
+ options: { did?: string; limit?: number } = {},
183
+ ): Promise<BlobKeyRef[]> {
184
+ const limit = Math.max(1, Math.min(options.limit ?? 100, 1000));
185
+ const cutoff = blobDeletionCutoff();
186
+ const result = options.did
187
+ ? await env.ALTERAN_DB.prepare(
188
+ `SELECT did, key
189
+ FROM blob b
190
+ WHERE b.did = ?
191
+ AND b.uploaded_at <= ?
192
+ AND NOT EXISTS (
193
+ SELECT 1 FROM blob_usage u WHERE u.did = b.did AND u.key = b.key
194
+ )
195
+ ORDER BY b.uploaded_at, b.did, b.key
196
+ LIMIT ?`,
197
+ ).bind(options.did, cutoff, limit).all<BlobKeyRef>()
198
+ : await env.ALTERAN_DB.prepare(
199
+ `SELECT did, key
200
+ FROM blob b
201
+ WHERE b.uploaded_at <= ?
202
+ AND NOT EXISTS (
203
+ SELECT 1 FROM blob_usage u WHERE u.did = b.did AND u.key = b.key
204
+ )
205
+ ORDER BY b.uploaded_at, b.did, b.key
206
+ LIMIT ?`,
207
+ ).bind(cutoff, limit).all<BlobKeyRef>();
208
+ return result.results ?? [];
209
+ }
210
+
211
+ export async function deleteBlobByKey(env: Env, key: string) {
212
+ const db = getDb(env);
213
+ await db.delete(blob_ref).where(eq(blob_ref.key, key)).run();
214
+ }
215
+
216
+ export async function getBlobQuota(env: Env, did: string) {
217
+ const db = getDb(env);
218
+ const quota = await db.select().from(blob_quota).where(eq(blob_quota.did, did)).get();
219
+ return quota ?? { did, total_bytes: 0, blob_count: 0, updated_at: Date.now() };
220
+ }
221
+
222
+ export async function updateBlobQuota(env: Env, did: string, bytesAdded: number, countAdded: number) {
223
+ const db = getDb(env);
224
+ const current = await getBlobQuota(env, did);
225
+
226
+ const newTotalBytes = Math.max(0, current.total_bytes + bytesAdded);
227
+ const newBlobCount = Math.max(0, current.blob_count + countAdded);
228
+ const now = Date.now();
229
+
230
+ await db
231
+ .insert(blob_quota)
232
+ .values({
233
+ did,
234
+ total_bytes: newTotalBytes,
235
+ blob_count: newBlobCount,
236
+ updated_at: now,
237
+ })
238
+ .onConflictDoUpdate({
239
+ target: blob_quota.did,
240
+ set: {
241
+ total_bytes: sql.raw(`excluded.${blob_quota.total_bytes.name}`),
242
+ blob_count: sql.raw(`excluded.${blob_quota.blob_count.name}`),
243
+ updated_at: sql.raw(`excluded.${blob_quota.updated_at.name}`),
244
+ },
245
+ });
246
+ }
247
+
248
+ export async function checkBlobQuota(env: Env, did: string, additionalBytes: number): Promise<boolean> {
249
+ const quota = await getBlobQuota(env, did);
250
+ const maxBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
251
+
252
+ return quota.total_bytes + additionalBytes <= maxBytes;
253
+ }
254
+
255
+ export async function deleteUnreferencedBlobRef(env: Env, did: string, cid: string): Promise<void> {
256
+ await env.ALTERAN_DB.batch([
257
+ env.ALTERAN_DB.prepare(
258
+ `DELETE FROM blob
259
+ WHERE did = ?
260
+ AND cid = ?
261
+ AND NOT EXISTS (
262
+ SELECT 1 FROM blob_usage u WHERE u.did = blob.did AND u.key = blob.key
263
+ )`,
264
+ ).bind(did, cid),
265
+ recomputeQuotaStatement(env, did),
266
+ ]);
267
+ }
268
+
269
+ function uniqueBlobKeyRefs(refs: readonly BlobKeyRef[]): BlobKeyRef[] {
270
+ const seen = new Set<string>();
271
+ const unique: BlobKeyRef[] = [];
272
+ for (const ref of refs) {
273
+ const id = `${ref.did}\0${ref.key}`;
274
+ if (seen.has(id)) continue;
275
+ seen.add(id);
276
+ unique.push(ref);
277
+ }
278
+ return unique;
279
+ }
280
+
281
+ function blobDeletionCutoff(): number {
282
+ return Date.now() - 60 * 60 * 1000;
283
+ }
284
+
285
+ function changedRows(result: unknown): number {
286
+ const meta = (result as { meta?: Record<string, unknown> } | undefined)?.meta;
287
+ const changes = meta?.changes ?? meta?.rows_written ?? meta?.rowsWritten;
288
+ return typeof changes === 'number' ? changes : 0;
289
+ }
290
+
291
+ function recomputeQuotaStatement(env: Env, did: string) {
292
+ return env.ALTERAN_DB.prepare(
293
+ `UPDATE blob_quota
294
+ SET total_bytes = (
295
+ SELECT COALESCE(SUM(size), 0) FROM blob WHERE did = ?
296
+ ),
297
+ blob_count = (
298
+ SELECT COUNT(*) FROM blob WHERE did = ?
299
+ ),
300
+ updated_at = ?
301
+ WHERE did = ?`,
302
+ ).bind(did, did, Date.now(), did);
303
+ }
304
+
305
+ async function getBlobRef(env: Env, did: string, cid: string): Promise<BlobRefMetadata | null> {
306
+ const row = await env.ALTERAN_DB.prepare(
307
+ `SELECT did, cid, key, mime, size, uploaded_at as uploadedAt
308
+ FROM blob
309
+ WHERE did = ? AND cid = ?
310
+ LIMIT 1`,
311
+ ).bind(did, cid).first<BlobRefMetadata>();
312
+ return row ?? null;
313
+ }
314
+
315
+ function uploadedBeforeCutoff(object: { uploaded?: Date | string | number }, cutoff: number): boolean {
316
+ const uploaded = object.uploaded;
317
+ if (uploaded instanceof Date) return uploaded.getTime() <= cutoff;
318
+ if (typeof uploaded === 'string' || typeof uploaded === 'number') {
319
+ const timestamp = new Date(uploaded).getTime();
320
+ return Number.isFinite(timestamp) && timestamp <= cutoff;
321
+ }
322
+ return false;
323
+ }