@alteran/astro 0.1.13 → 0.3.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 +28 -3
- package/index.js +2 -4
- package/migrations/0006_adorable_spectrum.sql +11 -0
- package/migrations/meta/0006_snapshot.json +429 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +6 -3
- package/src/db/account.ts +145 -0
- package/src/db/dal.ts +27 -9
- package/src/db/repo.ts +9 -8
- package/src/db/schema.ts +29 -11
- package/src/lib/actor.ts +133 -0
- package/src/lib/appview.ts +508 -0
- package/src/lib/auth.ts +26 -3
- package/src/lib/blob-refs.ts +9 -13
- package/src/lib/chat.ts +238 -0
- package/src/lib/config.ts +15 -7
- package/src/lib/feed.ts +165 -0
- package/src/lib/jwt.ts +144 -47
- package/src/lib/labeler.ts +91 -0
- package/src/lib/mst/blockstore.ts +98 -14
- package/src/lib/password.ts +40 -0
- package/src/lib/preferences.ts +73 -0
- package/src/lib/relay.ts +101 -0
- package/src/lib/secrets.ts +4 -1
- package/src/lib/session-tokens.ts +202 -0
- package/src/lib/token-cleanup.ts +3 -12
- package/src/lib/util.ts +17 -2
- package/src/middleware.ts +20 -21
- package/src/pages/.well-known/did.json.ts +45 -32
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +23 -0
- package/src/pages/xrpc/app.bsky.actor.getProfile.ts +34 -0
- package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +42 -0
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +36 -0
- package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +42 -0
- package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +37 -0
- package/src/pages/xrpc/app.bsky.feed.getPosts.ts +26 -0
- package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +35 -0
- package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +29 -0
- package/src/pages/xrpc/app.bsky.graph.getFollows.ts +29 -0
- package/src/pages/xrpc/app.bsky.labeler.getServices.ts +29 -0
- package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +20 -0
- package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +27 -0
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +19 -0
- package/src/pages/xrpc/app.bsky.unspecced.getConfig.ts +15 -0
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +26 -0
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +37 -0
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +64 -66
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +24 -0
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +127 -0
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +91 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +6 -2
- package/src/pages/xrpc/com.atproto.server.createSession.ts +36 -8
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +37 -4
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +64 -0
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +55 -32
- package/src/services/repo-manager.ts +15 -6
- package/src/worker/runtime.ts +9 -0
- package/types/env.d.ts +10 -1
- package/src/pages/xrpc/com.atproto.repo.importRepo.ts +0 -142
- package/src/pages/xrpc/com.atproto.server.activateAccount.ts +0 -53
- package/src/pages/xrpc/com.atproto.server.createAccount.ts +0 -99
- package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +0 -53
package/src/lib/chat.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
|
|
3
|
+
let tablesEnsured = false;
|
|
4
|
+
|
|
5
|
+
export interface ListConvosFilters {
|
|
6
|
+
readState?: 'unread' | null;
|
|
7
|
+
status?: 'request' | 'accepted' | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ConvoView {
|
|
11
|
+
id: string;
|
|
12
|
+
rev: string;
|
|
13
|
+
members: any[];
|
|
14
|
+
muted: boolean;
|
|
15
|
+
unreadCount: number;
|
|
16
|
+
status?: string;
|
|
17
|
+
lastMessage?: any;
|
|
18
|
+
lastReaction?: any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ConvoLogEntry =
|
|
22
|
+
| { $type: 'chat.bsky.convo.defs#logBeginConvo'; rev: string; convoId: string }
|
|
23
|
+
| { $type: 'chat.bsky.convo.defs#logCreateMessage'; rev: string; convoId: string; message: any }
|
|
24
|
+
| {
|
|
25
|
+
$type: 'chat.bsky.convo.defs#logAddReaction';
|
|
26
|
+
rev: string;
|
|
27
|
+
convoId: string;
|
|
28
|
+
message: any;
|
|
29
|
+
reaction: any;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function ensureChatTables(env: Env) {
|
|
33
|
+
if (tablesEnsured) return;
|
|
34
|
+
|
|
35
|
+
// Create chat_convo table
|
|
36
|
+
await env.DB.prepare(
|
|
37
|
+
'CREATE TABLE IF NOT EXISTS chat_convo (' +
|
|
38
|
+
'id TEXT PRIMARY KEY, ' +
|
|
39
|
+
'rev TEXT NOT NULL, ' +
|
|
40
|
+
'status TEXT NOT NULL DEFAULT \'accepted\', ' +
|
|
41
|
+
'muted INTEGER NOT NULL DEFAULT 0, ' +
|
|
42
|
+
'unread_count INTEGER NOT NULL DEFAULT 0, ' +
|
|
43
|
+
'last_message_json TEXT, ' +
|
|
44
|
+
'last_reaction_json TEXT, ' +
|
|
45
|
+
'updated_at INTEGER NOT NULL, ' +
|
|
46
|
+
'created_at INTEGER NOT NULL' +
|
|
47
|
+
')'
|
|
48
|
+
).run();
|
|
49
|
+
|
|
50
|
+
// Create chat_convo_member table
|
|
51
|
+
await env.DB.prepare(
|
|
52
|
+
'CREATE TABLE IF NOT EXISTS chat_convo_member (' +
|
|
53
|
+
'convo_id TEXT NOT NULL, ' +
|
|
54
|
+
'did TEXT NOT NULL, ' +
|
|
55
|
+
'handle TEXT NOT NULL, ' +
|
|
56
|
+
'display_name TEXT, ' +
|
|
57
|
+
'avatar TEXT, ' +
|
|
58
|
+
'position INTEGER NOT NULL DEFAULT 0, ' +
|
|
59
|
+
'PRIMARY KEY (convo_id, did)' +
|
|
60
|
+
')'
|
|
61
|
+
).run();
|
|
62
|
+
|
|
63
|
+
// Create index
|
|
64
|
+
await env.DB.prepare(
|
|
65
|
+
'CREATE INDEX IF NOT EXISTS chat_convo_member_did_idx ON chat_convo_member (did)'
|
|
66
|
+
).run();
|
|
67
|
+
|
|
68
|
+
tablesEnsured = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listChatConvos(
|
|
72
|
+
env: Env,
|
|
73
|
+
did: string,
|
|
74
|
+
limit: number,
|
|
75
|
+
cursor?: number,
|
|
76
|
+
filters: ListConvosFilters = {}
|
|
77
|
+
) {
|
|
78
|
+
await ensureChatTables(env);
|
|
79
|
+
|
|
80
|
+
const params: (string | number)[] = [did];
|
|
81
|
+
let query = `
|
|
82
|
+
SELECT rowid, id, rev, status, muted, unread_count, last_message_json, last_reaction_json, updated_at
|
|
83
|
+
FROM chat_convo
|
|
84
|
+
WHERE EXISTS (
|
|
85
|
+
SELECT 1 FROM chat_convo_member m WHERE m.convo_id = chat_convo.id AND m.did = ?
|
|
86
|
+
)
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
if (filters.readState === 'unread') {
|
|
90
|
+
query += ' AND unread_count > 0';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (filters.status === 'request' || filters.status === 'accepted') {
|
|
94
|
+
query += ' AND status = ?';
|
|
95
|
+
params.push(filters.status);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof cursor === 'number' && Number.isFinite(cursor)) {
|
|
99
|
+
query += ' AND rowid < ?';
|
|
100
|
+
params.push(cursor);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
query += ' ORDER BY rowid DESC LIMIT ?';
|
|
104
|
+
params.push(limit);
|
|
105
|
+
|
|
106
|
+
const result = await env.DB.prepare(query).bind(...params).all<{
|
|
107
|
+
rowid: number;
|
|
108
|
+
id: string;
|
|
109
|
+
rev: string;
|
|
110
|
+
status: string;
|
|
111
|
+
muted: number;
|
|
112
|
+
unread_count: number;
|
|
113
|
+
last_message_json: string | null;
|
|
114
|
+
last_reaction_json: string | null;
|
|
115
|
+
updated_at: number;
|
|
116
|
+
}>();
|
|
117
|
+
|
|
118
|
+
const convos: ConvoView[] = [];
|
|
119
|
+
|
|
120
|
+
if (result.results) {
|
|
121
|
+
for (const row of result.results) {
|
|
122
|
+
const members = await env.DB.prepare(
|
|
123
|
+
`SELECT did, handle, display_name, avatar FROM chat_convo_member WHERE convo_id = ? ORDER BY position ASC`
|
|
124
|
+
)
|
|
125
|
+
.bind(row.id)
|
|
126
|
+
.all<{
|
|
127
|
+
did: string;
|
|
128
|
+
handle: string;
|
|
129
|
+
display_name: string | null;
|
|
130
|
+
avatar: string | null;
|
|
131
|
+
}>();
|
|
132
|
+
|
|
133
|
+
const memberViews = (members.results ?? []).map((member) => {
|
|
134
|
+
const view: any = {
|
|
135
|
+
did: member.did,
|
|
136
|
+
handle: member.handle,
|
|
137
|
+
};
|
|
138
|
+
if (member.display_name) view.displayName = member.display_name;
|
|
139
|
+
if (member.avatar) view.avatar = member.avatar;
|
|
140
|
+
return view;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
convos.push({
|
|
144
|
+
id: row.id,
|
|
145
|
+
rev: row.rev,
|
|
146
|
+
members: memberViews,
|
|
147
|
+
muted: Boolean(row.muted),
|
|
148
|
+
status: row.status,
|
|
149
|
+
unreadCount: row.unread_count,
|
|
150
|
+
lastMessage: parseMaybeJson(row.last_message_json),
|
|
151
|
+
lastReaction: parseMaybeJson(row.last_reaction_json),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const nextCursor = result.results && result.results.length === limit
|
|
157
|
+
? String(result.results[result.results.length - 1].rowid)
|
|
158
|
+
: undefined;
|
|
159
|
+
|
|
160
|
+
return { convos, cursor: nextCursor };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function listChatConvoLogs(env: Env, did: string, cursor?: number, limit = 50) {
|
|
164
|
+
await ensureChatTables(env);
|
|
165
|
+
|
|
166
|
+
const params: (string | number)[] = [did];
|
|
167
|
+
let query = `
|
|
168
|
+
SELECT rowid, id, rev, last_message_json, last_reaction_json
|
|
169
|
+
FROM chat_convo
|
|
170
|
+
WHERE EXISTS (
|
|
171
|
+
SELECT 1 FROM chat_convo_member m WHERE m.convo_id = chat_convo.id AND m.did = ?
|
|
172
|
+
)
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
if (typeof cursor === 'number' && Number.isFinite(cursor)) {
|
|
176
|
+
query += ' AND rowid < ?';
|
|
177
|
+
params.push(cursor);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
query += ' ORDER BY rowid DESC LIMIT ?';
|
|
181
|
+
params.push(limit);
|
|
182
|
+
|
|
183
|
+
const result = await env.DB.prepare(query).bind(...params).all<{
|
|
184
|
+
rowid: number;
|
|
185
|
+
id: string;
|
|
186
|
+
rev: string;
|
|
187
|
+
last_message_json: string | null;
|
|
188
|
+
last_reaction_json: string | null;
|
|
189
|
+
}>();
|
|
190
|
+
|
|
191
|
+
const logs: ConvoLogEntry[] = [];
|
|
192
|
+
|
|
193
|
+
if (result.results) {
|
|
194
|
+
for (const row of result.results) {
|
|
195
|
+
logs.push({
|
|
196
|
+
$type: 'chat.bsky.convo.defs#logBeginConvo',
|
|
197
|
+
rev: row.rev,
|
|
198
|
+
convoId: row.id,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const message = parseMaybeJson(row.last_message_json);
|
|
202
|
+
if (message) {
|
|
203
|
+
logs.push({
|
|
204
|
+
$type: 'chat.bsky.convo.defs#logCreateMessage',
|
|
205
|
+
rev: row.rev,
|
|
206
|
+
convoId: row.id,
|
|
207
|
+
message,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const reaction = parseMaybeJson(row.last_reaction_json);
|
|
211
|
+
if (reaction) {
|
|
212
|
+
logs.push({
|
|
213
|
+
$type: 'chat.bsky.convo.defs#logAddReaction',
|
|
214
|
+
rev: row.rev,
|
|
215
|
+
convoId: row.id,
|
|
216
|
+
message,
|
|
217
|
+
reaction,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const nextCursor = result.results && result.results.length === limit
|
|
225
|
+
? String(result.results[result.results.length - 1].rowid)
|
|
226
|
+
: undefined;
|
|
227
|
+
|
|
228
|
+
return { logs, cursor: nextCursor };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseMaybeJson(input: string | null) {
|
|
232
|
+
if (!input) return undefined;
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(input);
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -7,9 +7,6 @@ import { logger } from './logger';
|
|
|
7
7
|
const REQUIRED_SECRETS = [
|
|
8
8
|
'PDS_DID',
|
|
9
9
|
'PDS_HANDLE',
|
|
10
|
-
'USER_PASSWORD',
|
|
11
|
-
'ACCESS_TOKEN_SECRET',
|
|
12
|
-
'REFRESH_TOKEN_SECRET',
|
|
13
10
|
] as const;
|
|
14
11
|
|
|
15
12
|
/**
|
|
@@ -23,6 +20,9 @@ const OPTIONAL_VARS = {
|
|
|
23
20
|
PDS_CORS_ORIGIN: '*',
|
|
24
21
|
PDS_SEQ_WINDOW: '512',
|
|
25
22
|
ENVIRONMENT: 'development',
|
|
23
|
+
PDS_BSKY_APP_VIEW_URL: 'https://public.api.bsky.app',
|
|
24
|
+
PDS_BSKY_APP_VIEW_DID: 'did:web:api.bsky.app',
|
|
25
|
+
PDS_BSKY_APP_VIEW_CDN_URL_PATTERN: '',
|
|
26
26
|
} as const;
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -108,6 +108,10 @@ export function validateConfig(env: Env): ConfigValidationResult {
|
|
|
108
108
|
warnings.push('REPO_SIGNING_KEY is not set - repository commits will not be signed');
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
if (!env.PDS_SERVICE_SIGNING_KEY_HEX) {
|
|
112
|
+
warnings.push('PDS_SERVICE_SIGNING_KEY_HEX is not set - service-to-service authentication will be disabled');
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
const valid = missing.length === 0;
|
|
112
116
|
|
|
113
117
|
return {
|
|
@@ -184,9 +188,6 @@ export function getConfig(env: Env) {
|
|
|
184
188
|
// Required
|
|
185
189
|
did: env.PDS_DID!,
|
|
186
190
|
handle: env.PDS_HANDLE!,
|
|
187
|
-
userPassword: env.USER_PASSWORD!,
|
|
188
|
-
accessTokenSecret: env.ACCESS_TOKEN_SECRET!,
|
|
189
|
-
refreshTokenSecret: env.REFRESH_TOKEN_SECRET!,
|
|
190
191
|
|
|
191
192
|
// Optional with defaults
|
|
192
193
|
allowedMime: result.config.optional.PDS_ALLOWED_MIME.split(','),
|
|
@@ -196,13 +197,20 @@ export function getConfig(env: Env) {
|
|
|
196
197
|
corsOrigin: result.config.optional.PDS_CORS_ORIGIN,
|
|
197
198
|
seqWindow: parseInt(result.config.optional.PDS_SEQ_WINDOW),
|
|
198
199
|
environment: result.config.optional.ENVIRONMENT,
|
|
200
|
+
appView: {
|
|
201
|
+
url: result.config.optional.PDS_BSKY_APP_VIEW_URL,
|
|
202
|
+
did: result.config.optional.PDS_BSKY_APP_VIEW_DID,
|
|
203
|
+
cdnUrlPattern:
|
|
204
|
+
result.config.optional.PDS_BSKY_APP_VIEW_CDN_URL_PATTERN?.trim() || undefined,
|
|
205
|
+
},
|
|
199
206
|
|
|
200
207
|
// Optional
|
|
201
208
|
repoSigningKey: env.REPO_SIGNING_KEY,
|
|
202
209
|
hostname: env.PDS_HOSTNAME,
|
|
203
210
|
accessTtlSec: env.PDS_ACCESS_TTL_SEC ? parseInt(env.PDS_ACCESS_TTL_SEC) : 3600,
|
|
204
211
|
refreshTtlSec: env.PDS_REFRESH_TTL_SEC ? parseInt(env.PDS_REFRESH_TTL_SEC) : 2592000,
|
|
212
|
+
serviceSigningKeyHex: env.PDS_SERVICE_SIGNING_KEY_HEX,
|
|
205
213
|
};
|
|
206
214
|
}
|
|
207
215
|
|
|
208
|
-
export type Config = ReturnType<typeof getConfig>;
|
|
216
|
+
export type Config = ReturnType<typeof getConfig>;
|
package/src/lib/feed.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { getPrimaryActor, buildProfileViewBasic } from './actor';
|
|
3
|
+
|
|
4
|
+
interface PostRow {
|
|
5
|
+
rowid: number;
|
|
6
|
+
uri: string;
|
|
7
|
+
cid: string;
|
|
8
|
+
json: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ParsedPost {
|
|
12
|
+
uri: string;
|
|
13
|
+
cid: string;
|
|
14
|
+
record: Record<string, unknown>;
|
|
15
|
+
indexedAt: string;
|
|
16
|
+
rowid: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const POST_COLLECTION = 'app.bsky.feed.post';
|
|
20
|
+
|
|
21
|
+
function inferCollectionFromUri(uri: string): string | undefined {
|
|
22
|
+
if (!uri.startsWith('at://')) return undefined;
|
|
23
|
+
const withoutScheme = uri.slice('at://'.length);
|
|
24
|
+
const parts = withoutScheme.split('/');
|
|
25
|
+
return parts.length >= 2 ? parts[1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseRow(row: PostRow): ParsedPost | null {
|
|
29
|
+
try {
|
|
30
|
+
const record = JSON.parse(row.json) ?? {};
|
|
31
|
+
if (record && typeof record === 'object' && !Array.isArray(record)) {
|
|
32
|
+
const collection = inferCollectionFromUri(row.uri);
|
|
33
|
+
if (collection && typeof (record as any).$type !== 'string') {
|
|
34
|
+
(record as any).$type = collection;
|
|
35
|
+
}
|
|
36
|
+
if (typeof (record as any).createdAt !== 'string') {
|
|
37
|
+
(record as any).createdAt = new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const createdAt =
|
|
42
|
+
record && typeof record === 'object' && typeof (record as any).createdAt === 'string'
|
|
43
|
+
? (record as any).createdAt
|
|
44
|
+
: new Date().toISOString();
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
uri: row.uri,
|
|
48
|
+
cid: row.cid,
|
|
49
|
+
record: record as Record<string, unknown>,
|
|
50
|
+
indexedAt: createdAt,
|
|
51
|
+
rowid: row.rowid,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listPosts(env: Env, limit: number, cursor?: string): Promise<ParsedPost[]> {
|
|
59
|
+
const did = (await getPrimaryActor(env)).did;
|
|
60
|
+
const safeLimit = Math.max(1, Math.min(limit || 50, 100));
|
|
61
|
+
const cursorRow = cursor ? Number.parseInt(cursor, 10) : undefined;
|
|
62
|
+
|
|
63
|
+
// Use range query instead of LIKE to avoid D1 complexity limits
|
|
64
|
+
const prefix = `at://${did}/${POST_COLLECTION}/`;
|
|
65
|
+
const upperBound = `${prefix}{`; // '{' sorts after 'z', safely bounding rkeys
|
|
66
|
+
|
|
67
|
+
const params: (string | number)[] = [prefix, upperBound];
|
|
68
|
+
let where = 'uri >= ? AND uri < ?';
|
|
69
|
+
if (cursorRow && Number.isFinite(cursorRow)) {
|
|
70
|
+
where += ' AND rowid < ?';
|
|
71
|
+
params.push(cursorRow);
|
|
72
|
+
}
|
|
73
|
+
params.push(safeLimit);
|
|
74
|
+
|
|
75
|
+
const res = await env.DB.prepare(
|
|
76
|
+
`SELECT rowid, uri, cid, json FROM record WHERE ${where} ORDER BY rowid DESC LIMIT ?`
|
|
77
|
+
)
|
|
78
|
+
.bind(...params)
|
|
79
|
+
.all<PostRow>();
|
|
80
|
+
|
|
81
|
+
if (!res?.results) return [];
|
|
82
|
+
return res.results.map(parseRow).filter((row): row is ParsedPost => row !== null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getPostsByUris(env: Env, uris: string[]): Promise<ParsedPost[]> {
|
|
86
|
+
if (!uris.length) return [];
|
|
87
|
+
const placeholders = uris.map(() => '?').join(',');
|
|
88
|
+
const res = await env.DB.prepare(
|
|
89
|
+
`SELECT rowid, uri, cid, json FROM record WHERE uri IN (${placeholders})`
|
|
90
|
+
)
|
|
91
|
+
.bind(...uris)
|
|
92
|
+
.all<PostRow>();
|
|
93
|
+
|
|
94
|
+
if (!res?.results) return [];
|
|
95
|
+
return res.results.map(parseRow).filter((row): row is ParsedPost => row !== null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function buildFeedViewPosts(env: Env, posts: ParsedPost[]) {
|
|
99
|
+
const actor = await getPrimaryActor(env);
|
|
100
|
+
const authorView = buildProfileViewBasic(actor);
|
|
101
|
+
return posts.map((post) => ({
|
|
102
|
+
$type: 'app.bsky.feed.defs#feedViewPost',
|
|
103
|
+
post: buildPostViewFromParsed(authorView, post),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildPostViewFromParsed(
|
|
108
|
+
authorView: ReturnType<typeof buildProfileViewBasic>,
|
|
109
|
+
post: ParsedPost,
|
|
110
|
+
) {
|
|
111
|
+
return {
|
|
112
|
+
$type: 'app.bsky.feed.defs#postView',
|
|
113
|
+
uri: post.uri,
|
|
114
|
+
cid: post.cid,
|
|
115
|
+
author: authorView,
|
|
116
|
+
record: post.record,
|
|
117
|
+
indexedAt: post.indexedAt,
|
|
118
|
+
likeCount: 0,
|
|
119
|
+
repostCount: 0,
|
|
120
|
+
replyCount: 0,
|
|
121
|
+
quoteCount: 0,
|
|
122
|
+
bookmarkCount: 0,
|
|
123
|
+
viewer: { $type: 'app.bsky.feed.defs#viewerState' },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function buildPostViews(env: Env, posts: ParsedPost[]) {
|
|
128
|
+
const actor = await getPrimaryActor(env);
|
|
129
|
+
const authorView = buildProfileViewBasic(actor);
|
|
130
|
+
return posts.map((post) => buildPostViewFromParsed(authorView, post));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function buildThreadView(env: Env, root: ParsedPost) {
|
|
134
|
+
const [post] = await buildPostViews(env, [root]);
|
|
135
|
+
return {
|
|
136
|
+
$type: 'app.bsky.feed.defs#threadViewPost',
|
|
137
|
+
post,
|
|
138
|
+
replies: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function countPosts(env: Env): Promise<number> {
|
|
143
|
+
const actor = await getPrimaryActor(env);
|
|
144
|
+
const prefix = `at://${actor.did}/${POST_COLLECTION}/`;
|
|
145
|
+
const upperBound = `${prefix}{`; // '{' sorts after 'z', safely bounding rkeys
|
|
146
|
+
const res = await env.DB.prepare(
|
|
147
|
+
'SELECT COUNT(*) as count FROM record WHERE uri >= ? AND uri < ?'
|
|
148
|
+
)
|
|
149
|
+
.bind(prefix, upperBound)
|
|
150
|
+
.first<{ count: number }>();
|
|
151
|
+
return res?.count ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function getPostByUri(env: Env, uri: string): Promise<ParsedPost | null> {
|
|
155
|
+
const res = await env.DB.prepare(
|
|
156
|
+
'SELECT rowid, uri, cid, json FROM record WHERE uri = ? LIMIT 1'
|
|
157
|
+
)
|
|
158
|
+
.bind(uri)
|
|
159
|
+
.first<PostRow>();
|
|
160
|
+
|
|
161
|
+
if (!res) return null;
|
|
162
|
+
return parseRow(res);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type { ParsedPost };
|
package/src/lib/jwt.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
2
|
import { getRuntimeString } from './secrets';
|
|
3
|
+
import { base58btc } from 'multiformats/bases/base58';
|
|
4
|
+
import { issueSessionTokens, verifyAccessToken, verifyRefreshToken } from './session-tokens';
|
|
3
5
|
|
|
4
6
|
export interface JwtClaims {
|
|
5
7
|
sub: string; // DID
|
|
@@ -12,48 +14,51 @@ export interface JwtClaims {
|
|
|
12
14
|
|
|
13
15
|
// JWT
|
|
14
16
|
export async function signJwt(env: Env, claims: JwtClaims, kind: 'access' | 'refresh'): Promise<string> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const ttlRefresh = Number((env.PDS_REFRESH_TTL_SEC as string | undefined) ?? 30 * 24 * 3600);
|
|
18
|
-
const exp = iat + (kind === 'access' ? ttlAccess : ttlRefresh);
|
|
19
|
-
|
|
20
|
-
// Build proper JWT claims
|
|
21
|
-
const payload: Record<string, unknown> = {
|
|
22
|
-
iss: env.PDS_HOSTNAME || 'alteran',
|
|
23
|
-
sub: claims.sub,
|
|
24
|
-
aud: claims.aud || env.PDS_HOSTNAME || 'alteran',
|
|
25
|
-
iat,
|
|
26
|
-
exp,
|
|
27
|
-
t: kind,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Add optional claims
|
|
31
|
-
if (claims.handle) payload.handle = claims.handle;
|
|
32
|
-
if (claims.scope) payload.scope = claims.scope;
|
|
33
|
-
if (claims.jti) payload.jti = claims.jti;
|
|
34
|
-
|
|
35
|
-
const secret = await getRuntimeString(
|
|
36
|
-
env,
|
|
37
|
-
kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET',
|
|
38
|
-
kind === 'access' ? 'dev-access' : 'dev-refresh'
|
|
39
|
-
);
|
|
40
|
-
if (!secret) {
|
|
41
|
-
throw new Error(`Missing ${kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET'}`);
|
|
42
|
-
}
|
|
43
|
-
const algorithm = (env.JWT_ALGORITHM as string | undefined) ?? 'HS256';
|
|
44
|
-
|
|
45
|
-
if (algorithm === 'EdDSA') {
|
|
46
|
-
return await eddsaJwtSign(payload, env);
|
|
17
|
+
if (!claims.sub) {
|
|
18
|
+
throw new Error('Cannot sign JWT without subject');
|
|
47
19
|
}
|
|
48
|
-
|
|
49
|
-
|
|
20
|
+
const { accessJwt, refreshJwt } = await issueSessionTokens(env, claims.sub, {
|
|
21
|
+
jti: claims.jti,
|
|
22
|
+
});
|
|
23
|
+
return kind === 'access' ? accessJwt : refreshJwt;
|
|
50
24
|
}
|
|
51
25
|
|
|
52
|
-
export async function verifyJwt(env: Env, token: string): Promise<{ valid: boolean; payload:
|
|
26
|
+
export async function verifyJwt(env: Env, token: string): Promise<{ valid: boolean; payload: JwtClaims } | null> {
|
|
53
27
|
const parts = token.split('.');
|
|
54
28
|
if (parts.length !== 3) return null;
|
|
55
29
|
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
|
|
56
30
|
|
|
31
|
+
if (header.typ === 'at+jwt') {
|
|
32
|
+
const payload = await verifyAccessToken(env, token).catch(() => null);
|
|
33
|
+
if (!payload) return null;
|
|
34
|
+
if (!payload.sub) return null;
|
|
35
|
+
const claims: JwtClaims = {
|
|
36
|
+
sub: String(payload.sub),
|
|
37
|
+
aud: payload.aud as string | undefined,
|
|
38
|
+
scope: payload.scope as string | undefined,
|
|
39
|
+
jti: payload.jti as string | undefined,
|
|
40
|
+
t: 'access',
|
|
41
|
+
};
|
|
42
|
+
if (payload.handle) {
|
|
43
|
+
claims.handle = String(payload.handle);
|
|
44
|
+
}
|
|
45
|
+
return { valid: true, payload: claims };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (header.typ === 'refresh+jwt') {
|
|
49
|
+
const verified = await verifyRefreshToken(env, token).catch(() => null);
|
|
50
|
+
if (!verified) return null;
|
|
51
|
+
if (!verified.payload.sub) return null;
|
|
52
|
+
const payload: JwtClaims = {
|
|
53
|
+
sub: String(verified.payload.sub),
|
|
54
|
+
aud: verified.payload.aud as string | undefined,
|
|
55
|
+
scope: verified.payload.scope as string | undefined,
|
|
56
|
+
jti: verified.payload.jti as string | undefined,
|
|
57
|
+
t: 'refresh',
|
|
58
|
+
};
|
|
59
|
+
return { valid: true, payload };
|
|
60
|
+
}
|
|
61
|
+
|
|
57
62
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
58
63
|
|
|
59
64
|
let ok = false;
|
|
@@ -73,7 +78,7 @@ export async function verifyJwt(env: Env, token: string): Promise<{ valid: boole
|
|
|
73
78
|
|
|
74
79
|
const now = Math.floor(Date.now() / 1000);
|
|
75
80
|
if (!ok || (payload.exp && now > payload.exp)) return null;
|
|
76
|
-
return { valid: true, payload };
|
|
81
|
+
return { valid: true, payload: payload as JwtClaims };
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
async function hmacJwtSign(payload: any, secret: string): Promise<string> {
|
|
@@ -127,22 +132,25 @@ async function eddsaJwtVerify(data: string, sigB64: string, env: Env): Promise<b
|
|
|
127
132
|
const enc = new TextEncoder();
|
|
128
133
|
|
|
129
134
|
// Import Ed25519 public key from env
|
|
130
|
-
const keyData = await getRuntimeString(env, '
|
|
135
|
+
const keyData = await getRuntimeString(env, 'REPO_SIGNING_KEY_PUBLIC');
|
|
131
136
|
if (!keyData) {
|
|
137
|
+
console.error('EdDSA JWT verification failed: REPO_SIGNING_KEY_PUBLIC not configured');
|
|
132
138
|
return false;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
['verify']
|
|
142
|
-
);
|
|
141
|
+
try {
|
|
142
|
+
const key = await importEd25519PublicKey(keyData);
|
|
143
|
+
if (!key) {
|
|
144
|
+
console.error('EdDSA JWT verification failed: unsupported public key format for Ed25519');
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
143
147
|
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
const ok = await crypto.subtle.verify('Ed25519', key, b64urlDecode(sigB64), enc.encode(data));
|
|
149
|
+
return !!ok;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('EdDSA JWT verification error:', error);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
function b64url(bytes: ArrayBuffer | Uint8Array): string {
|
|
@@ -161,3 +169,92 @@ function b64urlDecode(s: string): Uint8Array {
|
|
|
161
169
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
162
170
|
return out;
|
|
163
171
|
}
|
|
172
|
+
|
|
173
|
+
async function importEd25519PublicKey(value: string): Promise<CryptoKey | null> {
|
|
174
|
+
const attempts = buildPublicKeyCandidates(value);
|
|
175
|
+
for (const attempt of attempts) {
|
|
176
|
+
try {
|
|
177
|
+
return await crypto.subtle.importKey(
|
|
178
|
+
attempt.format,
|
|
179
|
+
attempt.data,
|
|
180
|
+
{ name: 'Ed25519', namedCurve: 'Ed25519' } as any,
|
|
181
|
+
false,
|
|
182
|
+
['verify']
|
|
183
|
+
);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('EdDSA JWT verification warning: failed to import key candidate', error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type KeyImportAttempt = { format: KeyFormat; data: Uint8Array };
|
|
192
|
+
type KeyFormat = 'raw' | 'spki';
|
|
193
|
+
|
|
194
|
+
function buildPublicKeyCandidates(value: string): KeyImportAttempt[] {
|
|
195
|
+
const trimmed = value.trim();
|
|
196
|
+
const attempts: KeyImportAttempt[] = [];
|
|
197
|
+
|
|
198
|
+
const didKeyCandidate = decodeDidKey(trimmed);
|
|
199
|
+
if (didKeyCandidate) {
|
|
200
|
+
attempts.push({ format: 'raw', data: didKeyCandidate });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const pemMatch = trimmed.match(/-----BEGIN PUBLIC KEY-----([\s\S]+?)-----END PUBLIC KEY-----/);
|
|
204
|
+
if (pemMatch) {
|
|
205
|
+
const derBytes = decodeBase64(pemMatch[1].replace(/\s+/g, ''));
|
|
206
|
+
if (derBytes) {
|
|
207
|
+
attempts.push({ format: 'spki', data: derBytes });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const compact = trimmed.replace(/\s+/g, '');
|
|
212
|
+
const decoded = decodeBase64(compact);
|
|
213
|
+
if (decoded) {
|
|
214
|
+
if (decoded.length === 32) {
|
|
215
|
+
attempts.push({ format: 'raw', data: decoded });
|
|
216
|
+
} else {
|
|
217
|
+
attempts.push({ format: 'spki', data: decoded });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return attempts;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decodeBase64(value: string): Uint8Array | null {
|
|
225
|
+
const cleaned = value.replace(/\s+/g, '');
|
|
226
|
+
if (!cleaned) return null;
|
|
227
|
+
try {
|
|
228
|
+
return b64urlDecode(cleaned);
|
|
229
|
+
} catch {
|
|
230
|
+
const normalized = cleaned.replace(/-/g, '+').replace(/_/g, '/');
|
|
231
|
+
const padLength = normalized.length % 4;
|
|
232
|
+
const padded =
|
|
233
|
+
padLength === 0
|
|
234
|
+
? normalized
|
|
235
|
+
: padLength === 2
|
|
236
|
+
? normalized + '=='
|
|
237
|
+
: padLength === 3
|
|
238
|
+
? normalized + '='
|
|
239
|
+
: normalized + '===';
|
|
240
|
+
const bin = atob(padded);
|
|
241
|
+
const out = new Uint8Array(bin.length);
|
|
242
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function decodeDidKey(didKey: string): Uint8Array | null {
|
|
248
|
+
if (!didKey.startsWith('did:key:')) return null;
|
|
249
|
+
try {
|
|
250
|
+
const multibase = didKey.slice('did:key:'.length);
|
|
251
|
+
const bytes = base58btc.decode(multibase);
|
|
252
|
+
if (bytes.length === 34 && bytes[0] === 0xed && bytes[1] === 0x01) {
|
|
253
|
+
return bytes.slice(2);
|
|
254
|
+
}
|
|
255
|
+
console.warn('EdDSA JWT verification warning: unsupported did:key multicodec prefix');
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.warn('EdDSA JWT verification warning: failed to parse did:key', error);
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|