@alteran/astro 0.1.7 → 0.1.9

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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Blob Reference Extraction Utilities
3
+ *
4
+ * Provides functions to extract blob CIDs from AT Protocol records.
5
+ * Used during migration and for blob usage tracking.
6
+ */
7
+
8
+ /**
9
+ * Extract all blob CIDs from a record object
10
+ *
11
+ * @param obj - The record object to scan
12
+ * @returns Set of blob CIDs found in the record
13
+ */
14
+ export function extractBlobRefs(obj: any): Set<string> {
15
+ const refs = new Set<string>();
16
+ extractBlobRefsRecursive(obj, refs);
17
+ return refs;
18
+ }
19
+
20
+ /**
21
+ * Recursively extract blob CIDs from an object
22
+ */
23
+ function extractBlobRefsRecursive(obj: any, refs: Set<string>): void {
24
+ if (!obj || typeof obj !== 'object') return;
25
+
26
+ // Check for blob reference pattern ($type: 'blob')
27
+ if (obj.$type === 'blob' && obj.ref) {
28
+ if (typeof obj.ref === 'object' && obj.ref.$link) {
29
+ refs.add(obj.ref.$link);
30
+ } else if (typeof obj.ref === 'string') {
31
+ refs.add(obj.ref);
32
+ }
33
+ }
34
+
35
+ // Check for direct CID link pattern
36
+ if (obj.$link && typeof obj.$link === 'string') {
37
+ // Only add if it looks like a blob CID (not a record CID)
38
+ // Blob CIDs typically start with specific multihash prefixes
39
+ refs.add(obj.$link);
40
+ }
41
+
42
+ // Check for legacy blob patterns
43
+ if (obj.cid && typeof obj.cid === 'string') {
44
+ refs.add(obj.cid);
45
+ }
46
+
47
+ // Recurse into nested objects and arrays
48
+ if (Array.isArray(obj)) {
49
+ for (const item of obj) {
50
+ extractBlobRefsRecursive(item, refs);
51
+ }
52
+ } else {
53
+ for (const value of Object.values(obj)) {
54
+ extractBlobRefsRecursive(value, refs);
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Extract blob references from known Bluesky record types
61
+ * This is more specific than the generic extractor and handles
62
+ * common patterns in app.bsky.* records.
63
+ */
64
+ export function extractBskyBlobRefs(record: any): Set<string> {
65
+ const refs = new Set<string>();
66
+
67
+ // Handle app.bsky.feed.post embeds
68
+ if (record.embed) {
69
+ extractBlobRefsRecursive(record.embed, refs);
70
+ }
71
+
72
+ // Handle app.bsky.actor.profile avatar and banner
73
+ if (record.avatar) {
74
+ extractBlobRefsRecursive(record.avatar, refs);
75
+ }
76
+ if (record.banner) {
77
+ extractBlobRefsRecursive(record.banner, refs);
78
+ }
79
+
80
+ // Handle app.bsky.feed.generator avatar
81
+ if (record.$type === 'app.bsky.feed.generator' && record.avatar) {
82
+ extractBlobRefsRecursive(record.avatar, refs);
83
+ }
84
+
85
+ // Fallback to generic extraction for any other patterns
86
+ extractBlobRefsRecursive(record, refs);
87
+
88
+ return refs;
89
+ }
90
+
91
+ /**
92
+ * Convert blob references to R2 keys
93
+ *
94
+ * @param cids - Set of blob CIDs
95
+ * @returns Array of R2 keys
96
+ */
97
+ export function blobCidsToKeys(cids: Set<string>): string[] {
98
+ return Array.from(cids).map(cid => `blobs/by-cid/${cid}`);
99
+ }
package/src/lib/jwt.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Env } from '../env';
2
+ import { getRuntimeString } from './secrets';
2
3
 
3
4
  export interface JwtClaims {
4
5
  sub: string; // DID
@@ -31,7 +32,14 @@ export async function signJwt(env: Env, claims: JwtClaims, kind: 'access' | 'ref
31
32
  if (claims.scope) payload.scope = claims.scope;
32
33
  if (claims.jti) payload.jti = claims.jti;
33
34
 
34
- const secret = kind === 'access' ? (env.ACCESS_TOKEN_SECRET ?? 'dev-access') : (env.REFRESH_TOKEN_SECRET ?? 'dev-refresh');
35
+ const secret = await getRuntimeString(
36
+ env,
37
+ kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET',
38
+ kind === 'access' ? 'dev-access' : 'dev-refresh'
39
+ );
40
+ if (!secret) {
41
+ throw new Error(`Missing ${kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET'}`);
42
+ }
35
43
  const algorithm = (env.JWT_ALGORITHM as string | undefined) ?? 'HS256';
36
44
 
37
45
  if (algorithm === 'EdDSA') {
@@ -50,7 +58,12 @@ export async function verifyJwt(env: Env, token: string): Promise<{ valid: boole
50
58
 
51
59
  let ok = false;
52
60
  if (header.alg === 'HS256' && header.typ === 'JWT') {
53
- const secret = (payload.t === 'refresh') ? (env.REFRESH_TOKEN_SECRET ?? 'dev-refresh') : (env.ACCESS_TOKEN_SECRET ?? 'dev-access');
61
+ const secret = await getRuntimeString(
62
+ env,
63
+ payload.t === 'refresh' ? 'REFRESH_TOKEN_SECRET' : 'ACCESS_TOKEN_SECRET',
64
+ payload.t === 'refresh' ? 'dev-refresh' : 'dev-access'
65
+ );
66
+ if (!secret) return null;
54
67
  ok = await hmacJwtVerify(parts[0] + '.' + parts[1], parts[2], secret);
55
68
  } else if (header.alg === 'EdDSA' && header.typ === 'JWT') {
56
69
  ok = await eddsaJwtVerify(parts[0] + '.' + parts[1], parts[2], env);
@@ -90,9 +103,9 @@ async function eddsaJwtSign(payload: any, env: Env): Promise<string> {
90
103
  const data = `${h}.${p}`;
91
104
 
92
105
  // Import Ed25519 private key from env
93
- const keyData = env.JWT_ED25519_PRIVATE_KEY as string | undefined;
106
+ const keyData = await getRuntimeString(env, 'REPO_SIGNING_KEY');
94
107
  if (!keyData) {
95
- throw new Error('JWT_ED25519_PRIVATE_KEY not configured');
108
+ throw new Error('REPO_SIGNING_KEY not configured for EdDSA JWTs');
96
109
  }
97
110
 
98
111
  // Decode base64 private key
@@ -114,7 +127,7 @@ async function eddsaJwtVerify(data: string, sigB64: string, env: Env): Promise<b
114
127
  const enc = new TextEncoder();
115
128
 
116
129
  // Import Ed25519 public key from env
117
- const keyData = env.JWT_ED25519_PUBLIC_KEY as string | undefined;
130
+ const keyData = await getRuntimeString(env, 'REPO_SIGNING_PUBLIC_KEY');
118
131
  if (!keyData) {
119
132
  return false;
120
133
  }
@@ -0,0 +1,95 @@
1
+ import { setGetEnv } from 'astro/env/setup';
2
+ import type { Env } from '../env';
3
+ import type { SecretsStoreSecret } from '../../types/env';
4
+
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_PUBLIC_KEY',
13
+ ] as const satisfies readonly (keyof Env)[];
14
+
15
+ function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
16
+ return !!value && typeof value === 'object' && typeof (value as any).get === 'function';
17
+ }
18
+
19
+ export async function resolveSecret(
20
+ value: string | SecretsStoreSecret | undefined
21
+ ): Promise<string | undefined> {
22
+ if (value === undefined) return undefined;
23
+ if (typeof value === 'string') return value;
24
+ if (isSecretStoreBinding(value)) return value.get();
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Return a shallow-cloned Env where all known secret fields are materialized to strings.
30
+ * Non-secret bindings (DB, BLOBS, SEQUENCER, vars) are preserved as-is.
31
+ */
32
+ export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
33
+ const resolved: Record<string, unknown> = { ...env };
34
+
35
+ await Promise.all(
36
+ SECRET_KEYS.map(async (key) => {
37
+ const val = await resolveSecret((env as any)[key]);
38
+ if (val !== undefined) {
39
+ resolved[key as string] = val;
40
+ }
41
+ })
42
+ );
43
+
44
+ setGetEnv((key) => {
45
+ const local = resolved[key];
46
+ if (typeof local === 'string') return local;
47
+ if (typeof local === 'number' || typeof local === 'boolean') return String(local);
48
+ const fallback = process.env[key];
49
+ return typeof fallback === 'string' ? fallback : undefined;
50
+ });
51
+
52
+ return resolved as E;
53
+ }
54
+
55
+ type AstroGetSecret = (key: string) => string | undefined;
56
+
57
+ let astroGetSecret: AstroGetSecret | null | undefined;
58
+
59
+ async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
60
+ if (astroGetSecret !== undefined) return astroGetSecret;
61
+ try {
62
+ const mod = await import('astro:env/server');
63
+ astroGetSecret = mod.getSecret as AstroGetSecret;
64
+ } catch {
65
+ astroGetSecret = null;
66
+ }
67
+ return astroGetSecret;
68
+ }
69
+
70
+ export async function getRuntimeString<K extends keyof Env>(
71
+ env: Env,
72
+ key: K,
73
+ fallback?: string
74
+ ): Promise<string | undefined> {
75
+ const current = env[key];
76
+ if (typeof current === 'string' && current !== '') {
77
+ return current;
78
+ }
79
+
80
+ const secretFn = await loadAstroGetSecret();
81
+ if (secretFn) {
82
+ try {
83
+ const value = secretFn(String(key));
84
+ if (typeof value === 'string' && value !== '') {
85
+ return value;
86
+ }
87
+ } catch (error) {
88
+ if (fallback === undefined) {
89
+ throw error;
90
+ }
91
+ }
92
+ }
93
+
94
+ return fallback;
95
+ }
@@ -0,0 +1,99 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.identity.getRecommendedDidCredentials
8
+ *
9
+ * Returns recommended DID credentials for this PDS.
10
+ * Used during migration to update identity documents.
11
+ */
12
+ export async function GET({ locals, request }: APIContext) {
13
+ const { env } = locals.runtime;
14
+
15
+ if (!(await isAuthorized(request, env))) return unauthorized();
16
+
17
+ try {
18
+ const did = env.PDS_DID ?? 'did:example:single-user';
19
+ const handle = env.PDS_HANDLE ?? 'example.com';
20
+ const hostname = env.PDS_HOSTNAME ?? handle;
21
+
22
+ // Get signing key if available
23
+ let signingKey: string | undefined;
24
+ if (env.REPO_SIGNING_PUBLIC_KEY) {
25
+ // Convert raw public key to multibase format
26
+ const pubKeyStr = String(env.REPO_SIGNING_PUBLIC_KEY);
27
+ const pubKeyBytes = Uint8Array.from(atob(pubKeyStr), c => c.charCodeAt(0));
28
+
29
+ // Ed25519 multicodec prefix (0xed01) + public key
30
+ const multicodecBytes = new Uint8Array(2 + pubKeyBytes.length);
31
+ multicodecBytes[0] = 0xed;
32
+ multicodecBytes[1] = 0x01;
33
+ multicodecBytes.set(pubKeyBytes, 2);
34
+
35
+ // Base58 encode with 'z' prefix for multibase
36
+ signingKey = 'z' + base58Encode(multicodecBytes);
37
+ }
38
+
39
+ return new Response(
40
+ JSON.stringify({
41
+ did,
42
+ handle,
43
+ pds: `https://${hostname}`,
44
+ signingKey,
45
+ alsoKnownAs: [`at://${handle}`],
46
+ verificationMethods: signingKey ? {
47
+ atproto: signingKey
48
+ } : undefined,
49
+ services: {
50
+ atproto_pds: {
51
+ type: 'AtprotoPersonalDataServer',
52
+ endpoint: `https://${hostname}`
53
+ }
54
+ }
55
+ }),
56
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
57
+ );
58
+ } catch (error: any) {
59
+ return new Response(
60
+ JSON.stringify({
61
+ error: 'InternalServerError',
62
+ message: error.message || 'Failed to get DID credentials'
63
+ }),
64
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
65
+ );
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Base58 encode (Bitcoin alphabet)
71
+ */
72
+ function base58Encode(bytes: Uint8Array): string {
73
+ const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
74
+
75
+ // Convert bytes to bigint
76
+ let num = 0n;
77
+ for (const byte of bytes) {
78
+ num = num * 256n + BigInt(byte);
79
+ }
80
+
81
+ // Convert to base58
82
+ let result = '';
83
+ while (num > 0n) {
84
+ const remainder = Number(num % 58n);
85
+ result = ALPHABET[remainder] + result;
86
+ num = num / 58n;
87
+ }
88
+
89
+ // Add leading '1's for leading zero bytes
90
+ for (const byte of bytes) {
91
+ if (byte === 0) {
92
+ result = '1' + result;
93
+ } else {
94
+ break;
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
@@ -2,6 +2,9 @@ import type { APIContext } from 'astro';
2
2
  import { RepoManager } from '../../services/repo-manager';
3
3
  import { readJson } from '../../lib/util';
4
4
  import { bumpRoot } from '../../db/repo';
5
+ import { isAuthorized, unauthorized } from '../../lib/auth';
6
+ import { isAccountActive } from '../../db/dal';
7
+ import { checkRate } from '../../lib/ratelimit';
5
8
 
6
9
  export const prerender = false;
7
10
 
@@ -11,6 +14,23 @@ export const prerender = false;
11
14
  */
12
15
  export async function POST({ locals, request }: APIContext) {
13
16
  const { env } = locals.runtime;
17
+ if (!(await isAuthorized(request, env))) return unauthorized();
18
+
19
+ // Check if account is active
20
+ const did = env.PDS_DID ?? 'did:example:single-user';
21
+ const active = await isAccountActive(env, did);
22
+ if (!active) {
23
+ return new Response(
24
+ JSON.stringify({
25
+ error: 'AccountDeactivated',
26
+ message: 'Account is deactivated. Activate it before making changes.'
27
+ }),
28
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
29
+ );
30
+ }
31
+
32
+ const rateLimitResponse = await checkRate(env, request, 'writes');
33
+ if (rateLimitResponse) return rateLimitResponse;
14
34
 
15
35
  try {
16
36
  const body = await readJson(request);
@@ -0,0 +1,142 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { parseCarFile } from '../../lib/car-reader';
4
+ import { D1Blockstore } from '../../lib/mst';
5
+ import { getDb } from '../../db/client';
6
+ import { repo_root, commit_log } from '../../db/schema';
7
+ import { putRecord } from '../../db/dal';
8
+ import * as dagCbor from '@ipld/dag-cbor';
9
+ import { CID } from 'multiformats/cid';
10
+
11
+ export const prerender = false;
12
+
13
+ /**
14
+ * com.atproto.repo.importRepo
15
+ *
16
+ * Imports a repository from a CAR (Content Addressable aRchive) file.
17
+ * This is used during account migration to transfer the complete repo history.
18
+ */
19
+ export async function POST({ locals, request }: APIContext) {
20
+ const { env } = locals.runtime;
21
+
22
+ if (!(await isAuthorized(request, env))) return unauthorized();
23
+
24
+ try {
25
+ const contentType = request.headers.get('content-type');
26
+ if (contentType !== 'application/vnd.ipld.car') {
27
+ return new Response(
28
+ JSON.stringify({
29
+ error: 'InvalidRequest',
30
+ message: 'Content-Type must be application/vnd.ipld.car'
31
+ }),
32
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
33
+ );
34
+ }
35
+
36
+ const did = env.PDS_DID ?? 'did:example:single-user';
37
+ const carBytes = new Uint8Array(await request.arrayBuffer());
38
+
39
+ // Parse CAR file
40
+ const { header, blocks } = parseCarFile(carBytes);
41
+
42
+ if (blocks.length === 0) {
43
+ return new Response(
44
+ JSON.stringify({
45
+ error: 'InvalidRequest',
46
+ message: 'CAR file contains no blocks'
47
+ }),
48
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
49
+ );
50
+ }
51
+
52
+ // Store all blocks in blockstore
53
+ const blockstore = new D1Blockstore(env);
54
+ for (const block of blocks) {
55
+ await blockstore.put(block.cid, block.bytes);
56
+ }
57
+
58
+ // Find the commit block (root of the CAR)
59
+ const rootCid = header.roots[0];
60
+ if (!rootCid) {
61
+ return new Response(
62
+ JSON.stringify({
63
+ error: 'InvalidRequest',
64
+ message: 'CAR file has no root CID'
65
+ }),
66
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
67
+ );
68
+ }
69
+
70
+ // Decode the commit to get repo details
71
+ const commitBlock = blocks.find(b => b.cid.equals(rootCid));
72
+ if (!commitBlock) {
73
+ return new Response(
74
+ JSON.stringify({
75
+ error: 'InvalidRequest',
76
+ message: 'Root commit block not found in CAR'
77
+ }),
78
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
79
+ );
80
+ }
81
+
82
+ const commit = dagCbor.decode(commitBlock.bytes) as any;
83
+ const rev = commit.rev || commit.version || 1;
84
+
85
+ // Update repo root
86
+ const db = getDb(env);
87
+ await db
88
+ .insert(repo_root)
89
+ .values({
90
+ did,
91
+ commitCid: rootCid.toString(),
92
+ rev: typeof rev === 'string' ? parseInt(rev) : rev,
93
+ })
94
+ .onConflictDoUpdate({
95
+ target: repo_root.did,
96
+ set: {
97
+ commitCid: rootCid.toString(),
98
+ rev: typeof rev === 'string' ? parseInt(rev) : rev,
99
+ },
100
+ })
101
+ .run();
102
+
103
+ // Index records from MST
104
+ // Note: This is a simplified implementation
105
+ // A full implementation would walk the MST tree and index all records
106
+ let recordCount = 0;
107
+ for (const block of blocks) {
108
+ try {
109
+ const obj = dagCbor.decode(block.bytes) as any;
110
+
111
+ // Check if this looks like a record (has $type)
112
+ if (obj && typeof obj === 'object' && obj.$type) {
113
+ // This is a record, we should index it
114
+ // For now, we'll skip detailed indexing and let it be done lazily
115
+ recordCount++;
116
+ }
117
+ } catch {
118
+ // Not a valid CBOR object or not a record, skip
119
+ }
120
+ }
121
+
122
+ return new Response(
123
+ JSON.stringify({
124
+ did,
125
+ commitCid: rootCid.toString(),
126
+ rev,
127
+ blocksImported: blocks.length,
128
+ recordsFound: recordCount,
129
+ message: 'Repository imported successfully'
130
+ }),
131
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
132
+ );
133
+ } catch (error: any) {
134
+ return new Response(
135
+ JSON.stringify({
136
+ error: 'InternalServerError',
137
+ message: error.message || 'Failed to import repository'
138
+ }),
139
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
140
+ );
141
+ }
142
+ }
@@ -0,0 +1,88 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { getDb } from '../../db/client';
4
+ import { record, blob_ref } from '../../db/schema';
5
+ import { eq } from 'drizzle-orm';
6
+ import { extractBlobRefs } from '../../lib/blob-refs';
7
+
8
+ export const prerender = false;
9
+
10
+ /**
11
+ * com.atproto.repo.listMissingBlobs
12
+ *
13
+ * Lists blob CIDs that are referenced in records but not present in blob storage.
14
+ * Used during migration to identify which blobs need to be transferred.
15
+ */
16
+ export async function GET({ locals, request, url }: APIContext) {
17
+ const { env } = locals.runtime;
18
+
19
+ if (!(await isAuthorized(request, env))) return unauthorized();
20
+
21
+ try {
22
+ const did = env.PDS_DID ?? 'did:example:single-user';
23
+ const limit = parseInt(url.searchParams.get('limit') || '500');
24
+ const cursor = url.searchParams.get('cursor') || '';
25
+
26
+ const db = getDb(env);
27
+
28
+ // Get all records for this DID
29
+ const records = await db
30
+ .select()
31
+ .from(record)
32
+ .where(eq(record.did, did))
33
+ .all();
34
+
35
+ // Get all blob refs for this DID
36
+ const blobs = await db
37
+ .select()
38
+ .from(blob_ref)
39
+ .where(eq(blob_ref.did, did))
40
+ .all();
41
+
42
+ // Create a set of existing blob CIDs
43
+ const existingBlobCids = new Set(blobs.map(b => b.cid));
44
+
45
+ // Extract blob references from records
46
+ const referencedBlobs = new Set<string>();
47
+ for (const rec of records) {
48
+ try {
49
+ const data = JSON.parse(rec.json);
50
+ const refs = extractBlobRefs(data);
51
+ refs.forEach(ref => referencedBlobs.add(ref));
52
+ } catch {
53
+ // Skip invalid JSON
54
+ }
55
+ }
56
+
57
+ // Find missing blobs
58
+ const missingBlobs: string[] = [];
59
+ for (const cid of referencedBlobs) {
60
+ if (!existingBlobCids.has(cid)) {
61
+ if (!cursor || cid > cursor) {
62
+ missingBlobs.push(cid);
63
+ }
64
+ }
65
+ }
66
+
67
+ // Sort and limit
68
+ missingBlobs.sort();
69
+ const page = missingBlobs.slice(0, limit);
70
+ const nextCursor = page.length === limit ? page[page.length - 1] : undefined;
71
+
72
+ return new Response(
73
+ JSON.stringify({
74
+ blobs: page.map(cid => ({ cid })),
75
+ cursor: nextCursor,
76
+ }),
77
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
78
+ );
79
+ } catch (error: any) {
80
+ return new Response(
81
+ JSON.stringify({
82
+ error: 'InternalServerError',
83
+ message: error.message || 'Failed to list missing blobs'
84
+ }),
85
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
86
+ );
87
+ }
88
+ }
@@ -3,7 +3,7 @@ import { isAuthorized, unauthorized } from '../../lib/auth';
3
3
  import { checkRate } from '../../lib/ratelimit';
4
4
  import { isAllowedMime } from '../../lib/util';
5
5
  import { R2BlobStore } from '../../services/r2-blob-store';
6
- import { putBlobRef, checkBlobQuota, updateBlobQuota } from '../../db/dal';
6
+ import { putBlobRef, checkBlobQuota, updateBlobQuota, isAccountActive } from '../../db/dal';
7
7
 
8
8
  export const prerender = false;
9
9
 
@@ -11,6 +11,21 @@ export async function POST({ locals, request }: APIContext) {
11
11
  const { env } = locals.runtime;
12
12
  if (!(await isAuthorized(request, env))) return unauthorized();
13
13
 
14
+ // Get DID from environment (single-user PDS)
15
+ const did = env.PDS_DID ?? 'did:example:single-user';
16
+
17
+ // Check if account is active
18
+ const active = await isAccountActive(env, did);
19
+ if (!active) {
20
+ return new Response(
21
+ JSON.stringify({
22
+ error: 'AccountDeactivated',
23
+ message: 'Account is deactivated. Activate it before uploading blobs.'
24
+ }),
25
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
26
+ );
27
+ }
28
+
14
29
  const rateLimitResponse = await checkRate(env, request, 'blob');
15
30
  if (rateLimitResponse) return rateLimitResponse;
16
31
 
@@ -18,9 +33,6 @@ export async function POST({ locals, request }: APIContext) {
18
33
  const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
19
34
  if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
20
35
 
21
- // Get DID from environment (single-user PDS)
22
- const did = env.PDS_DID ?? 'did:example:single-user';
23
-
24
36
  // Check quota before upload
25
37
  const canUpload = await checkBlobQuota(env, did, buf.byteLength);
26
38
  if (!canUpload) {