@alteran/astro 0.3.9 → 0.6.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -30
  3. package/index.js +34 -28
  4. package/migrations/0007_bored_spitfire.sql +26 -0
  5. package/migrations/0008_furry_ozymandias.sql +2 -0
  6. package/migrations/meta/0007_snapshot.json +534 -0
  7. package/migrations/meta/0008_snapshot.json +548 -0
  8. package/migrations/meta/_journal.json +14 -0
  9. package/package.json +10 -9
  10. package/src/app.ts +8 -4
  11. package/src/db/account.ts +25 -6
  12. package/src/db/client.ts +1 -1
  13. package/src/db/dal.ts +34 -23
  14. package/src/db/repo.ts +38 -38
  15. package/src/db/schema.ts +5 -1
  16. package/src/db/seed.ts +5 -13
  17. package/src/entrypoints/server.ts +2 -22
  18. package/src/handlers/debug.ts +1 -1
  19. package/src/handlers/ready.ts +1 -1
  20. package/src/handlers/root.ts +4 -4
  21. package/src/handlers/xrpc.server.refreshSession.ts +6 -6
  22. package/src/lib/account-state.ts +156 -0
  23. package/src/lib/actor.ts +29 -13
  24. package/src/lib/appview/auth-policy.ts +66 -0
  25. package/src/lib/appview/did-resolver.ts +233 -0
  26. package/src/lib/appview/proxy.ts +221 -0
  27. package/src/lib/appview/service-config.ts +61 -0
  28. package/src/lib/appview/service-jwt.ts +93 -0
  29. package/src/lib/appview/types.ts +25 -0
  30. package/src/lib/appview.ts +5 -532
  31. package/src/lib/auth-errors.ts +24 -0
  32. package/src/lib/auth.ts +63 -15
  33. package/src/lib/blockstore-gc.ts +6 -5
  34. package/src/lib/cache.ts +30 -4
  35. package/src/lib/chat.ts +20 -14
  36. package/src/lib/commit-log-pruning.ts +2 -2
  37. package/src/lib/commit.ts +26 -36
  38. package/src/lib/config.ts +26 -15
  39. package/src/lib/did-document.ts +32 -0
  40. package/src/lib/errors.ts +54 -0
  41. package/src/lib/feed.ts +18 -19
  42. package/src/lib/firehose/frames.ts +87 -47
  43. package/src/lib/firehose/validation.ts +3 -3
  44. package/src/lib/jwt.ts +85 -177
  45. package/src/lib/labeler.ts +43 -30
  46. package/src/lib/logger.ts +4 -0
  47. package/src/lib/mst/block-map.ts +172 -0
  48. package/src/lib/mst/blockstore.ts +56 -93
  49. package/src/lib/mst/index.ts +1 -0
  50. package/src/lib/mst/leaf.ts +25 -0
  51. package/src/lib/mst/mst.ts +81 -237
  52. package/src/lib/mst/serialize.ts +97 -0
  53. package/src/lib/mst/types.ts +21 -0
  54. package/src/lib/oauth/clients.ts +67 -0
  55. package/src/lib/oauth/dpop-errors.ts +15 -0
  56. package/src/lib/oauth/dpop.ts +150 -0
  57. package/src/lib/oauth/resource.ts +199 -0
  58. package/src/lib/oauth/store.ts +77 -0
  59. package/src/lib/preferences.ts +12 -37
  60. package/src/lib/ratelimit.ts +4 -4
  61. package/src/lib/refresh-session.ts +161 -0
  62. package/src/lib/relay.ts +10 -8
  63. package/src/lib/secrets.ts +6 -7
  64. package/src/lib/sequencer.ts +14 -5
  65. package/src/lib/service-auth.ts +184 -0
  66. package/src/lib/session-tokens.ts +28 -76
  67. package/src/lib/streaming-car.ts +3 -0
  68. package/src/lib/tracing.ts +4 -3
  69. package/src/lib/util.ts +65 -15
  70. package/src/middleware.ts +1 -1
  71. package/src/pages/.well-known/did.json.ts +27 -30
  72. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  73. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  74. package/src/pages/debug/blob/[...key].ts +2 -2
  75. package/src/pages/debug/db/bootstrap.ts +1 -1
  76. package/src/pages/debug/db/commits.ts +1 -1
  77. package/src/pages/debug/gc/blobs.ts +1 -1
  78. package/src/pages/debug/record.ts +1 -1
  79. package/src/pages/debug/sequencer.ts +28 -0
  80. package/src/pages/health.ts +4 -4
  81. package/src/pages/oauth/authorize.ts +78 -0
  82. package/src/pages/oauth/consent.ts +80 -0
  83. package/src/pages/oauth/par.ts +121 -0
  84. package/src/pages/oauth/token.ts +158 -0
  85. package/src/pages/ready.ts +2 -2
  86. package/src/pages/xrpc/[...nsid].ts +61 -0
  87. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  88. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  89. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  90. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  91. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  92. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  93. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  94. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  95. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  96. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  97. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  99. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  100. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  101. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  102. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  103. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  104. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  105. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  106. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  107. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  108. package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
  109. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  110. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  111. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  112. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  113. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
  114. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  115. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  116. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  117. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  118. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  119. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  120. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  121. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  122. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
  123. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  124. package/src/services/car.ts +209 -57
  125. package/src/services/r2-blob-store.ts +4 -4
  126. package/src/services/repo/blockstore-ops.ts +29 -0
  127. package/src/services/repo/operations.ts +133 -0
  128. package/src/services/repo-manager.ts +203 -254
  129. package/src/worker/runtime.ts +56 -11
  130. package/src/worker/sequencer/broadcast.ts +91 -0
  131. package/src/worker/sequencer/cid-helpers.ts +39 -0
  132. package/src/worker/sequencer/payload.ts +84 -0
  133. package/src/worker/sequencer/types.ts +36 -0
  134. package/src/worker/sequencer/upgrade.ts +141 -0
  135. package/src/worker/sequencer.ts +264 -406
  136. package/types/env.d.ts +18 -6
  137. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  138. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  139. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  140. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  141. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  142. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  143. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  144. package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
  145. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  146. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  147. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  148. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  149. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
  150. package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
@@ -0,0 +1,121 @@
1
+ import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
3
+ import { getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
4
+ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
+ import { savePar } from '../../lib/oauth/store';
6
+ import { fetchClientMetadata, isHttpsUrl, verifyClientAssertion } from '../../lib/oauth/clients';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ // Enforce DPoP with nonce; if missing or stale, return use_dpop_nonce
14
+ try {
15
+ const ver = await verifyDpop(env, request);
16
+
17
+ // Parse form-encoded body
18
+ const bodyText = await request.text();
19
+ const form = new URLSearchParams(bodyText);
20
+ const client_id = form.get('client_id') || '';
21
+ const response_type = form.get('response_type') || '';
22
+ const redirect_uri = form.get('redirect_uri') || '';
23
+ const scope = form.get('scope') || '';
24
+ const state = form.get('state') || '';
25
+ const code_challenge = form.get('code_challenge') || '';
26
+ const code_challenge_method = form.get('code_challenge_method') || '';
27
+ const login_hint = form.get('login_hint') || undefined;
28
+ const client_assertion_type = form.get('client_assertion_type') || '';
29
+ const client_assertion = form.get('client_assertion') || '';
30
+
31
+ if (!client_id || !isHttpsUrl(client_id)) {
32
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
33
+ }
34
+ if (response_type !== 'code') {
35
+ return new Response(JSON.stringify({ error: 'unsupported_response_type' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
36
+ }
37
+ if (!redirect_uri || !isHttpsUrl(redirect_uri)) {
38
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
39
+ }
40
+ if (!scope || !scope.split(' ').includes('atproto')) {
41
+ return new Response(JSON.stringify({ error: 'invalid_scope' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
42
+ }
43
+ if (!state) {
44
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
45
+ }
46
+ if (!code_challenge || code_challenge_method !== 'S256') {
47
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'PKCE (S256) required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
48
+ }
49
+
50
+ // Fetch and validate client metadata
51
+ let clientMeta: any = null;
52
+ try {
53
+ clientMeta = await fetchClientMetadata(client_id);
54
+ } catch (e) {
55
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
56
+ }
57
+
58
+ if (clientMeta?.client_id !== client_id) {
59
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id mismatch' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
60
+ }
61
+ const redirects: string[] = Array.isArray(clientMeta?.redirect_uris) ? clientMeta.redirect_uris : [];
62
+ if (!redirects.includes(redirect_uri)) {
63
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri not registered' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
64
+ }
65
+ if (clientMeta?.dpop_bound_access_tokens !== true) {
66
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client must require DPoP' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
67
+ }
68
+ const url = new URL(request.url);
69
+ const issuerOrigin = `${url.protocol}//${url.host}`;
70
+ const authMethod = clientMeta?.token_endpoint_auth_method;
71
+ if (authMethod === 'private_key_jwt') {
72
+ // Load JWKS (inline or via URI)
73
+ let jwks = clientMeta?.jwks;
74
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
75
+ try {
76
+ const response = await fetch(clientMeta.jwks_uri);
77
+ jwks = await response.json();
78
+ } catch (e) {
79
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Failed to fetch jwks_uri' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
80
+ }
81
+ }
82
+ if (!jwks) {
83
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing JWKS' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
84
+ }
85
+ if (client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' || !client_assertion) {
86
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing client assertion' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
87
+ }
88
+ const ok = await verifyClientAssertion(client_id, issuerOrigin, client_assertion, jwks);
89
+ if (!ok) {
90
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Invalid client assertion' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
91
+ }
92
+ }
93
+
94
+ const id = crypto.randomUUID().replace(/-/g, '');
95
+ const now = Math.floor(Date.now() / 1000);
96
+ const rec = {
97
+ client_id,
98
+ redirect_uri,
99
+ code_challenge,
100
+ code_challenge_method: 'S256' as const,
101
+ scope,
102
+ state,
103
+ login_hint,
104
+ dpopJkt: ver.jkt,
105
+ createdAt: now,
106
+ expiresAt: now + 300, // 5 minutes
107
+ };
108
+ await savePar(env, id, rec);
109
+
110
+ const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
111
+ const headers = new Headers({ 'Content-Type': 'application/json' });
112
+ setDpopNonceHeader(headers, await getAuthzNonce(env));
113
+ return new Response(JSON.stringify({ request_uri }), { status: 201, headers });
114
+ } catch (e) {
115
+ if (e instanceof DpopNonceError) {
116
+ return dpopErrorResponse(env, e);
117
+ }
118
+ const headers = new Headers({ 'Content-Type': 'application/json' });
119
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: errorMessage(e) ?? 'Unknown error' }), { status: 400, headers });
120
+ }
121
+ }
@@ -0,0 +1,158 @@
1
+ import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
3
+ import { verifyDpop, dpopErrorResponse, getAuthzNonce } from '../../lib/oauth/dpop';
4
+ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
+ import { consumeCode } from '../../lib/oauth/store';
6
+ import { sha256b64url } from '../../lib/oauth/dpop';
7
+ import { issueSessionTokens, verifyRefreshToken, verifyAccessToken, computeGraceExpiry } from '../../lib/session-tokens';
8
+ import { fetchClientMetadata, verifyClientAssertion } from '../../lib/oauth/clients';
9
+ import { storeRefreshToken, getRefreshToken, markRefreshTokenRotated } from '../../db/account';
10
+
11
+ export const prerender = false;
12
+
13
+ export async function POST({ locals, request }: APIContext) {
14
+ const { env } = locals.runtime;
15
+
16
+ try {
17
+ const ver = await verifyDpop(env, request);
18
+
19
+ const form = new URLSearchParams(await request.text());
20
+ const grant_type = form.get('grant_type') || '';
21
+
22
+ if (grant_type === 'authorization_code') {
23
+ const code = form.get('code') || '';
24
+ const client_id = form.get('client_id') || '';
25
+ const redirect_uri = form.get('redirect_uri') || '';
26
+ const code_verifier = form.get('code_verifier') || '';
27
+ const client_assertion_type = form.get('client_assertion_type') || '';
28
+ const client_assertion = form.get('client_assertion') || '';
29
+
30
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
31
+ return jsonError('invalid_request', 'Missing parameters');
32
+ }
33
+
34
+ const rec = await consumeCode(env, code);
35
+ if (!rec) return jsonError('invalid_grant', 'Invalid or used code');
36
+ if (rec.client_id !== client_id) return jsonError('invalid_grant', 'client_id mismatch');
37
+ if (rec.redirect_uri !== redirect_uri) return jsonError('invalid_grant', 'redirect_uri mismatch');
38
+
39
+ const expected = await sha256b64url(code_verifier);
40
+ if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
41
+
42
+ if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
43
+
44
+ // If confidential client, verify assertion
45
+ let clientMeta: any = null;
46
+ try {
47
+ clientMeta = await fetchClientMetadata(client_id);
48
+ } catch {
49
+ // Public clients have no fetchable metadata; only confidential clients gate on it below.
50
+ }
51
+ if (clientMeta?.token_endpoint_auth_method === 'private_key_jwt') {
52
+ let jwks = clientMeta?.jwks;
53
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
54
+ const response = await fetch(clientMeta.jwks_uri);
55
+ jwks = await response.json();
56
+ }
57
+ const origin = `${new URL(request.url).protocol}//${new URL(request.url).host}`;
58
+ if (!client_assertion || client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
59
+ return jsonError('invalid_client', 'Missing client assertion');
60
+ const ok = await verifyClientAssertion(client_id, origin, client_assertion, jwks);
61
+ if (!ok) return jsonError('invalid_client', 'Invalid client assertion');
62
+ }
63
+
64
+ // Issue tokens bound to this DID and include DPoP cnf in access token
65
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, rec.did);
66
+ await storeRefreshToken(env, { id: refreshPayload.jti, did: rec.did, expiresAt: refreshExpiry, appPasswordName: null });
67
+
68
+ // Derive expires_in from access token
69
+ const payload = await verifyAccessToken(env, accessJwt).catch(() => null);
70
+ const now = Math.floor(Date.now() / 1000);
71
+ const expires_in = payload && typeof payload.exp === 'number' ? Math.max(0, payload.exp - now) : 7200;
72
+
73
+ const out = {
74
+ access_token: accessJwt,
75
+ token_type: 'DPoP',
76
+ expires_in,
77
+ refresh_token: refreshJwt,
78
+ scope: rec.scope,
79
+ sub: rec.did,
80
+ } as const;
81
+ const headers = new Headers({ 'Content-Type': 'application/json' });
82
+ headers.set('DPoP-Nonce', await getAuthzNonce(env));
83
+ return new Response(JSON.stringify(out), { status: 200, headers });
84
+ }
85
+
86
+ if (grant_type === 'refresh_token') {
87
+ const refresh_token = form.get('refresh_token') || '';
88
+ const client_id = form.get('client_id') || '';
89
+ const client_assertion_type = form.get('client_assertion_type') || '';
90
+ const client_assertion = form.get('client_assertion') || '';
91
+ if (!refresh_token) return jsonError('invalid_request', 'Missing refresh_token');
92
+
93
+ // If confidential client, verify assertion
94
+ if (client_id) {
95
+ let clientMeta: any = null;
96
+ try {
97
+ clientMeta = await fetchClientMetadata(client_id);
98
+ } catch {
99
+ // Public clients have no fetchable metadata; only confidential clients gate on it below.
100
+ }
101
+ if (clientMeta?.token_endpoint_auth_method === 'private_key_jwt') {
102
+ let jwks = clientMeta?.jwks;
103
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
104
+ const response = await fetch(clientMeta.jwks_uri);
105
+ jwks = await response.json();
106
+ }
107
+ const origin = `${new URL(request.url).protocol}//${new URL(request.url).host}`;
108
+ if (!client_assertion || client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
109
+ return jsonError('invalid_client', 'Missing client assertion');
110
+ const ok = await verifyClientAssertion(client_id, origin, client_assertion, jwks);
111
+ if (!ok) return jsonError('invalid_client', 'Invalid client assertion');
112
+ }
113
+ }
114
+
115
+ const verification = await verifyRefreshToken(env, refresh_token).catch(() => null);
116
+ if (!verification || !verification.decoded) return jsonError('invalid_grant', 'Invalid refresh token');
117
+ const nowSec = Math.floor(Date.now() / 1000);
118
+ if (verification.decoded.exp <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
119
+
120
+ const stored = await getRefreshToken(env, verification.decoded.jti);
121
+ if (!stored) return jsonError('invalid_grant', 'Refresh token revoked');
122
+ if (stored.expiresAt <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
123
+ if (stored.did !== verification.decoded.sub) return jsonError('invalid_grant', 'Subject mismatch');
124
+
125
+ const did = stored.did;
126
+ // Rotate refresh, issue new pair
127
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, did, { jti: stored.nextId ?? undefined });
128
+ await storeRefreshToken(env, { id: refreshPayload.jti, did, expiresAt: refreshExpiry, appPasswordName: stored.appPasswordName ?? null });
129
+ const graceExpiry = computeGraceExpiry(stored.expiresAt, nowSec);
130
+ await markRefreshTokenRotated(env, verification.decoded.jti, refreshPayload.jti, graceExpiry);
131
+
132
+ const payload = await verifyAccessToken(env, accessJwt).catch(() => null);
133
+ const expires_in = payload && typeof payload.exp === 'number' ? Math.max(0, payload.exp - nowSec) : 7200;
134
+
135
+ const out = {
136
+ access_token: accessJwt,
137
+ token_type: 'DPoP',
138
+ expires_in,
139
+ refresh_token: refreshJwt,
140
+ scope: 'atproto',
141
+ sub: did,
142
+ } as const;
143
+ const headers = new Headers({ 'Content-Type': 'application/json' });
144
+ headers.set('DPoP-Nonce', await getAuthzNonce(env));
145
+ return new Response(JSON.stringify(out), { status: 200, headers });
146
+ }
147
+
148
+ return jsonError('unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
149
+ } catch (e) {
150
+ if (e instanceof DpopNonceError) return dpopErrorResponse(env, e);
151
+ return jsonError('invalid_request', errorMessage(e) ?? 'Unknown error');
152
+ }
153
+ }
154
+
155
+ function jsonError(code: string, desc?: string): Response {
156
+ const headers = new Headers({ 'Content-Type': 'application/json' });
157
+ return new Response(JSON.stringify({ error: code, error_description: desc }), { status: 400, headers });
158
+ }
@@ -6,8 +6,8 @@ export async function GET({ locals }: APIContext) {
6
6
  const { env } = locals.runtime;
7
7
 
8
8
  try {
9
- if (env.DB) {
10
- await env.DB.prepare('select 1').first();
9
+ if (env.ALTERAN_DB) {
10
+ await env.ALTERAN_DB.prepare('select 1').first();
11
11
  }
12
12
  return new Response('ok');
13
13
  } catch (e) {
@@ -0,0 +1,61 @@
1
+ import type { APIContext } from 'astro';
2
+ import { proxyAppView } from '../../lib/appview';
3
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
4
+
5
+ export const prerender = false;
6
+
7
+ function shouldProxy(nsid: string): boolean {
8
+ return (
9
+ nsid.startsWith('app.bsky.') ||
10
+ nsid.startsWith('chat.bsky.') ||
11
+ nsid.startsWith('tools.ozone.')
12
+ );
13
+ }
14
+
15
+ function nsidFromParams(params: Record<string, any>): string {
16
+ const p = (params as any).nsid;
17
+ if (Array.isArray(p)) return p.join('');
18
+ return typeof p === 'string' ? p : '';
19
+ }
20
+
21
+ async function handle({ locals, request, params }: APIContext): Promise<Response> {
22
+ const { env } = locals.runtime;
23
+ try {
24
+ if (!(await isAuthorized(request, env))) return unauthorized();
25
+ } catch (error) {
26
+ if (error instanceof AuthTokenExpiredError) {
27
+ return expiredToken();
28
+ }
29
+ throw error;
30
+ }
31
+
32
+ const nsid = nsidFromParams(params).trim();
33
+ console.log('xrpc catchall invoked:', { nsid, url: request.url });
34
+ if (!nsid) {
35
+ return new Response(JSON.stringify({ error: 'NotFound' }), {
36
+ status: 404,
37
+ headers: { 'Content-Type': 'application/json' },
38
+ });
39
+ }
40
+
41
+ if (!shouldProxy(nsid)) {
42
+ return new Response(JSON.stringify({ error: 'NotImplemented' }), {
43
+ status: 404,
44
+ headers: { 'Content-Type': 'application/json' },
45
+ });
46
+ }
47
+
48
+ return proxyAppView({ request, env, lxm: nsid });
49
+ }
50
+
51
+ export async function GET(ctx: APIContext) {
52
+ return handle(ctx);
53
+ }
54
+
55
+ export async function HEAD(ctx: APIContext) {
56
+ return handle(ctx);
57
+ }
58
+
59
+ export async function POST(ctx: APIContext) {
60
+ return handle(ctx);
61
+ }
@@ -1,23 +1,22 @@
1
1
  import type { APIContext } from 'astro';
2
- import { proxyAppView } from '../../lib/appview';
3
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
4
3
  import { getActorPreferences } from '../../lib/preferences';
5
4
 
6
5
  export const prerender = false;
7
6
 
8
7
  export async function GET({ locals, request }: APIContext) {
9
8
  const { env } = locals.runtime;
10
- if (!(await isAuthorized(request, env))) return unauthorized();
9
+ try {
10
+ if (!(await isAuthorized(request, env))) return unauthorized();
11
+ } catch (error) {
12
+ if (error instanceof AuthTokenExpiredError) {
13
+ return expiredToken();
14
+ }
15
+ throw error;
16
+ }
11
17
 
12
- return proxyAppView({
13
- request,
14
- env,
15
- lxm: 'app.bsky.actor.getPreferences',
16
- fallback: async () => {
17
- const { preferences } = await getActorPreferences(env);
18
- return new Response(JSON.stringify({ preferences }), {
19
- headers: { 'Content-Type': 'application/json' },
20
- });
21
- },
18
+ const { preferences } = await getActorPreferences(env);
19
+ return new Response(JSON.stringify({ preferences: Array.isArray(preferences) ? preferences : [] }), {
20
+ headers: { 'Content-Type': 'application/json' },
22
21
  });
23
22
  }
@@ -1,6 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { proxyAppView } from '../../lib/appview';
3
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { errorCode, errorMessage } from '../../lib/errors';
3
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
4
4
  import { readJsonBounded } from '../../lib/util';
5
5
  import { setActorPreferences } from '../../lib/preferences';
6
6
 
@@ -8,29 +8,29 @@ export const prerender = false;
8
8
 
9
9
  export async function POST({ locals, request }: APIContext) {
10
10
  const { env } = locals.runtime;
11
- if (!(await isAuthorized(request, env))) return unauthorized();
11
+ try {
12
+ if (!(await isAuthorized(request, env))) return unauthorized();
13
+ } catch (error) {
14
+ if (error instanceof AuthTokenExpiredError) {
15
+ return expiredToken();
16
+ }
17
+ throw error;
18
+ }
12
19
 
13
- return proxyAppView({
14
- request,
15
- env,
16
- lxm: 'app.bsky.actor.putPreferences',
17
- fallback: async () => {
18
- let body: any;
19
- try {
20
- body = await readJsonBounded(env, request);
21
- } catch (err: any) {
22
- if (err?.code === 'PayloadTooLarge') {
23
- return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
24
- }
25
- return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
26
- }
20
+ let body: any;
21
+ try {
22
+ body = await readJsonBounded(env, request);
23
+ } catch (error) {
24
+ if (errorCode(error) === 'PayloadTooLarge') {
25
+ return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
26
+ }
27
+ return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
28
+ }
27
29
 
28
- const preferences = Array.isArray(body?.preferences) ? body.preferences : [];
29
- await setActorPreferences(env, preferences);
30
+ const preferences = Array.isArray(body?.preferences) ? body.preferences : [];
31
+ await setActorPreferences(env, preferences);
30
32
 
31
- return new Response(JSON.stringify({}), {
32
- headers: { 'Content-Type': 'application/json' },
33
- });
34
- },
33
+ return new Response(JSON.stringify({}), {
34
+ headers: { 'Content-Type': 'application/json' },
35
35
  });
36
36
  }
@@ -1,11 +1,18 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
 
4
4
  export const prerender = false;
5
5
 
6
6
  export async function GET({ locals, request }: APIContext) {
7
7
  const { env } = locals.runtime;
8
- if (!(await isAuthorized(request, env))) return unauthorized();
8
+ try {
9
+ if (!(await isAuthorized(request, env))) return unauthorized();
10
+ } catch (error) {
11
+ if (error instanceof AuthTokenExpiredError) {
12
+ return expiredToken();
13
+ }
14
+ throw error;
15
+ }
9
16
 
10
17
  return new Response(
11
18
  JSON.stringify({
@@ -1,5 +1,5 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
  import { getPrimaryActor } from '../../lib/actor';
4
4
  import { listChatConvoLogs } from '../../lib/chat';
5
5
 
@@ -7,7 +7,14 @@ export const prerender = false;
7
7
 
8
8
  export async function GET({ locals, request }: APIContext) {
9
9
  const { env } = locals.runtime;
10
- if (!(await isAuthorized(request, env))) return unauthorized();
10
+ try {
11
+ if (!(await isAuthorized(request, env))) return unauthorized();
12
+ } catch (error) {
13
+ if (error instanceof AuthTokenExpiredError) {
14
+ return expiredToken();
15
+ }
16
+ throw error;
17
+ }
11
18
 
12
19
  const url = new URL(request.url);
13
20
  const cursorParam = url.searchParams.get('cursor');
@@ -1,5 +1,5 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
  import { listChatConvos } from '../../lib/chat';
4
4
  import { getPrimaryActor } from '../../lib/actor';
5
5
 
@@ -7,7 +7,14 @@ export const prerender = false;
7
7
 
8
8
  export async function GET({ locals, request }: APIContext) {
9
9
  const { env } = locals.runtime;
10
- if (!(await isAuthorized(request, env))) return unauthorized();
10
+ try {
11
+ if (!(await isAuthorized(request, env))) return unauthorized();
12
+ } catch (error) {
13
+ if (error instanceof AuthTokenExpiredError) {
14
+ return expiredToken();
15
+ }
16
+ throw error;
17
+ }
11
18
 
12
19
  const url = new URL(request.url);
13
20
  const limitInput = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
@@ -1,7 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
  import { resolveSecret } from '../../lib/secrets';
4
- import * as uint8arrays from 'uint8arrays';
5
4
 
6
5
  export const prerender = false;
7
6
 
@@ -15,49 +14,53 @@ export const prerender = false;
15
14
  export async function GET({ locals, request }: APIContext) {
16
15
  const { env } = locals.runtime;
17
16
 
18
- if (!(await isAuthorized(request, env))) return unauthorized();
17
+ try {
18
+ if (!(await isAuthorized(request, env))) return unauthorized();
19
+ } catch (error) {
20
+ if (error instanceof AuthTokenExpiredError) {
21
+ return expiredToken();
22
+ }
23
+ throw error;
24
+ }
19
25
 
20
26
  try {
21
27
  const handle = (await resolveSecret(env.PDS_HANDLE)) ?? 'example.com';
22
28
  const hostname = env.PDS_HOSTNAME ?? handle;
23
29
 
24
- // Load signing key (Ed25519 PKCS#8 base64)
25
- const signingKeyBase64 = await resolveSecret(env.REPO_SIGNING_KEY);
26
- if (!signingKeyBase64) {
30
+ // Always ES256K: derive did:key from the secp256k1 signing key
31
+ let didKey: string | undefined;
32
+ const priv = (await resolveSecret(env.REPO_SIGNING_KEY))?.trim();
33
+ if (!priv) {
27
34
  return new Response(
28
- JSON.stringify({
29
- error: 'InvalidRequest',
30
- message: 'Signing key not configured'
31
- }),
32
- { status: 400, headers: { 'Content-Type': 'application/json' } }
35
+ JSON.stringify({ error: 'InvalidRequest', message: 'REPO_SIGNING_KEY not configured for ES256K' }),
36
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
37
+ );
38
+ }
39
+ try {
40
+ const { Secp256k1Keypair } = await import('@atproto/crypto');
41
+ let keypair: { did: () => string };
42
+ if (/^[0-9a-fA-F]{64}$/.test(priv)) {
43
+ keypair = await Secp256k1Keypair.import(priv);
44
+ } else {
45
+ const bin = atob(priv.replace(/\s+/g, ''));
46
+ const bytes = new Uint8Array(bin.length);
47
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
48
+ keypair = await Secp256k1Keypair.import(bytes);
49
+ }
50
+ didKey = keypair.did();
51
+ } catch (keypairError) {
52
+ console.error('REPO_SIGNING_KEY did:key derivation failed:', keypairError);
53
+ return new Response(
54
+ JSON.stringify({ error: 'InvalidRequest', message: 'Failed to derive secp256k1 did:key from REPO_SIGNING_KEY' }),
55
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
33
56
  );
34
57
  }
35
-
36
- // Import Ed25519 private key from PKCS#8 base64
37
- const b64 = signingKeyBase64.replace(/\s+/g, '');
38
- const bin = atob(b64);
39
- const pkcs8 = new Uint8Array(bin.length);
40
- for (let i = 0; i < bin.length; i++) pkcs8[i] = bin.charCodeAt(i);
41
-
42
- // Ed25519 PKCS#8 format: the public key is the last 32 bytes of the private key section
43
- // PKCS#8 structure for Ed25519:
44
- // - Header (16 bytes)
45
- // - Private key (32 bytes)
46
- // - Public key (32 bytes)
47
- // Total: 80 bytes for unencrypted PKCS#8
48
- const publicKeyBytes = pkcs8.slice(-32);
49
-
50
- // Create did:key from public key
51
- // Ed25519 multicodec prefix is 0xed01
52
- const multicodecPrefix = new Uint8Array([0xed, 0x01]);
53
- const multicodecKey = new Uint8Array(multicodecPrefix.length + publicKeyBytes.length);
54
- multicodecKey.set(multicodecPrefix);
55
- multicodecKey.set(publicKeyBytes, multicodecPrefix.length);
56
-
57
- const didKey = 'did:key:z' + uint8arrays.toString(multicodecKey, 'base58btc');
58
58
 
59
59
  // Get current PLC data to preserve rotation keys
60
- const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
60
+ const did = await resolveSecret(env.PDS_DID);
61
+ if (!did) {
62
+ return new Response(JSON.stringify({ error: 'InvalidRequest', message: 'PDS_DID is not configured' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
63
+ }
61
64
  const plcResponse = await fetch(`https://plc.directory/${did}/data`);
62
65
 
63
66
  let rotationKeys: string[] = [];
@@ -69,9 +72,7 @@ export async function GET({ locals, request }: APIContext) {
69
72
  const credentials = {
70
73
  rotationKeys,
71
74
  alsoKnownAs: [`at://${handle}`],
72
- verificationMethods: {
73
- atproto: didKey
74
- },
75
+ verificationMethods: { atproto: didKey },
75
76
  services: {
76
77
  atproto_pds: {
77
78
  type: 'AtprotoPersonalDataServer',
@@ -84,14 +85,15 @@ export async function GET({ locals, request }: APIContext) {
84
85
  JSON.stringify(credentials),
85
86
  { status: 200, headers: { 'Content-Type': 'application/json' } }
86
87
  );
87
- } catch (error: any) {
88
+ } catch (error) {
88
89
  console.error('Get recommended credentials error:', error);
90
+ const message = error instanceof Error ? error.message : 'Failed to get recommended credentials';
89
91
  return new Response(
90
92
  JSON.stringify({
91
93
  error: 'InternalServerError',
92
- message: error.message || 'Failed to get recommended credentials'
94
+ message,
93
95
  }),
94
96
  { status: 500, headers: { 'Content-Type': 'application/json' } }
95
97
  );
96
98
  }
97
- }
99
+ }