@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.
Files changed (62) hide show
  1. package/README.md +28 -3
  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 +6 -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 +26 -3
  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 +144 -47
  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 +4 -1
  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 +9 -0
  58. package/types/env.d.ts +10 -1
  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 };
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
- const iat = Math.floor(Date.now() / 1000);
16
- const ttlAccess = Number((env.PDS_ACCESS_TTL_SEC as string | undefined) ?? 3600);
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
- return await hmacJwtSign(payload, secret);
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: any } | null> {
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, 'REPO_SIGNING_PUBLIC_KEY');
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
- const keyBytes = b64urlDecode(keyData);
136
- const key = await crypto.subtle.importKey(
137
- 'raw',
138
- keyBytes,
139
- { name: 'Ed25519', namedCurve: 'Ed25519' } as any,
140
- false,
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
- const ok = await crypto.subtle.verify('Ed25519', key, b64urlDecode(sigB64), enc.encode(data));
145
- return !!ok;
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
+ }