@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,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
|
+
}
|