@alteran/astro 0.7.7 → 0.8.2
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 +25 -25
- package/migrations/0010_eminent_klaw.sql +37 -0
- package/migrations/0011_chief_darwin.sql +31 -0
- package/migrations/0012_backfill_blob_usage.sql +39 -0
- package/migrations/meta/0010_snapshot.json +790 -0
- package/migrations/meta/0011_snapshot.json +813 -0
- package/migrations/meta/_journal.json +22 -1
- package/package.json +24 -41
- package/src/db/blob.ts +323 -0
- package/src/db/dal.ts +224 -78
- package/src/db/repo.ts +205 -25
- package/src/db/schema.ts +14 -5
- package/src/handlers/debug.ts +4 -3
- package/src/lib/appview/auth-policy.ts +7 -24
- package/src/lib/appview/proxy.ts +56 -23
- package/src/lib/appview/types.ts +1 -6
- package/src/lib/auth-scope.ts +399 -0
- package/src/lib/auth.ts +40 -39
- package/src/lib/commit.ts +37 -15
- package/src/lib/did-document.ts +4 -5
- package/src/lib/jwt.ts +3 -1
- package/src/lib/mime.ts +9 -0
- package/src/lib/oauth/resource.ts +49 -0
- package/src/lib/preference-policy.ts +45 -0
- package/src/lib/preferences.ts +0 -4
- package/src/lib/public-host.ts +127 -0
- package/src/lib/ratelimit.ts +37 -12
- package/src/lib/relay.ts +7 -27
- package/src/lib/repo-write-blob-constraints.ts +141 -0
- package/src/lib/repo-write-data.ts +195 -0
- package/src/lib/repo-write-error.ts +46 -0
- package/src/lib/repo-write-validation.ts +463 -0
- package/src/lib/session-tokens.ts +22 -5
- package/src/lib/unsupported-routes.ts +32 -0
- package/src/lib/util.ts +57 -2
- package/src/pages/.well-known/atproto-did.ts +15 -3
- package/src/pages/.well-known/did.json.ts +13 -7
- package/src/pages/debug/db/bootstrap.ts +4 -3
- package/src/pages/debug/gc/blobs.ts +11 -8
- package/src/pages/debug/record.ts +11 -0
- package/src/pages/xrpc/[...nsid].ts +17 -9
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
- package/src/services/car.ts +13 -0
- package/src/services/repo/apply-prepared-writes.ts +185 -0
- package/src/services/repo/blob-refs.ts +48 -0
- package/src/services/repo/blockstore-ops.ts +59 -17
- package/src/services/repo/list-blobs.ts +43 -0
- package/src/services/repo-manager.ts +221 -78
- package/src/worker/runtime.ts +1 -1
- package/src/worker/sequencer/upgrade.ts +4 -1
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { AuthScope, isBearerAccessScope, type BearerAccessScope } from '../auth-scope';
|
|
2
2
|
|
|
3
|
-
const
|
|
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<
|
|
7
|
-
|
|
8
|
-
|
|
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):
|
|
49
|
-
|
|
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
|
}
|
package/src/lib/appview/proxy.ts
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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,
|
|
180
|
+
serviceJwt = await createServiceJwt(env, issuerDid, serviceJwtAudience, lxm);
|
|
167
181
|
} catch (error) {
|
|
168
182
|
console.error('AppView service token error:', error);
|
|
169
|
-
|
|
183
|
+
return new Response(JSON.stringify({ error: 'ServiceAuthFailed' }), {
|
|
184
|
+
status: 502,
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
});
|
|
170
187
|
}
|
|
171
188
|
|
|
172
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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(
|
|
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,
|
package/src/lib/appview/types.ts
CHANGED
|
@@ -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
|
+
}
|