@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.
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +113 -0
- package/dist/backfill.d.ts +36 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +153 -37
- package/dist/car.js +1 -1
- package/dist/classifiers.d.ts +228 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +688 -0
- package/dist/cli.js +248 -1043
- package/dist/config.d.ts +179 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +78 -8
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +16 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +76 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/database/db.d.ts +290 -0
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +2073 -0
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +122 -34
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/database/schema.d.ts +91 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +153 -54
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +131 -0
- package/dist/feeds.d.ts +26 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +72 -16
- package/dist/hooks.d.ts +102 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +186 -0
- package/dist/hydrate.d.ts +55 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +194 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +627 -92
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +238 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
- package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +212 -75
- package/dist/mst.d.ts +15 -0
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +13 -0
- package/dist/oauth/db.d.ts +27 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +80 -19
- package/dist/oauth/discovery.d.ts +10 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +54 -2
- package/dist/oauth/server.d.ts +131 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +504 -39
- package/dist/oauth/service-describe.d.ts +42 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +108 -0
- package/dist/oauth/session.d.ts +32 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +131 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +94 -42
- package/dist/pds-proxy.d.ts +92 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +396 -0
- package/dist/private-collections.d.ts +13 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +27 -0
- package/dist/push.d.ts +54 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +367 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +22 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +92 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +75 -0
- package/dist/server.d.ts +46 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1137 -649
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/spaces/blob.d.ts +112 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +460 -0
- package/dist/spaces/credential.d.ts +82 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +220 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +563 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +93 -0
- package/dist/spaces/index.d.ts +56 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +134 -0
- package/dist/spaces/notify.d.ts +60 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +128 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/signature.d.ts +36 -0
- package/dist/spaces/signature.d.ts.map +1 -0
- package/dist/spaces/signature.js +83 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +46 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +94 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +18 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +53 -0
- package/dist/test.d.ts +3 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +60 -36
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -66
- package/dist/xrpc.d.ts +143 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +282 -55
- package/package.json +29 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +652 -61
- package/dist/db.d.ts +0 -135
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1365
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts +0 -51
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- 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,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,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,
|
|
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) => {
|
package/dist/test.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|