@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.
- package/README.md +35 -10
- 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 +7 -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 +22 -2
- 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 +231 -79
- 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 +29 -21
- 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 +21 -0
- package/types/env.d.ts +11 -2
- 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,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
|
@@ -1,26 +1,33 @@
|
|
|
1
|
-
import { setGetEnv } from
|
|
2
|
-
import type { Env } from
|
|
3
|
-
import type { SecretsStoreSecret } from
|
|
1
|
+
import { setGetEnv } from "astro/env/setup";
|
|
2
|
+
import type { Env } from "../env";
|
|
3
|
+
import type { SecretsStoreSecret } from "../../types/env";
|
|
4
4
|
|
|
5
5
|
const SECRET_KEYS = [
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
"PDS_DID",
|
|
7
|
+
"PDS_HANDLE",
|
|
8
|
+
"USER_PASSWORD",
|
|
9
|
+
"REFRESH_TOKEN",
|
|
10
|
+
"REFRESH_TOKEN_SECRET",
|
|
11
|
+
"SESSION_JWT_SECRET",
|
|
12
|
+
"REPO_SIGNING_KEY",
|
|
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 {
|
|
16
|
-
return
|
|
19
|
+
return (
|
|
20
|
+
!!value &&
|
|
21
|
+
typeof value === "object" &&
|
|
22
|
+
typeof (value as any).get === "function"
|
|
23
|
+
);
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export async function resolveSecret(
|
|
20
|
-
value: string | SecretsStoreSecret | undefined
|
|
27
|
+
value: string | SecretsStoreSecret | undefined,
|
|
21
28
|
): Promise<string | undefined> {
|
|
22
29
|
if (value === undefined) return undefined;
|
|
23
|
-
if (typeof value ===
|
|
30
|
+
if (typeof value === "string") return value;
|
|
24
31
|
if (isSecretStoreBinding(value)) return value.get();
|
|
25
32
|
return undefined;
|
|
26
33
|
}
|
|
@@ -38,15 +45,16 @@ export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
|
|
|
38
45
|
if (val !== undefined) {
|
|
39
46
|
resolved[key as string] = val;
|
|
40
47
|
}
|
|
41
|
-
})
|
|
48
|
+
}),
|
|
42
49
|
);
|
|
43
50
|
|
|
44
51
|
setGetEnv((key) => {
|
|
45
52
|
const local = resolved[key];
|
|
46
|
-
if (typeof local ===
|
|
47
|
-
if (typeof local ===
|
|
53
|
+
if (typeof local === "string") return local;
|
|
54
|
+
if (typeof local === "number" || typeof local === "boolean")
|
|
55
|
+
return String(local);
|
|
48
56
|
const fallback = process.env[key];
|
|
49
|
-
return typeof fallback ===
|
|
57
|
+
return typeof fallback === "string" ? fallback : undefined;
|
|
50
58
|
});
|
|
51
59
|
|
|
52
60
|
return resolved as E;
|
|
@@ -59,7 +67,7 @@ let astroGetSecret: AstroGetSecret | null | undefined;
|
|
|
59
67
|
async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
|
|
60
68
|
if (astroGetSecret !== undefined) return astroGetSecret;
|
|
61
69
|
try {
|
|
62
|
-
const mod = await import(
|
|
70
|
+
const mod = await import("astro:env/server");
|
|
63
71
|
astroGetSecret = mod.getSecret as AstroGetSecret;
|
|
64
72
|
} catch {
|
|
65
73
|
astroGetSecret = null;
|
|
@@ -70,10 +78,10 @@ async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
|
|
|
70
78
|
export async function getRuntimeString<K extends keyof Env>(
|
|
71
79
|
env: Env,
|
|
72
80
|
key: K,
|
|
73
|
-
fallback?: string
|
|
81
|
+
fallback?: string,
|
|
74
82
|
): Promise<string | undefined> {
|
|
75
83
|
const current = env[key];
|
|
76
|
-
if (typeof current ===
|
|
84
|
+
if (typeof current === "string" && current !== "") {
|
|
77
85
|
return current;
|
|
78
86
|
}
|
|
79
87
|
|
|
@@ -81,7 +89,7 @@ export async function getRuntimeString<K extends keyof Env>(
|
|
|
81
89
|
if (secretFn) {
|
|
82
90
|
try {
|
|
83
91
|
const value = secretFn(String(key));
|
|
84
|
-
if (typeof value ===
|
|
92
|
+
if (typeof value === "string" && value !== "") {
|
|
85
93
|
return value;
|
|
86
94
|
}
|
|
87
95
|
} catch (error) {
|
|
@@ -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
|
+
}
|
package/src/lib/util.ts
CHANGED
|
@@ -38,10 +38,25 @@ export function bearerToken(request: Request): string | null {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function isAllowedMime(env: any, mime: string): boolean {
|
|
41
|
-
const def = [
|
|
41
|
+
const def = [
|
|
42
|
+
// Images
|
|
43
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif',
|
|
44
|
+
// Videos
|
|
45
|
+
'video/mp4', 'video/mpeg', 'video/webm', 'video/quicktime',
|
|
46
|
+
// Audio
|
|
47
|
+
'audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/webm',
|
|
48
|
+
// JSON (for some Bluesky data)
|
|
49
|
+
'application/json',
|
|
50
|
+
// Generic fallback
|
|
51
|
+
'application/octet-stream'
|
|
52
|
+
];
|
|
42
53
|
const raw = (env.PDS_ALLOWED_MIME as string | undefined) ?? def.join(',');
|
|
43
54
|
const set = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
44
|
-
|
|
55
|
+
|
|
56
|
+
// Extract base MIME type (remove charset and other parameters)
|
|
57
|
+
const baseMime = mime.toLowerCase().split(';')[0].trim();
|
|
58
|
+
|
|
59
|
+
return set.has(baseMime);
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
export function randomRkey(): string {
|
package/src/middleware.ts
CHANGED
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
import { defineMiddleware, sequence } from 'astro:middleware';
|
|
2
2
|
|
|
3
3
|
const cors = defineMiddleware(async ({ locals, request }, next) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// In production, never allow wildcard - require explicit origins
|
|
9
|
-
const isProduction = env.PDS_HOSTNAME && !env.PDS_HOSTNAME.includes('localhost');
|
|
10
|
-
const allowWildcard = !isProduction && corsOrigins.includes('*');
|
|
11
|
-
|
|
12
|
-
// Check if origin is in allowlist
|
|
13
|
-
const isAllowed = allowWildcard || corsOrigins.includes(origin);
|
|
4
|
+
// Match atproto CORS implementation: use wildcard for public endpoints
|
|
5
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
|
6
|
+
// For requests without credentials, "*" can be specified as a wildcard
|
|
7
|
+
// This is safer than reflecting the request origin and matches atproto standard
|
|
14
8
|
|
|
15
9
|
if (request.method === 'OPTIONS') {
|
|
16
|
-
|
|
17
|
-
return new Response('CORS origin not allowed', { status: 403 });
|
|
18
|
-
}
|
|
19
|
-
|
|
10
|
+
// CORS preflight - match atproto PDS implementation
|
|
20
11
|
const headers = new Headers({
|
|
21
|
-
'Access-Control-Allow-Origin':
|
|
22
|
-
|
|
23
|
-
'Access-Control-Allow-
|
|
24
|
-
|
|
12
|
+
'Access-Control-Allow-Origin': '*',
|
|
13
|
+
// Use wildcard for methods (atproto standard)
|
|
14
|
+
'Access-Control-Allow-Methods': '*',
|
|
15
|
+
// Use wildcard for headers to allow atproto-accept-labelers and other custom headers
|
|
16
|
+
'Access-Control-Allow-Headers': '*',
|
|
17
|
+
// Match atproto: 1 day max-age for CORS preflight cache
|
|
18
|
+
'Access-Control-Max-Age': '86400',
|
|
25
19
|
});
|
|
26
20
|
return new Response(null, { status: 204, headers });
|
|
27
21
|
}
|
|
28
22
|
|
|
29
23
|
const response = await next();
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
// Set CORS headers on all responses (atproto standard)
|
|
26
|
+
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
27
|
+
|
|
28
|
+
// Expose DPoP-Nonce header for OAuth clients (atproto standard)
|
|
29
|
+
// This allows clients to read the DPoP-Nonce header from responses
|
|
30
|
+
const dpopNonce = response.headers.get('DPoP-Nonce');
|
|
31
|
+
if (dpopNonce) {
|
|
32
|
+
response.headers.set('Access-Control-Expose-Headers', 'DPoP-Nonce');
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
return response;
|