@hatk/hatk 0.0.1-alpha.40 → 0.0.1-alpha.42

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 (89) hide show
  1. package/dist/cli.js +16 -553
  2. package/dist/database/adapters/sqlite.d.ts.map +1 -1
  3. package/dist/database/adapters/sqlite.js +2 -1
  4. package/dist/database/db.d.ts +23 -0
  5. package/dist/database/db.d.ts.map +1 -1
  6. package/dist/database/db.js +81 -4
  7. package/dist/dev-entry.d.ts.map +1 -1
  8. package/dist/dev-entry.js +2 -1
  9. package/dist/hooks.d.ts +24 -3
  10. package/dist/hooks.d.ts.map +1 -1
  11. package/dist/hooks.js +34 -7
  12. package/dist/indexer.d.ts +2 -0
  13. package/dist/indexer.d.ts.map +1 -1
  14. package/dist/indexer.js +17 -0
  15. package/dist/labels.d.ts +2 -0
  16. package/dist/labels.d.ts.map +1 -1
  17. package/dist/labels.js +5 -0
  18. package/dist/lexicon-resolve.d.ts.map +1 -1
  19. package/dist/lexicon-resolve.js +27 -112
  20. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  21. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  22. package/dist/lexicons/com/atproto/repo/strongRef.json +24 -0
  23. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  24. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  25. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  26. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  27. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  28. package/dist/lexicons/dev/hatk/describeLabels.json +31 -0
  29. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  30. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  31. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  32. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  33. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  34. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  35. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  36. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  37. package/dist/main.js +2 -1
  38. package/dist/oauth/server.d.ts.map +1 -1
  39. package/dist/oauth/server.js +3 -2
  40. package/dist/pds-proxy.d.ts.map +1 -1
  41. package/dist/pds-proxy.js +15 -0
  42. package/dist/server-init.d.ts.map +1 -1
  43. package/dist/server-init.js +3 -2
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +91 -13
  46. package/dist/templates/feed.tpl +14 -0
  47. package/dist/templates/hook.tpl +5 -0
  48. package/dist/templates/label.tpl +15 -0
  49. package/dist/templates/og.tpl +17 -0
  50. package/dist/templates/seed.tpl +11 -0
  51. package/dist/templates/setup.tpl +5 -0
  52. package/dist/templates/test-feed.tpl +19 -0
  53. package/dist/templates/test-xrpc.tpl +19 -0
  54. package/dist/templates/xrpc.tpl +41 -0
  55. package/dist/xrpc.d.ts +14 -0
  56. package/dist/xrpc.d.ts.map +1 -1
  57. package/dist/xrpc.js +27 -0
  58. package/package.json +3 -2
  59. package/public/admin.html +133 -0
  60. package/dist/cloudflare/container.d.ts +0 -73
  61. package/dist/cloudflare/container.d.ts.map +0 -1
  62. package/dist/cloudflare/container.js +0 -232
  63. package/dist/cloudflare/hooks.d.ts +0 -33
  64. package/dist/cloudflare/hooks.d.ts.map +0 -1
  65. package/dist/cloudflare/hooks.js +0 -40
  66. package/dist/cloudflare/init.d.ts +0 -27
  67. package/dist/cloudflare/init.d.ts.map +0 -1
  68. package/dist/cloudflare/init.js +0 -103
  69. package/dist/cloudflare/worker.d.ts +0 -27
  70. package/dist/cloudflare/worker.d.ts.map +0 -1
  71. package/dist/cloudflare/worker.js +0 -54
  72. package/dist/database/adapters/d1.d.ts +0 -56
  73. package/dist/database/adapters/d1.d.ts.map +0 -1
  74. package/dist/database/adapters/d1.js +0 -108
  75. package/dist/db.d.ts +0 -134
  76. package/dist/db.d.ts.map +0 -1
  77. package/dist/db.js +0 -1327
  78. package/dist/fts.d.ts +0 -20
  79. package/dist/fts.d.ts.map +0 -1
  80. package/dist/fts.js +0 -767
  81. package/dist/oauth/hooks.d.ts +0 -10
  82. package/dist/oauth/hooks.d.ts.map +0 -1
  83. package/dist/oauth/hooks.js +0 -40
  84. package/dist/schema.d.ts +0 -59
  85. package/dist/schema.d.ts.map +0 -1
  86. package/dist/schema.js +0 -387
  87. package/dist/test-browser.d.ts +0 -14
  88. package/dist/test-browser.d.ts.map +0 -1
  89. package/dist/test-browser.js +0 -26
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join, extname } from 'node:path';
4
- import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getRepoStatusCounts, getDatabaseSize, deleteLabels, getRecentRecords, listActiveRepoDids, removeRepo, getRepoHandle, getPreferences, putPreference, } from "./database/db.js";
4
+ 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";
5
5
  import { executeFeed, listFeeds } from "./feeds.js";
6
6
  import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from "./xrpc.js";
7
7
  import { resolveRecords } from "./hydrate.js";
@@ -16,8 +16,8 @@ import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyErr
16
16
  import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
17
17
  import { serve } from "./adapter.js";
18
18
  import { renderPage } from "./renderer.js";
19
- function scopeMissingResponse(acceptEncoding) {
20
- const res = withCors(jsonError(401, 'ScopeMissingError', acceptEncoding));
19
+ function scopeMissingResponse(acceptEncoding, handle) {
20
+ const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
21
21
  res.headers.append('Set-Cookie', clearSessionCookieHeader());
22
22
  return res;
23
23
  }
@@ -154,6 +154,53 @@ export function registerCoreHandlers(collections, oauth) {
154
154
  throw new InvalidRequestError('Authentication required');
155
155
  return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
156
156
  });
157
+ registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
158
+ if (!viewer)
159
+ throw new InvalidRequestError('Authentication required');
160
+ const body = input;
161
+ if (!body.subject)
162
+ throw new InvalidRequestError('Missing subject');
163
+ if (!body.label || typeof body.label !== 'string')
164
+ throw new InvalidRequestError('Missing or invalid label');
165
+ const defs = getLabelDefinitions();
166
+ if (!defs.some((d) => d.identifier === body.label)) {
167
+ throw new InvalidRequestError(`Unknown label: ${body.label}`);
168
+ }
169
+ if (body.reason && body.reason.length > 2000) {
170
+ throw new InvalidRequestError('Reason must be 2000 characters or less');
171
+ }
172
+ let subjectUri;
173
+ let subjectDid;
174
+ if (body.subject.uri) {
175
+ subjectUri = body.subject.uri;
176
+ const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/);
177
+ if (!match)
178
+ throw new InvalidRequestError('Invalid subject URI');
179
+ subjectDid = match[1];
180
+ }
181
+ else if (body.subject.did) {
182
+ subjectUri = `at://${body.subject.did}`;
183
+ subjectDid = body.subject.did;
184
+ }
185
+ else {
186
+ throw new InvalidRequestError('Subject must have uri or did');
187
+ }
188
+ const result = await insertReport({
189
+ subjectUri,
190
+ subjectDid,
191
+ label: body.label,
192
+ reason: body.reason,
193
+ reportedBy: viewer.did,
194
+ });
195
+ return {
196
+ id: result.id,
197
+ subject: body.subject,
198
+ label: body.label,
199
+ reason: body.reason || null,
200
+ reportedBy: viewer.did,
201
+ createdAt: new Date().toISOString(),
202
+ };
203
+ });
157
204
  }
158
205
  }
159
206
  /**
@@ -561,7 +608,38 @@ export function createHandler(config) {
561
608
  heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
562
609
  external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
563
610
  };
564
- return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding));
611
+ const openReports = await getOpenReportCount();
612
+ return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts, openReports }, 200, acceptEncoding));
613
+ }
614
+ // GET /admin/reports — list reports
615
+ if (url.pathname === '/admin/reports' && request.method === 'GET') {
616
+ const denied = requireAdmin(viewer, acceptEncoding);
617
+ if (denied)
618
+ return denied;
619
+ const status = url.searchParams.get('status') || 'open';
620
+ const label = url.searchParams.get('label') || undefined;
621
+ const limit = parseInt(url.searchParams.get('limit') || '50');
622
+ const offset = parseInt(url.searchParams.get('offset') || '0');
623
+ const result = await queryReports({ status, label, limit, offset });
624
+ return withCors(json(result, 200, acceptEncoding));
625
+ }
626
+ // POST /admin/reports/resolve — resolve or dismiss a report
627
+ if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
628
+ const denied = requireAdmin(viewer, acceptEncoding);
629
+ if (denied)
630
+ return denied;
631
+ const { id, action } = JSON.parse(await request.text());
632
+ if (!id || !action)
633
+ return withCors(jsonError(400, 'Missing id or action', acceptEncoding));
634
+ if (action !== 'resolve' && action !== 'dismiss')
635
+ return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding));
636
+ const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer.did);
637
+ if (!report)
638
+ return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding));
639
+ if (action === 'resolve') {
640
+ await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]);
641
+ }
642
+ return withCors(json({ ok: true }, 200, acceptEncoding));
565
643
  }
566
644
  // GET /admin/info/:did — repo status info
567
645
  if (url.pathname.startsWith('/admin/info/did:')) {
@@ -757,9 +835,9 @@ export function createHandler(config) {
757
835
  }
758
836
  catch (err) {
759
837
  if (err instanceof ScopeMissingProxyError)
760
- return scopeMissingResponse(acceptEncoding);
838
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
761
839
  if (err instanceof ProxyError)
762
- return withCors(jsonError(err.status, err.message, acceptEncoding));
840
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
763
841
  throw err;
764
842
  }
765
843
  }
@@ -774,9 +852,9 @@ export function createHandler(config) {
774
852
  }
775
853
  catch (err) {
776
854
  if (err instanceof ScopeMissingProxyError)
777
- return scopeMissingResponse(acceptEncoding);
855
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
778
856
  if (err instanceof ProxyError)
779
- return withCors(jsonError(err.status, err.message, acceptEncoding));
857
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
780
858
  throw err;
781
859
  }
782
860
  }
@@ -791,9 +869,9 @@ export function createHandler(config) {
791
869
  }
792
870
  catch (err) {
793
871
  if (err instanceof ScopeMissingProxyError)
794
- return scopeMissingResponse(acceptEncoding);
872
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
795
873
  if (err instanceof ProxyError)
796
- return withCors(jsonError(err.status, err.message, acceptEncoding));
874
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
797
875
  throw err;
798
876
  }
799
877
  }
@@ -809,9 +887,9 @@ export function createHandler(config) {
809
887
  }
810
888
  catch (err) {
811
889
  if (err instanceof ScopeMissingProxyError)
812
- return scopeMissingResponse(acceptEncoding);
890
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
813
891
  if (err instanceof ProxyError)
814
- return withCors(jsonError(err.status, err.message, acceptEncoding));
892
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
815
893
  throw err;
816
894
  }
817
895
  }
@@ -873,7 +951,7 @@ export function createHandler(config) {
873
951
  }
874
952
  catch (err) {
875
953
  if (err instanceof ScopeMissingProxyError)
876
- return scopeMissingResponse(acceptEncoding);
954
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
877
955
  if (err instanceof InvalidRequestError) {
878
956
  return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
879
957
  }
@@ -0,0 +1,14 @@
1
+ import { defineFeed } from '$hatk'
2
+
3
+ export default defineFeed({
4
+ collection: 'your.collection.here',
5
+ label: '{{Name}}',
6
+
7
+ async generate(ctx) {
8
+ const { rows, cursor } = await ctx.paginate<{ uri: string }>(
9
+ `SELECT uri, cid, indexed_at FROM "your.collection.here"`,
10
+ )
11
+
12
+ return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
13
+ },
14
+ })
@@ -0,0 +1,5 @@
1
+ import { defineHook } from '$hatk'
2
+
3
+ export default defineHook('{{name}}', async (ctx) => {
4
+ // Hook logic here
5
+ })
@@ -0,0 +1,15 @@
1
+ import { defineLabel } from '$hatk'
2
+
3
+ export default defineLabel({
4
+ definition: {
5
+ identifier: '{{name}}',
6
+ severity: 'inform',
7
+ blurs: 'none',
8
+ defaultSetting: 'warn',
9
+ locales: [{ lang: 'en', name: '{{Name}}', description: 'Description here' }],
10
+ },
11
+ async evaluate(ctx) {
12
+ // Return array of label identifiers to apply, or empty array
13
+ return []
14
+ },
15
+ })
@@ -0,0 +1,17 @@
1
+ import type { OpengraphContext, OpengraphResult } from '@hatk/hatk/opengraph'
2
+
3
+ export default {
4
+ path: '/og/{{name}}/:id',
5
+ async generate(ctx: OpengraphContext): Promise<OpengraphResult> {
6
+ const { db, params } = ctx
7
+ return {
8
+ element: {
9
+ type: 'div',
10
+ props: {
11
+ style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' },
12
+ children: params.id,
13
+ },
14
+ },
15
+ }
16
+ },
17
+ }
@@ -0,0 +1,11 @@
1
+ import { seed } from '$hatk'
2
+
3
+ const { createAccount, createRecord } = seed()
4
+
5
+ const alice = await createAccount('alice.test')
6
+
7
+ // await createRecord(alice, 'your.collection.here', {
8
+ // field: 'value',
9
+ // }, { rkey: 'my-record' })
10
+
11
+ console.log('\n[seed] Done!')
@@ -0,0 +1,5 @@
1
+ import { defineSetup } from '$hatk'
2
+
3
+ export default defineSetup(async (ctx) => {
4
+ // Setup logic here — runs before the server starts
5
+ })
@@ -0,0 +1,19 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
2
+ import { createTestContext } from '@hatk/hatk/test'
3
+
4
+ let ctx: Awaited<ReturnType<typeof createTestContext>>
5
+
6
+ beforeAll(async () => {
7
+ ctx = await createTestContext()
8
+ await ctx.loadFixtures()
9
+ })
10
+
11
+ afterAll(async () => ctx?.close())
12
+
13
+ describe('{{name}} feed', () => {
14
+ test('returns results', async () => {
15
+ const feed = ctx.loadFeed('{{name}}')
16
+ const result = await feed.generate(ctx.feedContext({ limit: 10 }))
17
+ expect(result).toBeDefined()
18
+ })
19
+ })
@@ -0,0 +1,19 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
2
+ import { createTestContext } from '@hatk/hatk/test'
3
+
4
+ let ctx: Awaited<ReturnType<typeof createTestContext>>
5
+
6
+ beforeAll(async () => {
7
+ ctx = await createTestContext()
8
+ await ctx.loadFixtures()
9
+ })
10
+
11
+ afterAll(async () => ctx?.close())
12
+
13
+ describe('{{name}}', () => {
14
+ test('returns response', async () => {
15
+ const handler = ctx.loadXrpc('{{name}}')
16
+ const result = await handler.handler({ params: {} })
17
+ expect(result).toBeDefined()
18
+ })
19
+ })
@@ -0,0 +1,41 @@
1
+ import { defineQuery } from '$hatk'
2
+
3
+ export default defineQuery('{{name}}', async (ctx) => {
4
+ const { ok, db, params, packCursor, unpackCursor } = ctx
5
+ const limit = params.limit ?? 30
6
+ const cursor = params.cursor
7
+
8
+ const conditions: string[] = []
9
+ const sqlParams: (string | number)[] = []
10
+ let paramIdx = 1
11
+
12
+ if (cursor) {
13
+ const parsed = unpackCursor(cursor)
14
+ if (parsed) {
15
+ conditions.push(`(s.indexed_at < $${paramIdx} OR (s.indexed_at = $${paramIdx + 1} AND s.cid < $${paramIdx + 2}))`)
16
+ sqlParams.push(parsed.primary, parsed.primary, parsed.cid)
17
+ paramIdx += 3
18
+ }
19
+ }
20
+
21
+ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
22
+
23
+ const rows = (await db.query(
24
+ `SELECT s.* FROM "your.collection.here" s ${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $${paramIdx}`,
25
+ sqlParams.concat([limit + 1]),
26
+ )) as {
27
+ uri: string
28
+ cid: string
29
+ did: string
30
+ indexed_at: string
31
+ }[]
32
+
33
+ const hasMore = rows.length > limit
34
+ if (hasMore) rows.pop()
35
+ const lastRow = rows[rows.length - 1]
36
+
37
+ return ok({
38
+ items: rows,
39
+ cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined,
40
+ })
41
+ })
package/dist/xrpc.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { BaseContext } from './hydrate.ts';
2
2
  import type { Row, FlatRow } from './lex-types.ts';
3
+ import type { OAuthConfig } from './config.ts';
3
4
  export type { Row, FlatRow };
5
+ /** Set the OAuth config used for record write helpers. Called once during boot. */
6
+ export declare function configureOAuth(config: OAuthConfig | null): void;
4
7
  /** Thrown from XRPC handlers to return a 400 response with an error message. */
5
8
  export declare class InvalidRequestError extends Error {
6
9
  status: number;
@@ -47,6 +50,17 @@ export interface XrpcContext<P = Record<string, string>, Records extends Record<
47
50
  }>;
48
51
  resolve: <R = unknown>(uris: string[]) => Promise<Row<R>[]>;
49
52
  exists: (collection: string, filters: Record<string, string>) => Promise<boolean>;
53
+ createRecord: (collection: string, record: Record<string, unknown>, opts?: {
54
+ rkey?: string;
55
+ }) => Promise<{
56
+ uri?: string;
57
+ cid?: string;
58
+ }>;
59
+ putRecord: (collection: string, rkey: string, record: Record<string, unknown>) => Promise<{
60
+ uri?: string;
61
+ cid?: string;
62
+ }>;
63
+ deleteRecord: (collection: string, rkey: string) => Promise<void>;
50
64
  }
51
65
  /** Set the relay URL used for blob URL generation. Called once during boot. */
52
66
  export declare function configureRelay(relay: string): void;
@@ -1 +1 @@
1
- {"version":3,"file":"xrpc.d.ts","sourceRoot":"","sources":["../src/xrpc.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"xrpc.d.ts","sourceRoot":"","sources":["../src/xrpc.ts"],"names":[],"mappings":"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"}
package/dist/xrpc.js CHANGED
@@ -32,6 +32,12 @@ import { log, emit, timer } from "./logger.js";
32
32
  import { querySQL, runSQL, packCursor, unpackCursor, isTakendownDid, filterTakendownDids, searchRecords, findUriByFields, } from "./database/db.js";
33
33
  import { resolveRecords, buildBaseContext } from "./hydrate.js";
34
34
  import { getLexicon } from "./database/schema.js";
35
+ import { pdsCreateRecord, pdsPutRecord, pdsDeleteRecord } from "./pds-proxy.js";
36
+ let _oauthConfig = null;
37
+ /** Set the OAuth config used for record write helpers. Called once during boot. */
38
+ export function configureOAuth(config) {
39
+ _oauthConfig = config;
40
+ }
35
41
  /** Thrown from XRPC handlers to return a 400 response with an error message. */
36
42
  export class InvalidRequestError extends Error {
37
43
  status = 400;
@@ -89,6 +95,27 @@ export function buildXrpcContext(params, cursor, limit, viewer, input) {
89
95
  const uri = await findUriByFields(collection, conditions);
90
96
  return uri !== null;
91
97
  },
98
+ createRecord: async (collection, record, opts) => {
99
+ if (!_oauthConfig)
100
+ throw new Error('No OAuth config — cannot write to PDS');
101
+ if (!viewer)
102
+ throw new Error('Authentication required to write records');
103
+ return pdsCreateRecord(_oauthConfig, viewer, { collection, record, rkey: opts?.rkey });
104
+ },
105
+ putRecord: async (collection, rkey, record) => {
106
+ if (!_oauthConfig)
107
+ throw new Error('No OAuth config — cannot write to PDS');
108
+ if (!viewer)
109
+ throw new Error('Authentication required to write records');
110
+ return pdsPutRecord(_oauthConfig, viewer, { collection, rkey, record });
111
+ },
112
+ deleteRecord: async (collection, rkey) => {
113
+ if (!_oauthConfig)
114
+ throw new Error('No OAuth config — cannot write to PDS');
115
+ if (!viewer)
116
+ throw new Error('Authentication required to write records');
117
+ await pdsDeleteRecord(_oauthConfig, viewer, { collection, rkey });
118
+ },
92
119
  };
93
120
  }
94
121
  const handlers = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.40",
3
+ "version": "0.0.1-alpha.42",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"
@@ -28,7 +28,8 @@
28
28
  "./renderer": "./dist/renderer.js"
29
29
  },
30
30
  "scripts": {
31
- "build": "tsc -p tsconfig.build.json",
31
+ "clean": "rm -rf dist",
32
+ "build": "npm run clean && tsc -p tsconfig.build.json && cp -r src/templates dist/templates && cp -r src/lexicons dist/lexicons",
32
33
  "prepublishOnly": "npm run build"
33
34
  },
34
35
  "dependencies": {
package/public/admin.html CHANGED
@@ -1203,6 +1203,7 @@
1203
1203
  <button class="tab active" data-tab="overview">Overview</button>
1204
1204
  <button class="tab" data-tab="repos">Repos</button>
1205
1205
  <button class="tab" data-tab="content">Content</button>
1206
+ <button class="tab" data-tab="reports">Reports</button>
1206
1207
  </nav>
1207
1208
 
1208
1209
  <!-- Overview -->
@@ -1277,6 +1278,21 @@
1277
1278
  <div class="loading">Loading</div>
1278
1279
  </div>
1279
1280
  </div>
1281
+
1282
+ <!-- Reports -->
1283
+ <div class="tab-panel" id="panel-reports">
1284
+ <div class="search-bar">
1285
+ <select class="search-input" id="reports-status" style="max-width: 200px">
1286
+ <option value="open">Open</option>
1287
+ <option value="resolved">Resolved</option>
1288
+ <option value="dismissed">Dismissed</option>
1289
+ </select>
1290
+ <select class="search-input" id="reports-label-filter" style="max-width: 200px">
1291
+ <option value="">All labels</option>
1292
+ </select>
1293
+ </div>
1294
+ <div id="reports-results"></div>
1295
+ </div>
1280
1296
  </div>
1281
1297
 
1282
1298
  <!-- Bottom nav (mobile) -->
@@ -1285,6 +1301,7 @@
1285
1301
  <button class="bnav-btn active" data-tab="overview">Overview</button>
1286
1302
  <button class="bnav-btn" data-tab="repos">Repos</button>
1287
1303
  <button class="bnav-btn" data-tab="content">Content</button>
1304
+ <button class="bnav-btn" data-tab="reports">Reports</button>
1288
1305
  </div>
1289
1306
  </div>
1290
1307
  </div>
@@ -1468,6 +1485,7 @@
1468
1485
  if (tab === 'overview') loadOverview()
1469
1486
  if (tab === 'repos') loadRepos()
1470
1487
  if (tab === 'content') loadContent()
1488
+ if (tab === 'reports') loadReports()
1471
1489
  if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
1472
1490
  }
1473
1491
 
@@ -1494,6 +1512,7 @@
1494
1512
  <div class="stat-card"><div class="stat-label">Pending</div><div class="stat-value yellow">${fmt(repoStatuses.pending)}</div></div>
1495
1513
  <div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${fmt(repoStatuses.failed)}</div></div>
1496
1514
  <div class="stat-card"><div class="stat-label">Taken Down</div><div class="stat-value red">${fmt(repoStatuses.takendown)}</div></div>
1515
+ ${info.openReports > 0 ? `<div class="stat-card" style="cursor:pointer" onclick="activateTab('reports')"><div class="stat-label">Open Reports</div><div class="stat-value yellow">${fmt(info.openReports)}</div></div>` : ''}
1497
1516
  `
1498
1517
 
1499
1518
  const collectionCards = document.getElementById('collection-cards')
@@ -2107,6 +2126,120 @@
2107
2126
  })
2108
2127
  })
2109
2128
  }
2129
+
2130
+ // ── Reports ──
2131
+
2132
+ const reportsPage = { limit: 50, offset: 0 }
2133
+
2134
+ function populateReportsLabelFilter() {
2135
+ const select = document.getElementById('reports-label-filter')
2136
+ const current = select.value
2137
+ select.innerHTML = '<option value="">All labels</option>' +
2138
+ labelDefinitions.map(d => `<option value="${d.identifier}">${d.identifier}</option>`).join('')
2139
+ select.value = current
2140
+ }
2141
+
2142
+ document.getElementById('reports-status').addEventListener('change', () => {
2143
+ reportsPage.offset = 0
2144
+ loadReports()
2145
+ })
2146
+ document.getElementById('reports-label-filter').addEventListener('change', () => {
2147
+ reportsPage.offset = 0
2148
+ loadReports()
2149
+ })
2150
+
2151
+ async function loadReports() {
2152
+ populateReportsLabelFilter()
2153
+ const status = document.getElementById('reports-status').value
2154
+ const label = document.getElementById('reports-label-filter').value
2155
+ const container = document.getElementById('reports-results')
2156
+ container.innerHTML = '<div class="loading">Loading</div>'
2157
+ try {
2158
+ let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}`
2159
+ if (label) url += `&label=${encodeURIComponent(label)}`
2160
+ const result = await api(url)
2161
+ renderReports(result.reports || [], result.total)
2162
+ } catch (e) {
2163
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2164
+ }
2165
+ }
2166
+
2167
+ function renderReports(reports, total) {
2168
+ const container = document.getElementById('reports-results')
2169
+ if (!reports.length) {
2170
+ container.innerHTML = '<div class="empty-state">No reports found</div>'
2171
+ return
2172
+ }
2173
+
2174
+ const showPagination = total != null && total > reportsPage.limit
2175
+ const paginationHtml = showPagination ? `
2176
+ <div class="pagination">
2177
+ <span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span>
2178
+ <div class="pagination-buttons">
2179
+ <button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button>
2180
+ <button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button>
2181
+ </div>
2182
+ </div>
2183
+ ` : ''
2184
+
2185
+ const countLabel = total != null
2186
+ ? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}`
2187
+ : `${reports.length} result${reports.length !== 1 ? 's' : ''}`
2188
+
2189
+ const isOpen = document.getElementById('reports-status').value === 'open'
2190
+
2191
+ container.innerHTML = `
2192
+ <div class="card">
2193
+ <div class="result-count">${countLabel}</div>
2194
+ ${reports.map(r => {
2195
+ const reporterDisplay = r.reported_by_handle ? `@${escapeHtml(r.reported_by_handle)}` : escapeHtml(r.reported_by)
2196
+ const date = new Date(r.created_at).toLocaleString()
2197
+ return `<div class="record-card">
2198
+ <div class="record-header">
2199
+ <div class="record-meta">
2200
+ <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
2201
+ <div class="record-summary">
2202
+ <span class="label-tag">${escapeHtml(r.label)}</span>
2203
+ reported by ${reporterDisplay} &middot; ${date}
2204
+ </div>
2205
+ ${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''}
2206
+ ${!isOpen ? `<div class="record-summary" style="margin-top:0.25rem;opacity:0.6">${escapeHtml(r.status)}${r.resolved_by ? ` by ${escapeHtml(r.resolved_by)}` : ''}</div>` : ''}
2207
+ </div>
2208
+ ${isOpen ? `<div class="record-actions">
2209
+ <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="resolve" style="background:var(--accent);color:white">Apply Label</button>
2210
+ <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button>
2211
+ </div>` : ''}
2212
+ </div>
2213
+ </div>`
2214
+ }).join('')}
2215
+ ${paginationHtml}
2216
+ </div>
2217
+ `
2218
+
2219
+ container.querySelectorAll('[data-reports-page="prev"]').forEach(b => {
2220
+ b.addEventListener('click', () => { reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit); loadReports() })
2221
+ })
2222
+ container.querySelectorAll('[data-reports-page="next"]').forEach(b => {
2223
+ b.addEventListener('click', () => { reportsPage.offset += reportsPage.limit; loadReports() })
2224
+ })
2225
+
2226
+ container.querySelectorAll('[data-action="resolve-report"]').forEach(btn => {
2227
+ btn.addEventListener('click', async () => {
2228
+ const action = btn.dataset.resolve
2229
+ try {
2230
+ await api('/admin/reports/resolve', {
2231
+ method: 'POST',
2232
+ headers: { 'Content-Type': 'application/json' },
2233
+ body: JSON.stringify({ id: parseInt(btn.dataset.id), action }),
2234
+ })
2235
+ toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success')
2236
+ loadReports()
2237
+ } catch (e) {
2238
+ toast(e.message, 'error')
2239
+ }
2240
+ })
2241
+ })
2242
+ }
2110
2243
  </script>
2111
2244
  </body>
2112
2245
  </html>