@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 only way to ask: atproto publishes no method
6
- * list, so the alternative is to call a method and read the failure, where a
7
- * server without an optional feature and one that is broken look alike.
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. An empty set for a server that answers no
15
- * describe — which is every PDS that predates the method, so absence has to
16
- * read as "no optional features" rather than as an error.
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;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAU/C;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAwB/E;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,CAUjB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
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
- * Every XRPC method a PDS serves. An empty set for a server that answers no
9
- * describe — which is every PDS that predates the method, so absence has to
10
- * read as "no optional features" rather than as an error.
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
- export async function describeMethods(pdsEndpoint) {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.83",
3
+ "version": "0.0.1-alpha.84",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"