@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.
Files changed (90) hide show
  1. package/README.md +558 -0
  2. package/index.d.ts +12 -0
  3. package/index.js +129 -0
  4. package/package.json +75 -0
  5. package/src/_worker.ts +44 -0
  6. package/src/app.ts +10 -0
  7. package/src/db/client.ts +7 -0
  8. package/src/db/dal.ts +97 -0
  9. package/src/db/repo.ts +135 -0
  10. package/src/db/schema.ts +89 -0
  11. package/src/db/seed.ts +14 -0
  12. package/src/env.d.ts +4 -0
  13. package/src/handlers/debug.ts +34 -0
  14. package/src/handlers/health.ts +6 -0
  15. package/src/handlers/ready.ts +14 -0
  16. package/src/handlers/root.ts +5 -0
  17. package/src/handlers/wellknown.ts +7 -0
  18. package/src/handlers/xrpc.repo.core.ts +57 -0
  19. package/src/handlers/xrpc.server.createSession.ts +25 -0
  20. package/src/handlers/xrpc.server.refreshSession.ts +43 -0
  21. package/src/lib/auth.ts +20 -0
  22. package/src/lib/blockstore-gc.ts +197 -0
  23. package/src/lib/cache.ts +236 -0
  24. package/src/lib/car-reader.ts +157 -0
  25. package/src/lib/commit-log-pruning.ts +76 -0
  26. package/src/lib/commit.ts +162 -0
  27. package/src/lib/config.ts +208 -0
  28. package/src/lib/errors.ts +142 -0
  29. package/src/lib/firehose/frames.ts +229 -0
  30. package/src/lib/firehose/parse.ts +82 -0
  31. package/src/lib/firehose/validation.ts +9 -0
  32. package/src/lib/handle.ts +90 -0
  33. package/src/lib/jwt.ts +150 -0
  34. package/src/lib/logger.ts +73 -0
  35. package/src/lib/metrics.ts +194 -0
  36. package/src/lib/mst/blockstore.ts +105 -0
  37. package/src/lib/mst/index.ts +3 -0
  38. package/src/lib/mst/mst.ts +643 -0
  39. package/src/lib/mst/util.ts +86 -0
  40. package/src/lib/ratelimit.ts +34 -0
  41. package/src/lib/sequencer.ts +10 -0
  42. package/src/lib/streaming-car.ts +137 -0
  43. package/src/lib/token-cleanup.ts +38 -0
  44. package/src/lib/tracing.ts +136 -0
  45. package/src/lib/util.ts +55 -0
  46. package/src/middleware.ts +102 -0
  47. package/src/pages/.well-known/atproto-did.ts +7 -0
  48. package/src/pages/.well-known/did.json.ts +76 -0
  49. package/src/pages/debug/blob/[...key].ts +27 -0
  50. package/src/pages/debug/db/bootstrap.ts +23 -0
  51. package/src/pages/debug/db/commits.ts +20 -0
  52. package/src/pages/debug/gc/blobs.ts +16 -0
  53. package/src/pages/debug/record.ts +33 -0
  54. package/src/pages/health.ts +68 -0
  55. package/src/pages/index.astro +57 -0
  56. package/src/pages/index.ts +2 -0
  57. package/src/pages/ready.ts +16 -0
  58. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
  59. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
  60. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
  61. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
  62. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
  63. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
  64. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
  65. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
  66. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
  67. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
  68. package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
  69. package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
  70. package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
  71. package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
  72. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
  73. package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
  74. package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
  75. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
  76. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
  77. package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
  78. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
  79. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
  80. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
  81. package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
  82. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
  83. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
  84. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
  85. package/src/services/car.ts +249 -0
  86. package/src/services/r2-blob-store.ts +87 -0
  87. package/src/services/repo-manager.ts +339 -0
  88. package/src/shims/astro-internal-handler.d.ts +4 -0
  89. package/src/worker/sequencer.ts +563 -0
  90. 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
+ }