@alteran/astro 0.1.0
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.
- package/README.md +558 -0
- package/index.d.ts +12 -0
- package/index.js +129 -0
- package/package.json +75 -0
- package/src/_worker.ts +44 -0
- package/src/app.ts +10 -0
- package/src/db/client.ts +7 -0
- package/src/db/dal.ts +97 -0
- package/src/db/repo.ts +135 -0
- package/src/db/schema.ts +89 -0
- package/src/db/seed.ts +14 -0
- package/src/env.d.ts +4 -0
- package/src/handlers/debug.ts +34 -0
- package/src/handlers/health.ts +6 -0
- package/src/handlers/ready.ts +14 -0
- package/src/handlers/root.ts +5 -0
- package/src/handlers/wellknown.ts +7 -0
- package/src/handlers/xrpc.repo.core.ts +57 -0
- package/src/handlers/xrpc.server.createSession.ts +25 -0
- package/src/handlers/xrpc.server.refreshSession.ts +43 -0
- package/src/lib/auth.ts +20 -0
- package/src/lib/blockstore-gc.ts +197 -0
- package/src/lib/cache.ts +236 -0
- package/src/lib/car-reader.ts +157 -0
- package/src/lib/commit-log-pruning.ts +76 -0
- package/src/lib/commit.ts +162 -0
- package/src/lib/config.ts +208 -0
- package/src/lib/errors.ts +142 -0
- package/src/lib/firehose/frames.ts +229 -0
- package/src/lib/firehose/parse.ts +82 -0
- package/src/lib/firehose/validation.ts +9 -0
- package/src/lib/handle.ts +90 -0
- package/src/lib/jwt.ts +150 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/metrics.ts +194 -0
- package/src/lib/mst/blockstore.ts +105 -0
- package/src/lib/mst/index.ts +3 -0
- package/src/lib/mst/mst.ts +643 -0
- package/src/lib/mst/util.ts +86 -0
- package/src/lib/ratelimit.ts +34 -0
- package/src/lib/sequencer.ts +10 -0
- package/src/lib/streaming-car.ts +137 -0
- package/src/lib/token-cleanup.ts +38 -0
- package/src/lib/tracing.ts +136 -0
- package/src/lib/util.ts +55 -0
- package/src/middleware.ts +102 -0
- package/src/pages/.well-known/atproto-did.ts +7 -0
- package/src/pages/.well-known/did.json.ts +76 -0
- package/src/pages/debug/blob/[...key].ts +27 -0
- package/src/pages/debug/db/bootstrap.ts +23 -0
- package/src/pages/debug/db/commits.ts +20 -0
- package/src/pages/debug/gc/blobs.ts +16 -0
- package/src/pages/debug/record.ts +33 -0
- package/src/pages/health.ts +68 -0
- package/src/pages/index.astro +57 -0
- package/src/pages/index.ts +2 -0
- package/src/pages/ready.ts +16 -0
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
- package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
- package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
- package/src/services/car.ts +249 -0
- package/src/services/r2-blob-store.ts +87 -0
- package/src/services/repo-manager.ts +339 -0
- package/src/shims/astro-internal-handler.d.ts +4 -0
- package/src/worker/sequencer.ts +563 -0
- package/types/env.d.ts +48 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { commit_log } from '../db/schema';
|
|
4
|
+
import { lt, desc } from 'drizzle-orm';
|
|
5
|
+
import { logger } from './logger';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Prune old commits from the commit log to prevent unbounded growth.
|
|
9
|
+
* Keeps the most recent N commits (default: 10000).
|
|
10
|
+
*
|
|
11
|
+
* This is safe because:
|
|
12
|
+
* - The current repo state is preserved in the MST and record tables
|
|
13
|
+
* - Recent commits are kept for firehose subscribers
|
|
14
|
+
* - Very old commits are not needed for sync operations
|
|
15
|
+
*
|
|
16
|
+
* @param env - Worker environment
|
|
17
|
+
* @param keepCount - Number of recent commits to keep (default: 10000)
|
|
18
|
+
* @returns Number of commits pruned
|
|
19
|
+
*/
|
|
20
|
+
export async function pruneOldCommits(env: Env, keepCount: number = 10000): Promise<number> {
|
|
21
|
+
const db = drizzle(env.DB);
|
|
22
|
+
|
|
23
|
+
// Get the sequence number of the Nth most recent commit
|
|
24
|
+
const threshold = await db
|
|
25
|
+
.select({ seq: commit_log.seq })
|
|
26
|
+
.from(commit_log)
|
|
27
|
+
.orderBy(desc(commit_log.seq))
|
|
28
|
+
.limit(1)
|
|
29
|
+
.offset(keepCount)
|
|
30
|
+
.get();
|
|
31
|
+
|
|
32
|
+
if (!threshold) {
|
|
33
|
+
// Less than keepCount commits exist, nothing to prune
|
|
34
|
+
logger.debug('commit_log_pruning', { message: 'No commits to prune', totalCommits: keepCount });
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Delete all commits older than the threshold
|
|
39
|
+
const result = await db
|
|
40
|
+
.delete(commit_log)
|
|
41
|
+
.where(lt(commit_log.seq, threshold.seq))
|
|
42
|
+
.run();
|
|
43
|
+
|
|
44
|
+
const pruned = result.meta.changes || 0;
|
|
45
|
+
logger.info('commit_log_pruning', {
|
|
46
|
+
message: 'Pruned old commits',
|
|
47
|
+
pruned,
|
|
48
|
+
threshold: threshold.seq,
|
|
49
|
+
kept: keepCount
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return pruned;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get commit log statistics
|
|
57
|
+
*/
|
|
58
|
+
export async function getCommitLogStats(env: Env): Promise<{
|
|
59
|
+
total: number;
|
|
60
|
+
oldest: number | null;
|
|
61
|
+
newest: number | null;
|
|
62
|
+
}> {
|
|
63
|
+
const db = drizzle(env.DB);
|
|
64
|
+
|
|
65
|
+
const [oldest, newest, count] = await Promise.all([
|
|
66
|
+
db.select({ seq: commit_log.seq }).from(commit_log).orderBy(commit_log.seq).limit(1).get(),
|
|
67
|
+
db.select({ seq: commit_log.seq }).from(commit_log).orderBy(desc(commit_log.seq)).limit(1).get(),
|
|
68
|
+
db.select({ count: commit_log.seq }).from(commit_log).all(),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
total: count.length,
|
|
73
|
+
oldest: oldest?.seq ?? null,
|
|
74
|
+
newest: newest?.seq ?? null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { CID } from 'multiformats/cid';
|
|
2
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
3
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AT Protocol Commit Structure
|
|
7
|
+
*
|
|
8
|
+
* A commit represents a snapshot of the repository at a specific revision.
|
|
9
|
+
* It includes:
|
|
10
|
+
* - did: The DID of the repository owner
|
|
11
|
+
* - version: Protocol version (currently 3)
|
|
12
|
+
* - data: CID of the MST root
|
|
13
|
+
* - rev: Revision number (TID format)
|
|
14
|
+
* - prev: CID of the previous commit (null for first commit)
|
|
15
|
+
* - sig: Ed25519 signature over the commit data
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface CommitData {
|
|
19
|
+
did: string;
|
|
20
|
+
version: number;
|
|
21
|
+
data: CID; // MST root CID
|
|
22
|
+
rev: string; // TID format revision
|
|
23
|
+
prev: CID | null; // Previous commit CID
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SignedCommit extends CommitData {
|
|
27
|
+
sig: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a commit object
|
|
32
|
+
*/
|
|
33
|
+
export function createCommit(
|
|
34
|
+
did: string,
|
|
35
|
+
mstRoot: CID,
|
|
36
|
+
rev: string,
|
|
37
|
+
prev: CID | null = null,
|
|
38
|
+
): CommitData {
|
|
39
|
+
return {
|
|
40
|
+
did,
|
|
41
|
+
version: 3,
|
|
42
|
+
data: mstRoot,
|
|
43
|
+
rev,
|
|
44
|
+
prev,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sign a commit with Ed25519 private key
|
|
50
|
+
*/
|
|
51
|
+
export async function signCommit(
|
|
52
|
+
commit: CommitData,
|
|
53
|
+
privateKeyBase64: string,
|
|
54
|
+
): Promise<SignedCommit> {
|
|
55
|
+
// Encode commit to CBOR for signing
|
|
56
|
+
const commitBytes = dagCbor.encode(commit);
|
|
57
|
+
|
|
58
|
+
// Import private key (PKCS#8 base64)
|
|
59
|
+
const b64 = privateKeyBase64.replace(/\s+/g, '');
|
|
60
|
+
const bin = atob(b64);
|
|
61
|
+
const pkcs8 = new Uint8Array(bin.length);
|
|
62
|
+
for (let i = 0; i < bin.length; i++) pkcs8[i] = bin.charCodeAt(i);
|
|
63
|
+
const privateKey = await crypto.subtle.importKey(
|
|
64
|
+
'pkcs8',
|
|
65
|
+
pkcs8,
|
|
66
|
+
{ name: 'Ed25519', namedCurve: 'Ed25519' } as any,
|
|
67
|
+
false,
|
|
68
|
+
['sign']
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Sign the commit bytes
|
|
72
|
+
const signature = await crypto.subtle.sign('Ed25519', privateKey, new Uint8Array(commitBytes as unknown as Uint8Array));
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...commit,
|
|
76
|
+
sig: new Uint8Array(signature),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify a signed commit
|
|
82
|
+
*/
|
|
83
|
+
export async function verifyCommit(
|
|
84
|
+
signedCommit: SignedCommit,
|
|
85
|
+
publicKeyBase64: string,
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
// Extract commit data (without signature)
|
|
89
|
+
const { sig, ...commit } = signedCommit;
|
|
90
|
+
const commitBytes = dagCbor.encode(commit);
|
|
91
|
+
|
|
92
|
+
// Import public key (SPKI base64)
|
|
93
|
+
const b64 = publicKeyBase64.replace(/\s+/g, '');
|
|
94
|
+
const bin = atob(b64);
|
|
95
|
+
const spki = new Uint8Array(bin.length);
|
|
96
|
+
for (let i = 0; i < bin.length; i++) spki[i] = bin.charCodeAt(i);
|
|
97
|
+
const publicKey = await crypto.subtle.importKey(
|
|
98
|
+
'spki',
|
|
99
|
+
spki,
|
|
100
|
+
{ name: 'Ed25519', namedCurve: 'Ed25519' } as any,
|
|
101
|
+
false,
|
|
102
|
+
['verify']
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Verify signature
|
|
106
|
+
return await crypto.subtle.verify('Ed25519', publicKey, sig as any, new Uint8Array(commitBytes as unknown as Uint8Array));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Commit verification failed:', error);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Calculate CID for a signed commit
|
|
115
|
+
*/
|
|
116
|
+
export async function commitCid(signedCommit: SignedCommit): Promise<CID> {
|
|
117
|
+
const bytes = dagCbor.encode(signedCommit);
|
|
118
|
+
const hash = await sha256.digest(bytes);
|
|
119
|
+
return CID.create(1, dagCbor.code, hash);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serialize signed commit to bytes
|
|
124
|
+
*/
|
|
125
|
+
export function serializeCommit(signedCommit: SignedCommit): Uint8Array {
|
|
126
|
+
return dagCbor.encode(signedCommit);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Deserialize commit from bytes
|
|
131
|
+
*/
|
|
132
|
+
export function deserializeCommit(bytes: Uint8Array): SignedCommit {
|
|
133
|
+
return dagCbor.decode(bytes) as SignedCommit;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate a TID (Timestamp Identifier) for use as revision
|
|
138
|
+
* TIDs are lexicographically sortable timestamps
|
|
139
|
+
*/
|
|
140
|
+
export function generateTid(): string {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const timestamp = now * 1000; // microseconds
|
|
143
|
+
|
|
144
|
+
// Convert to base32 (simplified version)
|
|
145
|
+
const chars = '234567abcdefghijklmnopqrstuvwxyz';
|
|
146
|
+
let tid = '';
|
|
147
|
+
let remaining = timestamp;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < 13; i++) {
|
|
150
|
+
tid = chars[remaining % 32] + tid;
|
|
151
|
+
remaining = Math.floor(remaining / 32);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return tid;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate TID format
|
|
159
|
+
*/
|
|
160
|
+
export function isValidTid(tid: string): boolean {
|
|
161
|
+
return /^[234567abcdefghijklmnopqrstuvwxyz]{13}$/.test(tid);
|
|
162
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { logger } from './logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Required environment variables/secrets
|
|
6
|
+
*/
|
|
7
|
+
const REQUIRED_SECRETS = [
|
|
8
|
+
'PDS_DID',
|
|
9
|
+
'PDS_HANDLE',
|
|
10
|
+
'USER_PASSWORD',
|
|
11
|
+
'ACCESS_TOKEN_SECRET',
|
|
12
|
+
'REFRESH_TOKEN_SECRET',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional environment variables with defaults
|
|
17
|
+
*/
|
|
18
|
+
const OPTIONAL_VARS = {
|
|
19
|
+
PDS_ALLOWED_MIME: 'image/jpeg,image/png,image/webp,image/gif,image/avif',
|
|
20
|
+
PDS_MAX_BLOB_SIZE: '5242880', // 5MB
|
|
21
|
+
PDS_MAX_JSON_BYTES: '65536', // 64KB
|
|
22
|
+
PDS_RATE_LIMIT_PER_MIN: '60',
|
|
23
|
+
PDS_CORS_ORIGIN: '*',
|
|
24
|
+
PDS_SEQ_WINDOW: '512',
|
|
25
|
+
ENVIRONMENT: 'development',
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration validation result
|
|
30
|
+
*/
|
|
31
|
+
export interface ConfigValidationResult {
|
|
32
|
+
valid: boolean;
|
|
33
|
+
missing: string[];
|
|
34
|
+
warnings: string[];
|
|
35
|
+
config: {
|
|
36
|
+
required: Record<string, string>;
|
|
37
|
+
optional: Record<string, string>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate environment configuration on startup
|
|
43
|
+
* Checks for required secrets and logs configuration status
|
|
44
|
+
*
|
|
45
|
+
* @param env - Worker environment
|
|
46
|
+
* @returns Validation result with missing secrets and warnings
|
|
47
|
+
*/
|
|
48
|
+
export function validateConfig(env: Env): ConfigValidationResult {
|
|
49
|
+
const missing: string[] = [];
|
|
50
|
+
const warnings: string[] = [];
|
|
51
|
+
const required: Record<string, string> = {};
|
|
52
|
+
const optional: Record<string, string> = {};
|
|
53
|
+
|
|
54
|
+
// Check required secrets
|
|
55
|
+
for (const secret of REQUIRED_SECRETS) {
|
|
56
|
+
const value = env[secret];
|
|
57
|
+
if (!value || value === '') {
|
|
58
|
+
missing.push(secret);
|
|
59
|
+
} else {
|
|
60
|
+
required[secret] = '***'; // Mask secret values in logs
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check optional vars and apply defaults
|
|
65
|
+
for (const [key, defaultValue] of Object.entries(OPTIONAL_VARS)) {
|
|
66
|
+
const value = env[key as keyof Env] as string | undefined;
|
|
67
|
+
optional[key] = value || defaultValue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate specific configurations
|
|
71
|
+
|
|
72
|
+
// CORS validation
|
|
73
|
+
const corsOrigin = optional.PDS_CORS_ORIGIN;
|
|
74
|
+
if (corsOrigin === '*' && optional.ENVIRONMENT === 'production') {
|
|
75
|
+
warnings.push('PDS_CORS_ORIGIN is set to wildcard (*) in production - this is insecure');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// DID format validation
|
|
79
|
+
const did = env.PDS_DID;
|
|
80
|
+
if (did && !did.startsWith('did:')) {
|
|
81
|
+
warnings.push(`PDS_DID should start with 'did:' (got: ${did})`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle format validation
|
|
85
|
+
const handle = env.PDS_HANDLE;
|
|
86
|
+
if (handle && handle.includes('://')) {
|
|
87
|
+
warnings.push(`PDS_HANDLE should not include protocol (got: ${handle})`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Numeric validation
|
|
91
|
+
const maxBlobSize = parseInt(optional.PDS_MAX_BLOB_SIZE);
|
|
92
|
+
if (isNaN(maxBlobSize) || maxBlobSize <= 0) {
|
|
93
|
+
warnings.push(`PDS_MAX_BLOB_SIZE must be a positive number (got: ${optional.PDS_MAX_BLOB_SIZE})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const maxJsonBytes = parseInt(optional.PDS_MAX_JSON_BYTES);
|
|
97
|
+
if (isNaN(maxJsonBytes) || maxJsonBytes <= 0) {
|
|
98
|
+
warnings.push(`PDS_MAX_JSON_BYTES must be a positive number (got: ${optional.PDS_MAX_JSON_BYTES})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rateLimit = parseInt(optional.PDS_RATE_LIMIT_PER_MIN);
|
|
102
|
+
if (isNaN(rateLimit) || rateLimit <= 0) {
|
|
103
|
+
warnings.push(`PDS_RATE_LIMIT_PER_MIN must be a positive number (got: ${optional.PDS_RATE_LIMIT_PER_MIN})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check for signing key
|
|
107
|
+
if (!env.REPO_SIGNING_KEY) {
|
|
108
|
+
warnings.push('REPO_SIGNING_KEY is not set - repository commits will not be signed');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const valid = missing.length === 0;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
valid,
|
|
115
|
+
missing,
|
|
116
|
+
warnings,
|
|
117
|
+
config: {
|
|
118
|
+
required,
|
|
119
|
+
optional,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Log configuration validation results
|
|
126
|
+
*
|
|
127
|
+
* @param result - Validation result
|
|
128
|
+
*/
|
|
129
|
+
export function logConfigValidation(result: ConfigValidationResult): void {
|
|
130
|
+
if (result.valid) {
|
|
131
|
+
logger.info('config_validation', {
|
|
132
|
+
message: 'Configuration validated successfully',
|
|
133
|
+
environment: result.config.optional.ENVIRONMENT,
|
|
134
|
+
warnings: result.warnings.length,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (result.warnings.length > 0) {
|
|
138
|
+
for (const warning of result.warnings) {
|
|
139
|
+
logger.warn('config_validation', { message: warning });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
logger.error('config_validation', {
|
|
144
|
+
message: 'Configuration validation failed',
|
|
145
|
+
missing: result.missing,
|
|
146
|
+
warnings: result.warnings,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate configuration and fail fast if invalid
|
|
153
|
+
* Call this on worker startup to ensure proper configuration
|
|
154
|
+
*
|
|
155
|
+
* @param env - Worker environment
|
|
156
|
+
* @throws Error if configuration is invalid
|
|
157
|
+
*/
|
|
158
|
+
export function validateConfigOrThrow(env: Env): void {
|
|
159
|
+
const result = validateConfig(env);
|
|
160
|
+
logConfigValidation(result);
|
|
161
|
+
|
|
162
|
+
if (!result.valid) {
|
|
163
|
+
const error = new Error(
|
|
164
|
+
`Configuration validation failed. Missing required secrets: ${result.missing.join(', ')}`
|
|
165
|
+
);
|
|
166
|
+
logger.error('config_validation', {
|
|
167
|
+
message: 'Startup failed due to invalid configuration',
|
|
168
|
+
error: error.message,
|
|
169
|
+
});
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get parsed configuration values with defaults applied
|
|
176
|
+
*
|
|
177
|
+
* @param env - Worker environment
|
|
178
|
+
* @returns Parsed configuration object
|
|
179
|
+
*/
|
|
180
|
+
export function getConfig(env: Env) {
|
|
181
|
+
const result = validateConfig(env);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
// Required
|
|
185
|
+
did: env.PDS_DID!,
|
|
186
|
+
handle: env.PDS_HANDLE!,
|
|
187
|
+
userPassword: env.USER_PASSWORD!,
|
|
188
|
+
accessTokenSecret: env.ACCESS_TOKEN_SECRET!,
|
|
189
|
+
refreshTokenSecret: env.REFRESH_TOKEN_SECRET!,
|
|
190
|
+
|
|
191
|
+
// Optional with defaults
|
|
192
|
+
allowedMime: result.config.optional.PDS_ALLOWED_MIME.split(','),
|
|
193
|
+
maxBlobSize: parseInt(result.config.optional.PDS_MAX_BLOB_SIZE),
|
|
194
|
+
maxJsonBytes: parseInt(result.config.optional.PDS_MAX_JSON_BYTES),
|
|
195
|
+
rateLimitPerMin: parseInt(result.config.optional.PDS_RATE_LIMIT_PER_MIN),
|
|
196
|
+
corsOrigin: result.config.optional.PDS_CORS_ORIGIN,
|
|
197
|
+
seqWindow: parseInt(result.config.optional.PDS_SEQ_WINDOW),
|
|
198
|
+
environment: result.config.optional.ENVIRONMENT,
|
|
199
|
+
|
|
200
|
+
// Optional
|
|
201
|
+
repoSigningKey: env.REPO_SIGNING_KEY,
|
|
202
|
+
hostname: env.PDS_HOSTNAME,
|
|
203
|
+
accessTtlSec: env.PDS_ACCESS_TTL_SEC ? parseInt(env.PDS_ACCESS_TTL_SEC) : 3600,
|
|
204
|
+
refreshTtlSec: env.PDS_REFRESH_TTL_SEC ? parseInt(env.PDS_REFRESH_TTL_SEC) : 2592000,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type Config = ReturnType<typeof getConfig>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XRPC Error Hierarchy
|
|
3
|
+
* Implements AT Protocol error codes and consistent error handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class XRPCError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
public code: string,
|
|
9
|
+
message: string,
|
|
10
|
+
public status: number,
|
|
11
|
+
public details?: Record<string, unknown>
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'XRPCError';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toJSON() {
|
|
18
|
+
return {
|
|
19
|
+
error: this.code,
|
|
20
|
+
message: this.message,
|
|
21
|
+
...(this.details && { details: this.details }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toResponse(requestId?: string): Response {
|
|
26
|
+
const headers = new Headers({
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (requestId) {
|
|
31
|
+
headers.set('X-Request-ID', requestId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return new Response(JSON.stringify(this.toJSON()), {
|
|
35
|
+
status: this.status,
|
|
36
|
+
headers,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 400 - Bad Request
|
|
42
|
+
export class InvalidRequest extends XRPCError {
|
|
43
|
+
constructor(message: string = 'Invalid request parameters', details?: Record<string, unknown>) {
|
|
44
|
+
super('InvalidRequest', message, 400, details);
|
|
45
|
+
this.name = 'InvalidRequest';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 401 - Unauthorized
|
|
50
|
+
export class AuthRequired extends XRPCError {
|
|
51
|
+
constructor(message: string = 'Authentication required', details?: Record<string, unknown>) {
|
|
52
|
+
super('AuthRequired', message, 401, details);
|
|
53
|
+
this.name = 'AuthRequired';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class InvalidToken extends XRPCError {
|
|
58
|
+
constructor(message: string = 'Invalid or expired token', details?: Record<string, unknown>) {
|
|
59
|
+
super('InvalidToken', message, 401, details);
|
|
60
|
+
this.name = 'InvalidToken';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 403 - Forbidden
|
|
65
|
+
export class Forbidden extends XRPCError {
|
|
66
|
+
constructor(message: string = 'Access forbidden', details?: Record<string, unknown>) {
|
|
67
|
+
super('Forbidden', message, 403, details);
|
|
68
|
+
this.name = 'Forbidden';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 404 - Not Found
|
|
73
|
+
export class NotFound extends XRPCError {
|
|
74
|
+
constructor(message: string = 'Resource not found', details?: Record<string, unknown>) {
|
|
75
|
+
super('NotFound', message, 404, details);
|
|
76
|
+
this.name = 'NotFound';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 429 - Rate Limit
|
|
81
|
+
export class RateLimitExceeded extends XRPCError {
|
|
82
|
+
constructor(message: string = 'Rate limit exceeded', details?: Record<string, unknown>) {
|
|
83
|
+
super('RateLimitExceeded', message, 429, details);
|
|
84
|
+
this.name = 'RateLimitExceeded';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 500 - Internal Server Error
|
|
89
|
+
export class InternalServerError extends XRPCError {
|
|
90
|
+
constructor(message: string = 'Internal server error', details?: Record<string, unknown>) {
|
|
91
|
+
super('InternalServerError', message, 500, details);
|
|
92
|
+
this.name = 'InternalServerError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* User-friendly error messages
|
|
98
|
+
* Maps technical errors to actionable guidance
|
|
99
|
+
*/
|
|
100
|
+
export const USER_FRIENDLY_MESSAGES: Record<string, string> = {
|
|
101
|
+
AuthRequired: 'Please log in to continue.',
|
|
102
|
+
InvalidToken: 'Your session has expired. Please log in again.',
|
|
103
|
+
InvalidRequest: 'The request contains invalid data. Please check your input.',
|
|
104
|
+
Forbidden: 'You do not have permission to perform this action.',
|
|
105
|
+
NotFound: 'The requested resource could not be found.',
|
|
106
|
+
RateLimitExceeded: 'Too many requests. Please try again later.',
|
|
107
|
+
InternalServerError: 'An unexpected error occurred. Please try again.',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get user-friendly message for error code
|
|
112
|
+
*/
|
|
113
|
+
export function getUserFriendlyMessage(code: string): string {
|
|
114
|
+
return USER_FRIENDLY_MESSAGES[code] || 'An error occurred. Please try again.';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Categorize error by status code
|
|
119
|
+
*/
|
|
120
|
+
export function categorizeError(status: number): 'client' | 'server' {
|
|
121
|
+
return status >= 400 && status < 500 ? 'client' : 'server';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert any error to XRPCError
|
|
126
|
+
*/
|
|
127
|
+
export function toXRPCError(error: unknown): XRPCError {
|
|
128
|
+
if (error instanceof XRPCError) {
|
|
129
|
+
return error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (error instanceof Error) {
|
|
133
|
+
return new InternalServerError(error.message, {
|
|
134
|
+
originalError: error.name,
|
|
135
|
+
stack: error.stack,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return new InternalServerError('Unknown error occurred', {
|
|
140
|
+
error: String(error),
|
|
141
|
+
});
|
|
142
|
+
}
|