@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,229 @@
1
+ import * as dagCbor from '@ipld/dag-cbor';
2
+ import * as uint8arrays from 'uint8arrays';
3
+ import { CID } from 'multiformats/cid';
4
+
5
+ /**
6
+ * Frame types for AT Protocol firehose
7
+ */
8
+ export enum FrameType {
9
+ Message = 1,
10
+ Error = -1,
11
+ }
12
+
13
+ /**
14
+ * Frame header structure
15
+ */
16
+ export interface FrameHeader {
17
+ op: FrameType;
18
+ t?: string; // Message type discriminator
19
+ }
20
+
21
+ /**
22
+ * Error frame body
23
+ */
24
+ export interface ErrorFrameBody {
25
+ error: string;
26
+ message?: string;
27
+ }
28
+
29
+ /**
30
+ * Base frame class
31
+ */
32
+ export abstract class Frame {
33
+ abstract header: FrameHeader;
34
+ abstract body: unknown;
35
+
36
+ get op(): FrameType {
37
+ return this.header.op;
38
+ }
39
+
40
+ /**
41
+ * Encode frame to bytes (header + body as CBOR)
42
+ */
43
+ toBytes(): Uint8Array {
44
+ const headerBytes = dagCbor.encode(this.header);
45
+ const bodyBytes = dagCbor.encode(this.body);
46
+ return uint8arrays.concat([headerBytes, bodyBytes]);
47
+ }
48
+
49
+ /**
50
+ * Encode with 4-byte big-endian length prefix (payload = header||body encoded as dag-cbor)
51
+ */
52
+ toFramedBytes(): Uint8Array {
53
+ const payload = this.toBytes();
54
+ const prefix = new Uint8Array(4);
55
+ const len = payload.byteLength >>> 0;
56
+ prefix[0] = (len >>> 24) & 0xff;
57
+ prefix[1] = (len >>> 16) & 0xff;
58
+ prefix[2] = (len >>> 8) & 0xff;
59
+ prefix[3] = len & 0xff;
60
+ return uint8arrays.concat([prefix, payload]);
61
+ }
62
+
63
+ isMessage(): this is MessageFrame {
64
+ return this.op === FrameType.Message;
65
+ }
66
+
67
+ isError(): this is ErrorFrame {
68
+ return this.op === FrameType.Error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Message frame for firehose events
74
+ */
75
+ export class MessageFrame<T = unknown> extends Frame {
76
+ header: FrameHeader;
77
+ body: T;
78
+
79
+ constructor(body: T, type?: string) {
80
+ super();
81
+ this.header = type ? { op: FrameType.Message, t: type } : { op: FrameType.Message };
82
+ this.body = body;
83
+ }
84
+
85
+ get type(): string | undefined {
86
+ return this.header.t;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Error frame
92
+ */
93
+ export class ErrorFrame extends Frame {
94
+ header: FrameHeader;
95
+ body: ErrorFrameBody;
96
+
97
+ constructor(error: string, message?: string) {
98
+ super();
99
+ this.header = { op: FrameType.Error };
100
+ this.body = { error, message };
101
+ }
102
+
103
+ get code(): string {
104
+ return this.body.error;
105
+ }
106
+
107
+ get message(): string | undefined {
108
+ return this.body.message;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Firehose message types
114
+ */
115
+
116
+ export interface InfoMessage {
117
+ name: string;
118
+ message?: string;
119
+ }
120
+
121
+ export interface RepoOp {
122
+ action: 'create' | 'update' | 'delete';
123
+ path: string;
124
+ cid: CID | null;
125
+ prev?: CID;
126
+ }
127
+
128
+ export interface CommitMessage {
129
+ seq: number;
130
+ rebase: boolean;
131
+ tooBig: boolean;
132
+ repo: string; // DID
133
+ commit: CID;
134
+ prev: CID | null;
135
+ rev: string; // TID
136
+ since: string | null; // Previous TID
137
+ blocks: Uint8Array; // CAR bytes
138
+ ops: RepoOp[];
139
+ blobs: CID[];
140
+ time: string; // ISO 8601
141
+ prevData?: CID; // Previous MST root
142
+ }
143
+
144
+ export interface IdentityMessage {
145
+ seq: number;
146
+ did: string;
147
+ time: string;
148
+ handle?: string;
149
+ }
150
+
151
+ export interface AccountMessage {
152
+ seq: number;
153
+ did: string;
154
+ time: string;
155
+ active: boolean;
156
+ status?: string;
157
+ }
158
+
159
+ export interface SyncMessage {
160
+ seq: number;
161
+ did: string;
162
+ time: string;
163
+ active: boolean;
164
+ status?: string;
165
+ }
166
+
167
+ /**
168
+ * Create an #info frame
169
+ */
170
+ export function createInfoFrame(name: string, message?: string): MessageFrame<InfoMessage> {
171
+ return new MessageFrame({ name, message }, '#info');
172
+ }
173
+
174
+ /**
175
+ * Create a #commit frame
176
+ */
177
+ export function createCommitFrame(data: CommitMessage): MessageFrame<CommitMessage> {
178
+ return new MessageFrame(data, '#commit');
179
+ }
180
+
181
+ /**
182
+ * Create an #identity frame
183
+ */
184
+ export function createIdentityFrame(data: IdentityMessage): MessageFrame<IdentityMessage> {
185
+ return new MessageFrame(data, '#identity');
186
+ }
187
+
188
+ /**
189
+ * Create an #account frame
190
+ */
191
+ export function createAccountFrame(data: AccountMessage): MessageFrame<AccountMessage> {
192
+ return new MessageFrame(data, '#account');
193
+ }
194
+
195
+ /**
196
+ * Create a #sync frame (alias/compat for account-status changes)
197
+ */
198
+ export function createSyncFrame(data: SyncMessage): MessageFrame<SyncMessage> {
199
+ return new MessageFrame(data, '#sync');
200
+ }
201
+
202
+ /**
203
+ * Create an error frame
204
+ */
205
+ export function createErrorFrame(error: string, message?: string): ErrorFrame {
206
+ return new ErrorFrame(error, message);
207
+ }
208
+
209
+ // Binary encoders (with 4-byte length prefix)
210
+ export function encodeInfoFrame(name: string, message?: string): Uint8Array {
211
+ return createInfoFrame(name, message).toFramedBytes();
212
+ }
213
+
214
+ export function encodeCommitFrame(data: CommitMessage): Uint8Array {
215
+ return createCommitFrame(data).toFramedBytes();
216
+ }
217
+
218
+ export function encodeIdentityFrame(data: IdentityMessage): Uint8Array {
219
+ return createIdentityFrame(data).toFramedBytes();
220
+ }
221
+
222
+ export function encodeAccountFrame(data: AccountMessage): Uint8Array {
223
+ return createAccountFrame(data).toFramedBytes();
224
+ }
225
+
226
+ // Alias for TODO nomenclature (#sync)
227
+ export function encodeSyncFrame(data: SyncMessage): Uint8Array {
228
+ return createSyncFrame(data).toFramedBytes();
229
+ }
@@ -0,0 +1,82 @@
1
+ import * as dagCbor from '@ipld/dag-cbor';
2
+
3
+ function readU32BE(buf: Uint8Array, off = 0): number {
4
+ return ((buf[off] << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3]) >>> 0;
5
+ }
6
+
7
+ function readVarUint(buf: Uint8Array, off: number, addl: number): { value: number; off: number } {
8
+ if (addl < 24) return { value: addl, off };
9
+ if (addl === 24) return { value: buf[off], off: off + 1 };
10
+ if (addl === 25) return { value: (buf[off] << 8) | buf[off + 1], off: off + 2 };
11
+ if (addl === 26)
12
+ return {
13
+ value: (buf[off] * 2 ** 24) + (buf[off + 1] << 16) + (buf[off + 2] << 8) + buf[off + 3],
14
+ off: off + 4,
15
+ };
16
+ if (addl === 27) throw new Error('uint64 not supported in header');
17
+ throw new Error('indefinite lengths not supported');
18
+ }
19
+
20
+ function skipItem(buf: Uint8Array, off: number): number {
21
+ const ib = buf[off];
22
+ if (ib === undefined) throw new Error('unexpected EOF');
23
+ const major = ib >>> 5;
24
+ const addl = ib & 0x1f;
25
+ off += 1;
26
+ const readLen = () => {
27
+ const r = readVarUint(buf, off, addl);
28
+ off = r.off;
29
+ return r.value;
30
+ };
31
+
32
+ switch (major) {
33
+ case 0: // unsigned
34
+ case 1: // negative
35
+ if (addl >= 24) off = readVarUint(buf, off, addl).off;
36
+ return off;
37
+ case 2: // byte string
38
+ case 3: { // text string
39
+ const len = readLen();
40
+ return off + len;
41
+ }
42
+ case 4: { // array
43
+ const len = readLen();
44
+ for (let i = 0; i < len; i++) off = skipItem(buf, off);
45
+ return off;
46
+ }
47
+ case 5: { // map
48
+ const len = readLen();
49
+ for (let i = 0; i < len; i++) {
50
+ off = skipItem(buf, off); // key
51
+ off = skipItem(buf, off); // value
52
+ }
53
+ return off;
54
+ }
55
+ case 6: { // tag
56
+ // skip tag and the tagged item
57
+ if (addl >= 24) off = readVarUint(buf, off, addl).off;
58
+ return skipItem(buf, off);
59
+ }
60
+ case 7: // simple/float/bool/null
61
+ if (addl === 24) return off + 1; // simple(8)
62
+ if (addl === 25) return off + 2; // half float
63
+ if (addl === 26) return off + 4; // float
64
+ if (addl === 27) return off + 8; // double
65
+ return off; // simple values (true/false/null)
66
+ default:
67
+ throw new Error('unknown major type');
68
+ }
69
+ }
70
+
71
+ export function parseFramedFrame<T = unknown>(framed: Uint8Array): { header: any; body: T } {
72
+ if (framed.byteLength < 5) throw new Error('frame too small');
73
+ const total = readU32BE(framed, 0);
74
+ if (total !== framed.byteLength - 4) throw new Error('length prefix mismatch');
75
+ const payload = framed.subarray(4);
76
+
77
+ const hdrEnd = skipItem(payload, 0);
78
+ const header = dagCbor.decode(payload.subarray(0, hdrEnd));
79
+ const body = dagCbor.decode(payload.subarray(hdrEnd)) as T;
80
+ return { header, body };
81
+ }
82
+
@@ -0,0 +1,9 @@
1
+ import { createErrorFrame } from './frames';
2
+
3
+ export function checkCursor(cursor: number, currentSeq: number): Uint8Array | null {
4
+ if (Number.isFinite(cursor) && Number.isFinite(currentSeq) && cursor > currentSeq) {
5
+ return createErrorFrame('FutureCursor', 'Cursor is ahead of current sequence').toFramedBytes();
6
+ }
7
+ return null;
8
+ }
9
+
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Handle validation and normalization utilities
3
+ *
4
+ * Handles must:
5
+ * - Be lowercase
6
+ * - Contain only alphanumeric characters, dots, and hyphens
7
+ * - Not start or end with dots or hyphens
8
+ * - Have valid TLD
9
+ */
10
+
11
+ /**
12
+ * Validate handle format
13
+ */
14
+ export function isValidHandle(handle: string): boolean {
15
+ if (!handle || typeof handle !== 'string') {
16
+ return false;
17
+ }
18
+
19
+ // Must be lowercase
20
+ if (handle !== handle.toLowerCase()) {
21
+ return false;
22
+ }
23
+
24
+ // Length constraints (3-253 characters)
25
+ if (handle.length < 3 || handle.length > 253) {
26
+ return false;
27
+ }
28
+
29
+ // Must match pattern: alphanumeric, dots, hyphens
30
+ // Cannot start/end with dot or hyphen
31
+ const handleRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
32
+ if (!handleRegex.test(handle)) {
33
+ return false;
34
+ }
35
+
36
+ // Must have at least one dot (domain requirement)
37
+ if (!handle.includes('.')) {
38
+ return false;
39
+ }
40
+
41
+ // Check TLD is valid (at least 2 characters)
42
+ const parts = handle.split('.');
43
+ const tld = parts[parts.length - 1];
44
+ if (tld.length < 2) {
45
+ return false;
46
+ }
47
+
48
+ // No consecutive dots
49
+ if (handle.includes('..')) {
50
+ return false;
51
+ }
52
+
53
+ // No consecutive hyphens
54
+ if (handle.includes('--')) {
55
+ return false;
56
+ }
57
+
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Normalize handle (lowercase, trim)
63
+ */
64
+ export function normalizeHandle(handle: string): string {
65
+ return handle.toLowerCase().trim();
66
+ }
67
+
68
+ /**
69
+ * Validate and normalize handle
70
+ */
71
+ export function validateAndNormalizeHandle(handle: string): string | null {
72
+ const normalized = normalizeHandle(handle);
73
+ return isValidHandle(normalized) ? normalized : null;
74
+ }
75
+
76
+ /**
77
+ * Extract domain from handle
78
+ */
79
+ export function getHandleDomain(handle: string): string {
80
+ const parts = handle.split('.');
81
+ return parts.slice(-2).join('.');
82
+ }
83
+
84
+ /**
85
+ * Check if handle is a subdomain
86
+ */
87
+ export function isSubdomain(handle: string): boolean {
88
+ const parts = handle.split('.');
89
+ return parts.length > 2;
90
+ }
package/src/lib/jwt.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { Env } from '../env';
2
+
3
+ export interface JwtClaims {
4
+ sub: string; // DID
5
+ handle?: string;
6
+ scope?: string;
7
+ aud?: string;
8
+ jti?: string;
9
+ t: 'access' | 'refresh';
10
+ }
11
+
12
+ // JWT
13
+ export async function signJwt(env: Env, claims: JwtClaims, kind: 'access' | 'refresh'): Promise<string> {
14
+ const iat = Math.floor(Date.now() / 1000);
15
+ const ttlAccess = Number((env.PDS_ACCESS_TTL_SEC as string | undefined) ?? 3600);
16
+ const ttlRefresh = Number((env.PDS_REFRESH_TTL_SEC as string | undefined) ?? 30 * 24 * 3600);
17
+ const exp = iat + (kind === 'access' ? ttlAccess : ttlRefresh);
18
+
19
+ // Build proper JWT claims
20
+ const payload: Record<string, unknown> = {
21
+ iss: env.PDS_HOSTNAME || 'alteran',
22
+ sub: claims.sub,
23
+ aud: claims.aud || env.PDS_HOSTNAME || 'alteran',
24
+ iat,
25
+ exp,
26
+ t: kind,
27
+ };
28
+
29
+ // Add optional claims
30
+ if (claims.handle) payload.handle = claims.handle;
31
+ if (claims.scope) payload.scope = claims.scope;
32
+ if (claims.jti) payload.jti = claims.jti;
33
+
34
+ const secret = kind === 'access' ? (env.ACCESS_TOKEN_SECRET ?? 'dev-access') : (env.REFRESH_TOKEN_SECRET ?? 'dev-refresh');
35
+ const algorithm = (env.JWT_ALGORITHM as string | undefined) ?? 'HS256';
36
+
37
+ if (algorithm === 'EdDSA') {
38
+ return await eddsaJwtSign(payload, env);
39
+ }
40
+
41
+ return await hmacJwtSign(payload, secret);
42
+ }
43
+
44
+ export async function verifyJwt(env: Env, token: string): Promise<{ valid: boolean; payload: any } | null> {
45
+ const parts = token.split('.');
46
+ if (parts.length !== 3) return null;
47
+ const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
48
+
49
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
50
+
51
+ let ok = false;
52
+ 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');
54
+ ok = await hmacJwtVerify(parts[0] + '.' + parts[1], parts[2], secret);
55
+ } else if (header.alg === 'EdDSA' && header.typ === 'JWT') {
56
+ ok = await eddsaJwtVerify(parts[0] + '.' + parts[1], parts[2], env);
57
+ } else {
58
+ return null;
59
+ }
60
+
61
+ const now = Math.floor(Date.now() / 1000);
62
+ if (!ok || (payload.exp && now > payload.exp)) return null;
63
+ return { valid: true, payload };
64
+ }
65
+
66
+ async function hmacJwtSign(payload: any, secret: string): Promise<string> {
67
+ const enc = new TextEncoder();
68
+ const header = { alg: 'HS256', typ: 'JWT' };
69
+ const h = b64url(enc.encode(JSON.stringify(header)));
70
+ const p = b64url(enc.encode(JSON.stringify(payload)));
71
+ const data = `${h}.${p}`;
72
+ const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
73
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data));
74
+ const s = b64url(new Uint8Array(sig));
75
+ return `${h}.${p}.${s}`;
76
+ }
77
+
78
+ async function hmacJwtVerify(data: string, sigB64: string, secret: string): Promise<boolean> {
79
+ const enc = new TextEncoder();
80
+ const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
81
+ const ok = await crypto.subtle.verify('HMAC', key, b64urlDecode(sigB64), enc.encode(data));
82
+ return !!ok;
83
+ }
84
+
85
+ async function eddsaJwtSign(payload: any, env: Env): Promise<string> {
86
+ const enc = new TextEncoder();
87
+ const header = { alg: 'EdDSA', typ: 'JWT' };
88
+ const h = b64url(enc.encode(JSON.stringify(header)));
89
+ const p = b64url(enc.encode(JSON.stringify(payload)));
90
+ const data = `${h}.${p}`;
91
+
92
+ // Import Ed25519 private key from env
93
+ const keyData = env.JWT_ED25519_PRIVATE_KEY as string | undefined;
94
+ if (!keyData) {
95
+ throw new Error('JWT_ED25519_PRIVATE_KEY not configured');
96
+ }
97
+
98
+ // Decode base64 private key
99
+ const keyBytes = b64urlDecode(keyData);
100
+ const key = await crypto.subtle.importKey(
101
+ 'raw',
102
+ keyBytes,
103
+ { name: 'Ed25519', namedCurve: 'Ed25519' } as any,
104
+ false,
105
+ ['sign']
106
+ );
107
+
108
+ const sig = await crypto.subtle.sign('Ed25519', key, enc.encode(data));
109
+ const s = b64url(new Uint8Array(sig));
110
+ return `${h}.${p}.${s}`;
111
+ }
112
+
113
+ async function eddsaJwtVerify(data: string, sigB64: string, env: Env): Promise<boolean> {
114
+ const enc = new TextEncoder();
115
+
116
+ // Import Ed25519 public key from env
117
+ const keyData = env.JWT_ED25519_PUBLIC_KEY as string | undefined;
118
+ if (!keyData) {
119
+ return false;
120
+ }
121
+
122
+ const keyBytes = b64urlDecode(keyData);
123
+ const key = await crypto.subtle.importKey(
124
+ 'raw',
125
+ keyBytes,
126
+ { name: 'Ed25519', namedCurve: 'Ed25519' } as any,
127
+ false,
128
+ ['verify']
129
+ );
130
+
131
+ const ok = await crypto.subtle.verify('Ed25519', key, b64urlDecode(sigB64), enc.encode(data));
132
+ return !!ok;
133
+ }
134
+
135
+ function b64url(bytes: ArrayBuffer | Uint8Array): string {
136
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
137
+ let s = '';
138
+ for (let i = 0; i < b.length; i++) {
139
+ s += String.fromCharCode(b[i]);
140
+ }
141
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
142
+ }
143
+
144
+ function b64urlDecode(s: string): Uint8Array {
145
+ const pad = s.length % 4 === 2 ? '==' : s.length % 4 === 3 ? '=' : '';
146
+ const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad);
147
+ const out = new Uint8Array(bin.length);
148
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
149
+ return out;
150
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Structured Logging
3
+ * Provides JSON-formatted logs with levels and context
4
+ */
5
+
6
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
7
+
8
+ export interface LogContext {
9
+ requestId?: string;
10
+ path?: string;
11
+ method?: string;
12
+ status?: number;
13
+ duration?: number;
14
+ error?: string;
15
+ stack?: string;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ export class Logger {
20
+ constructor(private context: LogContext = {}) {}
21
+
22
+ private log(level: LogLevel, message: string, extra: LogContext = {}) {
23
+ const entry = {
24
+ level,
25
+ message,
26
+ timestamp: new Date().toISOString(),
27
+ ...this.context,
28
+ ...extra,
29
+ };
30
+
31
+ console.log(JSON.stringify(entry));
32
+ }
33
+
34
+ debug(message: string, context?: LogContext) {
35
+ this.log('debug', message, context);
36
+ }
37
+
38
+ info(message: string, context?: LogContext) {
39
+ this.log('info', message, context);
40
+ }
41
+
42
+ warn(message: string, context?: LogContext) {
43
+ this.log('warn', message, context);
44
+ }
45
+
46
+ error(message: string, error?: Error | unknown, context?: LogContext) {
47
+ const errorContext: LogContext = { ...context };
48
+
49
+ if (error instanceof Error) {
50
+ errorContext.error = error.message;
51
+ errorContext.stack = error.stack;
52
+ errorContext.errorName = error.name;
53
+ } else if (error) {
54
+ errorContext.error = String(error);
55
+ }
56
+
57
+ this.log('error', message, errorContext);
58
+ }
59
+
60
+ child(context: LogContext): Logger {
61
+ return new Logger({ ...this.context, ...context });
62
+ }
63
+ }
64
+
65
+ // Global logger instance
66
+ export const logger = new Logger();
67
+
68
+ /**
69
+ * Create a request-scoped logger
70
+ */
71
+ export function createRequestLogger(requestId: string, path: string, method: string): Logger {
72
+ return logger.child({ requestId, path, method });
73
+ }