@alteran/astro 0.1.14 → 0.3.1

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 (62) hide show
  1. package/README.md +35 -10
  2. package/index.js +2 -4
  3. package/migrations/0006_adorable_spectrum.sql +11 -0
  4. package/migrations/meta/0006_snapshot.json +429 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +7 -3
  7. package/src/db/account.ts +145 -0
  8. package/src/db/dal.ts +27 -9
  9. package/src/db/repo.ts +9 -8
  10. package/src/db/schema.ts +29 -11
  11. package/src/lib/actor.ts +133 -0
  12. package/src/lib/appview.ts +508 -0
  13. package/src/lib/auth.ts +22 -2
  14. package/src/lib/blob-refs.ts +9 -13
  15. package/src/lib/chat.ts +238 -0
  16. package/src/lib/config.ts +15 -7
  17. package/src/lib/feed.ts +165 -0
  18. package/src/lib/jwt.ts +231 -79
  19. package/src/lib/labeler.ts +91 -0
  20. package/src/lib/mst/blockstore.ts +98 -14
  21. package/src/lib/password.ts +40 -0
  22. package/src/lib/preferences.ts +73 -0
  23. package/src/lib/relay.ts +101 -0
  24. package/src/lib/secrets.ts +29 -21
  25. package/src/lib/session-tokens.ts +202 -0
  26. package/src/lib/token-cleanup.ts +3 -12
  27. package/src/lib/util.ts +17 -2
  28. package/src/middleware.ts +20 -21
  29. package/src/pages/.well-known/did.json.ts +45 -32
  30. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +23 -0
  31. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +34 -0
  32. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +42 -0
  33. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +36 -0
  34. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +42 -0
  35. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +37 -0
  36. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +26 -0
  37. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +35 -0
  38. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +29 -0
  39. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +29 -0
  40. package/src/pages/xrpc/app.bsky.labeler.getServices.ts +29 -0
  41. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +20 -0
  42. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +27 -0
  43. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +19 -0
  44. package/src/pages/xrpc/app.bsky.unspecced.getConfig.ts +15 -0
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +26 -0
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +37 -0
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +64 -66
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +24 -0
  49. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +127 -0
  50. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +91 -0
  51. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +6 -2
  52. package/src/pages/xrpc/com.atproto.server.createSession.ts +36 -8
  53. package/src/pages/xrpc/com.atproto.server.describeServer.ts +37 -4
  54. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +64 -0
  55. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +55 -32
  56. package/src/services/repo-manager.ts +15 -6
  57. package/src/worker/runtime.ts +21 -0
  58. package/types/env.d.ts +11 -2
  59. package/src/pages/xrpc/com.atproto.repo.importRepo.ts +0 -142
  60. package/src/pages/xrpc/com.atproto.server.activateAccount.ts +0 -53
  61. package/src/pages/xrpc/com.atproto.server.createAccount.ts +0 -99
  62. package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +0 -53
@@ -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>;
@@ -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 };