@alteran/astro 0.7.7 → 0.8.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 (73) hide show
  1. package/README.md +25 -25
  2. package/migrations/0010_eminent_klaw.sql +37 -0
  3. package/migrations/0011_chief_darwin.sql +31 -0
  4. package/migrations/0012_backfill_blob_usage.sql +39 -0
  5. package/migrations/meta/0010_snapshot.json +790 -0
  6. package/migrations/meta/0011_snapshot.json +813 -0
  7. package/migrations/meta/_journal.json +22 -1
  8. package/package.json +24 -41
  9. package/src/db/blob.ts +323 -0
  10. package/src/db/dal.ts +224 -78
  11. package/src/db/repo.ts +205 -25
  12. package/src/db/schema.ts +14 -5
  13. package/src/handlers/debug.ts +4 -3
  14. package/src/lib/appview/auth-policy.ts +7 -24
  15. package/src/lib/appview/proxy.ts +56 -23
  16. package/src/lib/appview/types.ts +1 -6
  17. package/src/lib/auth-scope.ts +399 -0
  18. package/src/lib/auth.ts +40 -39
  19. package/src/lib/commit.ts +37 -15
  20. package/src/lib/did-document.ts +4 -5
  21. package/src/lib/jwt.ts +3 -1
  22. package/src/lib/mime.ts +9 -0
  23. package/src/lib/oauth/resource.ts +49 -0
  24. package/src/lib/preference-policy.ts +45 -0
  25. package/src/lib/preferences.ts +0 -4
  26. package/src/lib/public-host.ts +127 -0
  27. package/src/lib/ratelimit.ts +37 -12
  28. package/src/lib/relay.ts +7 -27
  29. package/src/lib/repo-write-blob-constraints.ts +141 -0
  30. package/src/lib/repo-write-data.ts +195 -0
  31. package/src/lib/repo-write-error.ts +46 -0
  32. package/src/lib/repo-write-validation.ts +463 -0
  33. package/src/lib/session-tokens.ts +22 -5
  34. package/src/lib/unsupported-routes.ts +32 -0
  35. package/src/lib/util.ts +57 -2
  36. package/src/pages/.well-known/atproto-did.ts +15 -3
  37. package/src/pages/.well-known/did.json.ts +13 -7
  38. package/src/pages/debug/db/bootstrap.ts +4 -3
  39. package/src/pages/debug/gc/blobs.ts +11 -8
  40. package/src/pages/debug/record.ts +11 -0
  41. package/src/pages/xrpc/[...nsid].ts +17 -9
  42. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  43. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  44. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  49. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  50. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  51. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  52. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  53. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  54. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  55. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  56. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  57. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  58. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  59. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  60. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  61. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  62. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  63. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  64. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  65. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  66. package/src/services/car.ts +13 -0
  67. package/src/services/repo/apply-prepared-writes.ts +185 -0
  68. package/src/services/repo/blob-refs.ts +48 -0
  69. package/src/services/repo/blockstore-ops.ts +59 -17
  70. package/src/services/repo/list-blobs.ts +43 -0
  71. package/src/services/repo-manager.ts +221 -78
  72. package/src/worker/runtime.ts +1 -1
  73. package/src/worker/sequencer/upgrade.ts +4 -1
@@ -1,11 +1,10 @@
1
- import type { AuthScope } from './types';
1
+ import { AuthScope, isBearerAccessScope, type BearerAccessScope } from '../auth-scope';
2
2
 
3
- const DEFAULT_ACCESS_SCOPE: AuthScope = 'com.atproto.access';
4
- export const TAKENDOWN_SCOPE: AuthScope = 'com.atproto.takendown';
3
+ export const TAKENDOWN_SCOPE: BearerAccessScope = AuthScope.Takendown;
5
4
 
6
- export const PRIVILEGED_SCOPES: ReadonlySet<AuthScope> = new Set([
7
- 'com.atproto.access',
8
- 'com.atproto.appPassPrivileged',
5
+ export const PRIVILEGED_SCOPES: ReadonlySet<BearerAccessScope> = new Set([
6
+ AuthScope.Access,
7
+ AuthScope.AppPassPrivileged,
9
8
  ]);
10
9
 
11
10
  export const PRIVILEGED_METHODS: ReadonlySet<string> = new Set([
@@ -45,22 +44,6 @@ export const PROTECTED_METHODS: ReadonlySet<string> = new Set([
45
44
  'com.atproto.server.updateEmail',
46
45
  ]);
47
46
 
48
- export function resolveAuthScope(scope: unknown): AuthScope {
49
- if (typeof scope !== 'string') {
50
- return DEFAULT_ACCESS_SCOPE;
51
- }
52
-
53
- switch (scope) {
54
- case 'access':
55
- return 'com.atproto.access';
56
- case 'com.atproto.access':
57
- case 'com.atproto.appPass':
58
- case 'com.atproto.appPassPrivileged':
59
- case 'com.atproto.signupQueued':
60
- case 'com.atproto.takendown':
61
- return scope;
62
- default:
63
- console.warn('Unknown auth scope, treating as access scope', scope);
64
- return DEFAULT_ACCESS_SCOPE;
65
- }
47
+ export function resolveAuthScope(scope: unknown): BearerAccessScope | null {
48
+ return isBearerAccessScope(scope) ? scope : null;
66
49
  }
@@ -1,11 +1,11 @@
1
1
  import type { Env } from '../../env';
2
2
  import { authErrorResponse, authenticateRequest, unauthorized, type AuthContext } from '../auth';
3
+ import { canMakeRpcCall, canUseAppPasswordLevelAccess } from '../auth-scope';
3
4
  import { InvalidProxyHeader } from '../errors';
4
5
  import {
5
6
  PRIVILEGED_METHODS,
6
7
  PRIVILEGED_SCOPES,
7
8
  PROTECTED_METHODS,
8
- TAKENDOWN_SCOPE,
9
9
  resolveAuthScope,
10
10
  } from './auth-policy';
11
11
  import { resolveProxyTargetWithRegistry } from './did-resolver';
@@ -100,25 +100,29 @@ export async function proxyAppView({
100
100
  );
101
101
  }
102
102
 
103
- const scope = resolveAuthScope(auth.claims.scope);
104
- if (scope === TAKENDOWN_SCOPE) {
105
- return new Response(JSON.stringify({ error: 'AccountTakendown' }), {
103
+ if (auth.access.accountStatus !== 'active') {
104
+ return new Response(JSON.stringify({ error: 'AccountInactive', message: 'Account is not active' }), {
106
105
  status: 403,
107
106
  headers: { 'Content-Type': 'application/json' },
108
107
  });
109
108
  }
110
- if (!PRIVILEGED_SCOPES.has(scope) && PRIVILEGED_METHODS.has(lxm)) {
111
- return new Response(JSON.stringify({ error: 'InvalidToken' }), {
109
+ const scope = resolveAuthScope(auth.claims.scope);
110
+ if (!auth.access.isOAuth && (!scope || !canUseAppPasswordLevelAccess(auth.access))) {
111
+ return new Response(JSON.stringify({ error: 'InvalidToken', message: 'bad token scope' }), {
112
112
  status: 401,
113
113
  headers: { 'Content-Type': 'application/json' },
114
114
  });
115
115
  }
116
116
 
117
117
  let target: ProxyTarget = { did: defaultService.did, url: defaultService.url };
118
+ let permissionAudience = `${defaultService.did}#${defaultService.id}`;
119
+ let serviceJwtAudience = target.did;
118
120
  const proxyHeader = request.headers.get('atproto-proxy');
119
121
  if (proxyHeader) {
120
122
  try {
121
123
  target = await resolveProxyTargetWithRegistry(env, proxyHeader, registry);
124
+ permissionAudience = proxyHeader.trim();
125
+ serviceJwtAudience = target.did;
122
126
  } catch (error) {
123
127
  console.error('AppView proxy header error:', error);
124
128
  const isHeaderError = error instanceof InvalidProxyHeader;
@@ -131,6 +135,21 @@ export async function proxyAppView({
131
135
  );
132
136
  }
133
137
  }
138
+ const hasPrivilegedAccess = auth.access.isOAuth
139
+ ? canMakeRpcCall(auth.access, lxm, permissionAudience)
140
+ : !!scope && PRIVILEGED_SCOPES.has(scope);
141
+ if (!hasPrivilegedAccess && PRIVILEGED_METHODS.has(lxm)) {
142
+ return new Response(JSON.stringify({ error: 'InvalidToken' }), {
143
+ status: 401,
144
+ headers: { 'Content-Type': 'application/json' },
145
+ });
146
+ }
147
+ if (auth.access.isOAuth && !canMakeRpcCall(auth.access, lxm, permissionAudience)) {
148
+ return new Response(JSON.stringify({ error: 'InvalidToken', message: 'bad token scope' }), {
149
+ status: 401,
150
+ headers: { 'Content-Type': 'application/json' },
151
+ });
152
+ }
134
153
 
135
154
  const originalUrl = new URL(request.url);
136
155
  const upstreamUrl = new URL(target.url);
@@ -152,24 +171,22 @@ export async function proxyAppView({
152
171
  }
153
172
  }
154
173
 
155
- // Service JWT is best-effort. Public AppView endpoints accept unauthenticated
156
- // reads, so a mint failure here should not block the proxy — we forward
157
- // without an Authorization header and let the upstream decide. Common
158
- // reasons we silently fall through: missing signing key on the viewer's DID
159
- // document, transient PLC lookup failure, or unsupported issuer DID method.
160
- let serviceJwt: string | null = null;
174
+ let serviceJwt: string;
161
175
  try {
162
176
  const issuerDid = auth.claims.sub;
163
177
  if (!issuerDid || !issuerDid.startsWith('did:')) {
164
178
  throw new Error(`Invalid issuer DID: ${issuerDid || '(empty)'}`);
165
179
  }
166
- serviceJwt = await createServiceJwt(env, issuerDid, target.did, lxm);
180
+ serviceJwt = await createServiceJwt(env, issuerDid, serviceJwtAudience, lxm);
167
181
  } catch (error) {
168
182
  console.error('AppView service token error:', error);
169
- serviceJwt = null;
183
+ return new Response(JSON.stringify({ error: 'ServiceAuthFailed' }), {
184
+ status: 502,
185
+ headers: { 'Content-Type': 'application/json' },
186
+ });
170
187
  }
171
188
 
172
- if (serviceJwt) headers.set('authorization', `Bearer ${serviceJwt}`);
189
+ headers.set('authorization', `Bearer ${serviceJwt}`);
173
190
 
174
191
  const method = request.method.toUpperCase();
175
192
  if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
@@ -186,6 +203,14 @@ export async function proxyAppView({
186
203
  headers.set('accept-encoding', 'identity');
187
204
  }
188
205
 
206
+ // Defensive: xrpc-server's `getBodyPresence` treats any `transfer-encoding`
207
+ // or non-zero `content-length` on the inbound request as a request body,
208
+ // and rejects query (GET) methods with `A request body was provided when
209
+ // none was expected`. Forwarded headers don't include these, but strip
210
+ // anyway in case a future change adds them or the runtime sneaks one in.
211
+ headers.delete('transfer-encoding');
212
+ headers.delete('content-length');
213
+
189
214
  if (method === 'POST') {
190
215
  const contentType = request.headers.get('content-type');
191
216
  if (contentType) headers.set('content-type', contentType);
@@ -194,17 +219,25 @@ export async function proxyAppView({
194
219
  }
195
220
 
196
221
  try {
197
- const init: RequestInit & { duplex?: 'half' } = {
198
- method,
199
- headers,
200
- };
201
-
222
+ // Build the upstream request explicitly so GET/HEAD never carry a body.
223
+ // Using `new Request(...)` (rather than fetch(url, init)) gives the runtime
224
+ // a single canonical Request object to forward and avoids body framing
225
+ // being inferred from an ambient init.body of `undefined`.
226
+ let upstreamRequest: Request;
202
227
  if (method === 'POST') {
203
- init.body = request.body;
204
- init.duplex = 'half';
228
+ upstreamRequest = new Request(upstreamUrl.toString(), {
229
+ method,
230
+ headers,
231
+ body: request.body,
232
+ // @ts-expect-error duplex is required by the Workers runtime when
233
+ // streaming a request body but is missing from the lib.dom types.
234
+ duplex: 'half',
235
+ });
236
+ } else {
237
+ upstreamRequest = new Request(upstreamUrl.toString(), { method, headers });
205
238
  }
206
239
 
207
- const upstream = await fetch(upstreamUrl.toString(), init);
240
+ const upstream = await fetch(upstreamRequest);
208
241
  const responseHeaders = new Headers(upstream.headers);
209
242
  return new Response(upstream.body, {
210
243
  status: upstream.status,
@@ -17,9 +17,4 @@ export type ProxyTarget = {
17
17
  readonly url: string;
18
18
  };
19
19
 
20
- export type AuthScope =
21
- | 'com.atproto.access'
22
- | 'com.atproto.appPass'
23
- | 'com.atproto.appPassPrivileged'
24
- | 'com.atproto.signupQueued'
25
- | 'com.atproto.takendown';
20
+ export type { AuthScope } from '../auth-scope';
@@ -0,0 +1,399 @@
1
+ import { mimeMatches } from './mime';
2
+
3
+ export const AuthScope = {
4
+ Access: 'com.atproto.access',
5
+ Refresh: 'com.atproto.refresh',
6
+ AppPass: 'com.atproto.appPass',
7
+ AppPassPrivileged: 'com.atproto.appPassPrivileged',
8
+ SignupQueued: 'com.atproto.signupQueued',
9
+ Takendown: 'com.atproto.takendown',
10
+ } as const;
11
+
12
+ export type AuthScope = (typeof AuthScope)[keyof typeof AuthScope];
13
+
14
+ export type BearerAccessScope =
15
+ | typeof AuthScope.Access
16
+ | typeof AuthScope.AppPass
17
+ | typeof AuthScope.AppPassPrivileged
18
+ | typeof AuthScope.SignupQueued
19
+ | typeof AuthScope.Takendown;
20
+
21
+ export type AuthAccountStatus =
22
+ | 'unknown'
23
+ | 'active'
24
+ | 'takendown'
25
+ | 'suspended'
26
+ | 'deactivated'
27
+ | 'deleted';
28
+
29
+ export type AuthAccessKind =
30
+ | 'full'
31
+ | 'app-password'
32
+ | 'app-password-privileged'
33
+ | 'oauth'
34
+ | 'signup-queued'
35
+ | 'takendown';
36
+
37
+ export type AuthAccessContext = {
38
+ readonly credentialType: 'bearer' | 'oauth-dpop';
39
+ readonly scope: string;
40
+ readonly kind: AuthAccessKind;
41
+ readonly accountStatus: AuthAccountStatus;
42
+ readonly isFullAccess: boolean;
43
+ readonly isPrivileged: boolean;
44
+ readonly isAppPassword: boolean;
45
+ readonly isOAuth: boolean;
46
+ readonly isTakendown: boolean;
47
+ readonly isSignupQueued: boolean;
48
+ };
49
+
50
+ const AUTH_SCOPE_VALUES = new Set<string>(Object.values(AuthScope));
51
+ const BEARER_ACCESS_SCOPE_VALUES = new Set<string>([
52
+ AuthScope.Access,
53
+ AuthScope.AppPass,
54
+ AuthScope.AppPassPrivileged,
55
+ AuthScope.SignupQueued,
56
+ AuthScope.Takendown,
57
+ ]);
58
+ const OAUTH_PROFILE_SCOPE = 'atproto';
59
+ const OAUTH_TRANSITION_GENERIC = 'transition:generic';
60
+ const OAUTH_TRANSITION_CHAT = 'transition:chat.bsky';
61
+ const OAUTH_TRANSITION_EMAIL = 'transition:email';
62
+ const OAUTH_TRANSITION_SCOPES = new Set([
63
+ OAUTH_TRANSITION_GENERIC,
64
+ OAUTH_TRANSITION_CHAT,
65
+ OAUTH_TRANSITION_EMAIL,
66
+ ]);
67
+ const REPO_ACTIONS = new Set(['create', 'update', 'delete']);
68
+ const ACCOUNT_ACTIONS = new Set(['read', 'manage']);
69
+ const OAUTH_RESOURCE_TYPES = new Set(['repo', 'rpc', 'blob', 'identity', 'account', 'include']);
70
+ const TRANSITION_GENERIC_BLOCKED_RPC = new Set([
71
+ 'chat.bsky.actor.deleteAccount',
72
+ 'com.atproto.admin.sendEmail',
73
+ 'com.atproto.identity.requestPlcOperationSignature',
74
+ 'com.atproto.identity.signPlcOperation',
75
+ 'com.atproto.identity.updateHandle',
76
+ 'com.atproto.server.activateAccount',
77
+ 'com.atproto.server.confirmEmail',
78
+ 'com.atproto.server.createAccount',
79
+ 'com.atproto.server.createAppPassword',
80
+ 'com.atproto.server.deactivateAccount',
81
+ 'com.atproto.server.getAccountInviteCodes',
82
+ 'com.atproto.server.listAppPasswords',
83
+ 'com.atproto.server.requestAccountDelete',
84
+ 'com.atproto.server.requestEmailConfirmation',
85
+ 'com.atproto.server.requestEmailUpdate',
86
+ 'com.atproto.server.revokeAppPassword',
87
+ 'com.atproto.server.updateEmail',
88
+ ]);
89
+
90
+ export function isAuthScope(scope: unknown): scope is AuthScope {
91
+ return typeof scope === 'string' && AUTH_SCOPE_VALUES.has(scope);
92
+ }
93
+
94
+ export function isBearerAccessScope(scope: unknown): scope is BearerAccessScope {
95
+ return typeof scope === 'string' && BEARER_ACCESS_SCOPE_VALUES.has(scope);
96
+ }
97
+
98
+ export function isOAuthScope(scope: unknown): scope is string {
99
+ const parts = oauthScopeParts(scope);
100
+ return parts !== null &&
101
+ parts.includes(OAUTH_PROFILE_SCOPE) &&
102
+ parts.every(isRecognizedOAuthScopePart);
103
+ }
104
+
105
+ export function isOAuthPermissionScope(scope: unknown): scope is string {
106
+ const parts = oauthScopeParts(scope);
107
+ return parts !== null &&
108
+ parts.includes(OAUTH_PROFILE_SCOPE) &&
109
+ parts.some((part) => part !== OAUTH_PROFILE_SCOPE && grantsOAuthResourceAccess(part)) &&
110
+ parts.every(isRecognizedOAuthScopePart);
111
+ }
112
+
113
+ export function bearerAccessContext(
114
+ scope: BearerAccessScope,
115
+ accountStatus: AuthAccountStatus = 'unknown',
116
+ ): AuthAccessContext {
117
+ switch (scope) {
118
+ case AuthScope.Access:
119
+ return buildAccessContext('bearer', scope, 'full', accountStatus);
120
+ case AuthScope.AppPass:
121
+ return buildAccessContext('bearer', scope, 'app-password', accountStatus);
122
+ case AuthScope.AppPassPrivileged:
123
+ return buildAccessContext('bearer', scope, 'app-password-privileged', accountStatus);
124
+ case AuthScope.SignupQueued:
125
+ return buildAccessContext('bearer', scope, 'signup-queued', accountStatus);
126
+ case AuthScope.Takendown:
127
+ return buildAccessContext('bearer', scope, 'takendown', accountStatus);
128
+ }
129
+ }
130
+
131
+ export function oauthAccessContext(
132
+ scope: string,
133
+ accountStatus: AuthAccountStatus = 'unknown',
134
+ ): AuthAccessContext {
135
+ return buildAccessContext('oauth-dpop', scope, 'oauth', accountStatus);
136
+ }
137
+
138
+ export function withAccountStatus(
139
+ context: AuthAccessContext,
140
+ accountStatus: AuthAccountStatus,
141
+ ): AuthAccessContext {
142
+ return buildAccessContext(context.credentialType, context.scope, context.kind, accountStatus);
143
+ }
144
+
145
+ function buildAccessContext(
146
+ credentialType: AuthAccessContext['credentialType'],
147
+ scope: string,
148
+ kind: AuthAccessKind,
149
+ accountStatus: AuthAccountStatus,
150
+ ): AuthAccessContext {
151
+ return {
152
+ credentialType,
153
+ scope,
154
+ kind,
155
+ accountStatus,
156
+ isFullAccess: kind === 'full',
157
+ isPrivileged: kind === 'full' || kind === 'app-password-privileged',
158
+ isAppPassword: kind === 'app-password' || kind === 'app-password-privileged',
159
+ isOAuth: kind === 'oauth',
160
+ isTakendown: kind === 'takendown' || accountStatus === 'takendown',
161
+ isSignupQueued: kind === 'signup-queued',
162
+ };
163
+ }
164
+
165
+ export type RepoWriteAction = 'create' | 'update' | 'delete';
166
+
167
+ export function canAccessActorPreferences(access: AuthAccessContext): boolean {
168
+ if (access.isTakendown || access.isSignupQueued) return false;
169
+ if (access.isFullAccess || access.isAppPassword) return true;
170
+ return access.isOAuth && oauthScopeAllowsTransitionGeneric(access.scope);
171
+ }
172
+
173
+ export function canAccessChat(access: AuthAccessContext): boolean {
174
+ if (access.isTakendown || access.isSignupQueued) return false;
175
+ if (access.isPrivileged) return true;
176
+ return access.isOAuth && oauthScopeAllowsTransitionChat(access.scope);
177
+ }
178
+
179
+ export function canAccessFullAccount(access: AuthAccessContext): boolean {
180
+ return access.isFullAccess && !access.isTakendown && !access.isSignupQueued;
181
+ }
182
+
183
+ export function canUseAppPasswordLevelAccess(access: AuthAccessContext): boolean {
184
+ if (access.isTakendown || access.isSignupQueued) return false;
185
+ if (access.isFullAccess || access.isAppPassword) return true;
186
+ return access.isOAuth && oauthScopeAllowsTransitionGeneric(access.scope);
187
+ }
188
+
189
+ export function canWriteRepo(
190
+ access: AuthAccessContext,
191
+ collection: unknown,
192
+ action: RepoWriteAction,
193
+ ): boolean {
194
+ if (access.isTakendown || access.isSignupQueued || typeof collection !== 'string') {
195
+ return false;
196
+ }
197
+ if (access.isFullAccess || access.isAppPassword) return true;
198
+ if (!access.isOAuth) return false;
199
+ const parts = oauthScopeParts(access.scope);
200
+ if (!parts) return false;
201
+ if (parts.includes(OAUTH_TRANSITION_GENERIC)) return true;
202
+ return parts.some((part) => oauthPermissionAllowsRepo(part, collection, action));
203
+ }
204
+
205
+ export function canUploadBlob(access: AuthAccessContext, mimeType: string): boolean {
206
+ if (access.isTakendown || access.isSignupQueued) return false;
207
+ if (access.isFullAccess || access.isAppPassword) return true;
208
+ if (!access.isOAuth) return false;
209
+ const parts = oauthScopeParts(access.scope);
210
+ if (!parts) return false;
211
+ if (parts.includes(OAUTH_TRANSITION_GENERIC)) return true;
212
+ return parts.some((part) => oauthPermissionAllowsBlob(part, mimeType));
213
+ }
214
+
215
+ export function canMakeRpcCall(
216
+ access: AuthAccessContext,
217
+ lxm: string | null | undefined,
218
+ aud: string | null | undefined,
219
+ ): boolean {
220
+ if (access.isTakendown || access.isSignupQueued) return false;
221
+ if (access.isFullAccess) return true;
222
+ if (access.isAppPassword) {
223
+ return access.isPrivileged || !isChatRpc(lxm);
224
+ }
225
+ if (!access.isOAuth || !lxm || !aud) return false;
226
+ const parts = oauthScopeParts(access.scope);
227
+ if (!parts) return false;
228
+ if (oauthScopeAllowsTransitionChat(access.scope) && isChatRpc(lxm)) return true;
229
+ if (
230
+ parts.includes(OAUTH_TRANSITION_GENERIC) &&
231
+ !isChatRpc(lxm) &&
232
+ !TRANSITION_GENERIC_BLOCKED_RPC.has(lxm)
233
+ ) return true;
234
+ return parts.some((part) => oauthPermissionAllowsRpc(part, lxm, aud));
235
+ }
236
+
237
+ function oauthScopeParts(scope: unknown): string[] | null {
238
+ if (typeof scope !== 'string') return null;
239
+ const parts = scope.split(/\s+/).filter(Boolean);
240
+ return parts.length > 0 ? parts : null;
241
+ }
242
+
243
+ function isRecognizedOAuthScopePart(scope: string): boolean {
244
+ if (scope === OAUTH_PROFILE_SCOPE || OAUTH_TRANSITION_SCOPES.has(scope)) return true;
245
+ return parsePermissionScope(scope) !== null;
246
+ }
247
+
248
+ function grantsOAuthResourceAccess(scope: string): boolean {
249
+ if (scope === OAUTH_TRANSITION_CHAT) return false;
250
+ if (OAUTH_TRANSITION_SCOPES.has(scope)) return true;
251
+ const parsed = parsePermissionScope(scope);
252
+ return parsed !== null && parsed.resource !== 'include';
253
+ }
254
+
255
+ function oauthScopeAllowsTransitionGeneric(scope: string): boolean {
256
+ return oauthScopeParts(scope)?.includes(OAUTH_TRANSITION_GENERIC) ?? false;
257
+ }
258
+
259
+ function oauthScopeAllowsTransitionChat(scope: string): boolean {
260
+ const parts = oauthScopeParts(scope);
261
+ return !!parts?.includes(OAUTH_TRANSITION_GENERIC) && parts.includes(OAUTH_TRANSITION_CHAT);
262
+ }
263
+
264
+ type ParsedPermissionScope =
265
+ | { resource: 'repo'; collections: string[]; actions: RepoWriteAction[] | null }
266
+ | { resource: 'rpc'; lxms: string[]; aud: string }
267
+ | { resource: 'blob'; accepts: string[] }
268
+ | { resource: 'account'; attr: string; action: 'read' | 'manage' }
269
+ | { resource: 'identity'; attr: string }
270
+ | { resource: 'include' };
271
+
272
+ function parsePermissionScope(scope: string): ParsedPermissionScope | null {
273
+ if (!/^[\x21-\x7e]+$/.test(scope)) return null;
274
+ const questionIndex = scope.indexOf('?');
275
+ const head = questionIndex === -1 ? scope : scope.slice(0, questionIndex);
276
+ const query = questionIndex === -1 ? '' : scope.slice(questionIndex + 1);
277
+ if (!head) return null;
278
+
279
+ const colonIndex = head.indexOf(':');
280
+ const resource = colonIndex === -1 ? head : head.slice(0, colonIndex);
281
+ const positional = colonIndex === -1 ? null : decodeScopeComponent(head.slice(colonIndex + 1));
282
+ if (!resource || (positional !== null && positional === '') || !OAUTH_RESOURCE_TYPES.has(resource)) {
283
+ return null;
284
+ }
285
+
286
+ const params = new URLSearchParams(query);
287
+ switch (resource) {
288
+ case 'repo':
289
+ return parseRepoScope(positional, params);
290
+ case 'rpc':
291
+ return parseRpcScope(positional, params);
292
+ case 'blob':
293
+ return parseBlobScope(positional, params);
294
+ case 'account':
295
+ return parseAccountScope(positional, params);
296
+ case 'identity':
297
+ return parseIdentityScope(positional, params);
298
+ case 'include':
299
+ return positional && paramsHaveOnly(params, new Set(['aud'])) ? { resource: 'include' } : null;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ function parseRepoScope(positional: string | null, params: URLSearchParams): ParsedPermissionScope | null {
305
+ if (!paramsHaveOnly(params, new Set(['collection', 'action']))) return null;
306
+ if (positional !== null && params.has('collection')) return null;
307
+ const collections = positional !== null ? [positional] : params.getAll('collection');
308
+ if (!collections.length || collections.some((value) => value === '')) return null;
309
+ const actionsRaw = params.getAll('action');
310
+ const actionValues = unique(actionsRaw);
311
+ const actions = actionValues.length
312
+ ? actionValues.filter((action): action is RepoWriteAction => REPO_ACTIONS.has(action))
313
+ : null;
314
+ if (actions && actions.length !== actionValues.length) return null;
315
+ return { resource: 'repo', collections, actions };
316
+ }
317
+
318
+ function parseRpcScope(positional: string | null, params: URLSearchParams): ParsedPermissionScope | null {
319
+ if (!paramsHaveOnly(params, new Set(['lxm', 'aud']))) return null;
320
+ if (positional !== null && params.has('lxm')) return null;
321
+ const lxms = positional !== null ? [positional] : params.getAll('lxm');
322
+ const audiences = params.getAll('aud');
323
+ if (!lxms.length || lxms.some((value) => value === '') || audiences.length !== 1 || audiences[0] === '') {
324
+ return null;
325
+ }
326
+ if (lxms.includes('*') && audiences[0] === '*') return null;
327
+ return { resource: 'rpc', lxms, aud: audiences[0] };
328
+ }
329
+
330
+ function parseBlobScope(positional: string | null, params: URLSearchParams): ParsedPermissionScope | null {
331
+ if (!paramsHaveOnly(params, new Set(['accept']))) return null;
332
+ if (positional !== null && params.has('accept')) return null;
333
+ const accepts = positional !== null ? [positional] : params.getAll('accept');
334
+ if (!accepts.length || accepts.some((value) => value === '')) return null;
335
+ return { resource: 'blob', accepts };
336
+ }
337
+
338
+ function parseAccountScope(positional: string | null, params: URLSearchParams): ParsedPermissionScope | null {
339
+ if (!paramsHaveOnly(params, new Set(['attr', 'action']))) return null;
340
+ if (positional !== null && params.has('attr')) return null;
341
+ const attrs = positional !== null ? [positional] : params.getAll('attr');
342
+ const actions = params.getAll('action');
343
+ if (attrs.length !== 1 || attrs[0] === '' || actions.length > 1) return null;
344
+ const action = actions[0] ?? 'read';
345
+ if (!ACCOUNT_ACTIONS.has(action)) return null;
346
+ return { resource: 'account', attr: attrs[0], action: action as 'read' | 'manage' };
347
+ }
348
+
349
+ function parseIdentityScope(positional: string | null, params: URLSearchParams): ParsedPermissionScope | null {
350
+ if (!paramsHaveOnly(params, new Set(['attr']))) return null;
351
+ if (positional !== null && params.has('attr')) return null;
352
+ const attrs = positional !== null ? [positional] : params.getAll('attr');
353
+ if (attrs.length !== 1 || attrs[0] === '') return null;
354
+ return { resource: 'identity', attr: attrs[0] };
355
+ }
356
+
357
+ function paramsHaveOnly(params: URLSearchParams, allowed: ReadonlySet<string>): boolean {
358
+ for (const key of params.keys()) {
359
+ if (!allowed.has(key)) return false;
360
+ }
361
+ return true;
362
+ }
363
+
364
+ function decodeScopeComponent(value: string): string {
365
+ try {
366
+ return decodeURIComponent(value);
367
+ } catch {
368
+ return '';
369
+ }
370
+ }
371
+
372
+ function unique(values: string[]): string[] {
373
+ return [...new Set(values)];
374
+ }
375
+
376
+ function oauthPermissionAllowsRepo(scope: string, collection: string, action: RepoWriteAction): boolean {
377
+ const parsed = parsePermissionScope(scope);
378
+ if (!parsed || parsed.resource !== 'repo') return false;
379
+ if (!parsed.collections.includes('*') && !parsed.collections.includes(collection)) return false;
380
+ return !parsed.actions || parsed.actions.includes(action);
381
+ }
382
+
383
+ function oauthPermissionAllowsBlob(scope: string, mimeType: string): boolean {
384
+ const parsed = parsePermissionScope(scope);
385
+ if (!parsed || parsed.resource !== 'blob') return false;
386
+ return parsed.accepts.some((accept) => mimeMatches(accept, mimeType));
387
+ }
388
+
389
+ function oauthPermissionAllowsRpc(scope: string, lxm: string, aud: string): boolean {
390
+ const parsed = parsePermissionScope(scope);
391
+ if (!parsed || parsed.resource !== 'rpc') return false;
392
+ const lxmAllowed = parsed.lxms.includes('*') || parsed.lxms.includes(lxm);
393
+ const audAllowed = parsed.aud === '*' || parsed.aud === aud;
394
+ return lxmAllowed && audAllowed;
395
+ }
396
+
397
+ function isChatRpc(lxm: string | null | undefined): boolean {
398
+ return typeof lxm === 'string' && lxm.startsWith('chat.bsky.');
399
+ }