@hatk/hatk 0.0.1-alpha.83 → 0.0.1-alpha.84
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.
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
* What a PDS says it serves, and the scopes worth asking it for.
|
|
3
3
|
*
|
|
4
4
|
* `community.lexicon.service.describe` answers with a service's roles and every
|
|
5
|
-
* XRPC method it routes. It is the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* XRPC method it routes. It is the direct question and the answer to trust when
|
|
6
|
+
* it comes — but it is still a proposal, and implementations exist that serve
|
|
7
|
+
* the features while serving no describe. The reference spaces build of
|
|
8
|
+
* `@atproto/pds` is one: it routes every space method and does not contain the
|
|
9
|
+
* describe NSID at all. Reading its silence as "no optional features" denies it
|
|
10
|
+
* scopes it could honor.
|
|
11
|
+
*
|
|
12
|
+
* So when describe says nothing, the methods a caller actually cares about are
|
|
13
|
+
* probed directly. Calling one and reading the failure cannot tell a missing
|
|
14
|
+
* feature from a broken server *on its own* — but it can against a control: ask
|
|
15
|
+
* for an NSID that certainly does not exist, learn how this server says "no such
|
|
16
|
+
* method", and a different answer for a real method is evidence it has one.
|
|
8
17
|
*
|
|
9
18
|
* Used before pushing an authorization request, so a PDS is asked for a
|
|
10
19
|
* feature's scopes only when it implements the feature.
|
|
11
20
|
*/
|
|
12
21
|
import type { OAuthConfig } from '../config.ts';
|
|
13
22
|
/**
|
|
14
|
-
* Every XRPC method a PDS serves
|
|
15
|
-
*
|
|
16
|
-
*
|
|
23
|
+
* Every XRPC method a PDS serves, as far as it can be established.
|
|
24
|
+
*
|
|
25
|
+
* `candidates` are the methods the caller needs an answer about. They are only
|
|
26
|
+
* used when describe says nothing — an explicit list is always preferred to
|
|
27
|
+
* inference, and a server that answers describe is taken at its word.
|
|
17
28
|
*/
|
|
18
|
-
export declare function describeMethods(pdsEndpoint: string): Promise<Set<string>>;
|
|
29
|
+
export declare function describeMethods(pdsEndpoint: string, candidates?: string[]): Promise<Set<string>>;
|
|
19
30
|
/**
|
|
20
31
|
* The scope string to request from this PDS: `base`, plus any conditional set
|
|
21
32
|
* whose `whenMethod` the server serves.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAkD/C;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,GAAE,MAAM,EAAO,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CA+B1G;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,MAAM,CAAC,CAajB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
|
|
@@ -4,12 +4,53 @@ const PROBE_TIMEOUT_MS = 5000;
|
|
|
4
4
|
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
5
5
|
/** Answers are a property of the server, so one entry covers every account on it. */
|
|
6
6
|
const cache = new Map();
|
|
7
|
+
/** An NSID no server routes, for learning how one says "no such method". */
|
|
8
|
+
const CONTROL_NSID = 'dev.hatk.unspecced.methodThatDoesNotExist';
|
|
9
|
+
/** How a server answered, as a comparable string: status and error code. */
|
|
10
|
+
async function signature(pdsEndpoint, nsid) {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`${pdsEndpoint.replace(/\/$/, '')}/xrpc/${nsid}`, {
|
|
13
|
+
headers: { accept: 'application/json' },
|
|
14
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
15
|
+
});
|
|
16
|
+
const body = (await res.json().catch(() => null));
|
|
17
|
+
return `${res.status}:${body?.error ?? ''}`;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
7
23
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
24
|
+
* Which of `candidates` this server appears to route, judged against how it
|
|
25
|
+
* answers for a method that does not exist.
|
|
26
|
+
*
|
|
27
|
+
* Conservative by construction. A server that answers identically either way —
|
|
28
|
+
* bsky.social returns `401 AuthMissing` for both — yields nothing, so a feature
|
|
29
|
+
* it does not have is never claimed. The cost is failing to see support that
|
|
30
|
+
* cannot be distinguished, which is where this started anyway.
|
|
11
31
|
*/
|
|
12
|
-
|
|
32
|
+
async function probeMethods(pdsEndpoint, candidates) {
|
|
33
|
+
const control = await signature(pdsEndpoint, CONTROL_NSID);
|
|
34
|
+
// Unreachable is not evidence about the server, so claim nothing.
|
|
35
|
+
if (control === null)
|
|
36
|
+
return new Set();
|
|
37
|
+
const answers = await Promise.all(candidates.map((n) => signature(pdsEndpoint, n)));
|
|
38
|
+
const found = new Set();
|
|
39
|
+
candidates.forEach((nsid, i) => {
|
|
40
|
+
const a = answers[i];
|
|
41
|
+
if (a !== null && a !== control)
|
|
42
|
+
found.add(nsid);
|
|
43
|
+
});
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Every XRPC method a PDS serves, as far as it can be established.
|
|
48
|
+
*
|
|
49
|
+
* `candidates` are the methods the caller needs an answer about. They are only
|
|
50
|
+
* used when describe says nothing — an explicit list is always preferred to
|
|
51
|
+
* inference, and a server that answers describe is taken at its word.
|
|
52
|
+
*/
|
|
53
|
+
export async function describeMethods(pdsEndpoint, candidates = []) {
|
|
13
54
|
const cached = cache.get(pdsEndpoint);
|
|
14
55
|
if (cached && cached.expires > Date.now())
|
|
15
56
|
return cached.methods;
|
|
@@ -32,6 +73,12 @@ export async function describeMethods(pdsEndpoint) {
|
|
|
32
73
|
catch {
|
|
33
74
|
methods = new Set();
|
|
34
75
|
}
|
|
76
|
+
// No describe, or one that listed nothing: fall back to asking about the
|
|
77
|
+
// methods this caller actually needs. Only those — a probe per method is a
|
|
78
|
+
// request per method, and the conditional sets name two.
|
|
79
|
+
if (methods.size === 0 && candidates.length > 0) {
|
|
80
|
+
methods = await probeMethods(pdsEndpoint, candidates);
|
|
81
|
+
}
|
|
35
82
|
cache.set(pdsEndpoint, { methods, expires: Date.now() + CACHE_TTL_MS });
|
|
36
83
|
return methods;
|
|
37
84
|
}
|
|
@@ -48,7 +95,7 @@ export async function negotiateScope(config, base, pdsEndpoint, isLoopback) {
|
|
|
48
95
|
const conditional = config.conditionalScopes ?? [];
|
|
49
96
|
if (conditional.length === 0 || !pdsEndpoint || isLoopback)
|
|
50
97
|
return base;
|
|
51
|
-
const methods = await describeMethods(pdsEndpoint);
|
|
98
|
+
const methods = await describeMethods(pdsEndpoint, conditional.map((c) => c.whenMethod));
|
|
52
99
|
const granted = conditional.filter((c) => methods.has(c.whenMethod)).flatMap((c) => c.scopes);
|
|
53
100
|
if (granted.length === 0)
|
|
54
101
|
return base;
|