@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,92 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { signJwt } from '@alteran/lib/jwt';
|
|
3
|
+
import { readJson } from '@alteran/lib/util';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
5
|
+
import { login_attempts } from '@alteran/db/schema';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
|
|
8
|
+
export const prerender = false;
|
|
9
|
+
|
|
10
|
+
const MAX_LOGIN_ATTEMPTS = 5;
|
|
11
|
+
const LOCKOUT_DURATION_SEC = 15 * 60; // 15 minutes
|
|
12
|
+
|
|
13
|
+
export async function POST({ locals, request }: APIContext) {
|
|
14
|
+
const { env } = locals.runtime;
|
|
15
|
+
const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
|
|
16
|
+
|
|
17
|
+
const db = drizzle(env.DB);
|
|
18
|
+
const now = Math.floor(Date.now() / 1000);
|
|
19
|
+
|
|
20
|
+
// Check if IP is locked out
|
|
21
|
+
const attempt = await db.select().from(login_attempts).where(eq(login_attempts.ip, clientIp)).get();
|
|
22
|
+
if (attempt && attempt.locked_until && attempt.locked_until > now) {
|
|
23
|
+
const remainingSeconds = attempt.locked_until - now;
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
error: 'RateLimitExceeded',
|
|
27
|
+
message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`
|
|
28
|
+
}),
|
|
29
|
+
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { identifier, password } = await readJson(request).catch(() => ({ identifier: '', password: '' }));
|
|
34
|
+
const ok = !!password && password === (env.USER_PASSWORD ?? 'changeme');
|
|
35
|
+
|
|
36
|
+
if (!ok) {
|
|
37
|
+
// Track failed attempt
|
|
38
|
+
const currentAttempts = (attempt?.attempts || 0) + 1;
|
|
39
|
+
const lockedUntil = currentAttempts >= MAX_LOGIN_ATTEMPTS ? now + LOCKOUT_DURATION_SEC : null;
|
|
40
|
+
|
|
41
|
+
if (attempt) {
|
|
42
|
+
await db.update(login_attempts)
|
|
43
|
+
.set({
|
|
44
|
+
attempts: currentAttempts,
|
|
45
|
+
locked_until: lockedUntil,
|
|
46
|
+
last_attempt: now
|
|
47
|
+
})
|
|
48
|
+
.where(eq(login_attempts.ip, clientIp))
|
|
49
|
+
.run();
|
|
50
|
+
} else {
|
|
51
|
+
await db.insert(login_attempts).values({
|
|
52
|
+
ip: clientIp,
|
|
53
|
+
attempts: currentAttempts,
|
|
54
|
+
locked_until: lockedUntil,
|
|
55
|
+
last_attempt: now,
|
|
56
|
+
}).run();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (lockedUntil) {
|
|
60
|
+
return new Response(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
error: 'RateLimitExceeded',
|
|
63
|
+
message: 'Too many failed login attempts. Account locked for 15 minutes.'
|
|
64
|
+
}),
|
|
65
|
+
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
error: 'AuthRequired',
|
|
72
|
+
message: 'Invalid credentials'
|
|
73
|
+
}),
|
|
74
|
+
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Successful login - reset attempts
|
|
79
|
+
if (attempt) {
|
|
80
|
+
await db.delete(login_attempts).where(eq(login_attempts.ip, clientIp)).run();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
84
|
+
const handle = env.PDS_HANDLE ?? identifier ?? 'user.example';
|
|
85
|
+
const jti = crypto.randomUUID();
|
|
86
|
+
const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
|
|
87
|
+
const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti }, 'refresh');
|
|
88
|
+
|
|
89
|
+
return new Response(JSON.stringify({ did, handle, accessJwt, refreshJwt }), {
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* com.atproto.server.deleteSession
|
|
7
|
+
* Delete the current session (logout)
|
|
8
|
+
*/
|
|
9
|
+
export async function POST({ locals }: APIContext) {
|
|
10
|
+
const { env } = locals.runtime;
|
|
11
|
+
|
|
12
|
+
// TODO: Implement proper session revocation
|
|
13
|
+
// For single-user PDS, we just return success
|
|
14
|
+
// In a full implementation, this would:
|
|
15
|
+
// 1. Extract refresh token from Authorization header
|
|
16
|
+
// 2. Add it to a blacklist/revocation list
|
|
17
|
+
// 3. Invalidate associated access tokens
|
|
18
|
+
|
|
19
|
+
return new Response(JSON.stringify({}), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: {
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
export function GET({ locals }: APIContext) {
|
|
6
|
+
const { env } = locals.runtime;
|
|
7
|
+
const body = {
|
|
8
|
+
version: 'experimental',
|
|
9
|
+
did: env.PDS_DID ?? null,
|
|
10
|
+
handle: env.PDS_HANDLE ?? null,
|
|
11
|
+
inviteCodeRequired: false,
|
|
12
|
+
links: {},
|
|
13
|
+
};
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* com.atproto.server.getSession
|
|
7
|
+
* Get information about the current session
|
|
8
|
+
*/
|
|
9
|
+
export async function GET({ locals }: APIContext) {
|
|
10
|
+
const { env } = locals.runtime;
|
|
11
|
+
|
|
12
|
+
// TODO: Implement proper session validation from Authorization header
|
|
13
|
+
// For now, return basic session info for single-user PDS
|
|
14
|
+
|
|
15
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
16
|
+
const handle = env.PDS_HANDLE ?? 'user.example.com';
|
|
17
|
+
|
|
18
|
+
return new Response(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
did,
|
|
21
|
+
handle,
|
|
22
|
+
email: 'user@example.com', // Single-user PDS doesn't have email
|
|
23
|
+
emailConfirmed: true,
|
|
24
|
+
emailAuthFactor: false,
|
|
25
|
+
didDoc: {
|
|
26
|
+
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
27
|
+
id: did,
|
|
28
|
+
alsoKnownAs: [`at://${handle}`],
|
|
29
|
+
verificationMethod: [],
|
|
30
|
+
service: [
|
|
31
|
+
{
|
|
32
|
+
id: '#atproto_pds',
|
|
33
|
+
type: 'AtprotoPersonalDataServer',
|
|
34
|
+
serviceEndpoint: `https://${handle}`,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
{
|
|
40
|
+
status: 200,
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { signJwt, verifyJwt } from '@alteran/lib/jwt';
|
|
3
|
+
import { bearerToken } from '@alteran/lib/util';
|
|
4
|
+
import { lazyCleanupExpiredTokens } from '@alteran/lib/token-cleanup';
|
|
5
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
6
|
+
import { token_revocation } from '@alteran/db/schema';
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
8
|
+
|
|
9
|
+
export const prerender = false;
|
|
10
|
+
|
|
11
|
+
export async function POST({ locals, request }: APIContext) {
|
|
12
|
+
const { env } = locals.runtime;
|
|
13
|
+
const token = bearerToken(request);
|
|
14
|
+
if (!token) {
|
|
15
|
+
return new Response(
|
|
16
|
+
JSON.stringify({ error: 'AuthRequired', message: 'No authorization token provided' }),
|
|
17
|
+
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ver = await verifyJwt(env, token).catch(() => null);
|
|
22
|
+
if (!ver || ver.payload.t !== 'refresh') {
|
|
23
|
+
return new Response(
|
|
24
|
+
JSON.stringify({ error: 'InvalidToken', message: 'Invalid or expired refresh token' }),
|
|
25
|
+
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Reject if JTI is revoked (single-use refresh tokens)
|
|
30
|
+
const jtiOld = String(ver.payload.jti || '');
|
|
31
|
+
if (jtiOld) {
|
|
32
|
+
const db = drizzle(env.DB);
|
|
33
|
+
const revoked = await db.select().from(token_revocation).where(eq(token_revocation.jti, jtiOld)).get();
|
|
34
|
+
if (revoked) {
|
|
35
|
+
return new Response(
|
|
36
|
+
JSON.stringify({ error: 'InvalidToken', message: 'Refresh token has already been used' }),
|
|
37
|
+
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const did = String(ver.payload.sub || (env.PDS_DID ?? 'did:example:single-user'));
|
|
43
|
+
const handle = String(ver.payload.handle || env.PDS_HANDLE || 'user.example');
|
|
44
|
+
|
|
45
|
+
// Rotate: generate new token pair with new JTI
|
|
46
|
+
const jtiNew = crypto.randomUUID();
|
|
47
|
+
const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
|
|
48
|
+
const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
|
|
49
|
+
|
|
50
|
+
// Revoke old refresh token by inserting into revocation table
|
|
51
|
+
if (jtiOld && ver.payload.exp) {
|
|
52
|
+
const db = drizzle(env.DB);
|
|
53
|
+
const now = Math.floor(Date.now() / 1000);
|
|
54
|
+
await db.insert(token_revocation).values({
|
|
55
|
+
jti: jtiOld,
|
|
56
|
+
exp: Number(ver.payload.exp),
|
|
57
|
+
revoked_at: now,
|
|
58
|
+
}).run();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Lazy cleanup of expired tokens (runs 1% of the time)
|
|
62
|
+
lazyCleanupExpiredTokens(env).catch(console.error);
|
|
63
|
+
|
|
64
|
+
return new Response(JSON.stringify({ did, handle, accessJwt, refreshJwt }), {
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getRecordsByCids as dalGetByCids } from '@alteran/db/dal';
|
|
3
|
+
import { tryParse } from '@alteran/lib/util';
|
|
4
|
+
|
|
5
|
+
export const prerender = false;
|
|
6
|
+
|
|
7
|
+
export async function GET({ locals, request }: APIContext) {
|
|
8
|
+
const { env } = locals.runtime;
|
|
9
|
+
const url = new URL(request.url);
|
|
10
|
+
const cids = (url.searchParams.get('cids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
11
|
+
const rows = await dalGetByCids(env, cids);
|
|
12
|
+
const blocks = rows.map((r) => ({ cid: r.cid, value: tryParse(r.json) }));
|
|
13
|
+
return new Response(JSON.stringify({ blocks }), {
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { NotFound } from '@alteran/lib/errors';
|
|
3
|
+
import { D1Blockstore } from '@alteran/lib/mst';
|
|
4
|
+
import { CID } from 'multiformats/cid';
|
|
5
|
+
import { encodeExistingBlocksToCAR } from '@alteran/services/car';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export async function GET({ locals, request }: APIContext) {
|
|
10
|
+
const { env } = locals.runtime;
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
const cids = (url.searchParams.get('cids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
13
|
+
|
|
14
|
+
if (!cids.length) {
|
|
15
|
+
return new Response(
|
|
16
|
+
JSON.stringify({ error: 'InvalidRequest', message: 'cids parameter required' }),
|
|
17
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const blockstore = new D1Blockstore(env);
|
|
22
|
+
const roots: CID[] = [];
|
|
23
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
24
|
+
const missingCids: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const c of cids) {
|
|
27
|
+
try {
|
|
28
|
+
const cid = CID.parse(c);
|
|
29
|
+
const bytes = await blockstore.get(cid);
|
|
30
|
+
if (bytes) {
|
|
31
|
+
roots.push(cid);
|
|
32
|
+
blocks.push({ cid, bytes });
|
|
33
|
+
} else {
|
|
34
|
+
missingCids.push(c);
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
missingCids.push(c);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (missingCids.length > 0) {
|
|
42
|
+
return new NotFound(
|
|
43
|
+
`Blocks not found: ${missingCids.join(', ')}`,
|
|
44
|
+
{ missingCids }
|
|
45
|
+
).toResponse(locals.requestId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const carBytes = encodeExistingBlocksToCAR(roots, blocks);
|
|
49
|
+
|
|
50
|
+
return new Response(carBytes as any, {
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/vnd.ipld.car; version=1',
|
|
53
|
+
'Content-Disposition': 'inline; filename="blocks.car"',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getRoot as getRepoRoot } from '@alteran/db/repo';
|
|
3
|
+
import { listRecords as dalListRecords } from '@alteran/db/dal';
|
|
4
|
+
import { tryParse } from '@alteran/lib/util';
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export async function GET({ locals, request }: APIContext) {
|
|
9
|
+
const { env } = locals.runtime;
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
|
|
12
|
+
const head = await getRepoRoot(env);
|
|
13
|
+
const rows = await dalListRecords(env);
|
|
14
|
+
const records = rows
|
|
15
|
+
.filter((r) => r.uri.startsWith(`at://${did}/`))
|
|
16
|
+
.map((r) => ({ uri: r.uri, cid: r.cid, value: tryParse(r.json) }));
|
|
17
|
+
return new Response(JSON.stringify({ did, head: head?.commitCid ?? null, rev: head?.rev ?? 0, records }), {
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { buildRepoCar, buildRepoCarRange } from '@alteran/services/car';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export async function GET({ locals, request }: APIContext) {
|
|
7
|
+
const { env } = locals.runtime;
|
|
8
|
+
const url = new URL(request.url);
|
|
9
|
+
const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
|
|
10
|
+
|
|
11
|
+
// Support commit range queries
|
|
12
|
+
const fromParam = url.searchParams.get('from');
|
|
13
|
+
const toParam = url.searchParams.get('to');
|
|
14
|
+
|
|
15
|
+
let car;
|
|
16
|
+
if (fromParam && toParam) {
|
|
17
|
+
// Return commits in range [from, to]
|
|
18
|
+
const fromSeq = parseInt(fromParam, 10);
|
|
19
|
+
const toSeq = parseInt(toParam, 10);
|
|
20
|
+
|
|
21
|
+
if (isNaN(fromSeq) || isNaN(toSeq) || fromSeq < 0 || toSeq < fromSeq) {
|
|
22
|
+
return new Response(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
error: 'InvalidRequest',
|
|
25
|
+
message: 'Invalid commit range: from and to must be valid sequence numbers with from <= to'
|
|
26
|
+
}),
|
|
27
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
car = await buildRepoCarRange(env, fromSeq, toSeq);
|
|
32
|
+
} else {
|
|
33
|
+
// Return full repo snapshot
|
|
34
|
+
car = await buildRepoCar(env, did);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Response(car.bytes as any, {
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/vnd.ipld.car; version=1',
|
|
40
|
+
'Content-Disposition': 'inline; filename="checkout.car"',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getRoot as getRepoRoot } from '@alteran/db/repo';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export async function GET({ locals }: APIContext) {
|
|
7
|
+
const { env } = locals.runtime;
|
|
8
|
+
const root = await getRepoRoot(env);
|
|
9
|
+
if (!root) return new Response(JSON.stringify({ root: null, rev: 0 }), { headers: { 'Content-Type': 'application/json' } });
|
|
10
|
+
return new Response(JSON.stringify({ root: root.commitCid, rev: root.rev }), { headers: { 'Content-Type': 'application/json' } });
|
|
11
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getRoot } from '@alteran/db/repo';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* com.atproto.sync.getLatestCommit
|
|
8
|
+
* Get the latest commit CID and revision for a repository
|
|
9
|
+
*/
|
|
10
|
+
export async function GET({ locals, url }: APIContext) {
|
|
11
|
+
const { env } = locals.runtime;
|
|
12
|
+
|
|
13
|
+
const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const root = await getRoot(env);
|
|
17
|
+
|
|
18
|
+
if (!root) {
|
|
19
|
+
return new Response(
|
|
20
|
+
JSON.stringify({ error: 'RepoNotFound' }),
|
|
21
|
+
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Response(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
cid: root.commitCid,
|
|
28
|
+
rev: root.rev.toString(),
|
|
29
|
+
}),
|
|
30
|
+
{
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('getLatestCommit error:', error);
|
|
37
|
+
return new Response(
|
|
38
|
+
JSON.stringify({ error: 'InternalServerError', message: String(error) }),
|
|
39
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { RepoManager } from '@alteran/services/repo-manager';
|
|
3
|
+
import { encodeRecordBlock } from '@alteran/services/car';
|
|
4
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
5
|
+
import { CID } from 'multiformats/cid';
|
|
6
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
7
|
+
|
|
8
|
+
export const prerender = false;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* com.atproto.sync.getRecord
|
|
12
|
+
* Get a single record as a CAR file
|
|
13
|
+
*/
|
|
14
|
+
export async function GET({ locals, url }: APIContext) {
|
|
15
|
+
const { env } = locals.runtime;
|
|
16
|
+
|
|
17
|
+
const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
|
|
18
|
+
const collection = url.searchParams.get('collection');
|
|
19
|
+
const rkey = url.searchParams.get('rkey');
|
|
20
|
+
|
|
21
|
+
if (!collection || !rkey) {
|
|
22
|
+
return new Response(
|
|
23
|
+
JSON.stringify({ error: 'InvalidRequest', message: 'collection and rkey required' }),
|
|
24
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const repoManager = new RepoManager(env);
|
|
30
|
+
const record = await repoManager.getRecord(collection, rkey);
|
|
31
|
+
|
|
32
|
+
if (!record) {
|
|
33
|
+
return new Response(
|
|
34
|
+
JSON.stringify({ error: 'RecordNotFound' }),
|
|
35
|
+
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Encode the record as a single-block CAR snapshot (root = record CID)
|
|
40
|
+
const { cid, bytes } = await encodeRecordBlock(record);
|
|
41
|
+
|
|
42
|
+
// Minimal CAR encoding (header + single block)
|
|
43
|
+
const header = dagCbor.encode({ version: 1, roots: [cid] });
|
|
44
|
+
const varint = (n: number) => { const a:number[]=[]; while(n>=0x80){a.push((n&0x7f)|0x80); n>>>=7;} a.push(n); return new Uint8Array(a); };
|
|
45
|
+
const concat = (parts: Uint8Array[]) => { const len = parts.reduce((n,p)=>n+p.byteLength,0); const out = new Uint8Array(len); let o=0; for(const p of parts){out.set(p,o); o+=p.byteLength;} return out; };
|
|
46
|
+
const block = concat([cid.bytes, bytes]);
|
|
47
|
+
const carBytes = concat([varint(header.byteLength), header, varint(block.byteLength), block]);
|
|
48
|
+
|
|
49
|
+
return new Response(carBytes as any, {
|
|
50
|
+
status: 200,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/vnd.ipld.car; version=1',
|
|
53
|
+
'Content-Disposition': 'inline; filename="record.car"',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('getRecord error:', error);
|
|
58
|
+
return new Response(
|
|
59
|
+
JSON.stringify({ error: 'InternalServerError', message: String(error) }),
|
|
60
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getRoot as getRepoRoot } from '@alteran/db/repo';
|
|
3
|
+
import { listRecords as dalListRecords } from '@alteran/db/dal';
|
|
4
|
+
import { tryParse } from '@alteran/lib/util';
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export async function GET({ locals, request }: APIContext) {
|
|
9
|
+
const { env } = locals.runtime;
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
|
|
12
|
+
const head = await getRepoRoot(env);
|
|
13
|
+
const rows = await dalListRecords(env);
|
|
14
|
+
const records = rows
|
|
15
|
+
.filter((r) => r.uri.startsWith(`at://${did}/`))
|
|
16
|
+
.map((r) => ({ uri: r.uri, cid: r.cid, value: tryParse(r.json) }));
|
|
17
|
+
return new Response(JSON.stringify({ did, head: head?.commitCid ?? null, rev: head?.rev ?? 0, records }), {
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { buildRepoCarRange } from '@alteran/services/car';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Non-standard helper route used by tests to stream a CAR by commit seq range.
|
|
8
|
+
* Query: ?from=<seq>&to=<seq>
|
|
9
|
+
*/
|
|
10
|
+
export async function GET({ locals, request }: APIContext) {
|
|
11
|
+
const { env } = locals.runtime;
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
|
|
14
|
+
const fromParam = url.searchParams.get('from');
|
|
15
|
+
const toParam = url.searchParams.get('to');
|
|
16
|
+
|
|
17
|
+
const fromSeq = parseInt(String(fromParam ?? ''), 10);
|
|
18
|
+
const toSeq = parseInt(String(toParam ?? ''), 10);
|
|
19
|
+
|
|
20
|
+
if (!Number.isFinite(fromSeq) || !Number.isFinite(toSeq) || fromSeq < 0 || toSeq < fromSeq) {
|
|
21
|
+
return new Response(
|
|
22
|
+
JSON.stringify({ error: 'InvalidRequest', message: 'Provide valid numeric from <= to' }),
|
|
23
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const car = await buildRepoCarRange(env, fromSeq, toSeq);
|
|
28
|
+
return new Response(car.bytes as any, {
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/vnd.ipld.car; version=1',
|
|
31
|
+
'Content-Disposition': 'inline; filename="repo-range.car"',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { buildRepoCar } from '@alteran/services/car';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export async function GET({ locals, request }: APIContext) {
|
|
7
|
+
const { env } = locals.runtime;
|
|
8
|
+
const url = new URL(request.url);
|
|
9
|
+
const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
|
|
10
|
+
const car = await buildRepoCar(env, did);
|
|
11
|
+
return new Response(car.bytes as any, {
|
|
12
|
+
headers: {
|
|
13
|
+
'content-type': 'application/vnd.ipld.car; version=1',
|
|
14
|
+
'content-disposition': 'inline; filename="repo.car"',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { blob_ref } from '@alteran/db/schema';
|
|
4
|
+
import { eq, gt, and } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* com.atproto.sync.listBlobs
|
|
10
|
+
* List blob CIDs for a DID
|
|
11
|
+
*/
|
|
12
|
+
export async function GET({ locals, url }: APIContext) {
|
|
13
|
+
const { env } = locals.runtime;
|
|
14
|
+
|
|
15
|
+
const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
|
|
16
|
+
const since = url.searchParams.get('since') || '';
|
|
17
|
+
const limit = parseInt(url.searchParams.get('limit') || '500', 10);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const db = drizzle(env.DB);
|
|
21
|
+
|
|
22
|
+
const blobs = since
|
|
23
|
+
? await db
|
|
24
|
+
.select()
|
|
25
|
+
.from(blob_ref)
|
|
26
|
+
.where(and(eq(blob_ref.did, did), gt(blob_ref.cid, since)))
|
|
27
|
+
.limit(limit)
|
|
28
|
+
.all()
|
|
29
|
+
: await db
|
|
30
|
+
.select()
|
|
31
|
+
.from(blob_ref)
|
|
32
|
+
.where(eq(blob_ref.did, did))
|
|
33
|
+
.limit(limit)
|
|
34
|
+
.all();
|
|
35
|
+
|
|
36
|
+
return new Response(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
cids: blobs.map(b => b.cid),
|
|
39
|
+
cursor: blobs.length > 0 ? blobs[blobs.length - 1].cid : undefined,
|
|
40
|
+
}),
|
|
41
|
+
{
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: { 'Content-Type': 'application/json' },
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('listBlobs error:', error);
|
|
48
|
+
return new Response(
|
|
49
|
+
JSON.stringify({ error: 'InternalServerError', message: String(error) }),
|
|
50
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* com.atproto.sync.listRepos
|
|
7
|
+
* List repositories (single-user PDS returns one repo)
|
|
8
|
+
*/
|
|
9
|
+
export async function GET({ locals, url }: APIContext) {
|
|
10
|
+
const { env } = locals.runtime;
|
|
11
|
+
|
|
12
|
+
const did = env.PDS_DID || 'did:example:single-user';
|
|
13
|
+
const handle = env.PDS_HANDLE || 'user.example.com';
|
|
14
|
+
|
|
15
|
+
return new Response(
|
|
16
|
+
JSON.stringify({
|
|
17
|
+
repos: [
|
|
18
|
+
{
|
|
19
|
+
did,
|
|
20
|
+
head: '', // TODO: Get from repo_root
|
|
21
|
+
rev: '', // TODO: Get from repo_root
|
|
22
|
+
active: true,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
{
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|