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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +32 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +130 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +171 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +77 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +130 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +211 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
@@ -0,0 +1,396 @@
1
+ // Shared PDS proxy functions — used by both HTTP route handlers and XRPC handlers.
2
+ import { getSession, getServerKey, deleteSession } from "./oauth/db.js";
3
+ import { createDpopProof } from "./oauth/dpop.js";
4
+ import { refreshPdsSession } from "./oauth/server.js";
5
+ import { validateRecord } from '@bigmoves/lexicon';
6
+ import { getLexiconArray } from "./database/schema.js";
7
+ import { insertRecord, deleteRecord as dbDeleteRecord } from "./database/db.js";
8
+ import { emit } from "./logger.js";
9
+ import { runLabelRules } from "./labels.js";
10
+ export class ProxyError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ export class ScopeMissingProxyError extends ProxyError {
18
+ constructor() {
19
+ super(401, 'ScopeMissingError');
20
+ }
21
+ }
22
+ /**
23
+ * A call the session's grant does not cover, where signing in again would not
24
+ * change that: the server granted less than it was asked for, on purpose. The
25
+ * session is kept and only this call fails, as a 403 — the answer is no, not
26
+ * "authorize again". Named apart from ScopeMissingError on purpose: the browser
27
+ * client and the admin page answer that name by starting a new sign-in.
28
+ */
29
+ export class ScopeWithheldProxyError extends ProxyError {
30
+ constructor() {
31
+ super(403, 'ScopeWithheld');
32
+ }
33
+ }
34
+ /**
35
+ * Did the server grant this session less than it was asked for?
36
+ *
37
+ * A scope refusal means one of two things. Either the session predates a
38
+ * scope the app has since added, and a new sign-in asks for it — or the server
39
+ * chose not to grant it, as a group's host does when someone signs in as the
40
+ * group, and a new sign-in gets the same answer. Ending the session is the
41
+ * fix for the first and, for the second, signs the account out on every page
42
+ * that makes a call it may not.
43
+ *
44
+ * Narrower is counted, not compared: a server reports its grant in its own
45
+ * words — the reference PDS drops default parameters, sorts collections and
46
+ * fills them in from a space's declaration — so a full grant rarely matches the
47
+ * request string for string. It does keep one scope for each one asked for,
48
+ * and narrowing takes whole scopes away, so fewer scopes granted than asked
49
+ * for is the sign.
50
+ *
51
+ * A session that predates recording either keeps the old answer.
52
+ */
53
+ function withheldByServer(session) {
54
+ if (!session.requested_scope || !session.granted_scope)
55
+ return false;
56
+ const count = (scope) => new Set(scope.split(/\s+/).filter(Boolean)).size;
57
+ return count(session.granted_scope) < count(session.requested_scope);
58
+ }
59
+ /** End the session so the user authorizes again — unless that could not help. */
60
+ async function refuseForScope(session) {
61
+ if (withheldByServer(session))
62
+ throw new ScopeWithheldProxyError();
63
+ await deleteSession(session.did);
64
+ throw new ScopeMissingProxyError();
65
+ }
66
+ /** Shared retry logic: DPoP nonce handling + token refresh. */
67
+ async function withDpopRetry(oauthConfig, session, doFetch) {
68
+ let accessToken = session.access_token;
69
+ let result = await doFetch(accessToken);
70
+ if (result.ok)
71
+ return result;
72
+ let nonce;
73
+ // Step 1: handle DPoP nonce requirement
74
+ if (isNonceChallenge(result)) {
75
+ nonce = result.headers.get('DPoP-Nonce') || undefined;
76
+ if (nonce) {
77
+ result = await doFetch(accessToken, nonce);
78
+ if (result.ok)
79
+ return result;
80
+ }
81
+ }
82
+ // Step 2: handle insufficient scope — see refuseForScope
83
+ if (result.body.error === 'ScopeMissingError')
84
+ await refuseForScope(session);
85
+ // Step 3: handle expired PDS token — refresh and retry
86
+ if (isTokenError(result.body.error)) {
87
+ const refreshed = await refreshPdsSession(oauthConfig, session);
88
+ if (refreshed) {
89
+ accessToken = refreshed.accessToken;
90
+ result = await doFetch(accessToken, nonce);
91
+ if (result.ok)
92
+ return result;
93
+ if (isNonceChallenge(result)) {
94
+ nonce = result.headers.get('DPoP-Nonce') || undefined;
95
+ if (nonce)
96
+ result = await doFetch(accessToken, nonce);
97
+ }
98
+ // A token minted seconds ago cannot be expired, so a second refusal is
99
+ // about the grant rather than the token: the session was authorized
100
+ // before the app asked for the scope this call needs, and no number of
101
+ // refreshes will add it. Read as a scope problem it becomes the prompt to
102
+ // authorize again; read as expiry it was an opaque 500.
103
+ //
104
+ // Not every PDS names this case. The reference answers ScopeMissingError,
105
+ // handled above; pds.js answers InvalidToken for a scope its permissioned
106
+ // space endpoints refuse, which is indistinguishable from expiry until
107
+ // the refresh rules expiry out.
108
+ if (!result.ok && isTokenError(result.body.error))
109
+ await refuseForScope(session);
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ /**
115
+ * Is this refusal a DPoP nonce challenge — a request to retry, not a dead token?
116
+ *
117
+ * A nonce challenge is the one 401 that costs nothing to satisfy: the server
118
+ * hands back the nonce it wants in the same response. Miss it and the failure
119
+ * falls through to the token-error branch below, which refreshes, replays the
120
+ * identical proof, is refused identically, and drops the user's session — a
121
+ * re-authorization to fix a header.
122
+ *
123
+ * So it is read the way RFC 9449 §8 writes it, from
124
+ * `WWW-Authenticate: DPoP error="use_dpop_nonce"`, and only then from the body.
125
+ * The body is not always available to read: zds sends that header but an
126
+ * `InvalidToken` body, because its XRPC error handler overwrites the
127
+ * `UseDpopNonce` one on the way out.
128
+ *
129
+ * The `DPoP-Nonce` header alone is not the signal. A server may attach one to
130
+ * every 401 it sends, including 401s about a genuinely invalid token, and
131
+ * treating those as challenges would replay every dead token once for nothing.
132
+ */
133
+ function isNonceChallenge(result) {
134
+ const error = result.body.error;
135
+ if (error === 'use_dpop_nonce' || error === 'UseDpopNonce')
136
+ return true;
137
+ return result.status === 401 && (result.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce');
138
+ }
139
+ /**
140
+ * The PDS returns 'InvalidToken' or 'ExpiredToken' (AT Proto PascalCase
141
+ * convention) while the OAuth spec uses 'invalid_token' (RFC 6750 snake_case).
142
+ */
143
+ function isTokenError(error) {
144
+ return error === 'invalid_token' || error === 'InvalidToken' || error === 'ExpiredToken';
145
+ }
146
+ async function proxyToPds(oauthConfig, session, method, pdsUrl, body) {
147
+ const serverKey = await getServerKey('appview-oauth-key');
148
+ const privateJwk = JSON.parse(serverKey.privateKey);
149
+ const publicJwk = JSON.parse(serverKey.publicKey);
150
+ return withDpopRetry(oauthConfig, session, async (token, nonce) => {
151
+ const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce);
152
+ // A GET carries no body, and sending a JSON content-type without one makes
153
+ // some servers try to parse it. Queries reach here through pdsXrpc.
154
+ const hasBody = method !== 'GET' && body !== undefined;
155
+ const res = await fetch(pdsUrl, {
156
+ method,
157
+ headers: {
158
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
159
+ Authorization: `DPoP ${token}`,
160
+ DPoP: proof,
161
+ },
162
+ body: hasBody ? JSON.stringify(body) : undefined,
163
+ });
164
+ const resBody = await res.json().catch(() => ({}));
165
+ return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
166
+ });
167
+ }
168
+ /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */
169
+ async function proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType) {
170
+ const serverKey = await getServerKey('appview-oauth-key');
171
+ const privateJwk = JSON.parse(serverKey.privateKey);
172
+ const publicJwk = JSON.parse(serverKey.publicKey);
173
+ return withDpopRetry(oauthConfig, session, async (token, nonce) => {
174
+ const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce);
175
+ const res = await fetch(pdsUrl, {
176
+ method: 'POST',
177
+ headers: {
178
+ 'Content-Type': contentType,
179
+ 'Content-Length': String(body.length),
180
+ Authorization: `DPoP ${token}`,
181
+ DPoP: proof,
182
+ },
183
+ body: Buffer.from(body),
184
+ });
185
+ const resBody = await res.json().catch(() => ({}));
186
+ return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
187
+ });
188
+ }
189
+ /**
190
+ * Call any XRPC method on the user's PDS with their session.
191
+ *
192
+ * The escape hatch beside the typed record helpers above, for methods hatk has
193
+ * no opinion about — `com.atproto.space.*` and the rest of proposal 0016 being
194
+ * the case it was added for. Nothing is validated or indexed: the response is
195
+ * whatever the PDS sent, and it is the caller's job to know its shape.
196
+ *
197
+ * The session's granted scopes still apply. A method the user never authorized
198
+ * comes back as a 403 from the PDS, not from here.
199
+ */
200
+ export async function pdsXrpc(oauthConfig, viewer, nsid, options = {}) {
201
+ const session = await getSession(viewer.did);
202
+ if (!session)
203
+ throw new ProxyError(401, 'No PDS session for user');
204
+ const method = options.method ?? 'GET';
205
+ const url = new URL(`${session.pds_endpoint}/xrpc/${nsid}`);
206
+ for (const [key, value] of Object.entries(options.params ?? {})) {
207
+ if (value === undefined)
208
+ continue;
209
+ if (Array.isArray(value)) {
210
+ for (const v of value)
211
+ url.searchParams.append(key, String(v));
212
+ }
213
+ else {
214
+ url.searchParams.append(key, String(value));
215
+ }
216
+ }
217
+ const res = await proxyToPds(oauthConfig, session, method, url.toString(), options.body);
218
+ if (!res.ok) {
219
+ throw new ProxyError(res.status, String(res.body.error || `${nsid} failed`));
220
+ }
221
+ return res.body;
222
+ }
223
+ export async function pdsCreateRecord(oauthConfig, viewer, input) {
224
+ const validationError = validateRecord(getLexiconArray(), input.collection, input.record);
225
+ if (validationError) {
226
+ throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
227
+ }
228
+ const session = await getSession(viewer.did);
229
+ if (!session)
230
+ throw new ProxyError(401, 'No PDS session for user');
231
+ const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord`;
232
+ const pdsBody = {
233
+ repo: viewer.did,
234
+ collection: input.collection,
235
+ rkey: input.rkey,
236
+ record: input.record,
237
+ };
238
+ const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody);
239
+ if (!pdsRes.ok)
240
+ throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS write failed'));
241
+ try {
242
+ await insertRecord(input.collection, String(pdsRes.body.uri), String(pdsRes.body.cid), viewer.did, input.record);
243
+ await runLabelRules({
244
+ uri: String(pdsRes.body.uri),
245
+ cid: String(pdsRes.body.cid),
246
+ did: viewer.did,
247
+ collection: input.collection,
248
+ value: input.record,
249
+ });
250
+ }
251
+ catch (err) {
252
+ emit('pds-proxy', 'local_index_error', {
253
+ op: 'createRecord',
254
+ error: err instanceof Error ? err.message : String(err),
255
+ });
256
+ }
257
+ return pdsRes.body;
258
+ }
259
+ export async function pdsDeleteRecord(oauthConfig, viewer, input) {
260
+ const session = await getSession(viewer.did);
261
+ if (!session)
262
+ throw new ProxyError(401, 'No PDS session for user');
263
+ const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord`;
264
+ const pdsBody = {
265
+ repo: viewer.did,
266
+ collection: input.collection,
267
+ rkey: input.rkey,
268
+ };
269
+ const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody);
270
+ if (!pdsRes.ok)
271
+ throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS delete failed'));
272
+ try {
273
+ const uri = `at://${viewer.did}/${input.collection}/${input.rkey}`;
274
+ await dbDeleteRecord(input.collection, uri);
275
+ }
276
+ catch (err) {
277
+ emit('pds-proxy', 'local_index_error', {
278
+ op: 'deleteRecord',
279
+ error: err instanceof Error ? err.message : String(err),
280
+ });
281
+ }
282
+ return pdsRes.body;
283
+ }
284
+ export async function pdsPutRecord(oauthConfig, viewer, input) {
285
+ const validationError = validateRecord(getLexiconArray(), input.collection, input.record);
286
+ if (validationError) {
287
+ throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
288
+ }
289
+ const session = await getSession(viewer.did);
290
+ if (!session)
291
+ throw new ProxyError(401, 'No PDS session for user');
292
+ const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord`;
293
+ const pdsBody = {
294
+ repo: viewer.did,
295
+ collection: input.collection,
296
+ rkey: input.rkey,
297
+ record: input.record,
298
+ };
299
+ const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody);
300
+ if (!pdsRes.ok)
301
+ throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS write failed'));
302
+ try {
303
+ await insertRecord(input.collection, String(pdsRes.body.uri), String(pdsRes.body.cid), viewer.did, input.record);
304
+ await runLabelRules({
305
+ uri: String(pdsRes.body.uri),
306
+ cid: String(pdsRes.body.cid),
307
+ did: viewer.did,
308
+ collection: input.collection,
309
+ value: input.record,
310
+ });
311
+ }
312
+ catch (err) {
313
+ emit('pds-proxy', 'local_index_error', { op: 'putRecord', error: err instanceof Error ? err.message : String(err) });
314
+ }
315
+ return pdsRes.body;
316
+ }
317
+ /** Map dev.hatk.applyWrites#* types to com.atproto.repo.applyWrites#* for PDS. */
318
+ function toPdsWriteType($type) {
319
+ return $type.replace('dev.hatk.applyWrites#', 'com.atproto.repo.applyWrites#');
320
+ }
321
+ function isCreateOrUpdate($type) {
322
+ const mapped = toPdsWriteType($type);
323
+ return mapped === 'com.atproto.repo.applyWrites#create' || mapped === 'com.atproto.repo.applyWrites#update';
324
+ }
325
+ export async function pdsApplyWrites(oauthConfig, viewer, input) {
326
+ // Validate all create/update records before sending
327
+ for (const write of input.writes) {
328
+ if (isCreateOrUpdate(write.$type) && write.value) {
329
+ const validationError = validateRecord(getLexiconArray(), write.collection, write.value);
330
+ if (validationError) {
331
+ throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
332
+ }
333
+ }
334
+ }
335
+ const session = await getSession(viewer.did);
336
+ if (!session)
337
+ throw new ProxyError(401, 'No PDS session for user');
338
+ const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.applyWrites`;
339
+ const pdsBody = {
340
+ repo: viewer.did,
341
+ writes: input.writes.map((w) => ({ ...w, $type: toPdsWriteType(w.$type) })),
342
+ };
343
+ const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody);
344
+ if (!pdsRes.ok)
345
+ throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS applyWrites failed'));
346
+ // Index results locally
347
+ const results = pdsRes.body.results ?? [];
348
+ for (let i = 0; i < input.writes.length; i++) {
349
+ const write = input.writes[i];
350
+ const result = results[i];
351
+ try {
352
+ const mapped = toPdsWriteType(write.$type);
353
+ if (mapped === 'com.atproto.repo.applyWrites#create' && result?.uri && result?.cid && write.value) {
354
+ await insertRecord(write.collection, result.uri, result.cid, viewer.did, write.value);
355
+ await runLabelRules({
356
+ uri: result.uri,
357
+ cid: result.cid,
358
+ did: viewer.did,
359
+ collection: write.collection,
360
+ value: write.value,
361
+ });
362
+ }
363
+ else if (mapped === 'com.atproto.repo.applyWrites#update' && result?.uri && result?.cid && write.value) {
364
+ await insertRecord(write.collection, result.uri, result.cid, viewer.did, write.value);
365
+ await runLabelRules({
366
+ uri: result.uri,
367
+ cid: result.cid,
368
+ did: viewer.did,
369
+ collection: write.collection,
370
+ value: write.value,
371
+ });
372
+ }
373
+ else if (mapped === 'com.atproto.repo.applyWrites#delete' && write.rkey) {
374
+ const uri = `at://${viewer.did}/${write.collection}/${write.rkey}`;
375
+ await dbDeleteRecord(write.collection, uri);
376
+ }
377
+ }
378
+ catch (err) {
379
+ emit('pds-proxy', 'local_index_error', {
380
+ op: 'applyWrites',
381
+ error: err instanceof Error ? err.message : String(err),
382
+ });
383
+ }
384
+ }
385
+ return pdsRes.body;
386
+ }
387
+ export async function pdsUploadBlob(oauthConfig, viewer, body, contentType) {
388
+ const session = await getSession(viewer.did);
389
+ if (!session)
390
+ throw new ProxyError(401, 'No PDS session for user');
391
+ const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.uploadBlob`;
392
+ const pdsRes = await proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType);
393
+ if (!pdsRes.ok)
394
+ throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS upload failed'));
395
+ return pdsRes.body;
396
+ }
@@ -0,0 +1,13 @@
1
+ export declare function setPrivateCollections(list: string[]): void;
2
+ export declare function isPrivateCollection(nsid: string | null | undefined): boolean;
3
+ /**
4
+ * The collection segment of a record AT-URI, in either shape.
5
+ *
6
+ * A repo record is `at://{did}/{collection}/{rkey}`; a space record is
7
+ * `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
8
+ * the position a collection used to occupy holds the literal 'space'. Reading
9
+ * it positionally without that distinction reports every space record as
10
+ * belonging to a collection named 'space', which no guard would match.
11
+ */
12
+ export declare function collectionFromUri(uri: string): string | undefined;
13
+ //# sourceMappingURL=private-collections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"private-collections.d.ts","sourceRoot":"","sources":["../src/private-collections.ts"],"names":[],"mappings":"AAWA,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE1D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE"}
@@ -0,0 +1,27 @@
1
+ import { collectionFromRecordUri } from "./spaces/uri.js";
2
+ /**
3
+ * Collections that are indexed and queryable in-process but must never be
4
+ * served by the built-in dev.hatk.* record endpoints.
5
+ *
6
+ * Held module-level, mirroring the schema registry, so the guard can be applied
7
+ * inside handlers without threading config through every signature.
8
+ */
9
+ let privateCollections = new Set();
10
+ export function setPrivateCollections(list) {
11
+ privateCollections = new Set(list);
12
+ }
13
+ export function isPrivateCollection(nsid) {
14
+ return nsid != null && privateCollections.has(nsid);
15
+ }
16
+ /**
17
+ * The collection segment of a record AT-URI, in either shape.
18
+ *
19
+ * A repo record is `at://{did}/{collection}/{rkey}`; a space record is
20
+ * `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
21
+ * the position a collection used to occupy holds the literal 'space'. Reading
22
+ * it positionally without that distinction reports every space record as
23
+ * belonging to a collection named 'space', which no guard would match.
24
+ */
25
+ export function collectionFromUri(uri) {
26
+ return collectionFromRecordUri(uri);
27
+ }
package/dist/push.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface ApnsConfig {
2
+ keyFile: string;
3
+ keyId: string;
4
+ teamId: string;
5
+ bundleId: string;
6
+ production?: boolean;
7
+ }
8
+ /**
9
+ * Firebase Cloud Messaging, for Android devices.
10
+ *
11
+ * `keyFile` is a Google service-account JSON with the Firebase Messaging role,
12
+ * resolved like the APNs key: relative to the config file. The project id is
13
+ * read from that file unless overridden here.
14
+ */
15
+ export interface FcmConfig {
16
+ keyFile: string;
17
+ projectId?: string;
18
+ }
19
+ export interface PushConfig {
20
+ apns?: ApnsConfig;
21
+ fcm?: FcmConfig;
22
+ }
23
+ export interface PushPayload {
24
+ did: string;
25
+ title: string;
26
+ body: string;
27
+ data?: Record<string, string>;
28
+ collapseId?: string;
29
+ badge?: number;
30
+ }
31
+ export interface PushInterface {
32
+ send: (payload: PushPayload) => Promise<void>;
33
+ }
34
+ /**
35
+ * Initialize push with config. Must be called before send().
36
+ *
37
+ * Each transport is loaded on its own: a missing or malformed credential
38
+ * disables that platform and leaves the other one sending, rather than taking
39
+ * push down entirely.
40
+ */
41
+ export declare function initPush(config: PushConfig, configDir: string): void;
42
+ /** Check if push is configured and at least one transport is usable. */
43
+ export declare function isPushEnabled(): boolean;
44
+ /** Which platforms this process can actually deliver to, for startup logging. */
45
+ export declare function enabledPushTransports(): string[];
46
+ /** Build the push interface injected into hook contexts. */
47
+ export declare function buildPushInterface(): PushInterface;
48
+ /** Register a push token for a DID. Upserts on conflict. */
49
+ export declare function registerToken(did: string, token: string, platform: string): Promise<void>;
50
+ /** Remove a push token. */
51
+ export declare function removeToken(token: string): Promise<void>;
52
+ /** Unregister a specific token for a DID. */
53
+ export declare function unregisterToken(did: string, token: string): Promise<void>;
54
+ //# sourceMappingURL=push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,GAAG,CAAC,EAAE,SAAS,CAAA;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAsBD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CA4CpE;AAED,wEAAwE;AACxE,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,iFAAiF;AACjF,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAKhD;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;AAmSD,4DAA4D;AAC5D,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO/F;AAED,2BAA2B;AAC3B,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAED,6CAA6C;AAC7C,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E"}