@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { getRecord } from '../db/dal';
|
|
3
|
+
import { buildProfileView, getPrimaryActor } from './actor';
|
|
4
|
+
|
|
5
|
+
export interface LabelerViewOptions {
|
|
6
|
+
detailed?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LABELER_COLLECTION = 'app.bsky.labeler.service';
|
|
10
|
+
const LABELER_RKEY = 'self';
|
|
11
|
+
|
|
12
|
+
export async function getLabelerServiceViews(
|
|
13
|
+
env: Env,
|
|
14
|
+
dids: string[],
|
|
15
|
+
options: LabelerViewOptions = {}
|
|
16
|
+
) {
|
|
17
|
+
const detailed = options.detailed ?? false;
|
|
18
|
+
const primaryActor = await getPrimaryActor(env);
|
|
19
|
+
|
|
20
|
+
const unique = Array.from(new Set(dids.map((did) => did.trim()).filter(Boolean)));
|
|
21
|
+
const views: any[] = [];
|
|
22
|
+
|
|
23
|
+
for (const did of unique) {
|
|
24
|
+
if (did !== primaryActor.did) continue; // Single-user PDS only has local labeler data
|
|
25
|
+
|
|
26
|
+
const uri = `at://${did}/${LABELER_COLLECTION}/${LABELER_RKEY}`;
|
|
27
|
+
const row = await getRecord(env, uri);
|
|
28
|
+
if (!row || !row.json) continue;
|
|
29
|
+
|
|
30
|
+
let record: any;
|
|
31
|
+
try {
|
|
32
|
+
record = JSON.parse(row.json);
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof record !== 'object' || record === null) continue;
|
|
38
|
+
|
|
39
|
+
const indexedAt = typeof record.createdAt === 'string' ? record.createdAt : new Date().toISOString();
|
|
40
|
+
const baseView: any = {
|
|
41
|
+
uri,
|
|
42
|
+
cid: row.cid,
|
|
43
|
+
creator: buildProfileView(primaryActor),
|
|
44
|
+
indexedAt,
|
|
45
|
+
likeCount: 0,
|
|
46
|
+
viewer: {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (detailed) {
|
|
50
|
+
const policies = normalizePolicies(record.policies);
|
|
51
|
+
views.push({
|
|
52
|
+
...baseView,
|
|
53
|
+
policies,
|
|
54
|
+
reasonTypes: Array.isArray(record.reasonTypes) ? record.reasonTypes : undefined,
|
|
55
|
+
subjectTypes: Array.isArray(record.subjectTypes) ? record.subjectTypes : undefined,
|
|
56
|
+
subjectCollections: Array.isArray(record.subjectCollections) ? record.subjectCollections : undefined,
|
|
57
|
+
labels: extractLabels(record.labels),
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
const labels = extractLabels(record.labels);
|
|
61
|
+
if (labels) baseView.labels = labels;
|
|
62
|
+
views.push(baseView);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return views;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizePolicies(input: any) {
|
|
70
|
+
if (input && typeof input === 'object') {
|
|
71
|
+
const labelValues = Array.isArray(input.labelValues) ? input.labelValues : [];
|
|
72
|
+
const labelValueDefinitions = Array.isArray(input.labelValueDefinitions)
|
|
73
|
+
? input.labelValueDefinitions
|
|
74
|
+
: undefined;
|
|
75
|
+
return {
|
|
76
|
+
labelValues,
|
|
77
|
+
labelValueDefinitions,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
labelValues: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractLabels(input: any) {
|
|
87
|
+
if (!input) return undefined;
|
|
88
|
+
if (Array.isArray(input)) return input.length ? input : undefined;
|
|
89
|
+
if (typeof input === 'object') return input;
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
@@ -72,23 +72,107 @@ export class D1Blockstore implements WritableBlockstore {
|
|
|
72
72
|
|
|
73
73
|
async put(cid: CID, bytes: Uint8Array): Promise<void> {
|
|
74
74
|
const db = drizzle(this.env.DB);
|
|
75
|
+
const cidStr = cid.toString();
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
// Check if block already exists - D1 has issues with ON CONFLICT DO NOTHING
|
|
78
|
+
const existing = await db
|
|
79
|
+
.select({ cid: blockstore.cid })
|
|
80
|
+
.from(blockstore)
|
|
81
|
+
.where(eq(blockstore.cid, cidStr))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
if (existing) {
|
|
85
|
+
// Block already exists, skip insert
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Encode Uint8Array to base64 string for storage. Chunk to avoid call-stack limits.
|
|
90
|
+
let binary = '';
|
|
91
|
+
const CHUNK_SIZE = 0x8000;
|
|
92
|
+
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
93
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE));
|
|
94
|
+
}
|
|
95
|
+
const base64 = btoa(binary);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await db
|
|
99
|
+
.insert(blockstore)
|
|
100
|
+
.values({
|
|
101
|
+
cid: cidStr,
|
|
102
|
+
bytes: base64,
|
|
103
|
+
})
|
|
104
|
+
.run();
|
|
105
|
+
} catch (error: any) {
|
|
106
|
+
// If we get a unique constraint error, another request inserted it - that's ok
|
|
107
|
+
if (error?.message?.includes('UNIQUE constraint failed') ||
|
|
108
|
+
error?.message?.includes('constraint failed')) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
console.error(JSON.stringify({
|
|
112
|
+
level: 'error',
|
|
113
|
+
type: 'blockstore_put',
|
|
114
|
+
cid: cidStr,
|
|
115
|
+
size: bytes.byteLength,
|
|
116
|
+
message: error?.message,
|
|
117
|
+
}));
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
87
120
|
}
|
|
88
121
|
|
|
89
122
|
async putMany(blocks: Map<CID, Uint8Array>): Promise<void> {
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
const db = drizzle(this.env.DB);
|
|
124
|
+
const BATCH_SIZE = 100; // Insert 100 blocks at a time
|
|
125
|
+
const entries = Array.from(blocks.entries());
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
|
128
|
+
const batch = entries.slice(i, i + BATCH_SIZE);
|
|
129
|
+
const values = [];
|
|
130
|
+
|
|
131
|
+
for (const [cid, bytes] of batch) {
|
|
132
|
+
const cidStr = cid.toString();
|
|
133
|
+
|
|
134
|
+
// Check if block already exists
|
|
135
|
+
const existing = await db
|
|
136
|
+
.select({ cid: blockstore.cid })
|
|
137
|
+
.from(blockstore)
|
|
138
|
+
.where(eq(blockstore.cid, cidStr))
|
|
139
|
+
.get();
|
|
140
|
+
|
|
141
|
+
if (existing) continue;
|
|
142
|
+
|
|
143
|
+
// Encode to base64
|
|
144
|
+
let binary = '';
|
|
145
|
+
const CHUNK_SIZE = 0x8000;
|
|
146
|
+
for (let j = 0; j < bytes.length; j += CHUNK_SIZE) {
|
|
147
|
+
binary += String.fromCharCode(...bytes.subarray(j, j + CHUNK_SIZE));
|
|
148
|
+
}
|
|
149
|
+
const base64 = btoa(binary);
|
|
150
|
+
|
|
151
|
+
values.push({ cid: cidStr, bytes: base64 });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (values.length > 0) {
|
|
155
|
+
try {
|
|
156
|
+
await db.insert(blockstore).values(values).run();
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
// If batch insert fails, fall back to individual inserts
|
|
159
|
+
for (const value of values) {
|
|
160
|
+
try {
|
|
161
|
+
await db.insert(blockstore).values(value).run();
|
|
162
|
+
} catch (e: any) {
|
|
163
|
+
if (!e?.message?.includes('UNIQUE constraint failed') &&
|
|
164
|
+
!e?.message?.includes('constraint failed')) {
|
|
165
|
+
console.error(JSON.stringify({
|
|
166
|
+
level: 'error',
|
|
167
|
+
type: 'blockstore_put_many',
|
|
168
|
+
cid: value.cid,
|
|
169
|
+
message: e?.message,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
92
176
|
}
|
|
93
177
|
}
|
|
94
178
|
|
|
@@ -102,4 +186,4 @@ export class D1Blockstore implements WritableBlockstore {
|
|
|
102
186
|
}
|
|
103
187
|
return dagCbor.decode(bytes) as T;
|
|
104
188
|
}
|
|
105
|
-
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
2
|
+
import { scryptAsync } from '@noble/hashes/scrypt.js';
|
|
3
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
|
|
5
|
+
const SALT_BYTES = 16;
|
|
6
|
+
const KEY_LEN = 64;
|
|
7
|
+
const SCRYPT_OPTS = {
|
|
8
|
+
N: 1 << 15, // Close to Node scrypt defaults while remaining worker-friendly
|
|
9
|
+
r: 8,
|
|
10
|
+
p: 1,
|
|
11
|
+
dkLen: KEY_LEN,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async function derive(password: string, saltHex: string): Promise<string> {
|
|
15
|
+
const salt = hexToBytes(saltHex);
|
|
16
|
+
const key = await scryptAsync(password, salt, SCRYPT_OPTS);
|
|
17
|
+
return bytesToHex(key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
21
|
+
const saltHex = bytesToHex(randomBytes(SALT_BYTES));
|
|
22
|
+
const hashHex = await derive(password, saltHex);
|
|
23
|
+
return `${saltHex}:${hashHex}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function verifyPassword(password: string, stored: string | null): Promise<boolean> {
|
|
27
|
+
if (!stored) return false;
|
|
28
|
+
const [saltHex, hashHex] = stored.split(':');
|
|
29
|
+
if (!saltHex || !hashHex) return false;
|
|
30
|
+
const candidate = await derive(password, saltHex);
|
|
31
|
+
return candidate === hashHex;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function rehashIfNeeded(password: string, stored: string | null): Promise<string | null> {
|
|
35
|
+
if (!stored) return null;
|
|
36
|
+
const [saltHex] = stored.split(':');
|
|
37
|
+
if (!saltHex) return null;
|
|
38
|
+
// Currently no adaptive parameters; placeholder for future upgrades.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { resolveSecret } from './secrets';
|
|
3
|
+
|
|
4
|
+
let tableEnsured = false;
|
|
5
|
+
|
|
6
|
+
async function ensureTable(env: Env) {
|
|
7
|
+
if (tableEnsured) return;
|
|
8
|
+
await env.DB.exec(
|
|
9
|
+
'CREATE TABLE IF NOT EXISTS actor_preferences (did TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at INTEGER NOT NULL)'
|
|
10
|
+
);
|
|
11
|
+
tableEnsured = true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PREFERENCES = [
|
|
15
|
+
{
|
|
16
|
+
$type: 'app.bsky.actor.defs#savedFeedsPrefV2',
|
|
17
|
+
items: [],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
$type: 'app.bsky.actor.defs#feedViewPref',
|
|
21
|
+
feed: 'home',
|
|
22
|
+
hideReplies: false,
|
|
23
|
+
hideRepliesByUnfollowed: false,
|
|
24
|
+
hideRepliesByLikeCount: 0,
|
|
25
|
+
hideReposts: false,
|
|
26
|
+
hideQuotePosts: false,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
$type: 'app.bsky.actor.defs#threadViewPref',
|
|
30
|
+
sort: 'oldest',
|
|
31
|
+
prioritizeFollowedUsers: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
$type: 'app.bsky.actor.defs#labelersPref',
|
|
35
|
+
labelers: [
|
|
36
|
+
{
|
|
37
|
+
did: 'did:plc:ar7c4by46qjdydhdevvrndac',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export async function getActorPreferences(env: Env): Promise<{ did: string; preferences: any[] }> {
|
|
44
|
+
await ensureTable(env);
|
|
45
|
+
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
46
|
+
const row = await env.DB.prepare('SELECT json FROM actor_preferences WHERE did = ?')
|
|
47
|
+
.bind(did)
|
|
48
|
+
.first<{ json: string }>();
|
|
49
|
+
|
|
50
|
+
if (!row?.json) {
|
|
51
|
+
return { did, preferences: DEFAULT_PREFERENCES };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(row.json);
|
|
56
|
+
const preferences = Array.isArray(parsed) ? parsed : [];
|
|
57
|
+
// If preferences exist but are empty, return defaults
|
|
58
|
+
return { did, preferences: preferences.length > 0 ? preferences : DEFAULT_PREFERENCES };
|
|
59
|
+
} catch {
|
|
60
|
+
return { did, preferences: DEFAULT_PREFERENCES };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function setActorPreferences(env: Env, preferences: any[]): Promise<void> {
|
|
65
|
+
await ensureTable(env);
|
|
66
|
+
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
await env.DB.prepare(
|
|
69
|
+
'INSERT INTO actor_preferences (did, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(did) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at'
|
|
70
|
+
)
|
|
71
|
+
.bind(did, JSON.stringify(preferences ?? []), now)
|
|
72
|
+
.run();
|
|
73
|
+
}
|
package/src/lib/relay.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the public hostname for this PDS.
|
|
5
|
+
* Priority: env.PDS_HOSTNAME -> request URL host.
|
|
6
|
+
* Ensures the value is a bare hostname (no scheme/port/path).
|
|
7
|
+
*/
|
|
8
|
+
export function resolvePdsHostname(env: Env, requestUrl?: string): string | null {
|
|
9
|
+
let host = (env.PDS_HOSTNAME as string | undefined)?.trim() || '';
|
|
10
|
+
|
|
11
|
+
if (!host && requestUrl) {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(requestUrl);
|
|
14
|
+
host = url.hostname;
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!host) return null;
|
|
19
|
+
|
|
20
|
+
// Normalize: strip protocol/port if somehow present
|
|
21
|
+
host = host.replace(/^https?:\/\//i, '').replace(/:\d+$/, '').trim();
|
|
22
|
+
|
|
23
|
+
// Skip obvious local hosts to avoid spamming relays from dev
|
|
24
|
+
const lower = host.toLowerCase();
|
|
25
|
+
if (
|
|
26
|
+
lower === 'localhost' ||
|
|
27
|
+
lower.endsWith('.localhost') ||
|
|
28
|
+
lower === '127.0.0.1' ||
|
|
29
|
+
lower === '0.0.0.0' ||
|
|
30
|
+
lower === '::1'
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse relay hosts from env or return default list.
|
|
40
|
+
* CSV of bare hostnames (e.g. "bsky.network,relay.example.org").
|
|
41
|
+
*/
|
|
42
|
+
export function getRelayHosts(env: Env): string[] {
|
|
43
|
+
const csv = (env.PDS_RELAY_HOSTS as string | undefined)?.trim();
|
|
44
|
+
const hosts = csv && csv.length > 0 ? csv.split(',') : ['bsky.network'];
|
|
45
|
+
return hosts
|
|
46
|
+
.map((h) => h.trim())
|
|
47
|
+
.filter((h) => h && !/^https?:\/\//i.test(h))
|
|
48
|
+
.filter((h, i, arr) => arr.indexOf(h) === i);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Notify a single relay host using com.atproto.sync.requestCrawl
|
|
53
|
+
*/
|
|
54
|
+
export async function requestCrawl(relayHost: string, pdsHostname: string): Promise<Response> {
|
|
55
|
+
const url = `https://${relayHost}/xrpc/com.atproto.sync.requestCrawl`;
|
|
56
|
+
const res = await fetch(url, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ hostname: pdsHostname }),
|
|
60
|
+
});
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// In-memory isolation-scoped throttle to avoid spamming relays on every request.
|
|
65
|
+
let lastNotifyTs = 0;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Best-effort: notify relays that our PDS is available.
|
|
69
|
+
* - No throw on failure; logs to console only.
|
|
70
|
+
* - Throttled per isolate to at most once every 12h.
|
|
71
|
+
*/
|
|
72
|
+
export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promise<void> {
|
|
73
|
+
// Allow disabling via flag
|
|
74
|
+
const disabled = String(env.PDS_RELAY_NOTIFY || '').toLowerCase() === 'false';
|
|
75
|
+
if (disabled) return;
|
|
76
|
+
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (now - lastNotifyTs < 12 * 60 * 60 * 1000) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hostname = resolvePdsHostname(env, requestUrl);
|
|
83
|
+
if (!hostname) return;
|
|
84
|
+
|
|
85
|
+
const relays = getRelayHosts(env);
|
|
86
|
+
lastNotifyTs = now; // set early to avoid stampedes
|
|
87
|
+
|
|
88
|
+
await Promise.allSettled(
|
|
89
|
+
relays.map(async (relay) => {
|
|
90
|
+
try {
|
|
91
|
+
const res = await requestCrawl(relay, hostname);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
console.warn('requestCrawl failed', { relay, status: res.status });
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn('requestCrawl error', { relay, error: String(err) });
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
package/src/lib/secrets.ts
CHANGED
|
@@ -8,8 +8,11 @@ const SECRET_KEYS = [
|
|
|
8
8
|
'USER_PASSWORD',
|
|
9
9
|
'ACCESS_TOKEN_SECRET',
|
|
10
10
|
'REFRESH_TOKEN_SECRET',
|
|
11
|
+
'SESSION_JWT_SECRET',
|
|
11
12
|
'REPO_SIGNING_KEY',
|
|
12
|
-
'
|
|
13
|
+
'REPO_SIGNING_KEY_PUBLIC',
|
|
14
|
+
'PDS_PLC_ROTATION_KEY',
|
|
15
|
+
'PDS_SERVICE_SIGNING_KEY_HEX',
|
|
13
16
|
] as const satisfies readonly (keyof Env)[];
|
|
14
17
|
|
|
15
18
|
function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
|
2
|
+
import type { Env } from '../env';
|
|
3
|
+
import { getRuntimeString } from './secrets';
|
|
4
|
+
import { getOrCreateSecret } from '../db/account';
|
|
5
|
+
|
|
6
|
+
const SESSION_SECRET_KEY = 'session_jwt_secret';
|
|
7
|
+
const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
|
|
8
|
+
const ACCESS_TTL_SECONDS = 120 * 60; // 120 minutes
|
|
9
|
+
const REFRESH_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
|
|
10
|
+
|
|
11
|
+
async function loadSecret(env: Env): Promise<string> {
|
|
12
|
+
const fromEnv = await getRuntimeString(env, 'SESSION_JWT_SECRET' as keyof Env, '');
|
|
13
|
+
if (fromEnv) {
|
|
14
|
+
// Mirror into D1 so Workers without env access can retrieve it
|
|
15
|
+
await getOrCreateSecret(env, SESSION_SECRET_KEY, async () => fromEnv);
|
|
16
|
+
return fromEnv;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return getOrCreateSecret(env, SESSION_SECRET_KEY, async () => bytesToHex(randomBytes(32)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function getJwtKey(env: Env): Promise<Uint8Array> {
|
|
23
|
+
const secret = await loadSecret(env);
|
|
24
|
+
return new TextEncoder().encode(secret);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getServiceDid(env: Env): Promise<string> {
|
|
28
|
+
const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
|
|
29
|
+
if (!did) {
|
|
30
|
+
throw new Error('PDS_DID is not configured');
|
|
31
|
+
}
|
|
32
|
+
return did;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function issueSessionTokens(env: Env, did: string, opts: { jti?: string } = {}) {
|
|
36
|
+
const jwtKey = await getJwtKey(env);
|
|
37
|
+
const serviceDid = await getServiceDid(env);
|
|
38
|
+
const now = Math.floor(Date.now() / 1000);
|
|
39
|
+
|
|
40
|
+
const accessExp = now + ACCESS_TTL_SECONDS;
|
|
41
|
+
const accessPayload: TokenPayload = {
|
|
42
|
+
scope: 'access',
|
|
43
|
+
aud: serviceDid,
|
|
44
|
+
sub: did,
|
|
45
|
+
iat: now,
|
|
46
|
+
exp: accessExp,
|
|
47
|
+
};
|
|
48
|
+
const accessJwt = await signJwt(jwtKey, 'at+jwt', accessPayload);
|
|
49
|
+
|
|
50
|
+
const jti = opts.jti ?? generateTokenId();
|
|
51
|
+
const refreshExp = now + REFRESH_TTL_SECONDS;
|
|
52
|
+
const refreshPayload: RefreshTokenPayload = {
|
|
53
|
+
scope: 'refresh',
|
|
54
|
+
aud: serviceDid,
|
|
55
|
+
sub: did,
|
|
56
|
+
iat: now,
|
|
57
|
+
exp: refreshExp,
|
|
58
|
+
jti,
|
|
59
|
+
};
|
|
60
|
+
const refreshJwt = await signJwt(jwtKey, 'refresh+jwt', refreshPayload);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
accessJwt,
|
|
64
|
+
refreshJwt,
|
|
65
|
+
refreshPayload,
|
|
66
|
+
refreshExpiry: refreshPayload.exp,
|
|
67
|
+
} as const;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function verifyRefreshToken(env: Env, token: string) {
|
|
71
|
+
const key = await getJwtKey(env);
|
|
72
|
+
const serviceDid = await getServiceDid(env);
|
|
73
|
+
const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
|
|
74
|
+
if (header.typ !== 'refresh+jwt') {
|
|
75
|
+
throw new Error('Invalid token type');
|
|
76
|
+
}
|
|
77
|
+
if (payload.scope !== 'refresh') {
|
|
78
|
+
throw new Error('Invalid refresh token scope');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
payload,
|
|
82
|
+
decoded: {
|
|
83
|
+
scope: payload.scope,
|
|
84
|
+
sub: payload.sub,
|
|
85
|
+
exp: payload.exp,
|
|
86
|
+
jti: payload.jti,
|
|
87
|
+
} as RefreshTokenPayload,
|
|
88
|
+
} as const;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function verifyAccessToken(env: Env, token: string) {
|
|
92
|
+
const key = await getJwtKey(env);
|
|
93
|
+
const serviceDid = await getServiceDid(env);
|
|
94
|
+
const { header, payload } = await decodeAndVerifyJwt(key, token, 'at+jwt', serviceDid);
|
|
95
|
+
if (header.typ !== 'at+jwt') {
|
|
96
|
+
throw new Error('Invalid token type');
|
|
97
|
+
}
|
|
98
|
+
if (payload.scope === 'refresh') {
|
|
99
|
+
throw new Error('Unexpected scope for access token');
|
|
100
|
+
}
|
|
101
|
+
return payload;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeGraceExpiry(previousExpiry: number, nowSeconds: number): number {
|
|
105
|
+
const candidate = nowSeconds + GRACE_PERIOD_SECONDS;
|
|
106
|
+
return Math.min(previousExpiry, candidate);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type TokenPayload = {
|
|
110
|
+
scope: string;
|
|
111
|
+
aud: string;
|
|
112
|
+
sub: string;
|
|
113
|
+
iat: number;
|
|
114
|
+
exp: number;
|
|
115
|
+
jti?: string;
|
|
116
|
+
[key: string]: unknown;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type RefreshTokenPayload = TokenPayload & { jti: string };
|
|
120
|
+
|
|
121
|
+
type TokenHeader = { alg: 'HS256'; typ: 'at+jwt' | 'refresh+jwt' };
|
|
122
|
+
|
|
123
|
+
async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenPayload): Promise<string> {
|
|
124
|
+
const header: TokenHeader = { alg: 'HS256', typ };
|
|
125
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
126
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
127
|
+
const data = `${encodedHeader}.${encodedPayload}`;
|
|
128
|
+
const signature = await hmacSign(key, data);
|
|
129
|
+
return `${data}.${signature}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
|
|
133
|
+
const parts = token.split('.');
|
|
134
|
+
if (parts.length !== 3) {
|
|
135
|
+
throw new Error('Invalid token format');
|
|
136
|
+
}
|
|
137
|
+
const header = JSON.parse(base64UrlDecode(parts[0])) as TokenHeader;
|
|
138
|
+
const payload = JSON.parse(base64UrlDecode(parts[1])) as TokenPayload;
|
|
139
|
+
|
|
140
|
+
if (header.alg !== 'HS256' || header.typ !== expectedTyp) {
|
|
141
|
+
throw new Error('Unexpected token header');
|
|
142
|
+
}
|
|
143
|
+
if (payload.aud !== audience) {
|
|
144
|
+
throw new Error('Token audience mismatch');
|
|
145
|
+
}
|
|
146
|
+
if (!payload.sub) {
|
|
147
|
+
throw new Error('Token missing subject');
|
|
148
|
+
}
|
|
149
|
+
if (typeof payload.exp !== 'number') {
|
|
150
|
+
throw new Error('Token missing expiry');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = `${parts[0]}.${parts[1]}`;
|
|
154
|
+
const ok = await hmacVerify(key, data, parts[2]);
|
|
155
|
+
if (!ok) {
|
|
156
|
+
throw new Error('Invalid token signature');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { header, payload };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function generateTokenId(): string {
|
|
163
|
+
const bytes = randomBytes(32);
|
|
164
|
+
return base64UrlEncode(bytes);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function hmacSign(keyBytes: Uint8Array, data: string): Promise<string> {
|
|
168
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
169
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, textEncoder.encode(data));
|
|
170
|
+
return base64UrlEncode(new Uint8Array(signature));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function hmacVerify(keyBytes: Uint8Array, data: string, signatureB64: string): Promise<boolean> {
|
|
174
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
175
|
+
return crypto.subtle.verify('HMAC', cryptoKey, base64UrlDecodeToBytes(signatureB64), textEncoder.encode(data));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function base64UrlEncode(value: string | Uint8Array): string {
|
|
179
|
+
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
180
|
+
let binary = '';
|
|
181
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
182
|
+
binary += String.fromCharCode(bytes[i]);
|
|
183
|
+
}
|
|
184
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function base64UrlDecode(encoded: string): string {
|
|
188
|
+
const pad = encoded.length % 4 === 2 ? '==' : encoded.length % 4 === 3 ? '=' : '';
|
|
189
|
+
const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/') + pad);
|
|
190
|
+
return binary;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function base64UrlDecodeToBytes(encoded: string): Uint8Array {
|
|
194
|
+
const binary = base64UrlDecode(encoded);
|
|
195
|
+
const bytes = new Uint8Array(binary.length);
|
|
196
|
+
for (let i = 0; i < binary.length; i++) {
|
|
197
|
+
bytes[i] = binary.charCodeAt(i);
|
|
198
|
+
}
|
|
199
|
+
return bytes;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const textEncoder = new TextEncoder();
|
package/src/lib/token-cleanup.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
|
-
import {
|
|
3
|
-
import { token_revocation } from '../db/schema';
|
|
4
|
-
import { lt } from 'drizzle-orm';
|
|
2
|
+
import { cleanupExpiredRefreshTokens } from '../db/account';
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Clean up expired tokens from the revocation table
|
|
8
6
|
* This prevents the table from growing indefinitely
|
|
9
7
|
*/
|
|
10
8
|
export async function cleanupExpiredTokens(env: Env): Promise<number> {
|
|
11
|
-
const db = drizzle(env.DB);
|
|
12
9
|
const now = Math.floor(Date.now() / 1000);
|
|
13
|
-
|
|
14
|
-
// Delete tokens where expiry is in the past
|
|
15
|
-
const result = await db.delete(token_revocation)
|
|
16
|
-
.where(lt(token_revocation.exp, now))
|
|
17
|
-
.run();
|
|
18
|
-
|
|
19
|
-
return result.meta.changes || 0;
|
|
10
|
+
return cleanupExpiredRefreshTokens(env, now);
|
|
20
11
|
}
|
|
21
12
|
|
|
22
13
|
/**
|
|
@@ -35,4 +26,4 @@ export async function lazyCleanupExpiredTokens(env: Env): Promise<void> {
|
|
|
35
26
|
} catch (error) {
|
|
36
27
|
console.error('Failed to cleanup expired tokens:', error);
|
|
37
28
|
}
|
|
38
|
-
}
|
|
29
|
+
}
|