@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.
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -0
- package/dist/cli.js +5 -1
- package/dist/config.d.ts +47 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -0
- package/dist/database/db.d.ts +11 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +157 -22
- package/dist/database/index.d.ts +1 -1
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +1 -1
- package/dist/database/schema.d.ts +2 -0
- package/dist/database/schema.d.ts.map +1 -1
- package/dist/database/schema.js +40 -3
- package/dist/dev-entry.d.ts.map +1 -1
- package/dist/dev-entry.js +2 -2
- package/dist/feeds.d.ts +14 -0
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +24 -13
- package/dist/hooks.d.ts +9 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +2 -2
- package/dist/hydrate.d.ts +42 -0
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +18 -5
- package/dist/indexer.d.ts +12 -0
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +20 -0
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/main.js +26 -2
- package/dist/oauth/db.d.ts +9 -0
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +12 -0
- package/dist/private-collections.d.ts +7 -2
- package/dist/private-collections.d.ts.map +1 -1
- package/dist/private-collections.js +9 -4
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +99 -10
- package/dist/spaces/blob.d.ts +51 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +111 -0
- package/dist/spaces/credential.d.ts +62 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +184 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +557 -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 +88 -0
- package/dist/spaces/index.d.ts +55 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +133 -0
- package/dist/spaces/notify.d.ts +51 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +121 -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/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 +39 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +87 -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 +4 -0
- package/dist/templates/xrpc.tpl +13 -1
- package/dist/test.d.ts +2 -0
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +8 -5
- package/dist/xrpc.d.ts +19 -3
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +61 -31
- package/package.json +5 -3
package/dist/main.js
CHANGED
|
@@ -12,7 +12,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
12
12
|
import { registerHatkResolveHook } from "./resolve-hatk.js";
|
|
13
13
|
import { log } from "./logger.js";
|
|
14
14
|
import { loadConfig } from "./config.js";
|
|
15
|
-
import { loadLexicons, storeLexicons, discoverCollections, buildSchemas } from "./database/schema.js";
|
|
15
|
+
import { validatableLexicons, loadLexicons, storeLexicons, discoverCollections, buildSchemas, } from "./database/schema.js";
|
|
16
16
|
import { discoverViews } from "./views.js";
|
|
17
17
|
import { initDatabase, getCursor, querySQL, getSqlDialect, getSchemaDump, migrateSchema } from "./database/db.js";
|
|
18
18
|
import { createAdapter } from "./database/adapter-factory.js";
|
|
@@ -37,6 +37,7 @@ import { loadOnLoginHook } from "./hooks.js";
|
|
|
37
37
|
import { enabledPushTransports, initPush, isPushEnabled } from "./push.js";
|
|
38
38
|
import { initSetup } from "./setup.js";
|
|
39
39
|
import { initServer } from "./server-init.js";
|
|
40
|
+
import { startSpaces } from "./spaces/index.js";
|
|
40
41
|
const configPath = process.argv[2] || 'hatk.config.ts';
|
|
41
42
|
const configDir = dirname(resolve(configPath));
|
|
42
43
|
registerHatkResolveHook();
|
|
@@ -47,7 +48,7 @@ configurePlc(config.plc);
|
|
|
47
48
|
configureCdn(config.cdn);
|
|
48
49
|
// 2. Load lexicons, validate schemas, and discover collections
|
|
49
50
|
const lexicons = loadLexicons(resolve(configDir, 'lexicons'));
|
|
50
|
-
const lexiconErrors = validateLexicons(
|
|
51
|
+
const lexiconErrors = validateLexicons(validatableLexicons(lexicons));
|
|
51
52
|
if (lexiconErrors) {
|
|
52
53
|
for (const [nsid, errors] of Object.entries(lexiconErrors)) {
|
|
53
54
|
for (const err of errors) {
|
|
@@ -180,6 +181,18 @@ const handler = createHandler({
|
|
|
180
181
|
oauth: config.oauth,
|
|
181
182
|
admins: config.admins,
|
|
182
183
|
onResync: runBackfillAndRestart,
|
|
184
|
+
...(config.spaces?.serviceDid
|
|
185
|
+
? {
|
|
186
|
+
spaces: {
|
|
187
|
+
serviceDid: config.spaces.serviceDid,
|
|
188
|
+
serviceFragment: config.spaces.serviceFragment ?? 'atproto_space_syncer',
|
|
189
|
+
// The origin an authority delivers to. The OAuth issuer is already
|
|
190
|
+
// the public origin an operator has had to get right, so it is the
|
|
191
|
+
// honest default rather than a second thing to configure wrong.
|
|
192
|
+
...(config.oauth?.issuer ? { publicUrl: config.oauth.issuer } : {}),
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
183
196
|
});
|
|
184
197
|
globalThis.__hatk_callXrpc = callXrpc;
|
|
185
198
|
globalThis.__hatk_parseSessionCookie = parseSessionCookie;
|
|
@@ -236,6 +249,17 @@ else {
|
|
|
236
249
|
}
|
|
237
250
|
// 7. Run backfill in background
|
|
238
251
|
runBackfillAndRestart();
|
|
252
|
+
// 8. Follow permissioned spaces, if this instance indexes any. Started after
|
|
253
|
+
// the firehose and backfill because it is neither: a space has no stream to
|
|
254
|
+
// tail, so it is a sweep on a timer rather than a source to resume.
|
|
255
|
+
if (config.spaces) {
|
|
256
|
+
startSpaces({
|
|
257
|
+
spaces: config.spaces,
|
|
258
|
+
oauth: config.oauth,
|
|
259
|
+
plc: config.plc,
|
|
260
|
+
collections: collectionSet,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
239
263
|
// Graceful shutdown
|
|
240
264
|
process.on('SIGTERM', () => {
|
|
241
265
|
log('[main] Received SIGTERM, shutting down...');
|
package/dist/oauth/db.d.ts
CHANGED
|
@@ -38,6 +38,15 @@ export declare function storeSession(did: string, data: {
|
|
|
38
38
|
tokenExpiresAt?: number;
|
|
39
39
|
}): Promise<void>;
|
|
40
40
|
export declare function getSession(did: string): Promise<any | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Every DID this instance holds a PDS session for, most recently refreshed
|
|
43
|
+
* first.
|
|
44
|
+
*
|
|
45
|
+
* Space indexing reads as a member rather than as itself, so this is the set of
|
|
46
|
+
* people it could borrow a delegation from. Ordered by recency because a
|
|
47
|
+
* session refreshed lately is the one most likely to still work.
|
|
48
|
+
*/
|
|
49
|
+
export declare function listSessionDids(): Promise<string[]>;
|
|
41
50
|
export declare function deleteSession(did: string): Promise<void>;
|
|
42
51
|
export declare function storeRefreshToken(token: string, data: {
|
|
43
52
|
clientId: string;
|
package/dist/oauth/db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/oauth/db.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/oauth/db.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth/db.js
CHANGED
|
@@ -146,6 +146,18 @@ export async function getSession(did) {
|
|
|
146
146
|
const rows = await querySQL('SELECT * FROM _oauth_sessions WHERE did = $1', [did]);
|
|
147
147
|
return rows.length > 0 ? rows[0] : null;
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Every DID this instance holds a PDS session for, most recently refreshed
|
|
151
|
+
* first.
|
|
152
|
+
*
|
|
153
|
+
* Space indexing reads as a member rather than as itself, so this is the set of
|
|
154
|
+
* people it could borrow a delegation from. Ordered by recency because a
|
|
155
|
+
* session refreshed lately is the one most likely to still work.
|
|
156
|
+
*/
|
|
157
|
+
export async function listSessionDids() {
|
|
158
|
+
const rows = (await querySQL('SELECT did FROM _oauth_sessions ORDER BY updated_at DESC, created_at DESC'));
|
|
159
|
+
return rows.map((r) => r.did);
|
|
160
|
+
}
|
|
149
161
|
export async function deleteSession(did) {
|
|
150
162
|
await runSQL('DELETE FROM _oauth_sessions WHERE did = $1', [did]);
|
|
151
163
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export declare function setPrivateCollections(list: string[]): void;
|
|
2
2
|
export declare function isPrivateCollection(nsid: string | null | undefined): boolean;
|
|
3
3
|
/**
|
|
4
|
-
* The collection segment of
|
|
5
|
-
*
|
|
4
|
+
* The collection segment of a record AT-URI, in either shape.
|
|
5
|
+
*
|
|
6
|
+
* A repo record is `at://{did}/{collection}/{rkey}`; a space record is
|
|
7
|
+
* `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
|
|
8
|
+
* the position a collection used to occupy holds the literal 'space'. Reading
|
|
9
|
+
* it positionally without that distinction reports every space record as
|
|
10
|
+
* belonging to a collection named 'space', which no guard would match.
|
|
6
11
|
*/
|
|
7
12
|
export declare function collectionFromUri(uri: string): string | undefined;
|
|
8
13
|
//# sourceMappingURL=private-collections.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"private-collections.d.ts","sourceRoot":"","sources":["../src/private-collections.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"private-collections.d.ts","sourceRoot":"","sources":["../src/private-collections.ts"],"names":[],"mappings":"AAWA,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE1D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectionFromRecordUri } from "./spaces/uri.js";
|
|
1
2
|
/**
|
|
2
3
|
* Collections that are indexed and queryable in-process but must never be
|
|
3
4
|
* served by the built-in dev.hatk.* record endpoints.
|
|
@@ -13,10 +14,14 @@ export function isPrivateCollection(nsid) {
|
|
|
13
14
|
return nsid != null && privateCollections.has(nsid);
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* The collection segment of
|
|
17
|
-
*
|
|
17
|
+
* The collection segment of a record AT-URI, in either shape.
|
|
18
|
+
*
|
|
19
|
+
* A repo record is `at://{did}/{collection}/{rkey}`; a space record is
|
|
20
|
+
* `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
|
|
21
|
+
* the position a collection used to occupy holds the literal 'space'. Reading
|
|
22
|
+
* it positionally without that distinction reports every space record as
|
|
23
|
+
* belonging to a collection named 'space', which no guard would match.
|
|
18
24
|
*/
|
|
19
25
|
export function collectionFromUri(uri) {
|
|
20
|
-
|
|
21
|
-
return parts.length > 4 ? parts[3] : undefined;
|
|
26
|
+
return collectionFromRecordUri(uri);
|
|
22
27
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ import type { OAuthConfig } from './config.ts';
|
|
|
4
4
|
* This makes them available to callXrpc() for use in SSR and server code.
|
|
5
5
|
*/
|
|
6
6
|
export declare function registerCoreHandlers(collections: string[], oauth: OAuthConfig | null): void;
|
|
7
|
+
export interface SpaceHandlerConfig {
|
|
8
|
+
serviceDid?: string;
|
|
9
|
+
serviceFragment?: string;
|
|
10
|
+
/** The origin an authority should deliver to, when it is not the request's own. */
|
|
11
|
+
publicUrl?: string;
|
|
12
|
+
}
|
|
7
13
|
export interface HandlerConfig {
|
|
8
14
|
collections: string[];
|
|
9
15
|
publicDir: string | null;
|
|
@@ -17,6 +23,8 @@ export interface HandlerConfig {
|
|
|
17
23
|
did: string;
|
|
18
24
|
} | null;
|
|
19
25
|
onResync?: () => void;
|
|
26
|
+
/** Permissioned-space notice delivery. Absent on an instance that indexes none. */
|
|
27
|
+
spaces?: SpaceHandlerConfig;
|
|
20
28
|
}
|
|
21
29
|
/**
|
|
22
30
|
* Create a Web Standard request handler for all hatk routes.
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAuE9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CA2M3F;AAgDD,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,mFAAmF;IACnF,MAAM,CAAC,EAAE,kBAAkB,CAAA;CAC5B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6pC5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, extname } from 'node:path';
|
|
|
5
5
|
import { gzipSync } from 'node:zlib';
|
|
6
6
|
import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getRepoStatusCounts, getDatabaseSize, deleteLabels, getRecentRecords, listActiveRepoDids, removeRepo, getRepoHandle, getPreferences, putPreference, insertReport, queryReports, resolveReport, getOpenReportCount, } from "./database/db.js";
|
|
7
7
|
import { executeFeed, listFeeds } from "./feeds.js";
|
|
8
|
-
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay } from "./xrpc.js";
|
|
8
|
+
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay, paramsFromSearch } from "./xrpc.js";
|
|
9
9
|
import { pdsFor } from "./backfill.js";
|
|
10
10
|
import { resolveRecords } from "./hydrate.js";
|
|
11
11
|
import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
|
|
@@ -18,6 +18,11 @@ import { getOAuthRequest, getSession } from "./oauth/db.js";
|
|
|
18
18
|
import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
|
|
19
19
|
import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
|
|
20
20
|
import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
|
|
21
|
+
import { enterReadableSpaces } from "./spaces/visibility.js";
|
|
22
|
+
import { readableSpacesFor } from "./spaces/viewer.js";
|
|
23
|
+
import { parseSpaceBlobRequest, serveSpaceBlob } from "./spaces/blob.js";
|
|
24
|
+
import { handleWriteNotice, NoticeError, parseWriteNotice, scheduleNoticeSync, spaceDidDocument, spaceServiceId, unwatchSpace, verifyNotice, } from "./spaces/index.js";
|
|
25
|
+
import { parseSpaceRef } from "./spaces/uri.js";
|
|
21
26
|
import { serve } from "./adapter.js";
|
|
22
27
|
import { renderPage } from "./renderer.js";
|
|
23
28
|
/**
|
|
@@ -253,7 +258,11 @@ export function registerCoreHandlers(collections, oauth) {
|
|
|
253
258
|
subjectDid = match[1];
|
|
254
259
|
}
|
|
255
260
|
else if (body.subject.did) {
|
|
256
|
-
|
|
261
|
+
// The bare DID, not `at://<did>`: resolving this report labels
|
|
262
|
+
// `subjectUri`, and an account label is keyed by DID alone. Ozone
|
|
263
|
+
// writes it that way and every appview reads it that way, so the
|
|
264
|
+
// `at://` form would apply a label nothing ever looks up.
|
|
265
|
+
subjectUri = body.subject.did;
|
|
257
266
|
subjectDid = body.subject.did;
|
|
258
267
|
}
|
|
259
268
|
else {
|
|
@@ -382,6 +391,19 @@ export function createHandler(config) {
|
|
|
382
391
|
}
|
|
383
392
|
catch { }
|
|
384
393
|
}
|
|
394
|
+
// Which permissioned spaces this request may be shown, established once and
|
|
395
|
+
// read by every query below. A signed-out visitor gets none — there is no
|
|
396
|
+
// anonymous read path into a space — and so does an instance that indexes
|
|
397
|
+
// none, which is every deployment that has not configured `spaces`.
|
|
398
|
+
try {
|
|
399
|
+
enterReadableSpaces(await readableSpacesFor(oauth, viewer));
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
// Failing to establish the scope must not widen it: the default is to
|
|
403
|
+
// serve no space row, and that is what an unresolved viewer gets.
|
|
404
|
+
enterReadableSpaces([]);
|
|
405
|
+
emit('spaces', 'viewer_resolve_error', { error: err.message });
|
|
406
|
+
}
|
|
385
407
|
try {
|
|
386
408
|
// GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
|
|
387
409
|
if (url.pathname === coreXrpc('getRecords')) {
|
|
@@ -449,10 +471,7 @@ export function createHandler(config) {
|
|
|
449
471
|
return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
|
|
450
472
|
const limit = parseInt(url.searchParams.get('limit') || '30');
|
|
451
473
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
452
|
-
const params =
|
|
453
|
-
for (const [key, value] of url.searchParams) {
|
|
454
|
-
params[key] = value;
|
|
455
|
-
}
|
|
474
|
+
const params = paramsFromSearch(url.searchParams);
|
|
456
475
|
const result = await executeFeed(feedName, params, cursor, limit, viewer);
|
|
457
476
|
if (!result)
|
|
458
477
|
return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
|
|
@@ -862,6 +881,12 @@ export function createHandler(config) {
|
|
|
862
881
|
}, 200, acceptEncoding));
|
|
863
882
|
}
|
|
864
883
|
// --- OAuth Endpoints ---
|
|
884
|
+
// GET /.well-known/did.json — this instance's own DID document, so a space
|
|
885
|
+
// authority can resolve where to deliver notices. Published only when a
|
|
886
|
+
// service DID is configured; it carries a service entry and no key.
|
|
887
|
+
if (url.pathname === '/.well-known/did.json' && config.spaces?.serviceDid) {
|
|
888
|
+
return withCors(json(spaceDidDocument(config.spaces.serviceDid, config.spaces.publicUrl ?? requestOrigin, config.spaces.serviceFragment ?? 'atproto_space_syncer'), 200, acceptEncoding));
|
|
889
|
+
}
|
|
865
890
|
// OAuth well-known endpoints
|
|
866
891
|
if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
|
|
867
892
|
return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
@@ -1214,14 +1239,68 @@ export function createHandler(config) {
|
|
|
1214
1239
|
return withCors(file(png, 'image/png', 'public, max-age=300'));
|
|
1215
1240
|
}
|
|
1216
1241
|
// GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
|
|
1242
|
+
// POST /xrpc/com.atproto.space.notifyWrite — a space authority telling us
|
|
1243
|
+
// one of its repos moved. Carries no records: it says look again, and the
|
|
1244
|
+
// read that follows uses our own credential like any other.
|
|
1245
|
+
//
|
|
1246
|
+
// Best-effort by protocol, so this answers before syncing and never lets
|
|
1247
|
+
// a sync failure become the authority's problem. A missed notice is
|
|
1248
|
+
// recovered by the reconcile sweep.
|
|
1249
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifyWrite' && request.method === 'POST') {
|
|
1250
|
+
const serviceId = spaceServiceId();
|
|
1251
|
+
if (!serviceId)
|
|
1252
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1253
|
+
const notice = parseWriteNotice(await request.json().catch(() => null));
|
|
1254
|
+
if (!notice)
|
|
1255
|
+
return withCors(jsonError(400, 'Expected space, repo and rev', acceptEncoding));
|
|
1256
|
+
try {
|
|
1257
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1258
|
+
iss: parseSpaceRef(notice.space).authority,
|
|
1259
|
+
aud: serviceId,
|
|
1260
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
if (err instanceof NoticeError)
|
|
1265
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1268
|
+
// Debounced per repo: one member writing a gallery sends a notice per
|
|
1269
|
+
// record, and each is an invitation to read the same repo again.
|
|
1270
|
+
scheduleNoticeSync(`${notice.space}|${notice.repo}`, () => handleWriteNotice(notice).then(() => { }));
|
|
1271
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
1272
|
+
}
|
|
1273
|
+
// POST /xrpc/com.atproto.space.notifySpaceDeleted — the authority saying a
|
|
1274
|
+
// space is gone and every copy of it should go too.
|
|
1275
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifySpaceDeleted' && request.method === 'POST') {
|
|
1276
|
+
const serviceId = spaceServiceId();
|
|
1277
|
+
if (!serviceId)
|
|
1278
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1279
|
+
const body = (await request.json().catch(() => null));
|
|
1280
|
+
const space = typeof body?.space === 'string' ? body.space : null;
|
|
1281
|
+
const ref = space ? parseSpaceRef(space) : null;
|
|
1282
|
+
if (!space || !ref)
|
|
1283
|
+
return withCors(jsonError(400, 'Expected a space', acceptEncoding));
|
|
1284
|
+
try {
|
|
1285
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1286
|
+
iss: ref.authority,
|
|
1287
|
+
aud: serviceId,
|
|
1288
|
+
lxm: 'com.atproto.space.notifySpaceDeleted',
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
if (err instanceof NoticeError)
|
|
1293
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1294
|
+
throw err;
|
|
1295
|
+
}
|
|
1296
|
+
await unwatchSpace(space);
|
|
1297
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
1298
|
+
}
|
|
1217
1299
|
if (url.pathname.startsWith('/xrpc/')) {
|
|
1218
1300
|
const method = url.pathname.slice('/xrpc/'.length);
|
|
1219
1301
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
1220
1302
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
1221
|
-
const params =
|
|
1222
|
-
for (const [key, value] of url.searchParams) {
|
|
1223
|
-
params[key] = value;
|
|
1224
|
-
}
|
|
1303
|
+
const params = paramsFromSearch(url.searchParams);
|
|
1225
1304
|
// Parse request body for POST (procedures)
|
|
1226
1305
|
let input;
|
|
1227
1306
|
if (request.method === 'POST') {
|
|
@@ -1252,6 +1331,16 @@ export function createHandler(config) {
|
|
|
1252
1331
|
throw err;
|
|
1253
1332
|
}
|
|
1254
1333
|
}
|
|
1334
|
+
// GET /space-blob?space=&repo=&cid= — a blob inside a permissioned space,
|
|
1335
|
+
// fetched with the viewer's own credential. Unlike /blob/ below it is not
|
|
1336
|
+
// dev-only and not cacheable: a space blob has no public URL by design,
|
|
1337
|
+
// and this response belongs to one viewer.
|
|
1338
|
+
if (url.pathname === '/space-blob') {
|
|
1339
|
+
const parsed = parseSpaceBlobRequest(url.searchParams);
|
|
1340
|
+
if (!parsed)
|
|
1341
|
+
return withCors(jsonError(400, 'Expected space, repo and cid', acceptEncoding));
|
|
1342
|
+
return withCors(await serveSpaceBlob(oauth, viewer, parsed));
|
|
1343
|
+
}
|
|
1255
1344
|
// GET /blob/:did/:cid — dev-only image proxy (see blobUrl in xrpc.ts).
|
|
1256
1345
|
// A PDS serves getBlob as an attachment with nosniff, which a browser
|
|
1257
1346
|
// refuses to render in <img>; re-serving it from here drops that and
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a blob that lives in a permissioned space.
|
|
3
|
+
*
|
|
4
|
+
* Every other image hatk hands out has a public URL — a CDN path, or the
|
|
5
|
+
* repo's own `com.atproto.sync.getBlob`. A space's blob has neither, by
|
|
6
|
+
* design: `com.atproto.space.getBlob` serves it only to a credential holder,
|
|
7
|
+
* and only for a space the blob is actually referenced from. Giving one a
|
|
8
|
+
* public URL would turn that URL into the capability the credential exists to
|
|
9
|
+
* replace.
|
|
10
|
+
*
|
|
11
|
+
* So the bytes come through here, fetched on the viewer's own behalf with the
|
|
12
|
+
* viewer's own credential, and go no further: `private, no-store` keeps them
|
|
13
|
+
* out of every shared cache between this server and that browser.
|
|
14
|
+
*/
|
|
15
|
+
import type { OAuthConfig } from '../config.ts';
|
|
16
|
+
/**
|
|
17
|
+
* The URL this appview serves a space blob at, for whoever is asking.
|
|
18
|
+
*
|
|
19
|
+
* Built here rather than by the app, because the route is hatk's: a blob in a
|
|
20
|
+
* space has no public address by design, and this is the one address it does
|
|
21
|
+
* have — answered per viewer, with that viewer's credential, uncacheable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function spaceBlobUrl(space: string, repo: string, cid: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The CID a blob reference names, in whichever spelling the read used.
|
|
26
|
+
*
|
|
27
|
+
* A repo read gives the lexicon's `{ ref: { $link } }`; a space read comes
|
|
28
|
+
* back through a lex client that decodes the same field into a CID object
|
|
29
|
+
* whose `/` is the string. One blob, two spellings, and no caller should have
|
|
30
|
+
* to know which it got.
|
|
31
|
+
*/
|
|
32
|
+
export declare function blobCid(blob: unknown): string | undefined;
|
|
33
|
+
export interface SpaceBlobRequest {
|
|
34
|
+
space: string;
|
|
35
|
+
/** The repo holding the blob — the writer's, not the authority's. */
|
|
36
|
+
repo: string;
|
|
37
|
+
cid: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function parseSpaceBlobRequest(params: URLSearchParams): SpaceBlobRequest | null;
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a space blob as this viewer, or answer why not.
|
|
42
|
+
*
|
|
43
|
+
* A viewer who cannot read the space gets the same 404 as a blob that is not
|
|
44
|
+
* there. The distinction between "you may not" and "it does not exist" is
|
|
45
|
+
* itself information about a private space, and the space host draws the line
|
|
46
|
+
* the same way when it reports a non-member's repo as simply absent.
|
|
47
|
+
*/
|
|
48
|
+
export declare function serveSpaceBlob(oauth: OAuthConfig | null, viewer: {
|
|
49
|
+
did: string;
|
|
50
|
+
} | null, request: SpaceBlobRequest): Promise<Response>;
|
|
51
|
+
//# sourceMappingURL=blob.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blob.d.ts","sourceRoot":"","sources":["../../src/spaces/blob.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAe/C;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG7E;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAMzD;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,eAAe,GAAG,gBAAgB,GAAG,IAAI,CAOtF;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC9B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,QAAQ,CAAC,CAsCnB"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a blob that lives in a permissioned space.
|
|
3
|
+
*
|
|
4
|
+
* Every other image hatk hands out has a public URL — a CDN path, or the
|
|
5
|
+
* repo's own `com.atproto.sync.getBlob`. A space's blob has neither, by
|
|
6
|
+
* design: `com.atproto.space.getBlob` serves it only to a credential holder,
|
|
7
|
+
* and only for a space the blob is actually referenced from. Giving one a
|
|
8
|
+
* public URL would turn that URL into the capability the credential exists to
|
|
9
|
+
* replace.
|
|
10
|
+
*
|
|
11
|
+
* So the bytes come through here, fetched on the viewer's own behalf with the
|
|
12
|
+
* viewer's own credential, and go no further: `private, no-store` keeps them
|
|
13
|
+
* out of every shared cache between this server and that browser.
|
|
14
|
+
*/
|
|
15
|
+
import { emit } from "../logger.js";
|
|
16
|
+
import { repoEndpoint } from "./identity.js";
|
|
17
|
+
import { parseSpaceRef } from "./uri.js";
|
|
18
|
+
import { viewerCredential } from "./viewer.js";
|
|
19
|
+
/**
|
|
20
|
+
* What we are willing to hand back, whatever the repo claims it is.
|
|
21
|
+
*
|
|
22
|
+
* The record naming this blob was written by an account hatk does not control,
|
|
23
|
+
* and so was its mime type. Echoing that unchecked would let a writer choose
|
|
24
|
+
* what a browser executes in this origin.
|
|
25
|
+
*/
|
|
26
|
+
const SERVABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']);
|
|
27
|
+
/**
|
|
28
|
+
* The URL this appview serves a space blob at, for whoever is asking.
|
|
29
|
+
*
|
|
30
|
+
* Built here rather than by the app, because the route is hatk's: a blob in a
|
|
31
|
+
* space has no public address by design, and this is the one address it does
|
|
32
|
+
* have — answered per viewer, with that viewer's credential, uncacheable.
|
|
33
|
+
*/
|
|
34
|
+
export function spaceBlobUrl(space, repo, cid) {
|
|
35
|
+
const params = new URLSearchParams({ space, repo, cid });
|
|
36
|
+
return `/space-blob?${params}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The CID a blob reference names, in whichever spelling the read used.
|
|
40
|
+
*
|
|
41
|
+
* A repo read gives the lexicon's `{ ref: { $link } }`; a space read comes
|
|
42
|
+
* back through a lex client that decodes the same field into a CID object
|
|
43
|
+
* whose `/` is the string. One blob, two spellings, and no caller should have
|
|
44
|
+
* to know which it got.
|
|
45
|
+
*/
|
|
46
|
+
export function blobCid(blob) {
|
|
47
|
+
const ref = blob?.ref;
|
|
48
|
+
if (!ref || typeof ref !== 'object')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (typeof ref.$link === 'string')
|
|
51
|
+
return ref.$link;
|
|
52
|
+
if (typeof ref['/'] === 'string')
|
|
53
|
+
return ref['/'];
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
export function parseSpaceBlobRequest(params) {
|
|
57
|
+
const space = params.get('space');
|
|
58
|
+
const repo = params.get('repo') ?? params.get('did');
|
|
59
|
+
const cid = params.get('cid');
|
|
60
|
+
if (!space || !repo || !cid)
|
|
61
|
+
return null;
|
|
62
|
+
if (!parseSpaceRef(space) || !repo.startsWith('did:'))
|
|
63
|
+
return null;
|
|
64
|
+
return { space, repo, cid };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch a space blob as this viewer, or answer why not.
|
|
68
|
+
*
|
|
69
|
+
* A viewer who cannot read the space gets the same 404 as a blob that is not
|
|
70
|
+
* there. The distinction between "you may not" and "it does not exist" is
|
|
71
|
+
* itself information about a private space, and the space host draws the line
|
|
72
|
+
* the same way when it reports a non-member's repo as simply absent.
|
|
73
|
+
*/
|
|
74
|
+
export async function serveSpaceBlob(oauth, viewer, request) {
|
|
75
|
+
if (!viewer)
|
|
76
|
+
return new Response('Unauthorized', { status: 401 });
|
|
77
|
+
const credential = await viewerCredential(oauth, viewer, request.space);
|
|
78
|
+
if (!credential)
|
|
79
|
+
return new Response('Not found', { status: 404 });
|
|
80
|
+
let upstream;
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(`${await repoEndpoint(request.repo)}/xrpc/com.atproto.space.getBlob`);
|
|
83
|
+
url.searchParams.set('space', request.space);
|
|
84
|
+
// `repo`, not `did`: a space read names the repo holding the record the
|
|
85
|
+
// same way everywhere. A server hosting one account falls back to it,
|
|
86
|
+
// which is what makes `did` ever appear to work.
|
|
87
|
+
url.searchParams.set('repo', request.repo);
|
|
88
|
+
url.searchParams.set('cid', request.cid);
|
|
89
|
+
upstream = await credential.fetch(url);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
emit('spaces', 'blob_error', { space: request.space, repo: request.repo, error: err.message });
|
|
93
|
+
return new Response('Blob unavailable', { status: 502 });
|
|
94
|
+
}
|
|
95
|
+
if (!upstream.ok)
|
|
96
|
+
return new Response('Not found', { status: upstream.status === 401 ? 404 : upstream.status });
|
|
97
|
+
const claimed = (upstream.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
98
|
+
const contentType = SERVABLE.has(claimed) ? claimed : 'application/octet-stream';
|
|
99
|
+
return new Response(upstream.body, {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: {
|
|
102
|
+
'content-type': contentType,
|
|
103
|
+
// Not a public cache: this response is one viewer's, and the next
|
|
104
|
+
// request for the same URL may be somebody with no right to it.
|
|
105
|
+
'cache-control': 'private, no-store',
|
|
106
|
+
'content-disposition': 'inline',
|
|
107
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
108
|
+
'x-content-type-options': 'nosniff',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 type { OAuthConfig } from '../config.ts';
|
|
29
|
+
export declare class SpaceCredentialError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code: string;
|
|
32
|
+
constructor(status: number, code: string, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** A refusal about who is asking, rather than about the space or the network. */
|
|
35
|
+
export declare function isNotAuthorized(err: unknown): boolean;
|
|
36
|
+
export declare function isSpaceGone(err: unknown): boolean;
|
|
37
|
+
export interface SpaceCredential {
|
|
38
|
+
/** The space this credential reads, and the only one it is accepted for. */
|
|
39
|
+
space: string;
|
|
40
|
+
/** The account whose delegation bought it. */
|
|
41
|
+
readerDid: string;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
/** A fetch that presents the credential with a fresh DPoP proof per request. */
|
|
44
|
+
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
/** Mint a credential for `space` using `readerDid`'s stored session. */
|
|
47
|
+
export declare function mintSpaceCredential(oauthConfig: OAuthConfig, space: string, readerDid: string): Promise<SpaceCredential>;
|
|
48
|
+
/**
|
|
49
|
+
* A credential for `space`, minted with whichever candidate session works.
|
|
50
|
+
*
|
|
51
|
+
* Candidates are tried in order and the first that succeeds is cached. A
|
|
52
|
+
* refusal about the reader — they were ejected, or never belonged — moves on to
|
|
53
|
+
* the next candidate; a refusal about the space itself stops the whole attempt,
|
|
54
|
+
* since no other reader would fare better.
|
|
55
|
+
*/
|
|
56
|
+
export declare function getSpaceCredential(oauthConfig: OAuthConfig, space: string, candidates: string[], opts?: {
|
|
57
|
+
refresh?: boolean;
|
|
58
|
+
}): Promise<SpaceCredential>;
|
|
59
|
+
export declare function forgetSpaceCredential(space: string): void;
|
|
60
|
+
/** For tests: drop every cached credential and the process binding key. */
|
|
61
|
+
export declare function resetSpaceCredentials(): void;
|
|
62
|
+
//# sourceMappingURL=credential.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential.d.ts","sourceRoot":"","sources":["../../src/spaces/credential.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAsB/C,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAErD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtE;AAiDD,wEAAwE;AACxE,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAiC1B;AAUD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,CAAC,CAwB1B;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C"}
|