@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.90

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 (218) 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 +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +137 -51
  7. package/dist/car.d.ts +20 -0
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +108 -0
  10. package/dist/cli.js +247 -1043
  11. package/dist/config.d.ts +145 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +61 -8
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +76 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +68 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/database/db.js +1775 -0
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +122 -34
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +16 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +126 -44
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +127 -0
  50. package/dist/feeds.d.ts +26 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +72 -16
  53. package/dist/hooks.d.ts +102 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +186 -0
  56. package/dist/hydrate.d.ts +49 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +21 -20
  59. package/dist/indexer.d.ts +156 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +539 -94
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lex-types.d.ts +2 -0
  69. package/dist/lex-types.d.ts.map +1 -1
  70. package/dist/lexicon-resolve.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.js +27 -112
  72. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  73. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  74. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  75. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  76. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  77. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  78. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  79. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  80. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  81. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  82. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  83. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  84. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  85. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  86. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  87. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  88. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  89. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  90. package/dist/logger.d.ts +29 -0
  91. package/dist/logger.d.ts.map +1 -1
  92. package/dist/logger.js +29 -0
  93. package/dist/main.js +198 -75
  94. package/dist/mst.d.ts +15 -0
  95. package/dist/mst.d.ts.map +1 -1
  96. package/dist/mst.js +13 -0
  97. package/dist/oauth/db.d.ts +16 -1
  98. package/dist/oauth/db.d.ts.map +1 -1
  99. package/dist/oauth/db.js +74 -19
  100. package/dist/oauth/discovery.d.ts +10 -0
  101. package/dist/oauth/discovery.d.ts.map +1 -1
  102. package/dist/oauth/discovery.js +54 -2
  103. package/dist/oauth/server.d.ts +93 -1
  104. package/dist/oauth/server.d.ts.map +1 -1
  105. package/dist/oauth/server.js +416 -39
  106. package/dist/oauth/service-describe.d.ts +42 -0
  107. package/dist/oauth/service-describe.d.ts.map +1 -0
  108. package/dist/oauth/service-describe.js +108 -0
  109. package/dist/oauth/session.d.ts +32 -0
  110. package/dist/oauth/session.d.ts.map +1 -0
  111. package/dist/oauth/session.js +131 -0
  112. package/dist/opengraph.d.ts +10 -0
  113. package/dist/opengraph.d.ts.map +1 -1
  114. package/dist/opengraph.js +94 -42
  115. package/dist/pds-proxy.d.ts +82 -0
  116. package/dist/pds-proxy.d.ts.map +1 -0
  117. package/dist/pds-proxy.js +356 -0
  118. package/dist/private-collections.d.ts +13 -0
  119. package/dist/private-collections.d.ts.map +1 -0
  120. package/dist/private-collections.js +27 -0
  121. package/dist/push.d.ts +54 -0
  122. package/dist/push.d.ts.map +1 -0
  123. package/dist/push.js +367 -0
  124. package/dist/renderer.d.ts +27 -0
  125. package/dist/renderer.d.ts.map +1 -0
  126. package/dist/renderer.js +46 -0
  127. package/dist/resolve-hatk.d.ts +6 -0
  128. package/dist/resolve-hatk.d.ts.map +1 -0
  129. package/dist/resolve-hatk.js +20 -0
  130. package/dist/response.d.ts +16 -0
  131. package/dist/response.d.ts.map +1 -0
  132. package/dist/response.js +69 -0
  133. package/dist/scanner.d.ts +21 -0
  134. package/dist/scanner.d.ts.map +1 -0
  135. package/dist/scanner.js +88 -0
  136. package/dist/seed.d.ts +19 -0
  137. package/dist/seed.d.ts.map +1 -1
  138. package/dist/seed.js +43 -4
  139. package/dist/server-init.d.ts +8 -0
  140. package/dist/server-init.d.ts.map +1 -0
  141. package/dist/server-init.js +62 -0
  142. package/dist/server.d.ts +34 -3
  143. package/dist/server.d.ts.map +1 -1
  144. package/dist/server.js +1021 -649
  145. package/dist/setup.d.ts +28 -1
  146. package/dist/setup.d.ts.map +1 -1
  147. package/dist/setup.js +50 -3
  148. package/dist/spaces/blob.d.ts +51 -0
  149. package/dist/spaces/blob.d.ts.map +1 -0
  150. package/dist/spaces/blob.js +111 -0
  151. package/dist/spaces/credential.d.ts +62 -0
  152. package/dist/spaces/credential.d.ts.map +1 -0
  153. package/dist/spaces/credential.js +184 -0
  154. package/dist/spaces/engine.d.ts +97 -0
  155. package/dist/spaces/engine.d.ts.map +1 -0
  156. package/dist/spaces/engine.js +559 -0
  157. package/dist/spaces/guard.d.ts +41 -0
  158. package/dist/spaces/guard.d.ts.map +1 -0
  159. package/dist/spaces/guard.js +79 -0
  160. package/dist/spaces/identity.d.ts +37 -0
  161. package/dist/spaces/identity.d.ts.map +1 -0
  162. package/dist/spaces/identity.js +88 -0
  163. package/dist/spaces/index.d.ts +55 -0
  164. package/dist/spaces/index.d.ts.map +1 -0
  165. package/dist/spaces/index.js +133 -0
  166. package/dist/spaces/notify.d.ts +51 -0
  167. package/dist/spaces/notify.d.ts.map +1 -0
  168. package/dist/spaces/notify.js +121 -0
  169. package/dist/spaces/records.d.ts +37 -0
  170. package/dist/spaces/records.d.ts.map +1 -0
  171. package/dist/spaces/records.js +63 -0
  172. package/dist/spaces/store.d.ts +51 -0
  173. package/dist/spaces/store.d.ts.map +1 -0
  174. package/dist/spaces/store.js +99 -0
  175. package/dist/spaces/uri.d.ts +60 -0
  176. package/dist/spaces/uri.d.ts.map +1 -0
  177. package/dist/spaces/uri.js +97 -0
  178. package/dist/spaces/verify.d.ts +39 -0
  179. package/dist/spaces/verify.d.ts.map +1 -0
  180. package/dist/spaces/verify.js +87 -0
  181. package/dist/spaces/viewer.d.ts +51 -0
  182. package/dist/spaces/viewer.d.ts.map +1 -0
  183. package/dist/spaces/viewer.js +167 -0
  184. package/dist/spaces/visibility.d.ts +57 -0
  185. package/dist/spaces/visibility.d.ts.map +1 -0
  186. package/dist/spaces/visibility.js +81 -0
  187. package/dist/templates/feed.tpl +18 -0
  188. package/dist/templates/hook.tpl +5 -0
  189. package/dist/templates/label.tpl +15 -0
  190. package/dist/templates/og.tpl +17 -0
  191. package/dist/templates/seed.tpl +11 -0
  192. package/dist/templates/setup.tpl +5 -0
  193. package/dist/templates/test-feed.tpl +19 -0
  194. package/dist/templates/test-xrpc.tpl +19 -0
  195. package/dist/templates/xrpc.tpl +53 -0
  196. package/dist/test.d.ts +3 -1
  197. package/dist/test.d.ts.map +1 -1
  198. package/dist/test.js +60 -36
  199. package/dist/views.js +1 -1
  200. package/dist/vite-plugin.d.ts +1 -1
  201. package/dist/vite-plugin.d.ts.map +1 -1
  202. package/dist/vite-plugin.js +254 -66
  203. package/dist/xrpc.d.ts +101 -12
  204. package/dist/xrpc.d.ts.map +1 -1
  205. package/dist/xrpc.js +255 -55
  206. package/package.json +21 -9
  207. package/public/admin-auth.js +271 -306
  208. package/public/admin.html +177 -61
  209. package/dist/db.d.ts.map +0 -1
  210. package/dist/db.js +0 -1361
  211. package/dist/fts.d.ts.map +0 -1
  212. package/dist/oauth/hooks.d.ts +0 -10
  213. package/dist/oauth/hooks.d.ts.map +0 -1
  214. package/dist/oauth/hooks.js +0 -40
  215. package/dist/schema.d.ts.map +0 -1
  216. package/dist/test-browser.d.ts +0 -14
  217. package/dist/test-browser.d.ts.map +0 -1
  218. package/dist/test-browser.js +0 -26
package/dist/server.js CHANGED
@@ -1,100 +1,436 @@
1
- import { createServer } from 'node:http';
2
- import { gzipSync } from 'node:zlib';
1
+ import { createHash } from 'node:crypto';
3
2
  import { existsSync } from 'node:fs';
4
3
  import { readFile } from 'node:fs/promises';
5
4
  import { join, extname } from 'node:path';
6
- import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, querySQL, insertRecord, deleteRecord, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, normalizeValue, getSchemaDump, getPreferences, putPreference, } from "./db.js";
5
+ import { gzipSync } from 'node:zlib';
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 } from "./xrpc.js";
9
- import { getLexiconArray } from "./schema.js";
10
- import { validateRecord } from '@bigmoves/lexicon';
8
+ import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay, paramsFromSearch } from "./xrpc.js";
9
+ import { pdsFor } from "./backfill.js";
11
10
  import { resolveRecords } from "./hydrate.js";
12
11
  import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
13
12
  import { getLabelDefinitions, rescanLabels } from "./labels.js";
14
13
  import { triggerAutoBackfill } from "./indexer.js";
15
- import { log, emit, timer } from "./logger.js";
16
- import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, handleToken, authenticate, refreshPdsSession, } from "./oauth/server.js";
17
- import { getOAuthRequest } from "./oauth/db.js";
18
- import { createDpopProof } from "./oauth/dpop.js";
19
- import { getServerKey, getSession } from "./oauth/db.js";
14
+ import { emit, timer } from "./logger.js";
15
+ import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, getClientJwks, handlePar, buildAuthorizeRedirect, handleCallback, handleCallbackError, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
16
+ import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, createAccountsCookie, accountsCookieHeader, clearAccountsCookieHeader, parseAccountsCookie, withAccount, withoutAccount, } from "./oauth/session.js";
17
+ import { getOAuthRequest, getSession } from "./oauth/db.js";
18
+ import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
19
+ import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
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";
26
+ import { serve } from "./adapter.js";
27
+ import { renderPage } from "./renderer.js";
28
+ /**
29
+ * Parse a client-supplied `limit`, or null if it is not a usable one.
30
+ *
31
+ * `parseInt` alone lets `limit=abc` through as NaN and `limit=-5` through as a
32
+ * negative bound, both of which reach the database driver: the first as a
33
+ * datatype error, the second as "no limit at all" on SQLite. Neither is a
34
+ * server fault, so the caller turns a null into a 400.
35
+ */
36
+ function parseLimit(raw, fallback) {
37
+ if (raw === null || raw === '')
38
+ return fallback;
39
+ if (!/^\d+$/.test(raw))
40
+ return null;
41
+ const value = Number(raw);
42
+ return value >= 1 ? value : null;
43
+ }
44
+ /** Whether `sort` names a column queryRecords can order this collection by. */
45
+ function isSortableField(collection, sort) {
46
+ if (sort === 'indexed_at')
47
+ return true;
48
+ const schema = getSchema(collection);
49
+ return !!schema?.columns.some((c) => c.originalName === sort || c.name === sort);
50
+ }
51
+ /** Narrow a client-supplied `order` to the two values that may reach ORDER BY. */
52
+ function parseOrder(raw) {
53
+ if (!raw)
54
+ return undefined;
55
+ const lowered = raw.toLowerCase();
56
+ return lowered === 'asc' || lowered === 'desc' ? lowered : null;
57
+ }
58
+ function scopeMissingResponse(acceptEncoding, handle) {
59
+ const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
60
+ res.headers.append('Set-Cookie', clearSessionCookieHeader());
61
+ return res;
62
+ }
20
63
  const MIME = {
21
64
  '.html': 'text/html',
22
65
  '.js': 'application/javascript',
23
66
  '.css': 'text/css',
24
67
  '.json': 'application/json',
25
68
  };
26
- function readBody(req) {
27
- return new Promise((resolve, reject) => {
28
- let body = '';
29
- req.on('data', (chunk) => (body += chunk));
30
- req.on('end', () => resolve(body));
31
- req.on('error', reject);
69
+ /**
70
+ * Register built-in dev.hatk.* XRPC handlers in the handler registry.
71
+ * This makes them available to callXrpc() for use in SSR and server code.
72
+ */
73
+ export function registerCoreHandlers(collections, oauth) {
74
+ registerCoreXrpcHandler('dev.hatk.getRecords', async (params, cursor, limit) => {
75
+ const collection = params.collection;
76
+ if (!collection)
77
+ throw new InvalidRequestError('Missing collection parameter');
78
+ if (isPrivateCollection(collection))
79
+ throw new NotFoundError(`Unknown collection: ${collection}`);
80
+ if (!getSchema(collection))
81
+ throw new NotFoundError(`Unknown collection: ${collection}`);
82
+ const sort = params.sort || undefined;
83
+ if (sort && !isSortableField(collection, sort))
84
+ throw new InvalidRequestError(`Invalid sort field: ${sort}`);
85
+ const order = parseOrder(params.order);
86
+ if (order === null)
87
+ throw new InvalidRequestError(`Invalid order: ${params.order}`);
88
+ const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
89
+ const filters = {};
90
+ for (const [key, value] of Object.entries(params)) {
91
+ if (!reserved.has(key))
92
+ filters[key] = value;
93
+ }
94
+ const result = await queryRecords(collection, {
95
+ limit,
96
+ cursor,
97
+ sort,
98
+ order,
99
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
100
+ });
101
+ const uris = result.records.map((r) => r.uri);
102
+ const items = await resolveRecords(uris);
103
+ return { items, cursor: result.cursor };
32
104
  });
33
- }
34
- function readBodyRaw(req) {
35
- return new Promise((resolve, reject) => {
36
- const chunks = [];
37
- req.on('data', (chunk) => chunks.push(chunk));
38
- req.on('end', () => resolve(Buffer.concat(chunks)));
39
- req.on('error', reject);
105
+ registerCoreXrpcHandler('dev.hatk.getRecord', async (params) => {
106
+ const uri = params.uri;
107
+ if (!uri)
108
+ throw new InvalidRequestError('Missing uri parameter');
109
+ if (isPrivateCollection(collectionFromUri(uri)))
110
+ throw new NotFoundError('Record not found');
111
+ const record = await getRecordByUri(uri);
112
+ if (!record)
113
+ throw new NotFoundError('Record not found');
114
+ const shaped = reshapeRow(record, record?.__childData);
115
+ const labelsMap = await queryLabelsForUris([record.uri]);
116
+ if (shaped)
117
+ shaped.labels = labelsMap.get(record.uri) || [];
118
+ return { record: shaped };
119
+ });
120
+ registerCoreXrpcHandler('dev.hatk.getFeed', async (params, cursor, limit, viewer) => {
121
+ const feedName = params.feed;
122
+ if (!feedName)
123
+ throw new InvalidRequestError('Missing feed parameter');
124
+ const result = await executeFeed(feedName, params, cursor, limit, viewer);
125
+ if (!result)
126
+ throw new NotFoundError(`Unknown feed: ${feedName}`);
127
+ return result;
128
+ });
129
+ registerCoreXrpcHandler('dev.hatk.searchRecords', async (params, cursor, limit) => {
130
+ const collection = params.collection;
131
+ const q = params.q;
132
+ if (!collection)
133
+ throw new InvalidRequestError('Missing collection parameter');
134
+ if (!q)
135
+ throw new InvalidRequestError('Missing q parameter');
136
+ if (isPrivateCollection(collection))
137
+ throw new NotFoundError(`Unknown collection: ${collection}`);
138
+ if (!getSchema(collection))
139
+ throw new NotFoundError(`Unknown collection: ${collection}`);
140
+ const fuzzy = params.fuzzy !== 'false';
141
+ const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
142
+ const uris = result.records.map((r) => r.uri);
143
+ const items = await resolveRecords(uris);
144
+ return { items, cursor: result.cursor };
145
+ });
146
+ registerCoreXrpcHandler('dev.hatk.describeFeeds', async () => {
147
+ return { feeds: listFeeds() };
40
148
  });
149
+ registerCoreXrpcHandler('dev.hatk.describeCollections', async () => {
150
+ const collectionInfo = collections
151
+ .filter((c) => !isPrivateCollection(c))
152
+ .map((c) => {
153
+ const schema = getSchema(c);
154
+ return {
155
+ collection: c,
156
+ columns: schema?.columns.map((col) => ({
157
+ name: col.name,
158
+ originalName: col.originalName,
159
+ type: col.sqlType,
160
+ required: col.notNull,
161
+ })),
162
+ };
163
+ });
164
+ return { collections: collectionInfo };
165
+ });
166
+ registerCoreXrpcHandler('dev.hatk.describeLabels', async () => {
167
+ return { definitions: getLabelDefinitions() };
168
+ });
169
+ // Write operations — proxy to user's PDS
170
+ if (oauth) {
171
+ registerCoreXrpcHandler('dev.hatk.getPreferences', async (_params, _cursor, _limit, viewer) => {
172
+ if (!viewer)
173
+ throw new InvalidRequestError('Authentication required');
174
+ const prefs = await getPreferences(viewer.did);
175
+ return { preferences: prefs };
176
+ });
177
+ registerCoreXrpcHandler('dev.hatk.putPreference', async (_params, _cursor, _limit, viewer, input) => {
178
+ if (!viewer)
179
+ throw new InvalidRequestError('Authentication required');
180
+ const body = input;
181
+ if (!body.key || typeof body.key !== 'string')
182
+ throw new InvalidRequestError('Missing or invalid key');
183
+ if (body.value === undefined)
184
+ throw new InvalidRequestError('Missing value');
185
+ await putPreference(viewer.did, body.key, body.value);
186
+ return { success: true };
187
+ });
188
+ registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => {
189
+ if (!viewer)
190
+ throw new InvalidRequestError('Authentication required');
191
+ return pdsCreateRecord(oauth, viewer, input);
192
+ });
193
+ registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => {
194
+ if (!viewer)
195
+ throw new InvalidRequestError('Authentication required');
196
+ return pdsDeleteRecord(oauth, viewer, input);
197
+ });
198
+ registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => {
199
+ if (!viewer)
200
+ throw new InvalidRequestError('Authentication required');
201
+ return pdsPutRecord(oauth, viewer, input);
202
+ });
203
+ registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => {
204
+ if (!viewer)
205
+ throw new InvalidRequestError('Authentication required');
206
+ return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
207
+ });
208
+ registerCoreXrpcHandler('dev.hatk.applyWrites', async (_params, _cursor, _limit, viewer, input) => {
209
+ if (!viewer)
210
+ throw new InvalidRequestError('Authentication required');
211
+ return pdsApplyWrites(oauth, viewer, input);
212
+ });
213
+ registerCoreXrpcHandler('dev.hatk.push.registerToken', async (_params, _cursor, _limit, viewer, input) => {
214
+ if (!viewer)
215
+ throw new InvalidRequestError('Authentication required');
216
+ const body = input;
217
+ if (!body.token || typeof body.token !== 'string')
218
+ throw new InvalidRequestError('Missing or invalid token');
219
+ const platform = body.platform || 'apns';
220
+ if (!['apns', 'fcm', 'web'].includes(platform))
221
+ throw new InvalidRequestError('Invalid platform');
222
+ const { registerToken } = await import("./push.js");
223
+ await registerToken(viewer.did, body.token, platform);
224
+ return { success: true };
225
+ });
226
+ registerCoreXrpcHandler('dev.hatk.push.unregisterToken', async (_params, _cursor, _limit, viewer, input) => {
227
+ if (!viewer)
228
+ throw new InvalidRequestError('Authentication required');
229
+ const body = input;
230
+ if (!body.token || typeof body.token !== 'string')
231
+ throw new InvalidRequestError('Missing or invalid token');
232
+ const { unregisterToken } = await import("./push.js");
233
+ await unregisterToken(viewer.did, body.token);
234
+ return { success: true };
235
+ });
236
+ registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
237
+ if (!viewer)
238
+ throw new InvalidRequestError('Authentication required');
239
+ const body = input;
240
+ if (!body.subject)
241
+ throw new InvalidRequestError('Missing subject');
242
+ if (!body.label || typeof body.label !== 'string')
243
+ throw new InvalidRequestError('Missing or invalid label');
244
+ const defs = getLabelDefinitions();
245
+ if (!defs.some((d) => d.identifier === body.label)) {
246
+ throw new InvalidRequestError(`Unknown label: ${body.label}`);
247
+ }
248
+ if (body.reason && body.reason.length > 2000) {
249
+ throw new InvalidRequestError('Reason must be 2000 characters or less');
250
+ }
251
+ let subjectUri;
252
+ let subjectDid;
253
+ if (body.subject.uri) {
254
+ subjectUri = body.subject.uri;
255
+ const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/);
256
+ if (!match)
257
+ throw new InvalidRequestError('Invalid subject URI');
258
+ subjectDid = match[1];
259
+ }
260
+ else if (body.subject.did) {
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;
266
+ subjectDid = body.subject.did;
267
+ }
268
+ else {
269
+ throw new InvalidRequestError('Subject must have uri or did');
270
+ }
271
+ const result = await insertReport({
272
+ subjectUri,
273
+ subjectDid,
274
+ label: body.label,
275
+ reason: body.reason,
276
+ reportedBy: viewer.did,
277
+ });
278
+ return {
279
+ id: result.id,
280
+ subject: body.subject,
281
+ label: body.label,
282
+ reason: body.reason || null,
283
+ reportedBy: viewer.did,
284
+ createdAt: new Date().toISOString(),
285
+ };
286
+ });
287
+ }
41
288
  }
42
- export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer) {
43
- const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
289
+ /**
290
+ * Assets we ship inside the package never change while the process is alive, so
291
+ * read, gzip and hash them once. Repeat visits then cost a 304 instead of the
292
+ * full (uncompressed) payload.
293
+ */
294
+ const bundledAssets = new Map();
295
+ async function serveBundledAsset(path, contentType, request, devMode) {
296
+ let asset = devMode ? undefined : bundledAssets.get(path);
297
+ if (!asset) {
298
+ const raw = await readFile(path);
299
+ asset = {
300
+ raw,
301
+ gzip: gzipSync(raw),
302
+ etag: `"${createHash('sha256').update(raw).digest('base64url').slice(0, 16)}"`,
303
+ };
304
+ if (!devMode)
305
+ bundledAssets.set(path, asset);
306
+ }
307
+ const headers = { ETag: asset.etag, 'Cache-Control': 'no-cache' };
308
+ if (request.headers.get('if-none-match') === asset.etag) {
309
+ return new Response(null, { status: 304, headers });
310
+ }
311
+ const acceptEncoding = request.headers.get('accept-encoding');
312
+ const useGzip = !!acceptEncoding && /\bgzip\b/.test(acceptEncoding);
313
+ if (useGzip) {
314
+ headers['Content-Encoding'] = 'gzip';
315
+ headers.Vary = 'Accept-Encoding';
316
+ }
317
+ headers['Content-Type'] = contentType;
318
+ return new Response(Buffer.from(useGzip ? asset.gzip : asset.raw), { status: 200, headers });
319
+ }
320
+ /**
321
+ * Create a Web Standard request handler for all hatk routes.
322
+ * Returns a pure function: (Request) → Promise<Response>
323
+ */
324
+ export function createHandler(config) {
325
+ const { collections, publicDir, oauth, admins } = config;
44
326
  const devMode = process.env.DEV_MODE === '1';
45
- function requireAdmin(viewer, res) {
46
- if (!viewer) {
47
- jsonError(res, 401, 'Authentication required');
48
- return false;
49
- }
50
- if (!devMode && !admins.includes(viewer.did)) {
51
- jsonError(res, 403, 'Admin access required');
52
- return false;
53
- }
54
- return true;
327
+ const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
328
+ function requireAdmin(viewer, acceptEncoding) {
329
+ if (!viewer)
330
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
331
+ if (!devMode && !admins.includes(viewer.did))
332
+ return withCors(jsonError(403, 'Admin access required', acceptEncoding));
333
+ return null; // auth OK
55
334
  }
56
- const server = createServer(async (req, res) => {
57
- const url = new URL(req.url, `http://localhost:${port}`);
58
- res.setHeader('Access-Control-Allow-Origin', '*');
59
- res.setHeader('Access-Control-Allow-Headers', '*');
60
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
61
- if (req.method === 'OPTIONS') {
62
- res.writeHead(200);
63
- res.end();
64
- return;
65
- }
335
+ // The /admin/info rollups count every row in every collection table, so they get
336
+ // slower as the index grows. Cache them for a few seconds — that collapses the
337
+ // overview's repeat loads and any concurrent requests into one scan — and drop
338
+ // the cache whenever an admin action changes what they report.
339
+ const ADMIN_STATS_TTL_MS = 10_000;
340
+ let adminStats = null;
341
+ function getAdminStats() {
342
+ if (adminStats && Date.now() - adminStats.at < ADMIN_STATS_TTL_MS)
343
+ return adminStats.data;
344
+ const data = (async () => {
345
+ const [repos, duckdb, collections, openReports] = await Promise.all([
346
+ getRepoStatusCounts(),
347
+ getDatabaseSize(),
348
+ getCollectionCounts(),
349
+ getOpenReportCount(),
350
+ ]);
351
+ return { repos, duckdb, collections, openReports };
352
+ })();
353
+ const entry = { at: Date.now(), data };
354
+ adminStats = entry;
355
+ // Don't cache a failure — and keep the rejection handled so it surfaces only
356
+ // at the awaiting request.
357
+ data.catch(() => {
358
+ if (adminStats === entry)
359
+ adminStats = null;
360
+ });
361
+ return data;
362
+ }
363
+ function invalidateAdminStats() {
364
+ adminStats = null;
365
+ }
366
+ return async (request) => {
367
+ const url = new URL(request.url);
368
+ const acceptEncoding = request.headers.get('accept-encoding');
369
+ // CORS preflight
370
+ if (request.method === 'OPTIONS')
371
+ return cors();
66
372
  const isXrpc = url.pathname.startsWith('/xrpc/');
67
373
  const isAdmin = url.pathname.startsWith('/admin') && !url.pathname.endsWith('.html') && !url.pathname.endsWith('.js');
68
374
  const elapsed = isXrpc || isAdmin ? timer() : null;
69
375
  let error;
70
- const requestOrigin = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host'] || `localhost:${port}`}`;
376
+ const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}`;
71
377
  // Authenticate viewer (optional — unauthenticated requests still work)
72
- let viewer = resolveViewer?.(req) ?? null;
378
+ let viewer = config.resolveViewer?.(request) ?? null;
73
379
  if (!viewer && oauth) {
74
380
  try {
75
- viewer = await authenticate(req.headers['authorization'] || null, req.headers['dpop'] || null, req.method, `${requestOrigin}${url.pathname}`);
381
+ viewer = await authenticate(request.headers.get('authorization'), request.headers.get('dpop'), request.method, `${requestOrigin}${url.pathname}`);
76
382
  }
77
383
  catch (err) {
78
384
  emit('oauth', 'authenticate_error', { error: err.message });
79
385
  }
80
386
  }
387
+ // Fallback: resolve viewer from session cookie (for browser requests without DPoP)
388
+ if (!viewer && oauth) {
389
+ try {
390
+ viewer = await parseSessionCookie(request);
391
+ }
392
+ catch { }
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
+ }
81
407
  try {
82
408
  // GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
83
409
  if (url.pathname === coreXrpc('getRecords')) {
84
410
  const collection = url.searchParams.get('collection');
85
- if (!collection) {
86
- jsonError(res, 400, 'Missing collection parameter');
87
- return;
88
- }
89
- if (!getSchema(collection)) {
90
- jsonError(res, 404, `Unknown collection: ${collection}`);
91
- return;
92
- }
93
- const limit = parseInt(url.searchParams.get('limit') || '20');
411
+ if (!collection)
412
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
413
+ if (isPrivateCollection(collection))
414
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
415
+ if (!getSchema(collection))
416
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
417
+ // Every one of these is attacker-controlled, and queryRecords trusts
418
+ // what it is handed: `sort` and `order` reach the ORDER BY clause and a
419
+ // non-numeric `limit` reaches the driver. Rejecting them here keeps a
420
+ // bad query string a 400 instead of a 500 (or worse).
421
+ const limit = parseLimit(url.searchParams.get('limit'), 20);
422
+ if (limit === null)
423
+ return withCors(jsonError(400, 'Invalid limit parameter', acceptEncoding));
94
424
  const cursor = url.searchParams.get('cursor') || undefined;
95
425
  const sort = url.searchParams.get('sort') || undefined;
96
- const order = (url.searchParams.get('order') || undefined);
97
- // Collect field filters (everything except reserved params)
426
+ const order = parseOrder(url.searchParams.get('order'));
427
+ if (order === null)
428
+ return withCors(jsonError(400, `Invalid order: ${url.searchParams.get('order')}`, acceptEncoding));
429
+ if (sort && !isSortableField(collection, sort))
430
+ return withCors(jsonError(400, `Invalid sort field: ${sort}`, acceptEncoding));
431
+ // Collect field filters (everything except reserved params). An
432
+ // unknown field is ignored rather than rejected — queryRecords does
433
+ // the same, and clients rely on passing through extra params.
98
434
  const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
99
435
  const filters = {};
100
436
  for (const [key, value] of url.searchParams) {
@@ -110,248 +446,209 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
110
446
  });
111
447
  const uris = result.records.map((r) => r.uri);
112
448
  const items = await resolveRecords(uris);
113
- jsonResponse(res, { items, cursor: result.cursor });
114
- return;
449
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
115
450
  }
116
451
  // GET /xrpc/dev.hatk.getRecord?uri=<at-uri>
117
452
  if (url.pathname === coreXrpc('getRecord')) {
118
453
  const uri = url.searchParams.get('uri');
119
- if (!uri) {
120
- jsonError(res, 400, 'Missing uri parameter');
121
- return;
122
- }
454
+ if (!uri)
455
+ return withCors(jsonError(400, 'Missing uri parameter', acceptEncoding));
456
+ if (isPrivateCollection(collectionFromUri(uri)))
457
+ return withCors(jsonError(404, 'Record not found', acceptEncoding));
123
458
  const record = await getRecordByUri(uri);
124
- if (!record) {
125
- jsonError(res, 404, 'Record not found');
126
- return;
127
- }
459
+ if (!record)
460
+ return withCors(jsonError(404, 'Record not found', acceptEncoding));
128
461
  const shaped = reshapeRow(record, record?.__childData);
129
462
  const labelsMap = await queryLabelsForUris([record.uri]);
130
463
  if (shaped)
131
464
  shaped.labels = labelsMap.get(record.uri) || [];
132
- jsonResponse(res, { record: shaped });
133
- return;
465
+ return withCors(json({ record: shaped }, 200, acceptEncoding));
134
466
  }
135
467
  // GET /xrpc/dev.hatk.getFeed?feed=<name>&limit=N&cursor=C
136
468
  if (url.pathname === coreXrpc('getFeed')) {
137
469
  const feedName = url.searchParams.get('feed');
138
- if (!feedName) {
139
- jsonError(res, 400, 'Missing feed parameter');
140
- return;
141
- }
470
+ if (!feedName)
471
+ return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
142
472
  const limit = parseInt(url.searchParams.get('limit') || '30');
143
473
  const cursor = url.searchParams.get('cursor') || undefined;
144
- const params = {};
145
- for (const [key, value] of url.searchParams) {
146
- params[key] = value;
147
- }
474
+ const params = paramsFromSearch(url.searchParams);
148
475
  const result = await executeFeed(feedName, params, cursor, limit, viewer);
149
- if (!result) {
150
- jsonError(res, 404, `Unknown feed: ${feedName}`);
151
- return;
152
- }
153
- jsonResponse(res, result);
154
- return;
476
+ if (!result)
477
+ return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
478
+ return withCors(json(result, 200, acceptEncoding));
155
479
  }
156
480
  // GET /xrpc/dev.hatk.searchRecords?collection=<nsid>&q=<query>&limit=N&cursor=C
157
481
  if (url.pathname === coreXrpc('searchRecords')) {
158
482
  const collection = url.searchParams.get('collection');
159
483
  const q = url.searchParams.get('q');
160
- if (!collection) {
161
- jsonError(res, 400, 'Missing collection parameter');
162
- return;
163
- }
164
- if (!q) {
165
- jsonError(res, 400, 'Missing q parameter');
166
- return;
167
- }
168
- if (!getSchema(collection)) {
169
- jsonError(res, 404, `Unknown collection: ${collection}`);
170
- return;
171
- }
484
+ if (!collection)
485
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
486
+ if (!q)
487
+ return withCors(jsonError(400, 'Missing q parameter', acceptEncoding));
488
+ if (isPrivateCollection(collection))
489
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
490
+ if (!getSchema(collection))
491
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
172
492
  const limit = parseInt(url.searchParams.get('limit') || '20');
173
493
  const cursor = url.searchParams.get('cursor') || undefined;
174
494
  const fuzzy = url.searchParams.get('fuzzy') !== 'false';
175
495
  const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
176
496
  const uris = result.records.map((r) => r.uri);
177
497
  const items = await resolveRecords(uris);
178
- jsonResponse(res, { items, cursor: result.cursor });
179
- return;
498
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
180
499
  }
181
500
  // GET /xrpc/dev.hatk.describeFeeds
182
501
  if (url.pathname === coreXrpc('describeFeeds')) {
183
- jsonResponse(res, { feeds: listFeeds() });
184
- return;
502
+ return withCors(json({ feeds: listFeeds() }, 200, acceptEncoding));
185
503
  }
186
504
  // GET /xrpc/dev.hatk.describeCollections
187
505
  if (url.pathname === coreXrpc('describeCollections')) {
188
- const collectionInfo = collections.map((c) => {
506
+ const collectionInfo = collections
507
+ .filter((c) => !isPrivateCollection(c))
508
+ .map((c) => {
189
509
  const schema = getSchema(c);
190
510
  return {
191
511
  collection: c,
192
512
  columns: schema?.columns.map((col) => ({
193
513
  name: col.name,
194
514
  originalName: col.originalName,
195
- type: col.duckdbType,
515
+ type: col.sqlType,
196
516
  required: col.notNull,
197
517
  })),
198
518
  };
199
519
  });
200
- jsonResponse(res, { collections: collectionInfo });
201
- return;
520
+ return withCors(json({ collections: collectionInfo }, 200, acceptEncoding));
202
521
  }
203
522
  // GET /xrpc/dev.hatk.describeLabels
204
523
  if (url.pathname === coreXrpc('describeLabels')) {
205
- jsonResponse(res, { definitions: getLabelDefinitions() });
206
- return;
524
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
207
525
  }
208
526
  // GET /xrpc/dev.hatk.getPreferences — get all preferences for authenticated user
209
527
  if (url.pathname === coreXrpc('getPreferences')) {
210
- if (!viewer) {
211
- jsonError(res, 401, 'Authentication required');
212
- return;
213
- }
528
+ if (!viewer)
529
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
214
530
  const prefs = await getPreferences(viewer.did);
215
- jsonResponse(res, { preferences: prefs });
216
- return;
531
+ return withCors(json({ preferences: prefs }, 200, acceptEncoding));
217
532
  }
218
533
  // POST /xrpc/dev.hatk.putPreference — set a single preference
219
- if (url.pathname === coreXrpc('putPreference') && req.method === 'POST') {
220
- if (!viewer) {
221
- jsonError(res, 401, 'Authentication required');
222
- return;
223
- }
224
- const body = JSON.parse(await readBody(req));
225
- if (!body.key || typeof body.key !== 'string') {
226
- jsonError(res, 400, 'Missing or invalid key');
227
- return;
228
- }
229
- if (body.value === undefined) {
230
- jsonError(res, 400, 'Missing value');
231
- return;
232
- }
534
+ if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') {
535
+ if (!viewer)
536
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
537
+ const body = JSON.parse(await request.text());
538
+ if (!body.key || typeof body.key !== 'string')
539
+ return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding));
540
+ if (body.value === undefined)
541
+ return withCors(jsonError(400, 'Missing value', acceptEncoding));
233
542
  await putPreference(viewer.did, body.key, body.value);
234
- jsonResponse(res, { success: true });
235
- return;
543
+ return withCors(json({ success: true }, 200, acceptEncoding));
236
544
  }
237
545
  // ── Admin Repo Management ──
238
546
  // POST /admin/repos/add — enqueue DIDs for backfill
239
- if (url.pathname === '/admin/repos/add' && req.method === 'POST') {
240
- if (!requireAdmin(viewer, res))
241
- return;
242
- const { dids } = JSON.parse(await readBody(req));
243
- if (!Array.isArray(dids)) {
244
- jsonError(res, 400, 'Missing dids array');
245
- return;
246
- }
547
+ if (url.pathname === '/admin/repos/add' && request.method === 'POST') {
548
+ const denied = requireAdmin(viewer, acceptEncoding);
549
+ if (denied)
550
+ return denied;
551
+ const { dids } = JSON.parse(await request.text());
552
+ if (!Array.isArray(dids))
553
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
247
554
  for (const did of dids) {
248
555
  await setRepoStatus(did, 'pending');
249
556
  triggerAutoBackfill(did);
250
557
  }
251
- jsonResponse(res, { added: dids.length });
252
- return;
558
+ invalidateAdminStats();
559
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
253
560
  }
254
561
  // POST /admin/labels/rescan — retroactively apply label rules
255
- if (url.pathname === '/admin/labels/rescan' && req.method === 'POST') {
256
- if (!requireAdmin(viewer, res))
257
- return;
562
+ if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') {
563
+ const denied = requireAdmin(viewer, acceptEncoding);
564
+ if (denied)
565
+ return denied;
258
566
  const result = await rescanLabels(collections);
259
- jsonResponse(res, result);
260
- return;
567
+ return withCors(json(result, 200, acceptEncoding));
261
568
  }
262
569
  // ── Admin Endpoints ──
263
570
  // GET /admin/whoami — check if current viewer is admin
264
571
  if (url.pathname === '/admin/whoami') {
265
- if (!requireAdmin(viewer, res))
266
- return;
267
- jsonResponse(res, { did: viewer.did, admin: true });
268
- return;
572
+ const denied = requireAdmin(viewer, acceptEncoding);
573
+ if (denied)
574
+ return denied;
575
+ return withCors(json({ did: viewer.did, admin: true }, 200, acceptEncoding));
269
576
  }
270
577
  // GET /admin/labels/definitions — get available label definitions
271
578
  if (url.pathname === '/admin/labels/definitions') {
272
- if (!requireAdmin(viewer, res))
273
- return;
274
- jsonResponse(res, { definitions: getLabelDefinitions() });
275
- return;
579
+ const denied = requireAdmin(viewer, acceptEncoding);
580
+ if (denied)
581
+ return denied;
582
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
276
583
  }
277
584
  // POST /admin/labels — apply a label
278
- if (url.pathname === '/admin/labels' && req.method === 'POST') {
279
- if (!requireAdmin(viewer, res))
280
- return;
281
- const { uri, val } = JSON.parse(await readBody(req));
282
- if (!uri || !val) {
283
- jsonError(res, 400, 'Missing uri or val');
284
- return;
285
- }
585
+ if (url.pathname === '/admin/labels' && request.method === 'POST') {
586
+ const denied = requireAdmin(viewer, acceptEncoding);
587
+ if (denied)
588
+ return denied;
589
+ const { uri, val } = JSON.parse(await request.text());
590
+ if (!uri || !val)
591
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
286
592
  await insertLabels([{ src: 'admin', uri, val }]);
287
- jsonResponse(res, { ok: true });
288
- return;
593
+ return withCors(json({ ok: true }, 200, acceptEncoding));
289
594
  }
290
595
  // POST /admin/labels/reset — delete all labels of a given type
291
- if (url.pathname === '/admin/labels/reset' && req.method === 'POST') {
292
- if (!requireAdmin(viewer, res))
293
- return;
294
- const { val } = JSON.parse(await readBody(req));
295
- if (!val) {
296
- jsonError(res, 400, 'Missing val');
297
- return;
298
- }
299
- const result = await querySQL(`SELECT COUNT(*)::INTEGER as count FROM _labels WHERE val = $1`, [val]);
300
- const count = Number(result[0]?.count || 0);
301
- await querySQL(`DELETE FROM _labels WHERE val = $1`, [val]);
302
- jsonResponse(res, { deleted: count });
303
- return;
596
+ if (url.pathname === '/admin/labels/reset' && request.method === 'POST') {
597
+ const denied = requireAdmin(viewer, acceptEncoding);
598
+ if (denied)
599
+ return denied;
600
+ const { val } = JSON.parse(await request.text());
601
+ if (!val)
602
+ return withCors(jsonError(400, 'Missing val', acceptEncoding));
603
+ const deleted = await deleteLabels(val);
604
+ return withCors(json({ deleted }, 200, acceptEncoding));
304
605
  }
305
606
  // POST /admin/labels/negate — negate a label
306
- if (url.pathname === '/admin/labels/negate' && req.method === 'POST') {
307
- if (!requireAdmin(viewer, res))
308
- return;
309
- const { uri, val } = JSON.parse(await readBody(req));
310
- if (!uri || !val) {
311
- jsonError(res, 400, 'Missing uri or val');
312
- return;
313
- }
607
+ if (url.pathname === '/admin/labels/negate' && request.method === 'POST') {
608
+ const denied = requireAdmin(viewer, acceptEncoding);
609
+ if (denied)
610
+ return denied;
611
+ const { uri, val } = JSON.parse(await request.text());
612
+ if (!uri || !val)
613
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
314
614
  await insertLabels([{ src: 'admin', uri, val, neg: true }]);
315
- jsonResponse(res, { ok: true });
316
- return;
615
+ return withCors(json({ ok: true }, 200, acceptEncoding));
317
616
  }
318
617
  // POST /admin/takedown — takedown an account
319
- if (url.pathname === '/admin/takedown' && req.method === 'POST') {
320
- if (!requireAdmin(viewer, res))
321
- return;
322
- const { did } = JSON.parse(await readBody(req));
323
- if (!did) {
324
- jsonError(res, 400, 'Missing did');
325
- return;
326
- }
618
+ if (url.pathname === '/admin/takedown' && request.method === 'POST') {
619
+ const denied = requireAdmin(viewer, acceptEncoding);
620
+ if (denied)
621
+ return denied;
622
+ const { did } = JSON.parse(await request.text());
623
+ if (!did)
624
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
327
625
  await setRepoStatus(did, 'takendown');
328
- jsonResponse(res, { ok: true });
329
- return;
626
+ invalidateAdminStats();
627
+ return withCors(json({ ok: true }, 200, acceptEncoding));
330
628
  }
331
629
  // POST /admin/reverse-takedown — reverse a takedown
332
- if (url.pathname === '/admin/reverse-takedown' && req.method === 'POST') {
333
- if (!requireAdmin(viewer, res))
334
- return;
335
- const { did } = JSON.parse(await readBody(req));
336
- if (!did) {
337
- jsonError(res, 400, 'Missing did');
338
- return;
339
- }
630
+ if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') {
631
+ const denied = requireAdmin(viewer, acceptEncoding);
632
+ if (denied)
633
+ return denied;
634
+ const { did } = JSON.parse(await request.text());
635
+ if (!did)
636
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
340
637
  await setRepoStatus(did, 'active');
341
- jsonResponse(res, { ok: true });
342
- return;
638
+ invalidateAdminStats();
639
+ return withCors(json({ ok: true }, 200, acceptEncoding));
343
640
  }
344
641
  // GET /admin/search — search records or accounts
345
642
  if (url.pathname === '/admin/search') {
346
- if (!requireAdmin(viewer, res))
347
- return;
643
+ const denied = requireAdmin(viewer, acceptEncoding);
644
+ if (denied)
645
+ return denied;
348
646
  const q = url.searchParams.get('q') || '';
349
647
  const type = url.searchParams.get('type') || 'records';
350
648
  const limit = parseInt(url.searchParams.get('limit') || '20');
351
649
  if (type === 'accounts') {
352
650
  const accounts = await searchAccounts(q, limit);
353
- jsonResponse(res, { accounts });
354
- return;
651
+ return withCors(json({ accounts }, 200, acceptEncoding));
355
652
  }
356
653
  // No query — live firehose activity (excludes bulk backfill records)
357
654
  if (!q) {
@@ -359,11 +656,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
359
656
  const allResults = [];
360
657
  for (const col of collections) {
361
658
  try {
362
- const schema = getSchema(col);
363
- if (!schema)
659
+ const rows = await getRecentRecords(col, limit + offset);
660
+ if (!rows.length)
364
661
  continue;
365
- // Only show records indexed after the repo's backfill completed (live activity)
366
- const rows = await querySQL(`SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, [limit + offset]);
367
662
  const uris = rows.map((r) => r.uri);
368
663
  const labelsMap = await queryLabelsForUris(uris);
369
664
  for (const rec of rows) {
@@ -378,22 +673,20 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
378
673
  return ta > tb ? -1 : ta < tb ? 1 : 0;
379
674
  });
380
675
  const page = allResults.slice(offset, offset + limit);
381
- jsonResponse(res, { records: page, total: allResults.length });
382
- return;
676
+ return withCors(json({ records: page, total: allResults.length }, 200, acceptEncoding));
383
677
  }
384
678
  // URI lookup
385
679
  if (q.startsWith('at://')) {
386
680
  const rec = await getRecordByUri(q);
387
681
  if (rec) {
388
682
  const labelsMap = await queryLabelsForUris([rec.uri]);
389
- jsonResponse(res, {
683
+ return withCors(json({
390
684
  records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }],
391
- });
685
+ }, 200, acceptEncoding));
392
686
  }
393
687
  else {
394
- jsonResponse(res, { records: [] });
688
+ return withCors(json({ records: [] }, 200, acceptEncoding));
395
689
  }
396
- return;
397
690
  }
398
691
  // DID lookup — find all records by this DID
399
692
  if (q.startsWith('did:')) {
@@ -412,8 +705,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
412
705
  }
413
706
  catch { }
414
707
  }
415
- jsonResponse(res, { records: allResults.slice(0, limit) });
416
- return;
708
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
417
709
  }
418
710
  // Default: full-text search records across all collections
419
711
  const allResults = [];
@@ -431,56 +723,62 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
431
723
  }
432
724
  catch { }
433
725
  }
434
- jsonResponse(res, { records: allResults.slice(0, limit) });
435
- return;
726
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
436
727
  }
437
728
  // POST /admin/repos/resync — re-download repos
438
- if (url.pathname === '/admin/repos/resync' && req.method === 'POST') {
439
- if (!requireAdmin(viewer, res))
440
- return;
441
- const body = await readBody(req);
442
- const { dids } = body ? JSON.parse(body) : {};
729
+ if (url.pathname === '/admin/repos/resync' && request.method === 'POST') {
730
+ const denied = requireAdmin(viewer, acceptEncoding);
731
+ if (denied)
732
+ return denied;
733
+ const bodyText = await request.text();
734
+ const { dids } = bodyText ? JSON.parse(bodyText) : {};
443
735
  let repoList;
444
736
  if (Array.isArray(dids) && dids.length > 0) {
445
737
  repoList = dids;
446
738
  }
447
739
  else {
448
- const rows = await querySQL(`SELECT did FROM _repos WHERE status = 'active'`);
449
- repoList = rows.map((r) => r.did);
740
+ repoList = await listActiveRepoDids();
450
741
  }
742
+ const isTargeted = Array.isArray(dids) && dids.length > 0;
451
743
  for (const did of repoList) {
452
744
  await setRepoStatus(did, 'pending');
453
- triggerAutoBackfill(did);
454
745
  }
455
- jsonResponse(res, { resyncing: repoList.length });
456
- return;
746
+ if (isTargeted) {
747
+ for (const did of repoList) {
748
+ triggerAutoBackfill(did);
749
+ }
750
+ }
751
+ else if (config.onResync) {
752
+ config.onResync();
753
+ }
754
+ else {
755
+ for (const did of repoList) {
756
+ triggerAutoBackfill(did);
757
+ }
758
+ }
759
+ invalidateAdminStats();
760
+ return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding));
457
761
  }
458
762
  // POST /admin/repos/remove — remove DIDs from tracking
459
- if (url.pathname === '/admin/repos/remove' && req.method === 'POST') {
460
- if (!requireAdmin(viewer, res))
461
- return;
462
- const { dids } = JSON.parse(await readBody(req));
463
- if (!Array.isArray(dids)) {
464
- jsonError(res, 400, 'Missing dids array');
465
- return;
466
- }
763
+ if (url.pathname === '/admin/repos/remove' && request.method === 'POST') {
764
+ const denied = requireAdmin(viewer, acceptEncoding);
765
+ if (denied)
766
+ return denied;
767
+ const { dids } = JSON.parse(await request.text());
768
+ if (!Array.isArray(dids))
769
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
467
770
  for (const did of dids) {
468
- await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]);
771
+ await removeRepo(did);
469
772
  }
470
- jsonResponse(res, { removed: dids.length });
471
- return;
773
+ invalidateAdminStats();
774
+ return withCors(json({ removed: dids.length }, 200, acceptEncoding));
472
775
  }
473
776
  // GET /admin/info — aggregate status + db size + collection counts
474
777
  if (url.pathname === '/admin/info') {
475
- if (!requireAdmin(viewer, res))
476
- return;
477
- const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`);
478
- const counts = {};
479
- for (const row of rows)
480
- counts[row.status] = Number(row.count);
481
- const sizeRows = await querySQL(`SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()`);
482
- const dbInfo = sizeRows[0] ?? {};
483
- const collectionCounts = await getCollectionCounts();
778
+ const denied = requireAdmin(viewer, acceptEncoding);
779
+ if (denied)
780
+ return denied;
781
+ const stats = await getAdminStats();
484
782
  const mem = process.memoryUsage();
485
783
  const node = {
486
784
  rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MiB`,
@@ -488,372 +786,526 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
488
786
  heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
489
787
  external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
490
788
  };
491
- jsonResponse(res, { repos: counts, duckdb: dbInfo, node, collections: collectionCounts });
492
- return;
789
+ return withCors(json({
790
+ repos: stats.repos,
791
+ duckdb: stats.duckdb,
792
+ node,
793
+ collections: stats.collections,
794
+ openReports: stats.openReports,
795
+ }, 200, acceptEncoding));
796
+ }
797
+ // GET /admin/reports — list reports
798
+ if (url.pathname === '/admin/reports' && request.method === 'GET') {
799
+ const denied = requireAdmin(viewer, acceptEncoding);
800
+ if (denied)
801
+ return denied;
802
+ const status = url.searchParams.get('status') || 'open';
803
+ const label = url.searchParams.get('label') || undefined;
804
+ const limit = parseInt(url.searchParams.get('limit') || '50');
805
+ const offset = parseInt(url.searchParams.get('offset') || '0');
806
+ const result = await queryReports({ status, label, limit, offset });
807
+ return withCors(json(result, 200, acceptEncoding));
808
+ }
809
+ // POST /admin/reports/resolve — resolve or dismiss a report
810
+ if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
811
+ const denied = requireAdmin(viewer, acceptEncoding);
812
+ if (denied)
813
+ return denied;
814
+ const { id, action } = JSON.parse(await request.text());
815
+ if (!id || !action)
816
+ return withCors(jsonError(400, 'Missing id or action', acceptEncoding));
817
+ if (action !== 'resolve' && action !== 'dismiss')
818
+ return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding));
819
+ const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer.did);
820
+ if (!report)
821
+ return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding));
822
+ if (action === 'resolve') {
823
+ await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]);
824
+ }
825
+ invalidateAdminStats();
826
+ return withCors(json({ ok: true }, 200, acceptEncoding));
493
827
  }
494
828
  // GET /admin/info/:did — repo status info
495
829
  if (url.pathname.startsWith('/admin/info/did:')) {
496
- if (!requireAdmin(viewer, res))
497
- return;
830
+ const denied = requireAdmin(viewer, acceptEncoding);
831
+ if (denied)
832
+ return denied;
498
833
  const did = url.pathname.slice('/admin/info/'.length);
499
834
  const status = await getRepoStatus(did);
500
- if (!status) {
501
- jsonError(res, 404, 'Repo not found');
502
- return;
503
- }
835
+ if (!status)
836
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
504
837
  const retryInfo = await getRepoRetryInfo(did);
505
- jsonResponse(res, {
838
+ return withCors(json({
506
839
  did,
507
840
  status,
508
841
  retry_count: retryInfo?.retryCount ?? 0,
509
842
  retry_after: retryInfo?.retryAfter ?? 0,
510
- });
511
- return;
843
+ }, 200, acceptEncoding));
512
844
  }
513
845
  // GET /admin/repos — paginated repo listing
514
- if (url.pathname === '/admin/repos' && req.method === 'GET') {
515
- if (!requireAdmin(viewer, res))
516
- return;
846
+ if (url.pathname === '/admin/repos' && request.method === 'GET') {
847
+ const denied = requireAdmin(viewer, acceptEncoding);
848
+ if (denied)
849
+ return denied;
517
850
  const limit = parseInt(url.searchParams.get('limit') || '50');
518
851
  const offset = parseInt(url.searchParams.get('offset') || '0');
519
852
  const status = url.searchParams.get('status') || undefined;
520
853
  const q = url.searchParams.get('q') || undefined;
521
854
  const result = await listReposPaginated({ limit, offset, status, q });
522
- jsonResponse(res, result);
523
- return;
524
- }
525
- // GET /admin/schema — full DuckDB DDL dump + lexicons
526
- if (url.pathname === '/admin/schema') {
527
- if (!requireAdmin(viewer, res))
528
- return;
529
- const { getAllLexicons } = await import("./schema.js");
530
- const ddl = await getSchemaDump();
531
- jsonResponse(res, { ddl, lexicons: getAllLexicons() });
532
- return;
855
+ return withCors(json(result, 200, acceptEncoding));
533
856
  }
534
857
  // ── Public Repo Endpoints (used by hatk clients for auto-sync) ──
535
858
  // POST /repos/add — enqueue DIDs for backfill (public)
536
- if (url.pathname === '/repos/add' && req.method === 'POST') {
537
- const { dids } = JSON.parse(await readBody(req));
538
- if (!Array.isArray(dids)) {
539
- jsonError(res, 400, 'Missing dids array');
540
- return;
541
- }
859
+ if (url.pathname === '/repos/add' && request.method === 'POST') {
860
+ const { dids } = JSON.parse(await request.text());
861
+ if (!Array.isArray(dids))
862
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
542
863
  for (const did of dids) {
543
864
  await setRepoStatus(did, 'pending');
544
865
  triggerAutoBackfill(did);
545
866
  }
546
- jsonResponse(res, { added: dids.length });
547
- return;
867
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
548
868
  }
549
869
  // GET /info/:did — repo status info (public)
550
870
  if (url.pathname.startsWith('/info/did:')) {
551
871
  const did = url.pathname.slice('/info/'.length);
552
872
  const status = await getRepoStatus(did);
553
- if (!status) {
554
- jsonError(res, 404, 'Repo not found');
555
- return;
556
- }
873
+ if (!status)
874
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
557
875
  const retryInfo = await getRepoRetryInfo(did);
558
- jsonResponse(res, {
876
+ return withCors(json({
559
877
  did,
560
878
  status,
561
879
  retry_count: retryInfo?.retryCount ?? 0,
562
880
  retry_after: retryInfo?.retryAfter ?? 0,
563
- });
564
- return;
881
+ }, 200, acceptEncoding));
565
882
  }
566
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
+ }
567
890
  // OAuth well-known endpoints
568
891
  if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
569
- jsonResponse(res, getAuthServerMetadata(oauth.issuer, oauth));
570
- return;
892
+ return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
571
893
  }
572
894
  if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) {
573
- jsonResponse(res, getProtectedResourceMetadata(oauth.issuer, oauth));
574
- return;
895
+ return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding));
575
896
  }
576
897
  if (url.pathname === '/oauth/jwks' && oauth) {
577
- jsonResponse(res, getJwks());
578
- return;
898
+ return withCors(json(getJwks(), 200, acceptEncoding));
579
899
  }
580
900
  if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
581
- jsonResponse(res, getClientMetadata(oauth.issuer, oauth));
582
- return;
901
+ return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding));
902
+ }
903
+ // Public key a PDS fetches to verify our client assertions.
904
+ if (url.pathname === '/oauth/client-jwks.json' && oauth) {
905
+ return withCors(json(getClientJwks(), 200, acceptEncoding));
906
+ }
907
+ // Dev-only: create a session cookie for any DID (for testing)
908
+ if (url.pathname === '/__dev/login' && devMode && oauth) {
909
+ const did = url.searchParams.get('did');
910
+ if (!did)
911
+ return withCors(jsonError(400, 'did required', acceptEncoding));
912
+ const handle = (await getRepoHandle(did)) ?? did;
913
+ const cookieValue = await createSessionCookie({ did, handle });
914
+ const secure = url.protocol === 'https:';
915
+ const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), { did, handle }));
916
+ return new Response(JSON.stringify({ ok: true }), {
917
+ status: 200,
918
+ headers: [
919
+ ['Content-Type', 'application/json'],
920
+ ['Set-Cookie', sessionCookieHeader(cookieValue, secure)],
921
+ ['Set-Cookie', accountsCookieHeader(accounts, secure)],
922
+ ],
923
+ });
924
+ }
925
+ // OAuth Login (server-initiated, no DPoP required)
926
+ if (url.pathname === '/oauth/login' && oauth) {
927
+ const handle = url.searchParams.get('handle') || '';
928
+ const prompt = url.searchParams.get('prompt') || undefined;
929
+ const pds = url.searchParams.get('pds') || undefined;
930
+ if (!handle && prompt !== 'create')
931
+ return withCors(jsonError(400, 'handle required', acceptEncoding));
932
+ try {
933
+ const redirectUrl = await serverLogin(oauth, handle, { prompt, pds });
934
+ return new Response(null, {
935
+ status: 302,
936
+ headers: { Location: redirectUrl, 'Set-Cookie': clearSessionCookieHeader() },
937
+ });
938
+ }
939
+ catch (err) {
940
+ const message = err instanceof Error ? err.message : 'Login failed';
941
+ return withCors(jsonError(400, message, acceptEncoding));
942
+ }
583
943
  }
584
944
  // OAuth PAR
585
- if (url.pathname === '/oauth/par' && req.method === 'POST' && oauth) {
586
- const rawBody = await readBody(req);
945
+ if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) {
946
+ const rawBody = await request.text();
587
947
  let body;
588
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
948
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
589
949
  body = Object.fromEntries(new URLSearchParams(rawBody));
590
950
  }
591
951
  else {
592
952
  body = JSON.parse(rawBody);
593
953
  }
594
- const dpopHeader = req.headers['dpop'];
595
- if (!dpopHeader) {
596
- jsonError(res, 400, 'DPoP header required');
597
- return;
954
+ const dpopHeader = request.headers.get('dpop');
955
+ if (!dpopHeader)
956
+ return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
957
+ try {
958
+ const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
959
+ return withCors(json(result, 200, acceptEncoding));
960
+ }
961
+ catch (err) {
962
+ const message = err instanceof Error ? err.message : 'Unknown error';
963
+ return withCors(jsonError(400, message, acceptEncoding));
598
964
  }
599
- const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
600
- jsonResponse(res, result);
601
- return;
602
965
  }
603
966
  // OAuth Authorize
604
967
  if (url.pathname === '/oauth/authorize' && oauth) {
605
968
  const requestUri = url.searchParams.get('request_uri');
606
- if (!requestUri) {
607
- jsonError(res, 400, 'request_uri required');
608
- return;
609
- }
610
- const request = await getOAuthRequest(requestUri);
611
- if (!request) {
612
- jsonError(res, 400, 'Invalid or expired request_uri');
613
- return;
614
- }
615
- const redirectUrl = buildAuthorizeRedirect(oauth, request);
616
- res.writeHead(302, { Location: redirectUrl });
617
- res.end();
618
- return;
969
+ if (!requestUri)
970
+ return withCors(jsonError(400, 'request_uri required', acceptEncoding));
971
+ const oauthRequest = await getOAuthRequest(requestUri);
972
+ if (!oauthRequest)
973
+ return withCors(jsonError(400, 'Invalid or expired request_uri', acceptEncoding));
974
+ const redirectUrl = buildAuthorizeRedirect(oauth, oauthRequest);
975
+ return new Response(null, { status: 302, headers: { Location: redirectUrl } });
619
976
  }
620
977
  // OAuth Callback (PDS redirects here after user approves)
621
978
  // Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it
622
979
  if (url.pathname === '/oauth/callback' && oauth) {
623
980
  const iss = url.searchParams.get('iss');
624
- if (iss === oauth.issuer) {
625
- // Client-side callback — fall through to SPA
626
- }
627
- else {
981
+ if (iss !== oauth.issuer) {
628
982
  const code = url.searchParams.get('code');
629
983
  const state = url.searchParams.get('state');
630
- if (!code) {
631
- jsonError(res, 400, 'Missing code');
632
- return;
984
+ const oauthError = url.searchParams.get('error');
985
+ if (oauthError) {
986
+ const errorRedirect = await handleCallbackError(oauth, oauthError, url.searchParams.get('error_description'), state);
987
+ return new Response(null, {
988
+ status: 302,
989
+ headers: { Location: errorRedirect, 'Set-Cookie': clearSessionCookieHeader() },
990
+ });
991
+ }
992
+ if (!code)
993
+ return withCors(jsonError(400, 'Missing code', acceptEncoding));
994
+ let result;
995
+ try {
996
+ result = await handleCallback(oauth, code, state, iss);
997
+ }
998
+ catch (err) {
999
+ // Same convention as the token endpoint below: an error that names
1000
+ // its own status keeps it, anything else is a genuine failure and
1001
+ // falls through to the 500.
1002
+ if (err instanceof OAuthError)
1003
+ return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
1004
+ // A callback matching no pending authorization request is a stale
1005
+ // redirect, not a server fault — the request expired while a login
1006
+ // tab sat open, or the user came back to a login they already
1007
+ // finished. Answer like the other client errors on this route.
1008
+ if (err instanceof Error && err.message === 'No matching authorization request found')
1009
+ return withCors(jsonError(400, err.message, acceptEncoding));
1010
+ throw err;
633
1011
  }
634
- const result = await handleCallback(oauth, code, state, iss);
635
- res.writeHead(302, { Location: result.clientRedirectUri });
636
- res.end();
637
- return;
1012
+ const isSecure = requestOrigin.startsWith('https');
1013
+ const handle = (await getRepoHandle(result.did)) ?? result.did;
1014
+ const session = { did: result.did, handle };
1015
+ const cookie = await createSessionCookie(session);
1016
+ // Remember the account alongside the active session so the browser
1017
+ // can switch back to it later without another trip to the PDS.
1018
+ const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), session));
1019
+ // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
1020
+ const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
1021
+ return new Response(null, {
1022
+ status: 302,
1023
+ headers: [
1024
+ ['Location', redirectTo],
1025
+ ['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
1026
+ ['Set-Cookie', accountsCookieHeader(accounts, isSecure)],
1027
+ ],
1028
+ });
638
1029
  }
1030
+ // Client-side callback — fall through to SPA
1031
+ }
1032
+ // Session cookie logout. Also drops the account from the switch list —
1033
+ // leaving it there would keep a working path back into an account the
1034
+ // user believes they signed out of, which matters on a shared machine.
1035
+ if (url.pathname === '/auth/logout' && request.method === 'POST') {
1036
+ const current = await parseSessionCookie(request);
1037
+ const isSecure = requestOrigin.startsWith('https');
1038
+ const headers = [['Set-Cookie', clearSessionCookieHeader()]];
1039
+ if (current) {
1040
+ const remaining = withoutAccount(await parseAccountsCookie(request), current.did);
1041
+ headers.push([
1042
+ 'Set-Cookie',
1043
+ remaining.length
1044
+ ? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
1045
+ : clearAccountsCookieHeader(),
1046
+ ]);
1047
+ }
1048
+ return new Response(null, { status: 200, headers });
1049
+ }
1050
+ // Accounts this browser may switch between. The cookie is HttpOnly, so
1051
+ // this is the only way for a client to see what's in it.
1052
+ if (url.pathname === '/auth/accounts' && request.method === 'GET') {
1053
+ const stored = await parseAccountsCookie(request);
1054
+ const active = await parseSessionCookie(request);
1055
+ // Sessions created before this feature existed have a session cookie
1056
+ // and no accounts cookie. Adopt the active account into the list rather
1057
+ // than reporting it as unknown — otherwise the switcher looks empty and
1058
+ // signing into a second account appears to drop the first.
1059
+ const needsHealing = !!active && !stored.some((a) => a.did === active.did);
1060
+ const accounts = needsHealing ? withAccount(stored, active) : stored;
1061
+ // An account whose server-side OAuth session is gone can't be switched
1062
+ // to — say so rather than letting the client offer a dead option.
1063
+ // `__dev/login` fabricates a session with no OAuth grant behind it, so
1064
+ // in dev every remembered account counts as switchable.
1065
+ const resolved = await Promise.all(accounts.map(async (a) => ({ ...a, available: devMode || !!(await getSession(a.did)) })));
1066
+ const res = withCors(json({ accounts: resolved, active: active?.did ?? null }, 200, acceptEncoding));
1067
+ // Persist the adoption so the next switch has something to authorize
1068
+ // against, instead of healing the list again on every load.
1069
+ if (needsHealing) {
1070
+ res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(accounts), requestOrigin.startsWith('https')));
1071
+ }
1072
+ return res;
1073
+ }
1074
+ // Switch the active account without re-authorizing at the PDS.
1075
+ //
1076
+ // The OAuth grant belongs to this server acting for the user, not to a
1077
+ // browser session — so once a browser has proven it owns an account, we
1078
+ // can re-issue its session cookie from the stored session. The accounts
1079
+ // cookie is the proof: it's encrypted with the server key, so a client
1080
+ // can't name a DID it never signed in as.
1081
+ if (url.pathname === '/auth/switch' && request.method === 'POST') {
1082
+ let did;
1083
+ try {
1084
+ const body = await request.json();
1085
+ did = typeof body?.did === 'string' ? body.did : undefined;
1086
+ }
1087
+ catch {
1088
+ return withCors(jsonError(400, 'Invalid JSON body', acceptEncoding));
1089
+ }
1090
+ if (!did)
1091
+ return withCors(jsonError(400, 'did required', acceptEncoding));
1092
+ const accounts = await parseAccountsCookie(request);
1093
+ const account = accounts.find((a) => a.did === did);
1094
+ if (!account)
1095
+ return withCors(jsonError(403, 'Not signed in as that account', acceptEncoding));
1096
+ if (!devMode && !(await getSession(account.did))) {
1097
+ // The stored grant is gone (revoked, or cleaned up). Drop it from the
1098
+ // list so the client stops offering it and can send the user through
1099
+ // a fresh sign-in instead.
1100
+ const isSecure = requestOrigin.startsWith('https');
1101
+ const remaining = withoutAccount(accounts, account.did);
1102
+ const res = withCors(jsonError(409, 'Session expired for that account', acceptEncoding));
1103
+ res.headers.append('Set-Cookie', remaining.length
1104
+ ? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
1105
+ : clearAccountsCookieHeader());
1106
+ return res;
1107
+ }
1108
+ // Refresh the handle in passing — it may have changed while dormant.
1109
+ const handle = (await getRepoHandle(account.did)) ?? account.handle;
1110
+ const session = { did: account.did, handle };
1111
+ const isSecure = requestOrigin.startsWith('https');
1112
+ const res = withCors(json({ did: session.did, handle: session.handle }, 200, acceptEncoding));
1113
+ res.headers.append('Set-Cookie', sessionCookieHeader(await createSessionCookie(session), isSecure));
1114
+ res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(withAccount(accounts, session)), isSecure));
1115
+ return res;
639
1116
  }
640
1117
  // OAuth Token
641
- if (url.pathname === '/oauth/token' && req.method === 'POST' && oauth) {
642
- const rawBody = await readBody(req);
1118
+ if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
1119
+ const rawBody = await request.text();
643
1120
  let body;
644
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
1121
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
645
1122
  body = Object.fromEntries(new URLSearchParams(rawBody));
646
1123
  }
647
1124
  else {
648
1125
  body = JSON.parse(rawBody);
649
1126
  }
650
- const dpopHeader = req.headers['dpop'];
1127
+ const dpopHeader = request.headers.get('dpop');
651
1128
  if (!dpopHeader) {
652
- jsonError(res, 400, 'DPoP header required');
653
- return;
1129
+ return withCors(json({ error: 'invalid_request', error_description: 'DPoP header required' }, 400, acceptEncoding));
654
1130
  }
655
- const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
656
- jsonResponse(res, result);
657
- return;
658
- }
659
- // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
660
- if (url.pathname === coreXrpc('createRecord') && req.method === 'POST' && oauth) {
661
- if (!viewer) {
662
- jsonError(res, 401, 'Authentication required');
663
- return;
664
- }
665
- const body = JSON.parse(await readBody(req));
666
- const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
667
- if (validationError) {
668
- jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
669
- return;
670
- }
671
- const session = await getSession(viewer.did);
672
- if (!session) {
673
- jsonError(res, 401, 'No PDS session for user');
674
- return;
1131
+ try {
1132
+ const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
1133
+ return withCors(json(result, 200, acceptEncoding));
675
1134
  }
676
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord`;
677
- const pdsBody = {
678
- repo: viewer.did,
679
- collection: body.collection,
680
- rkey: body.rkey,
681
- record: body.record,
682
- };
683
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
684
- if (!pdsRes.ok) {
685
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
686
- return;
1135
+ catch (err) {
1136
+ if (err instanceof OAuthError) {
1137
+ return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
1138
+ }
1139
+ throw err;
687
1140
  }
688
- const result = pdsRes.body;
689
- // Index the record immediately
1141
+ }
1142
+ // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
1143
+ if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) {
1144
+ if (!viewer)
1145
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1146
+ const body = JSON.parse(await request.text());
690
1147
  try {
691
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
1148
+ const result = await pdsCreateRecord(oauth, viewer, body);
1149
+ return withCors(json(result, 200, acceptEncoding));
692
1150
  }
693
- catch {
694
- // Non-fatal — firehose will catch it
1151
+ catch (err) {
1152
+ if (err instanceof ScopeMissingProxyError)
1153
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1154
+ if (err instanceof ProxyError)
1155
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1156
+ throw err;
695
1157
  }
696
- jsonResponse(res, result);
697
- return;
698
1158
  }
699
1159
  // POST /xrpc/dev.hatk.deleteRecord — proxy delete to user's PDS
700
- if (url.pathname === coreXrpc('deleteRecord') && req.method === 'POST' && oauth) {
701
- if (!viewer) {
702
- jsonError(res, 401, 'Authentication required');
703
- return;
704
- }
705
- const body = JSON.parse(await readBody(req));
706
- const session = await getSession(viewer.did);
707
- if (!session) {
708
- jsonError(res, 401, 'No PDS session for user');
709
- return;
710
- }
711
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord`;
712
- const pdsBody = {
713
- repo: viewer.did,
714
- collection: body.collection,
715
- rkey: body.rkey,
716
- };
717
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
718
- if (!pdsRes.ok) {
719
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS delete failed');
720
- return;
721
- }
722
- const result = pdsRes.body;
723
- // Delete the record locally
1160
+ if (url.pathname === coreXrpc('deleteRecord') && request.method === 'POST' && oauth) {
1161
+ if (!viewer)
1162
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1163
+ const body = JSON.parse(await request.text());
724
1164
  try {
725
- const uri = `at://${viewer.did}/${body.collection}/${body.rkey}`;
726
- await deleteRecord(body.collection, uri);
1165
+ const result = await pdsDeleteRecord(oauth, viewer, body);
1166
+ return withCors(json(result, 200, acceptEncoding));
727
1167
  }
728
- catch {
729
- // Non-fatal — firehose will catch it
1168
+ catch (err) {
1169
+ if (err instanceof ScopeMissingProxyError)
1170
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1171
+ if (err instanceof ProxyError)
1172
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1173
+ throw err;
730
1174
  }
731
- jsonResponse(res, result);
732
- return;
733
1175
  }
734
1176
  // POST /xrpc/dev.hatk.putRecord — proxy create-or-update to user's PDS
735
- if (url.pathname === coreXrpc('putRecord') && req.method === 'POST' && oauth) {
736
- if (!viewer) {
737
- jsonError(res, 401, 'Authentication required');
738
- return;
739
- }
740
- const body = JSON.parse(await readBody(req));
741
- const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
742
- if (validationError) {
743
- jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
744
- return;
745
- }
746
- const session = await getSession(viewer.did);
747
- if (!session) {
748
- jsonError(res, 401, 'No PDS session for user');
749
- return;
750
- }
751
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord`;
752
- const pdsBody = {
753
- repo: viewer.did,
754
- collection: body.collection,
755
- rkey: body.rkey,
756
- record: body.record,
757
- };
758
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
759
- if (!pdsRes.ok) {
760
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
761
- return;
762
- }
763
- const result = pdsRes.body;
764
- // Re-index (insertRecord uses INSERT OR REPLACE so this handles both create and update)
1177
+ if (url.pathname === coreXrpc('putRecord') && request.method === 'POST' && oauth) {
1178
+ if (!viewer)
1179
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1180
+ const body = JSON.parse(await request.text());
765
1181
  try {
766
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
1182
+ const result = await pdsPutRecord(oauth, viewer, body);
1183
+ return withCors(json(result, 200, acceptEncoding));
767
1184
  }
768
- catch {
769
- // Non-fatal — firehose will catch it
1185
+ catch (err) {
1186
+ if (err instanceof ScopeMissingProxyError)
1187
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1188
+ if (err instanceof ProxyError)
1189
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1190
+ throw err;
770
1191
  }
771
- jsonResponse(res, result);
772
- return;
773
1192
  }
774
1193
  // POST /xrpc/dev.hatk.uploadBlob — proxy blob upload to user's PDS
775
- if (url.pathname === coreXrpc('uploadBlob') && req.method === 'POST' && oauth) {
776
- if (!viewer) {
777
- jsonError(res, 401, 'Authentication required');
778
- return;
779
- }
780
- const session = await getSession(viewer.did);
781
- if (!session) {
782
- jsonError(res, 401, 'No PDS session for user');
783
- return;
1194
+ if (url.pathname === coreXrpc('uploadBlob') && request.method === 'POST' && oauth) {
1195
+ if (!viewer)
1196
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1197
+ const contentType = request.headers.get('content-type') || 'application/octet-stream';
1198
+ const rawBody = new Uint8Array(await request.arrayBuffer());
1199
+ try {
1200
+ const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType);
1201
+ return withCors(json(result, 200, acceptEncoding));
784
1202
  }
785
- const contentType = req.headers['content-type'] || 'application/octet-stream';
786
- const rawBody = await readBodyRaw(req);
787
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.uploadBlob`;
788
- const pdsRes = await proxyToPdsRaw(oauth, session, pdsUrl, rawBody, contentType);
789
- if (!pdsRes.ok) {
790
- jsonError(res, pdsRes.status, String(pdsRes.body.error || 'PDS upload failed'));
791
- return;
1203
+ catch (err) {
1204
+ if (err instanceof ScopeMissingProxyError)
1205
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1206
+ if (err instanceof ProxyError)
1207
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1208
+ throw err;
792
1209
  }
793
- jsonResponse(res, pdsRes.body);
794
- return;
795
1210
  }
796
1211
  // GET /admin — serve admin UI from hatk package
797
1212
  if (url.pathname === '/admin' || url.pathname === '/admin/') {
798
1213
  const adminPath = join(import.meta.dirname, '../public/admin.html');
799
1214
  try {
800
- const content = await readFile(adminPath);
801
- res.writeHead(200, { 'Content-Type': 'text/html' });
802
- res.end(content);
803
- return;
1215
+ return withCors(await serveBundledAsset(adminPath, 'text/html', request, devMode));
804
1216
  }
805
1217
  catch {
806
- res.writeHead(404);
807
- res.end('Admin page not found');
808
- return;
1218
+ return withCors(new Response('Admin page not found', { status: 404 }));
809
1219
  }
810
1220
  }
811
1221
  // GET /admin/admin-auth.js — serve bundled OAuth client
812
1222
  if (url.pathname === '/admin/admin-auth.js') {
813
1223
  const authPath = join(import.meta.dirname, '../public/admin-auth.js');
814
1224
  try {
815
- const content = await readFile(authPath);
816
- res.writeHead(200, { 'Content-Type': 'application/javascript' });
817
- res.end(content);
818
- return;
1225
+ return withCors(await serveBundledAsset(authPath, 'application/javascript', request, devMode));
819
1226
  }
820
1227
  catch {
821
- res.writeHead(404);
822
- res.end('Not found');
823
- return;
1228
+ return notFound();
824
1229
  }
825
1230
  }
826
1231
  // GET /_health
827
1232
  if (url.pathname === '/_health') {
828
- jsonResponse(res, { status: 'ok' });
829
- return;
1233
+ return withCors(json({ status: 'ok' }, 200, acceptEncoding));
830
1234
  }
831
1235
  // GET /og/* — OpenGraph image routes
832
- if (url.pathname.startsWith('/og/') && !res.writableEnded) {
1236
+ if (url.pathname.startsWith('/og/')) {
833
1237
  const png = await handleOpengraphRequest(url.pathname);
834
- if (png) {
835
- res.writeHead(200, {
836
- 'Content-Type': 'image/png',
837
- 'Cache-Control': 'public, max-age=300',
1238
+ if (png)
1239
+ return withCors(file(png, 'image/png', 'public, max-age=300'));
1240
+ }
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',
838
1261
  });
839
- res.end(png);
840
- return;
841
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));
842
1272
  }
843
- // GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
844
- if (url.pathname.startsWith('/xrpc/') && !res.writableEnded) {
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
+ }
1299
+ if (url.pathname.startsWith('/xrpc/')) {
845
1300
  const method = url.pathname.slice('/xrpc/'.length);
846
1301
  const limit = parseInt(url.searchParams.get('limit') || '20');
847
1302
  const cursor = url.searchParams.get('cursor') || undefined;
848
- const params = {};
849
- for (const [key, value] of url.searchParams) {
850
- params[key] = value;
851
- }
1303
+ const params = paramsFromSearch(url.searchParams);
852
1304
  // Parse request body for POST (procedures)
853
1305
  let input;
854
- if (req.method === 'POST') {
1306
+ if (request.method === 'POST') {
855
1307
  try {
856
- input = JSON.parse(await readBody(req));
1308
+ input = JSON.parse(await request.text());
857
1309
  }
858
1310
  catch {
859
1311
  input = {};
@@ -861,19 +1313,59 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
861
1313
  }
862
1314
  try {
863
1315
  const result = await executeXrpc(method, params, cursor, limit, viewer, input);
864
- if (result) {
865
- jsonResponse(res, result);
866
- return;
867
- }
1316
+ // A handler may answer with a Response of its own, which is the only
1317
+ // way to serve anything but JSON — com.atproto.sync.getBlob is the
1318
+ // shape in the wild, and a lexicon declaring `encoding: "*/*"` has no
1319
+ // other way to honour it. Everything else is serialized as before.
1320
+ if (result instanceof Response)
1321
+ return withCors(result);
1322
+ if (result)
1323
+ return withCors(json(result, 200, acceptEncoding));
868
1324
  }
869
1325
  catch (err) {
1326
+ if (err instanceof ScopeMissingProxyError)
1327
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
870
1328
  if (err instanceof InvalidRequestError) {
871
- jsonError(res, err.status, err.errorName || err.message);
872
- return;
1329
+ return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
873
1330
  }
874
1331
  throw err;
875
1332
  }
876
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
+ }
1344
+ // GET /blob/:did/:cid — dev-only image proxy (see blobUrl in xrpc.ts).
1345
+ // A PDS serves getBlob as an attachment with nosniff, which a browser
1346
+ // refuses to render in <img>; re-serving it from here drops that and
1347
+ // lets one origin cover repos on any number of local PDSes.
1348
+ if (url.pathname.startsWith('/blob/') && isLocalRelay()) {
1349
+ const [did, cid] = url.pathname.slice('/blob/'.length).split('/');
1350
+ if (!did || !cid)
1351
+ return withCors(jsonError(400, 'Expected /blob/:did/:cid', acceptEncoding));
1352
+ try {
1353
+ const pds = await pdsFor(did);
1354
+ const upstream = await fetch(`${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`);
1355
+ if (!upstream.ok)
1356
+ return withCors(jsonError(upstream.status, 'Blob not found', acceptEncoding));
1357
+ return withCors(new Response(upstream.body, {
1358
+ status: 200,
1359
+ headers: {
1360
+ 'content-type': upstream.headers.get('content-type') || 'application/octet-stream',
1361
+ 'cache-control': 'public, max-age=3600',
1362
+ },
1363
+ }));
1364
+ }
1365
+ catch (err) {
1366
+ return withCors(jsonError(502, `Blob fetch failed: ${err.message}`, acceptEncoding));
1367
+ }
1368
+ }
877
1369
  // GET /robots.txt — serve from user's public dir or fall back to hatk default
878
1370
  if (url.pathname === '/robots.txt') {
879
1371
  const userRobots = publicDir ? join(publicDir, 'robots.txt') : null;
@@ -881,9 +1373,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
881
1373
  const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots;
882
1374
  try {
883
1375
  const content = await readFile(robotsPath);
884
- res.writeHead(200, { 'Content-Type': 'text/plain' });
885
- res.end(content);
886
- return;
1376
+ return withCors(file(content, 'text/plain'));
887
1377
  }
888
1378
  catch {
889
1379
  // fall through
@@ -894,38 +1384,45 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
894
1384
  try {
895
1385
  const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname);
896
1386
  const content = await readFile(filePath);
897
- res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'text/plain' });
898
- res.end(content);
899
- return;
1387
+ return withCors(file(content, MIME[extname(filePath)] || 'text/plain'));
900
1388
  }
901
1389
  catch { }
902
- // SPA fallback — serve index.html for client-side routes
1390
+ // SSR or SPA fallback — serve index.html for client-side routes
903
1391
  try {
904
- let content = await readFile(join(publicDir, 'index.html'), 'utf-8');
905
- // Inject OG meta tags for shareable routes
1392
+ const template = await readFile(join(publicDir, 'index.html'), 'utf-8');
906
1393
  const ogMeta = buildOgMeta(url.pathname, requestOrigin);
1394
+ globalThis.__hatk_viewer = viewer;
1395
+ let renderedHtml;
1396
+ try {
1397
+ renderedHtml = await renderPage(template, request, ogMeta);
1398
+ }
1399
+ finally {
1400
+ ;
1401
+ globalThis.__hatk_viewer = null;
1402
+ }
1403
+ if (renderedHtml) {
1404
+ return withCors(file(Buffer.from(renderedHtml), 'text/html'));
1405
+ }
1406
+ // SPA fallback — inject OG meta only
1407
+ let html = template;
907
1408
  if (ogMeta) {
908
- content = content.replace('</head>', `${ogMeta}\n</head>`);
1409
+ html = html.replace('</head>', `${ogMeta}\n</head>`);
909
1410
  }
910
- res.writeHead(200, { 'Content-Type': 'text/html' });
911
- res.end(content);
912
- return;
1411
+ return withCors(file(Buffer.from(html), 'text/html'));
913
1412
  }
914
1413
  catch { }
915
1414
  }
916
- res.writeHead(404);
917
- res.end('Not Found');
1415
+ return notFound();
918
1416
  }
919
1417
  catch (err) {
920
1418
  error = err.message;
921
- jsonError(res, 500, err.message);
1419
+ return withCors(jsonError(500, err.message, acceptEncoding));
922
1420
  }
923
1421
  finally {
924
- if (isXrpc || isAdmin) {
1422
+ if ((isXrpc || isAdmin) && elapsed) {
925
1423
  emit('server', 'request', {
926
- method: req.method,
1424
+ method: request.method,
927
1425
  path: url.pathname,
928
- status_code: res.statusCode,
929
1426
  duration_ms: elapsed(),
930
1427
  collection: url.searchParams.get('collection') || undefined,
931
1428
  query: url.searchParams.get('q') || undefined,
@@ -933,135 +1430,10 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
933
1430
  });
934
1431
  }
935
1432
  }
936
- });
937
- server.listen(port, () => log(`[server] ${oauth?.issuer || `http://localhost:${port}`}`));
938
- return server;
939
- }
940
- function sendJson(res, status, body) {
941
- const acceptEncoding = res.req?.headers['accept-encoding'] || '';
942
- if (body.length > 1024 && /\bgzip\b/.test(acceptEncoding)) {
943
- const compressed = gzipSync(body);
944
- res.writeHead(status, {
945
- 'Content-Type': 'application/json',
946
- 'Content-Encoding': 'gzip',
947
- Vary: 'Accept-Encoding',
948
- ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
949
- });
950
- res.end(compressed);
951
- }
952
- else {
953
- res.writeHead(status, {
954
- 'Content-Type': 'application/json',
955
- ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
956
- });
957
- res.end(body);
958
- }
959
- }
960
- function jsonResponse(res, data) {
961
- sendJson(res, 200, Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v))));
962
- }
963
- function jsonError(res, status, message) {
964
- if (res.headersSent)
965
- return;
966
- sendJson(res, status, Buffer.from(JSON.stringify({ error: message })));
967
- }
968
- /** Proxy a request to the user's PDS with DPoP + automatic nonce retry + token refresh. */
969
- async function proxyToPds(oauthConfig, session, method, pdsUrl, body) {
970
- const serverKey = await getServerKey('appview-oauth-key');
971
- const privateJwk = JSON.parse(serverKey.privateKey);
972
- const publicJwk = JSON.parse(serverKey.publicKey);
973
- let accessToken = session.access_token;
974
- async function doFetch(token, nonce) {
975
- const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce);
976
- const res = await fetch(pdsUrl, {
977
- method,
978
- headers: {
979
- 'Content-Type': 'application/json',
980
- Authorization: `DPoP ${token}`,
981
- DPoP: proof,
982
- },
983
- body: JSON.stringify(body),
984
- });
985
- const resBody = await res.json().catch(() => ({}));
986
- return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
987
- }
988
- let result = await doFetch(accessToken);
989
- if (result.ok)
990
- return result;
991
- let nonce;
992
- // Step 1: handle DPoP nonce requirement
993
- if (result.body.error === 'use_dpop_nonce') {
994
- nonce = result.headers.get('DPoP-Nonce') || undefined;
995
- if (nonce) {
996
- result = await doFetch(accessToken, nonce);
997
- if (result.ok)
998
- return result;
999
- }
1000
- }
1001
- // Step 2: handle expired PDS token — refresh and retry
1002
- if (result.body.error === 'invalid_token') {
1003
- const refreshed = await refreshPdsSession(oauthConfig, session);
1004
- if (refreshed) {
1005
- accessToken = refreshed.accessToken;
1006
- result = await doFetch(accessToken, nonce);
1007
- if (result.ok)
1008
- return result;
1009
- // May need DPoP nonce after refresh
1010
- if (result.body.error === 'use_dpop_nonce') {
1011
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1012
- if (nonce)
1013
- result = await doFetch(accessToken, nonce);
1014
- }
1015
- }
1016
- }
1017
- return result;
1433
+ };
1018
1434
  }
1019
- /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */
1020
- async function proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType) {
1021
- const serverKey = await getServerKey('appview-oauth-key');
1022
- const privateJwk = JSON.parse(serverKey.privateKey);
1023
- const publicJwk = JSON.parse(serverKey.publicKey);
1024
- let accessToken = session.access_token;
1025
- async function doFetch(token, nonce) {
1026
- const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce);
1027
- const res = await fetch(pdsUrl, {
1028
- method: 'POST',
1029
- headers: {
1030
- 'Content-Type': contentType,
1031
- 'Content-Length': String(body.length),
1032
- Authorization: `DPoP ${token}`,
1033
- DPoP: proof,
1034
- },
1035
- body: Buffer.from(body),
1036
- });
1037
- const resBody = await res.json().catch(() => ({}));
1038
- return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
1039
- }
1040
- let result = await doFetch(accessToken);
1041
- if (result.ok)
1042
- return result;
1043
- let nonce;
1044
- if (result.body.error === 'use_dpop_nonce') {
1045
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1046
- if (nonce) {
1047
- result = await doFetch(accessToken, nonce);
1048
- if (result.ok)
1049
- return result;
1050
- }
1051
- }
1052
- if (result.body.error === 'invalid_token') {
1053
- const refreshed = await refreshPdsSession(oauthConfig, session);
1054
- if (refreshed) {
1055
- accessToken = refreshed.accessToken;
1056
- result = await doFetch(accessToken, nonce);
1057
- if (result.ok)
1058
- return result;
1059
- if (result.body.error === 'use_dpop_nonce') {
1060
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1061
- if (nonce)
1062
- result = await doFetch(accessToken, nonce);
1063
- }
1064
- }
1065
- }
1066
- return result;
1435
+ // Backward-compatible wrapper
1436
+ export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer, onResync) {
1437
+ const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync });
1438
+ return serve(handler, port);
1067
1439
  }