@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111

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 (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +36 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +153 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +179 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +78 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +131 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +212 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Which spaces a given viewer may be shown.
3
+ *
4
+ * The question has exactly one authoritative answer, and hatk is not the one
5
+ * holding it: a space's authority decides who may read it, at the moment it
6
+ * mints a credential, from records and rules that live on the community's own
7
+ * host. Re-deriving that here — reading the access and membership records out
8
+ * of the index and evaluating them — would be a second implementation of
9
+ * somebody else's authorization, drifting from it in exactly the cases that
10
+ * matter. Ejected members keep their content readable in the alpha; an invites
11
+ * space is write-for-anyone and read-for-owner; a host may change its rules
12
+ * without telling us.
13
+ *
14
+ * So the check is the real thing: ask the authority to mint a credential as
15
+ * this viewer, and take the answer. It succeeds precisely when the viewer may
16
+ * read the space, because that is the same call their own browser would make.
17
+ *
18
+ * What that costs is a round trip per space, so the answer is cached — which
19
+ * means revocation lags by up to the cache's lifetime. Worth stating plainly:
20
+ * somebody ejected from a community may still be served that community's rows
21
+ * for a few minutes. The alternative is two round trips on every request, and
22
+ * the space host itself already lets a minted credential outlive a revocation
23
+ * by up to ten minutes.
24
+ */
25
+ import { emit } from "../logger.js";
26
+ import { mintSpaceCredential, isSpaceGone } from "./credential.js";
27
+ import { listSpaceWatches } from "./store.js";
28
+ /** How long a viewer's readable set is trusted before it is checked again. */
29
+ const TTL_MS = 5 * 60 * 1000;
30
+ /** Enough for a community's worth of spaces without opening a socket per space. */
31
+ const CONCURRENCY = 6;
32
+ /**
33
+ * Viewers held at once. Past this the oldest entry goes — a cache that grows
34
+ * with every account that has ever signed in is a leak, and the cost of a miss
35
+ * is one round trip.
36
+ */
37
+ const MAX_VIEWERS = 1000;
38
+ const cache = new Map();
39
+ /**
40
+ * Resolutions in flight, one per viewer. A page fires several requests at
41
+ * once and every one of them misses the same cold cache; without this each
42
+ * would mint its own credential per space, and a member landing on the home
43
+ * page cost six times what it needed to.
44
+ */
45
+ const inFlight = new Map();
46
+ function entryFor(viewerDid) {
47
+ const entry = cache.get(viewerDid);
48
+ if (!entry)
49
+ return undefined;
50
+ if (entry.expiresAt <= Date.now()) {
51
+ cache.delete(viewerDid);
52
+ return undefined;
53
+ }
54
+ // Re-inserted so iteration order is least-recently-used first.
55
+ cache.delete(viewerDid);
56
+ cache.set(viewerDid, entry);
57
+ return entry;
58
+ }
59
+ function store(viewerDid, credentials) {
60
+ const entry = { expiresAt: Date.now() + TTL_MS, credentials };
61
+ cache.set(viewerDid, entry);
62
+ while (cache.size > MAX_VIEWERS) {
63
+ const oldest = cache.keys().next();
64
+ if (oldest.done)
65
+ break;
66
+ cache.delete(oldest.value);
67
+ }
68
+ return entry;
69
+ }
70
+ async function inBatches(items, size, fn) {
71
+ const out = [];
72
+ for (let i = 0; i < items.length; i += size) {
73
+ out.push(...(await Promise.all(items.slice(i, i + size).map(fn))));
74
+ }
75
+ return out;
76
+ }
77
+ function resolve(oauth, viewerDid) {
78
+ const pending = inFlight.get(viewerDid);
79
+ if (pending)
80
+ return pending;
81
+ const task = resolveNow(oauth, viewerDid).finally(() => {
82
+ if (inFlight.get(viewerDid) === task)
83
+ inFlight.delete(viewerDid);
84
+ });
85
+ inFlight.set(viewerDid, task);
86
+ return task;
87
+ }
88
+ async function resolveNow(oauth, viewerDid) {
89
+ const watches = await listSpaceWatches();
90
+ const credentials = new Map();
91
+ if (watches.length === 0)
92
+ return store(viewerDid, credentials);
93
+ const elapsed = Date.now();
94
+ await inBatches(watches, CONCURRENCY, async (watch) => {
95
+ try {
96
+ credentials.set(watch.space, await mintSpaceCredential(oauth, watch.space, viewerDid));
97
+ }
98
+ catch (err) {
99
+ // Every refusal is a no, and a refusal is the ordinary shape of "not a
100
+ // member" rather than a fault worth reporting. A space that has been
101
+ // deleted is a no for everybody and the sweep will stop following it.
102
+ credentials.set(watch.space, null);
103
+ if (isSpaceGone(err))
104
+ return;
105
+ }
106
+ });
107
+ const readable = [...credentials].filter(([, c]) => c !== null).length;
108
+ emit('spaces', 'viewer_resolved', {
109
+ viewer_did: viewerDid,
110
+ checked: watches.length,
111
+ readable,
112
+ duration_ms: Date.now() - elapsed,
113
+ });
114
+ return store(viewerDid, credentials);
115
+ }
116
+ /**
117
+ * The spaces this viewer may be shown, as the scope for a request.
118
+ *
119
+ * Empty for a signed-out visitor, always: obtaining a credential begins with a
120
+ * delegation token from the reader's own PDS, so there is no anonymous read
121
+ * path into a space at all. `readableBy: ["public"]` means any authenticated
122
+ * reader, never anyone.
123
+ */
124
+ export async function readableSpacesFor(oauth, viewer) {
125
+ if (!oauth || !viewer)
126
+ return [];
127
+ const entry = entryFor(viewer.did) ?? (await resolve(oauth, viewer.did));
128
+ return [...entry.credentials].filter(([, c]) => c !== null).map(([space]) => space);
129
+ }
130
+ /**
131
+ * This viewer's own credential for a space, or null if they may not read it.
132
+ *
133
+ * For the reads that cannot go through the index — a blob, which has no public
134
+ * URL by design and must be fetched from the repo that holds it with the same
135
+ * credential as the record naming it.
136
+ */
137
+ export async function viewerCredential(oauth, viewer, space) {
138
+ if (!oauth || !viewer)
139
+ return null;
140
+ const entry = entryFor(viewer.did) ?? (await resolve(oauth, viewer.did));
141
+ const cached = entry.credentials.get(space);
142
+ if (cached !== undefined) {
143
+ // A credential can expire inside the entry's own lifetime; the answer to
144
+ // "may they read it" outlives the token that proved it.
145
+ if (cached === null)
146
+ return null;
147
+ if (cached.expiresAt > Date.now())
148
+ return cached;
149
+ }
150
+ try {
151
+ const credential = await mintSpaceCredential(oauth, space, viewer.did);
152
+ entry.credentials.set(space, credential);
153
+ return credential;
154
+ }
155
+ catch {
156
+ entry.credentials.set(space, null);
157
+ return null;
158
+ }
159
+ }
160
+ /** Drop a viewer's cached answer — after they sign out, or to stop honouring it early. */
161
+ export function forgetViewerSpaces(viewerDid) {
162
+ cache.delete(viewerDid);
163
+ }
164
+ export function resetViewerSpaces() {
165
+ cache.clear();
166
+ inFlight.clear();
167
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which permissioned spaces the read in flight is allowed to serve.
3
+ *
4
+ * Space records are not public data. A space's authority decides who may read
5
+ * it, and it decides that one caller at a time — so a row indexed out of a
6
+ * space carries an audience, and serving it to a request that has not proven
7
+ * it belongs to that audience discloses private data no matter how the query
8
+ * was written.
9
+ *
10
+ * hatk has no other per-row visibility concept. The takedown join is
11
+ * viewer-independent and `privateCollections` is all-or-nothing, so neither
12
+ * could carry this. The rule here is instead:
13
+ *
14
+ * a row with `space IS NULL` is public repo data and always readable;
15
+ * a row with a space is readable only inside a scope that names that space.
16
+ *
17
+ * The default is an empty scope, which serves no space rows at all. That is
18
+ * deliberate: a read path nobody remembered to scope fails closed and shows a
19
+ * member nothing, rather than failing open and showing a stranger everything.
20
+ * Every query over a record table applies `spaceFilterSql`; the scope is
21
+ * established per request by whoever established the viewer.
22
+ */
23
+ /**
24
+ * Run `fn` with exactly these spaces readable.
25
+ *
26
+ * Replaces rather than extends any enclosing scope: widening what a nested read
27
+ * may see is never what a caller means, and a scope that only ever narrows is
28
+ * one that can be reasoned about locally.
29
+ */
30
+ export declare function withReadableSpaces<T>(spaces: Iterable<string>, fn: () => T): T;
31
+ /**
32
+ * Set the readable spaces for the rest of the current async context.
33
+ *
34
+ * `withReadableSpaces` wants a callback, and a request handler is a long body
35
+ * rather than something to wrap — restructuring it around a closure to hold a
36
+ * scope would be the tail wagging the dog. A server handler already runs in its
37
+ * own async context per request, which is the case `enterWith` exists for.
38
+ */
39
+ export declare function enterReadableSpaces(spaces: Iterable<string>): void;
40
+ /** The spaces this read may serve. Empty outside any scope. */
41
+ export declare function readableSpaces(): ReadonlySet<string>;
42
+ /** Whether a space would be served to the read in flight. */
43
+ export declare function isSpaceReadable(space: string | null | undefined): boolean;
44
+ /**
45
+ * The space gate as a SQL predicate, for a table aliased `alias` (pass '' for
46
+ * an unaliased table).
47
+ *
48
+ * `startIdx` is the next free `$N` placeholder; the returned `nextIdx` is the
49
+ * next one after this predicate's own. With nothing readable the predicate
50
+ * binds no parameters at all, which is the shape almost every request takes.
51
+ */
52
+ export declare function spaceFilterSql(alias: string, startIdx: number): {
53
+ sql: string;
54
+ params: string[];
55
+ nextIdx: number;
56
+ };
57
+ //# sourceMappingURL=visibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visibility.d.ts","sourceRoot":"","sources":["../../src/spaces/visibility.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAaH;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,CAElE;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,IAAI,WAAW,CAAC,MAAM,CAAC,CAEpD;AAED,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGzE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAUlH"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Which permissioned spaces the read in flight is allowed to serve.
3
+ *
4
+ * Space records are not public data. A space's authority decides who may read
5
+ * it, and it decides that one caller at a time — so a row indexed out of a
6
+ * space carries an audience, and serving it to a request that has not proven
7
+ * it belongs to that audience discloses private data no matter how the query
8
+ * was written.
9
+ *
10
+ * hatk has no other per-row visibility concept. The takedown join is
11
+ * viewer-independent and `privateCollections` is all-or-nothing, so neither
12
+ * could carry this. The rule here is instead:
13
+ *
14
+ * a row with `space IS NULL` is public repo data and always readable;
15
+ * a row with a space is readable only inside a scope that names that space.
16
+ *
17
+ * The default is an empty scope, which serves no space rows at all. That is
18
+ * deliberate: a read path nobody remembered to scope fails closed and shows a
19
+ * member nothing, rather than failing open and showing a stranger everything.
20
+ * Every query over a record table applies `spaceFilterSql`; the scope is
21
+ * established per request by whoever established the viewer.
22
+ */
23
+ import { AsyncLocalStorage } from 'node:async_hooks';
24
+ const EMPTY = new Set();
25
+ /**
26
+ * Async-local rather than a module global: a server answers requests
27
+ * concurrently, and one viewer's readable set must never be observable from
28
+ * another's request while an await is outstanding.
29
+ */
30
+ const store = new AsyncLocalStorage();
31
+ /**
32
+ * Run `fn` with exactly these spaces readable.
33
+ *
34
+ * Replaces rather than extends any enclosing scope: widening what a nested read
35
+ * may see is never what a caller means, and a scope that only ever narrows is
36
+ * one that can be reasoned about locally.
37
+ */
38
+ export function withReadableSpaces(spaces, fn) {
39
+ return store.run(new Set(spaces), fn);
40
+ }
41
+ /**
42
+ * Set the readable spaces for the rest of the current async context.
43
+ *
44
+ * `withReadableSpaces` wants a callback, and a request handler is a long body
45
+ * rather than something to wrap — restructuring it around a closure to hold a
46
+ * scope would be the tail wagging the dog. A server handler already runs in its
47
+ * own async context per request, which is the case `enterWith` exists for.
48
+ */
49
+ export function enterReadableSpaces(spaces) {
50
+ store.enterWith(new Set(spaces));
51
+ }
52
+ /** The spaces this read may serve. Empty outside any scope. */
53
+ export function readableSpaces() {
54
+ return store.getStore() ?? EMPTY;
55
+ }
56
+ /** Whether a space would be served to the read in flight. */
57
+ export function isSpaceReadable(space) {
58
+ if (space == null)
59
+ return true;
60
+ return readableSpaces().has(space);
61
+ }
62
+ /**
63
+ * The space gate as a SQL predicate, for a table aliased `alias` (pass '' for
64
+ * an unaliased table).
65
+ *
66
+ * `startIdx` is the next free `$N` placeholder; the returned `nextIdx` is the
67
+ * next one after this predicate's own. With nothing readable the predicate
68
+ * binds no parameters at all, which is the shape almost every request takes.
69
+ */
70
+ export function spaceFilterSql(alias, startIdx) {
71
+ const col = alias ? `${alias}.space` : 'space';
72
+ const spaces = [...readableSpaces()];
73
+ if (spaces.length === 0)
74
+ return { sql: `${col} IS NULL`, params: [], nextIdx: startIdx };
75
+ const placeholders = spaces.map((_, i) => `$${startIdx + i}`).join(', ');
76
+ return {
77
+ sql: `(${col} IS NULL OR ${col} IN (${placeholders}))`,
78
+ params: spaces,
79
+ nextIdx: startIdx + spaces.length,
80
+ };
81
+ }
@@ -0,0 +1,18 @@
1
+ import { defineFeed } from '$hatk'
2
+
3
+ export default defineFeed({
4
+ collection: 'your.collection.here',
5
+ label: '{{Name}}',
6
+
7
+ async generate(ctx) {
8
+ // ctx.paginate applies the permissioned-space gate for you, so this page
9
+ // shows a member the spaces they may read and everyone else the public
10
+ // rows alone. A feed that builds its own SQL over a space-backed table is
11
+ // refused with an error naming the table; see ctx.spaceFilter.
12
+ const { rows, cursor } = await ctx.paginate<{ uri: string }>(
13
+ `SELECT uri, cid, indexed_at FROM "your.collection.here"`,
14
+ )
15
+
16
+ return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
17
+ },
18
+ })
@@ -0,0 +1,5 @@
1
+ import { defineHook } from '$hatk'
2
+
3
+ export default defineHook('{{name}}', async (ctx) => {
4
+ // Hook logic here
5
+ })
@@ -0,0 +1,15 @@
1
+ import { defineLabel } from '$hatk'
2
+
3
+ export default defineLabel({
4
+ definition: {
5
+ identifier: '{{name}}',
6
+ severity: 'inform',
7
+ blurs: 'none',
8
+ defaultSetting: 'warn',
9
+ locales: [{ lang: 'en', name: '{{Name}}', description: 'Description here' }],
10
+ },
11
+ async evaluate(ctx) {
12
+ // Return array of label identifiers to apply, or empty array
13
+ return []
14
+ },
15
+ })
@@ -0,0 +1,17 @@
1
+ import type { OpengraphContext, OpengraphResult } from '@hatk/hatk/opengraph'
2
+
3
+ export default {
4
+ path: '/og/{{name}}/:id',
5
+ async generate(ctx: OpengraphContext): Promise<OpengraphResult> {
6
+ const { db, params } = ctx
7
+ return {
8
+ element: {
9
+ type: 'div',
10
+ props: {
11
+ style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' },
12
+ children: params.id,
13
+ },
14
+ },
15
+ }
16
+ },
17
+ }
@@ -0,0 +1,11 @@
1
+ import { seed } from '$hatk'
2
+
3
+ const { createAccount, createRecord } = seed()
4
+
5
+ const alice = await createAccount('alice.test')
6
+
7
+ // await createRecord(alice, 'your.collection.here', {
8
+ // field: 'value',
9
+ // }, { rkey: 'my-record' })
10
+
11
+ console.log('\n[seed] Done!')
@@ -0,0 +1,5 @@
1
+ import { defineSetup } from '$hatk'
2
+
3
+ export default defineSetup(async (ctx) => {
4
+ // Setup logic here — runs before the server starts
5
+ })
@@ -0,0 +1,19 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
2
+ import { createTestContext } from '@hatk/hatk/test'
3
+
4
+ let ctx: Awaited<ReturnType<typeof createTestContext>>
5
+
6
+ beforeAll(async () => {
7
+ ctx = await createTestContext()
8
+ await ctx.loadFixtures()
9
+ })
10
+
11
+ afterAll(async () => ctx?.close())
12
+
13
+ describe('{{name}} feed', () => {
14
+ test('returns results', async () => {
15
+ const feed = ctx.loadFeed('{{name}}')
16
+ const result = await feed.generate(ctx.feedContext({ limit: 10 }))
17
+ expect(result).toBeDefined()
18
+ })
19
+ })
@@ -0,0 +1,19 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
2
+ import { createTestContext } from '@hatk/hatk/test'
3
+
4
+ let ctx: Awaited<ReturnType<typeof createTestContext>>
5
+
6
+ beforeAll(async () => {
7
+ ctx = await createTestContext()
8
+ await ctx.loadFixtures()
9
+ })
10
+
11
+ afterAll(async () => ctx?.close())
12
+
13
+ describe('{{name}}', () => {
14
+ test('returns response', async () => {
15
+ const handler = ctx.loadXrpc('{{name}}')
16
+ const result = await handler.handler({ params: {} })
17
+ expect(result).toBeDefined()
18
+ })
19
+ })
@@ -0,0 +1,53 @@
1
+ import { defineQuery } from '$hatk'
2
+
3
+ export default defineQuery('{{name}}', async (ctx) => {
4
+ const { ok, db, params, packCursor, unpackCursor, spaceFilter } = ctx
5
+ const limit = params.limit ?? 30
6
+ const cursor = params.cursor
7
+
8
+ const conditions: string[] = []
9
+ const sqlParams: (string | number)[] = []
10
+ let paramIdx = 1
11
+
12
+ // Which permissioned spaces this viewer may be shown. Typed helpers like
13
+ // ctx.lookup and ctx.getRecords apply this already; hand-written SQL cannot,
14
+ // because nothing can inject a predicate into a string you wrote. Drop it on
15
+ // a collection a space writes into and hatk refuses the query by name.
16
+ //
17
+ // Outside a viewer's scope it is `s.space IS NULL` and binds nothing, so on an
18
+ // instance that indexes no space it costs one test on an indexed column.
19
+ const spaces = spaceFilter('s', paramIdx)
20
+ conditions.push(spaces.sql)
21
+ sqlParams.push(...spaces.params)
22
+ paramIdx = spaces.nextIdx
23
+
24
+ if (cursor) {
25
+ const parsed = unpackCursor(cursor)
26
+ if (parsed) {
27
+ conditions.push(`(s.indexed_at < $${paramIdx} OR (s.indexed_at = $${paramIdx + 1} AND s.cid < $${paramIdx + 2}))`)
28
+ sqlParams.push(parsed.primary, parsed.primary, parsed.cid)
29
+ paramIdx += 3
30
+ }
31
+ }
32
+
33
+ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
34
+
35
+ const rows = (await db.query(
36
+ `SELECT s.* FROM "your.collection.here" s ${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $${paramIdx}`,
37
+ sqlParams.concat([limit + 1]),
38
+ )) as {
39
+ uri: string
40
+ cid: string
41
+ did: string
42
+ indexed_at: string
43
+ }[]
44
+
45
+ const hasMore = rows.length > limit
46
+ if (hasMore) rows.pop()
47
+ const lastRow = rows[rows.length - 1]
48
+
49
+ return ok({
50
+ items: rows,
51
+ cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined,
52
+ })
53
+ })
package/dist/test.d.ts CHANGED
@@ -4,7 +4,9 @@ import type { FeedContext } from './feeds.ts';
4
4
  export interface TestContext {
5
5
  db: {
6
6
  query: (sql: string, params?: any[]) => Promise<any[]>;
7
- run: (sql: string, ...params: any[]) => Promise<void>;
7
+ run: (sql: string, params?: any[]) => Promise<void>;
8
+ /** Raw SQL with the permissioned-space guard off, as the runtime contexts have it. */
9
+ unfiltered: (sql: string, params?: any[]) => Promise<any[]>;
8
10
  };
9
11
  loadFixtures: (dir?: string) => Promise<void>;
10
12
  loadFeed: (name: string) => {
@@ -1 +1 @@
1
- {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAiBzD,OAAO,EAAE,IAAI,IAAI,iBAAiB,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACtD,CAAA;IACD,YAAY,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,QAAQ,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;IAC5E,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;IACnE,WAAW,EAAE,CAAC,IAAI,CAAC,EAAE;QACnB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,MAAM,CAAC,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;QAC/B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAChC,KAAK,WAAW,CAAA;IACjB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1B,gBAAgB,CAAC,OAAO,EAAE,UAAU,CAAA;IACpC,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,CAAA;CACxC;AAED,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC7C,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC9D,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC7E,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,QAAQ,KAAK,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAA;IAC/D,aAAa,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAClE;AAYD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,WAAW,CAAC,CAiL9D;AA8BD,wBAAsB,eAAe,IAAI,OAAO,CAAC,UAAU,CAAC,CAgD3D"}
1
+ {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAmBzD,OAAO,EAAE,IAAI,IAAI,iBAAiB,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAA;AAIpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;QACnD,sFAAsF;QACtF,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;KAC5D,CAAA;IACD,YAAY,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,QAAQ,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;IAC5E,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;IACnE,WAAW,EAAE,CAAC,IAAI,CAAC,EAAE;QACnB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,MAAM,CAAC,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;QAC/B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAChC,KAAK,WAAW,CAAA;IACjB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1B,gBAAgB,CAAC,OAAO,EAAE,UAAU,CAAA;IACpC,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,CAAA;CACxC;AAED,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC7C,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC9D,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC7E,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,QAAQ,KAAK,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAA;IAC/D,aAAa,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAClE;AAYD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,WAAW,CAAC,CAyL9D;AA8BD,wBAAsB,eAAe,IAAI,OAAO,CAAC,UAAU,CAAC,CAiD3D"}