@hatk/hatk 0.0.1-alpha.85 → 0.0.1-alpha.87

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 (89) hide show
  1. package/dist/adapter.d.ts.map +1 -1
  2. package/dist/adapter.js +2 -0
  3. package/dist/cli.js +5 -1
  4. package/dist/config.d.ts +47 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +13 -0
  7. package/dist/database/db.d.ts +11 -0
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +157 -22
  10. package/dist/database/index.d.ts +1 -1
  11. package/dist/database/index.d.ts.map +1 -1
  12. package/dist/database/index.js +1 -1
  13. package/dist/database/schema.d.ts +2 -0
  14. package/dist/database/schema.d.ts.map +1 -1
  15. package/dist/database/schema.js +40 -3
  16. package/dist/dev-entry.d.ts.map +1 -1
  17. package/dist/dev-entry.js +2 -2
  18. package/dist/feeds.d.ts +14 -0
  19. package/dist/feeds.d.ts.map +1 -1
  20. package/dist/feeds.js +24 -13
  21. package/dist/hooks.d.ts +9 -0
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/hooks.js +2 -2
  24. package/dist/hydrate.d.ts +42 -0
  25. package/dist/hydrate.d.ts.map +1 -1
  26. package/dist/hydrate.js +18 -5
  27. package/dist/indexer.d.ts +12 -0
  28. package/dist/indexer.d.ts.map +1 -1
  29. package/dist/indexer.js +20 -0
  30. package/dist/lex-types.d.ts +2 -0
  31. package/dist/lex-types.d.ts.map +1 -1
  32. package/dist/main.js +26 -2
  33. package/dist/oauth/db.d.ts +9 -0
  34. package/dist/oauth/db.d.ts.map +1 -1
  35. package/dist/oauth/db.js +12 -0
  36. package/dist/private-collections.d.ts +7 -2
  37. package/dist/private-collections.d.ts.map +1 -1
  38. package/dist/private-collections.js +9 -4
  39. package/dist/server.d.ts +8 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +99 -10
  42. package/dist/spaces/blob.d.ts +51 -0
  43. package/dist/spaces/blob.d.ts.map +1 -0
  44. package/dist/spaces/blob.js +111 -0
  45. package/dist/spaces/credential.d.ts +62 -0
  46. package/dist/spaces/credential.d.ts.map +1 -0
  47. package/dist/spaces/credential.js +184 -0
  48. package/dist/spaces/engine.d.ts +97 -0
  49. package/dist/spaces/engine.d.ts.map +1 -0
  50. package/dist/spaces/engine.js +557 -0
  51. package/dist/spaces/guard.d.ts +41 -0
  52. package/dist/spaces/guard.d.ts.map +1 -0
  53. package/dist/spaces/guard.js +79 -0
  54. package/dist/spaces/identity.d.ts +37 -0
  55. package/dist/spaces/identity.d.ts.map +1 -0
  56. package/dist/spaces/identity.js +88 -0
  57. package/dist/spaces/index.d.ts +55 -0
  58. package/dist/spaces/index.d.ts.map +1 -0
  59. package/dist/spaces/index.js +133 -0
  60. package/dist/spaces/notify.d.ts +51 -0
  61. package/dist/spaces/notify.d.ts.map +1 -0
  62. package/dist/spaces/notify.js +121 -0
  63. package/dist/spaces/records.d.ts +37 -0
  64. package/dist/spaces/records.d.ts.map +1 -0
  65. package/dist/spaces/records.js +63 -0
  66. package/dist/spaces/store.d.ts +51 -0
  67. package/dist/spaces/store.d.ts.map +1 -0
  68. package/dist/spaces/store.js +99 -0
  69. package/dist/spaces/uri.d.ts +60 -0
  70. package/dist/spaces/uri.d.ts.map +1 -0
  71. package/dist/spaces/uri.js +97 -0
  72. package/dist/spaces/verify.d.ts +39 -0
  73. package/dist/spaces/verify.d.ts.map +1 -0
  74. package/dist/spaces/verify.js +87 -0
  75. package/dist/spaces/viewer.d.ts +51 -0
  76. package/dist/spaces/viewer.d.ts.map +1 -0
  77. package/dist/spaces/viewer.js +167 -0
  78. package/dist/spaces/visibility.d.ts +57 -0
  79. package/dist/spaces/visibility.d.ts.map +1 -0
  80. package/dist/spaces/visibility.js +81 -0
  81. package/dist/templates/feed.tpl +4 -0
  82. package/dist/templates/xrpc.tpl +13 -1
  83. package/dist/test.d.ts +2 -0
  84. package/dist/test.d.ts.map +1 -1
  85. package/dist/test.js +8 -5
  86. package/dist/xrpc.d.ts +19 -3
  87. package/dist/xrpc.d.ts.map +1 -1
  88. package/dist/xrpc.js +61 -31
  89. package/package.json +5 -3
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Getting into a space to read it.
3
+ *
4
+ * hatk is not a member of anything. It has no DID that any community has
5
+ * admitted, and asking every authority to admit an indexer would make indexing
6
+ * a per-community act of trust before a single record could be read.
7
+ *
8
+ * So it reads as somebody who is already inside: a viewer whose OAuth session
9
+ * it already holds, whose PDS will mint a delegation token for the space, and
10
+ * whom the authority already lets in. The exchange is the same one the
11
+ * community's own web app makes from the browser —
12
+ *
13
+ * the reader's PDS signs a delegation token (60s, single-use, addressed to
14
+ * the authority's space host)
15
+ * the authority checks it, checks the reader against the space's policy, and
16
+ * returns a credential bound to the key that signed our DPoP proof (2h)
17
+ *
18
+ * — and the credential is then presented to every writer's host in the space.
19
+ * It carries no audience, which is why it must be bound to a key rather than
20
+ * held as a bearer token: a host handed a bearer credential to serve its own
21
+ * repo could replay it against every other host in the space.
22
+ *
23
+ * The consequence to be honest about: a space is only readable while somebody
24
+ * who can read it has a live session here. That is the same bound the data has
25
+ * anyway — nobody outside the space's audience was ever entitled to this — but
26
+ * it does mean indexing lapses when the last member logs out.
27
+ */
28
+ import { computeJwkThumbprint, generateKeyPair } from "../oauth/crypto.js";
29
+ import { createDpopProof } from "../oauth/dpop.js";
30
+ import { pdsXrpc } from "../pds-proxy.js";
31
+ import { emit } from "../logger.js";
32
+ import { spaceHostEndpoint } from "./identity.js";
33
+ import { parseSpaceRef } from "./uri.js";
34
+ /** Re-mint this long before the credential's own expiry rather than racing it. */
35
+ const RENEW_LEAD_MS = 5 * 60 * 1000;
36
+ /**
37
+ * The key credentials are bound to, generated once per process and never
38
+ * persisted. It is registered nowhere, so a restart costs one round trip to
39
+ * mint fresh credentials and nothing else.
40
+ */
41
+ let bindingKey = null;
42
+ function getBindingKey() {
43
+ bindingKey ??= generateKeyPair();
44
+ return bindingKey;
45
+ }
46
+ export class SpaceCredentialError extends Error {
47
+ status;
48
+ code;
49
+ constructor(status, code, message) {
50
+ super(message);
51
+ this.status = status;
52
+ this.code = code;
53
+ }
54
+ }
55
+ /** A refusal about who is asking, rather than about the space or the network. */
56
+ export function isNotAuthorized(err) {
57
+ return err instanceof SpaceCredentialError && (err.code === 'UserNotAuthorized' || err.code === 'NotAuthorized');
58
+ }
59
+ export function isSpaceGone(err) {
60
+ return err instanceof SpaceCredentialError && (err.code === 'SpaceDeleted' || err.code === 'SpaceNotFound');
61
+ }
62
+ function expiryOf(jwt) {
63
+ try {
64
+ const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
65
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : Date.now() + 60 * 60 * 1000;
66
+ }
67
+ catch {
68
+ // An unreadable expiry is not a reason to refuse a credential the authority
69
+ // just issued; treat it as short-lived and re-mint sooner.
70
+ return Date.now() + 10 * 60 * 1000;
71
+ }
72
+ }
73
+ /**
74
+ * Exchange a delegation token for a credential at the authority.
75
+ *
76
+ * The proof sent here carries no `ath`: a delegation token is an authorization
77
+ * grant rather than an access token, so there is nothing to hash yet, and a
78
+ * server that checks will refuse a proof that includes one. `dpopJkt` is sent
79
+ * alongside because implementations differ on which they read — the reference
80
+ * reads the proof, others read the field — and they agree here by construction.
81
+ */
82
+ async function exchange(authorityHost, space, delegationToken) {
83
+ const { privateJwk, publicJwk } = await getBindingKey();
84
+ const url = `${authorityHost}/xrpc/com.atproto.space.getSpaceCredential`;
85
+ const proof = await createDpopProof(privateJwk, publicJwk, 'POST', url);
86
+ const dpopJkt = await computeJwkThumbprint(publicJwk);
87
+ const res = await fetch(url, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ Accept: 'application/json',
92
+ Authorization: `Bearer ${delegationToken}`,
93
+ DPoP: proof,
94
+ },
95
+ body: JSON.stringify({ space, dpopJkt }),
96
+ });
97
+ const body = (await res.json().catch(() => ({})));
98
+ if (!res.ok || !body.credential) {
99
+ throw new SpaceCredentialError(res.status, body.error ?? (res.status >= 500 ? 'UpstreamFailure' : 'InvalidRequest'), body.message ?? `getSpaceCredential refused (${res.status})`);
100
+ }
101
+ return body.credential;
102
+ }
103
+ /** Mint a credential for `space` using `readerDid`'s stored session. */
104
+ export async function mintSpaceCredential(oauthConfig, space, readerDid) {
105
+ const ref = parseSpaceRef(space);
106
+ if (!ref)
107
+ throw new SpaceCredentialError(400, 'InvalidRequest', `Not a space ref: ${space}`);
108
+ // The reader's own PDS signs this, under the whole-space read grant their
109
+ // session carries. A session without that scope is refused here, before the
110
+ // authority is ever contacted.
111
+ let token;
112
+ try {
113
+ const out = await pdsXrpc(oauthConfig, { did: readerDid }, 'com.atproto.space.getDelegationToken', {
114
+ params: { space },
115
+ });
116
+ if (typeof out.token !== 'string')
117
+ throw new Error('PDS returned no delegation token');
118
+ token = out.token;
119
+ }
120
+ catch (err) {
121
+ throw new SpaceCredentialError(err?.status ?? 502, 'DelegationFailed', err?.message ?? 'delegation failed');
122
+ }
123
+ const authorityHost = await spaceHostEndpoint(ref.authority);
124
+ const credential = await exchange(authorityHost, space, token);
125
+ const { privateJwk, publicJwk } = await getBindingKey();
126
+ const credentialFetch = async (input, init) => {
127
+ const url = typeof input === 'string' ? input : input.toString();
128
+ const method = (init?.method ?? 'GET').toUpperCase();
129
+ const proof = await createDpopProof(privateJwk, publicJwk, method, url, credential);
130
+ const headers = new Headers(init?.headers);
131
+ headers.set('Authorization', `DPoP ${credential}`);
132
+ headers.set('DPoP', proof);
133
+ return fetch(url, { ...init, method, headers });
134
+ };
135
+ return { space, readerDid, expiresAt: expiryOf(credential), fetch: credentialFetch };
136
+ }
137
+ // --- Cache ---
138
+ const cache = new Map();
139
+ function fresh(cred) {
140
+ return !!cred && cred.expiresAt - RENEW_LEAD_MS > Date.now();
141
+ }
142
+ /**
143
+ * A credential for `space`, minted with whichever candidate session works.
144
+ *
145
+ * Candidates are tried in order and the first that succeeds is cached. A
146
+ * refusal about the reader — they were ejected, or never belonged — moves on to
147
+ * the next candidate; a refusal about the space itself stops the whole attempt,
148
+ * since no other reader would fare better.
149
+ */
150
+ export async function getSpaceCredential(oauthConfig, space, candidates, opts = {}) {
151
+ if (!opts.refresh) {
152
+ const cached = cache.get(space);
153
+ if (fresh(cached))
154
+ return cached;
155
+ }
156
+ let lastError;
157
+ for (const readerDid of candidates) {
158
+ try {
159
+ const credential = await mintSpaceCredential(oauthConfig, space, readerDid);
160
+ cache.set(space, credential);
161
+ return credential;
162
+ }
163
+ catch (err) {
164
+ if (isSpaceGone(err))
165
+ throw err;
166
+ lastError = err;
167
+ emit('spaces', 'credential_refused', {
168
+ space,
169
+ reader_did: readerDid,
170
+ error: err instanceof Error ? err.message : String(err),
171
+ });
172
+ }
173
+ }
174
+ cache.delete(space);
175
+ throw lastError ?? new SpaceCredentialError(403, 'NoReader', `No session on this instance can read ${space}`);
176
+ }
177
+ export function forgetSpaceCredential(space) {
178
+ cache.delete(space);
179
+ }
180
+ /** For tests: drop every cached credential and the process binding key. */
181
+ export function resetSpaceCredentials() {
182
+ cache.clear();
183
+ bindingKey = null;
184
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Keeping the index level with a permissioned space.
3
+ *
4
+ * A space never reaches a firehose. There is no stream to tail and no relay
5
+ * that has seen it, so the only way to know a space has moved is to ask the
6
+ * authority who has written into it and then ask each of those writers' hosts
7
+ * what changed. Two loops do that:
8
+ *
9
+ * reconcile the authority's `listRepos` gives the writer set with each
10
+ * writer's current rev. Anything whose rev has moved is synced;
11
+ * anything the authority no longer names is dropped.
12
+ * sync one writer's repo, forward from the rev we last read, through
13
+ * `listRepoOps` — or wholesale through `listRecords` when there
14
+ * is no rev to start from, or when the op log has been compacted
15
+ * past it.
16
+ *
17
+ * Write notices exist too and are far cheaper, but they are best-effort by
18
+ * design: the reference host forwards them once from an in-memory queue and
19
+ * logs the failure. So reconcile is the thing that makes sync correct, and a
20
+ * notice only makes it prompt.
21
+ *
22
+ * What is deliberately not done here is verification. The alpha signs a commit
23
+ * over the repo's LtHash state, and a syncer can check it. hatk does not check
24
+ * the equivalent on the firehose path either — it reads records out of relayed
25
+ * CAR blocks and trusts them — so checking here would claim a guarantee the
26
+ * rest of the index does not make. Adding it means depending on `@atproto/space`
27
+ * for LtHash and the commit verifier, and is worth doing to both paths at once
28
+ * rather than to this one alone.
29
+ */
30
+ import type { OAuthConfig } from '../config.ts';
31
+ import { type SpaceCredential } from './credential.ts';
32
+ import { type SpaceWatch } from './store.ts';
33
+ export interface SpaceEngineOptions {
34
+ oauth: OAuthConfig;
35
+ /** Space type NSIDs this instance will follow. Anything else is refused. */
36
+ types: Set<string>;
37
+ /** Collections hatk indexes at all; a space collection outside this is skipped. */
38
+ collections: Set<string>;
39
+ /**
40
+ * This instance's service identifier — `did:web:...#atproto_space_syncer`.
41
+ * Absent when the instance receives no notices, in which case the sweep is
42
+ * the only thing that notices a write.
43
+ */
44
+ serviceId?: string;
45
+ }
46
+ export declare function configureSpaceEngine(opts: SpaceEngineOptions): void;
47
+ /**
48
+ * The collections a space of this type holds, narrowed to the ones hatk has a
49
+ * table for.
50
+ *
51
+ * A space type's lexicon declares its collections, which is what makes a space
52
+ * readable without the app being told what is in it — but a space may name
53
+ * collections this instance does not index, and those are simply not its
54
+ * business.
55
+ */
56
+ export declare function collectionsForSpaceType(spaceType: string): string[];
57
+ /** Bring one writer's repo in a space up to date, however that has to happen. */
58
+ export declare function syncSpaceRepo(watch: SpaceWatch, writer: string, credential: SpaceCredential): Promise<void>;
59
+ /**
60
+ * Compare the authority's writer set against what the index holds, and close
61
+ * the difference.
62
+ *
63
+ * The writer set is what the authority has been told, not what is true: a
64
+ * repo's own host is the source of truth, and after a community migrates hosts
65
+ * the set is empty until each writer writes again. So a writer the authority
66
+ * stops naming has their rows dropped, but the rev comparison is only ever a
67
+ * reason to read — never a reason to believe a repo has not moved.
68
+ */
69
+ export declare function reconcileSpace(watch: SpaceWatch): Promise<void>;
70
+ /** Follow a space from now on, and read it once. */
71
+ export declare function watchSpace(space: string): Promise<SpaceWatch>;
72
+ /** Stop following a space and drop everything indexed from it. */
73
+ export declare function unwatchSpace(space: string): Promise<void>;
74
+ /**
75
+ * Act on a write notice: read the one repo it names.
76
+ *
77
+ * Only for a space already followed. A notice about anything else is not an
78
+ * instruction to start following it — that decision belongs to config or to the
79
+ * app, never to whoever can reach this endpoint.
80
+ *
81
+ * The notice's `rev` is deliberately not trusted as the new position: it is
82
+ * read from the repo's own host, which is the source of truth, by the ordinary
83
+ * sync path. The notice only says "look again".
84
+ */
85
+ export declare function handleWriteNotice(notice: {
86
+ space: string;
87
+ repo: string;
88
+ }): Promise<boolean>;
89
+ /**
90
+ * Reconcile every followed space.
91
+ *
92
+ * Failures are per space and recorded rather than thrown: one community's host
93
+ * being unreachable is not a reason to stop reading the others, and a space
94
+ * whose last reader logged out should go quiet rather than take the sweep down.
95
+ */
96
+ export declare function reconcileAll(): Promise<void>;
97
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/spaces/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAQ/C,OAAO,EAA0D,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAE9G,OAAO,EAUL,KAAK,UAAU,EAChB,MAAM,YAAY,CAAA;AAMnB,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,WAAW,CAAA;IAClB,4EAA4E;IAC5E,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAClB,mFAAmF;IACnF,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAaD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,CAEnE;AAOD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAQnE;AAkUD,iFAAiF;AACjF,wBAAsB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBjH;AAkED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrE;AAID,oDAAoD;AACpD,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAUnE;AAED,kEAAkE;AAClE,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/D;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAKjG;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAclD"}