@preventive/triage 1.0.0-alpha.14 → 1.0.0-alpha.16

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 (59) hide show
  1. package/common/scan-server.ts +13 -0
  2. package/common/server-info.ts +1 -0
  3. package/common/utf8.d.ts +3 -0
  4. package/common/utf8.js +45 -0
  5. package/out/brotli-fallback.js +3 -3
  6. package/out/client-managed.js +83 -1
  7. package/out/client-sync.js +14 -14
  8. package/out/graph.js +30 -5
  9. package/out/index.html +49 -8
  10. package/out/prism.js +2 -2
  11. package/out/provider-icons/claude.svg +1 -0
  12. package/out/provider-icons/deepseek.svg +1 -0
  13. package/out/provider-icons/google.svg +1 -0
  14. package/out/provider-icons/grok.svg +1 -0
  15. package/out/provider-icons/moonshot.svg +1 -0
  16. package/out/provider-icons/nvidia.svg +1 -0
  17. package/out/provider-icons/openai.svg +1 -0
  18. package/out/provider-icons/openrouter.svg +1 -0
  19. package/out/provider-icons/qwen.svg +1 -0
  20. package/out/provider-icons/zai.svg +1 -0
  21. package/out/stasis.svg +45 -0
  22. package/out/terminal.js +266 -45
  23. package/out/view.css +1 -1
  24. package/out/view.js +157 -119
  25. package/package.json +31 -5
  26. package/report/index.js +253 -0
  27. package/report/src/finding-id.js +80 -0
  28. package/report/src/finding.js +300 -0
  29. package/report/src/labels.js +33 -0
  30. package/report/src/md-structure.js +471 -0
  31. package/report/src/md-text.js +167 -0
  32. package/report/src/meta.js +76 -0
  33. package/report/src/parse-codex.js +147 -0
  34. package/report/src/parse-deepsec.js +197 -0
  35. package/report/src/parse-deepview-fields.js +375 -0
  36. package/report/src/parse-deepview-md.js +185 -0
  37. package/report/src/parse-md-id.js +137 -0
  38. package/report/src/parse-md.js +322 -0
  39. package/report/src/parse-piolium-id.js +79 -0
  40. package/report/src/parse-piolium-rows.js +131 -0
  41. package/report/src/parse-piolium-tokens.js +175 -0
  42. package/report/src/parse-piolium.js +400 -0
  43. package/report/src/utf8.js +21 -0
  44. package/report/src/write-md-finding.js +273 -0
  45. package/report/src/write-md.js +291 -0
  46. package/server-common/scan-config.ts +19 -0
  47. package/server-e2e/bus-receiver.ts +1 -0
  48. package/server-e2e/config.ts +6 -0
  49. package/server-e2e/http.ts +1 -1
  50. package/server-e2e/hub.ts +37 -6
  51. package/server-e2e/index.ts +4 -4
  52. package/server-e2e/objstore/handlers.ts +6 -5
  53. package/server-e2e/objstore/init.ts +1 -1
  54. package/server-e2e/objstore/rest-mint.ts +2 -2
  55. package/server-e2e/objstore/rest.ts +2 -2
  56. package/server-e2e/pubsub.ts +8 -5
  57. package/server-e2e/static.ts +19 -11
  58. package/server-e2e/sync-handlers.ts +54 -28
  59. package/out/client-admin.js +0 -28
package/server-e2e/hub.ts CHANGED
@@ -7,9 +7,13 @@
7
7
  // testable and reasoned about on its own.
8
8
 
9
9
  import type { WebSocket } from 'ws'
10
+ import { Buffer } from 'node:buffer'
10
11
  import type { PeerRegistry } from './peer.ts'
11
12
 
12
13
  export type Hub = {
14
+ // Buffer broadcasts during async subscription snapshots. The returned
15
+ // release flushes them after the snapshot/chain replies, preserving order.
16
+ pauseBroadcasts(socket: WebSocket): () => void
13
17
  subscribe(socket: WebSocket, tag: string): void
14
18
  unsubscribeAll(socket: WebSocket): void
15
19
  send(socket: WebSocket, msg: object): void
@@ -38,18 +42,34 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
38
42
  // workspaceTag → Set<WebSocket>. The per-socket reverse index lives on
39
43
  // `Peer.tags` (see ./peer.ts) and is read by `unsubscribeAll` on close.
40
44
  const subscribers = new Map<string, Set<WebSocket>>()
45
+ const paused = new WeakMap<WebSocket, { depth: number; payloads: string[]; bytes: number }>()
46
+
47
+ function pauseBroadcasts(socket: WebSocket): () => void {
48
+ const pending = paused.get(socket) ?? { depth: 0, payloads: [], bytes: 0 }
49
+ pending.depth++
50
+ paused.set(socket, pending)
51
+ let released = false
52
+ return () => {
53
+ if (released) return
54
+ released = true
55
+ if (--pending.depth > 0 || paused.get(socket) !== pending) return
56
+ paused.delete(socket)
57
+ for (const payload of pending.payloads) sendRaw(socket, payload)
58
+ pending.payloads.length = 0
59
+ }
60
+ }
41
61
 
42
62
  function subscribe(socket: WebSocket, tag: string): void {
43
63
  let set = subscribers.get(tag)
44
- if (!set) {
45
- set = new Set()
46
- subscribers.set(tag, set)
47
- }
64
+ if (!set) { set = new Set(); subscribers.set(tag, set) }
48
65
  set.add(socket)
49
66
  peers.get(socket)?.tags.add(tag)
50
67
  }
51
68
 
52
69
  function unsubscribeAll(socket: WebSocket): void {
70
+ const pending = paused.get(socket)
71
+ if (pending) pending.payloads.length = 0
72
+ paused.delete(socket)
53
73
  const tags = peers.get(socket)?.tags
54
74
  if (!tags) return
55
75
  for (const tag of tags) {
@@ -102,6 +122,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
122
  }
103
123
 
104
124
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
125
+ const payloadBytes = Buffer.byteLength(payload)
105
126
  // Snapshot before iterating — a socket transitioning to CLOSED
106
127
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
107
128
  // mutating `set` while we walk it. The snapshot also keeps a future
@@ -109,9 +130,19 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
109
130
  // subscribers. Audit M4 round-3.
110
131
  for (const s of [...set]) {
111
132
  if (s === except) continue
112
- sendRaw(s, payload)
133
+ const pending = paused.get(s)
134
+ if (!pending) { sendRaw(s, payload); continue }
135
+ pending.bytes += payloadBytes
136
+ if (pending.bytes > maxBufferedBytes) {
137
+ // Snapshot waits must obey the same memory bound as socket writes.
138
+ pending.payloads.length = 0
139
+ paused.delete(s)
140
+ try { s.terminate() } catch {}
141
+ continue
142
+ }
143
+ pending.payloads.push(payload)
113
144
  }
114
145
  }
115
146
 
116
- return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
147
+ return { pauseBroadcasts, subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
117
148
  }
@@ -289,12 +289,13 @@ const publishRevision = (tag: string, revisionId: string): void => {
289
289
  const publishObjPut = (tag: string, resourceTag: string): void => {
290
290
  pubsub.publish({ kind: 'objput', tag, res: resourceTag })
291
291
  }
292
- const publishObjDeleted = (tag: string, resourceTag: string, version: number): void => {
293
- pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
292
+ const publishObjDeleted = (tag: string, resourceTag: string, version: number, incarnation: string): void => {
293
+ pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version, incarnation })
294
294
  }
295
295
 
296
296
  const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
297
297
  handle, send, broadcast, publishRevision, subscribe, getNonce,
298
+ pauseBroadcasts: hub.pauseBroadcasts,
298
299
  requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
299
300
  // Folds the objstore inventory into the `workspace-subscribed` ack.
300
301
  // The objstore store keeps its own richer `Handle`, so we wire the
@@ -345,7 +346,7 @@ const { track, isShuttingDown, install: installLifecycle } = createLifecycle()
345
346
  // The sync protocol this build advertises — emitted as a `server-info` frame
346
347
  // right after the challenge on every connection. This is the e2e boot, so it
347
348
  // always advertises e2e.
348
- const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null }
349
+ const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}) }
349
350
 
350
351
  // Shared per-connection dispatch surface. Both the WS plane
351
352
  // (installWsServer below) and the SSE+POST fallback (installSseServer
@@ -483,4 +484,3 @@ export { httpServer, wss }
483
484
  if (import.meta.main) {
484
485
  start()
485
486
  }
486
-
@@ -48,9 +48,9 @@ export type ObjstoreDeps = {
48
48
  // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
49
  // local `broadcast` after a successful delete so peers on OTHER
50
50
  // server instances see the version drop in real time. Carries the
51
- // full (tag, resourceTag, version) tuple inline — the workspace_object
51
+ // full (tag, resourceTag, version, incarnation) tuple inline — the workspace_object
52
52
  // row is gone post-delete, so the bus payload IS the wire data.
53
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
54
54
  getNonce: (socket: WebSocket) => string | undefined
55
55
  debug: boolean
56
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -191,13 +191,14 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
191
191
  // observes its own PUTs as echo events. The same symmetry on
192
192
  // `onDeleted` lets `session.onDeleted` fire for the session's
193
193
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
194
- deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
194
+ // A successful nonempty delete matched this authenticated incarnation.
195
+ deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
195
196
  // Cross-instance fan-out (Neon mode). The workspace_object row is
196
197
  // gone post-delete so the bus payload carries (tag, resourceTag,
197
- // version) inline; the receiver builds its `objstore-deleted`
198
+ // version, incarnation) inline; the receiver builds its `objstore-deleted`
198
199
  // broadcast directly from the bus envelope. SQLite mode publishes
199
200
  // to a no-op.
200
- deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
201
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion, prevIncarnation!)
201
202
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
202
203
  }
203
204
 
@@ -30,7 +30,7 @@ export type ObjstoreInitDeps = {
30
30
  // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
31
31
  // server-e2e/pubsub.ts for the bus design.
32
32
  publishObjPut: (tag: string, resourceTag: string) => void
33
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
33
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
34
34
  getNonce: (socket: WebSocket) => string | undefined
35
35
  debug: boolean
36
36
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -219,6 +219,6 @@ async function handleRestDelete(
219
219
  // fans out to subscribers (including the originator, matching the WS path's
220
220
  // `except: null`) so peers' `onDeleted` fire.
221
221
  if (result.deletedVersion === 0) return
222
- deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion }, null)
223
- deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion)
222
+ deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
223
+ deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion, prevIncarnation!)
224
224
  }
@@ -71,8 +71,8 @@ export type ObjstoreRestDeps = {
71
71
  // Cross-instance pub/sub for objstore-deleted — fired alongside the local
72
72
  // `broadcast` after a successful REST delete mint, so peers on OTHER
73
73
  // instances drop the resource in real time. Carries (tag, resourceTag,
74
- // version) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
74
+ // version, incarnation) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
76
76
  // New-workspace operator gate for the REST put-begin mint — the
77
77
  // connection-independent analog of the WS path's `authGate`. Returns
78
78
  // `true` to DENY (a password is configured AND the workspace is
@@ -57,13 +57,13 @@ function newSenderId(): string {
57
57
  // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
58
  // receiver SELECTs the live row by (tag, res) for the rest of the
59
59
  // metadata fields.
60
- // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
- // — inline because the row is gone from workspace_object after the
62
- // delete commit.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
61
+ // the deleted row, which is gone from workspace_object after commit.
62
+ // Incarnation is optional only for messages from older relay instances.
63
63
  export type BusMessage =
64
64
  | { kind: 'rev'; tag: string; id: string }
65
65
  | { kind: 'objput'; tag: string; res: string }
66
- | { kind: 'objdel'; tag: string; res: string; ver: number }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
67
67
 
68
68
  // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
69
  // runs once per remote message; failures are logged but don't crash the
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
388
388
  const ver = raw['ver']
389
389
  if (typeof res !== 'string') return null
390
390
  if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
391
- return { kind: 'objdel', tag, res, ver }
391
+ const incarnation = raw['incarnation']
392
+ if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
393
+ if (typeof incarnation !== 'string' || incarnation.length === 0) return null
394
+ return { kind: 'objdel', tag, res, ver, incarnation }
392
395
  }
393
396
  return null
394
397
  }
@@ -6,8 +6,9 @@
6
6
  // `join(staticDir, dirent.name)` from `readdirSync` at boot — the
7
7
  // request URL is only used as a Map key, never joined with the
8
8
  // filesystem — so the handler has no path-traversal surface at all:
9
- // `..`, percent-encoded slashes, nested subpaths and absolute-form
10
- // URIs all just produce a key that isn't in the map and 404.
9
+ // `..`, percent-encoded slashes and absolute-form URIs all just
10
+ // produce a key that isn't in the map and 404. Provider SVGs are
11
+ // the one explicitly allowed asset subdirectory.
11
12
  //
12
13
  // Compression: every entry whose extension is in COMPRESSIBLE gets
13
14
  // pre-computed brotli + gzip at boot. The handler picks brotli over
@@ -38,6 +39,7 @@ import { readFileSync, readdirSync } from 'node:fs'
38
39
  import { createHash } from 'node:crypto'
39
40
  import { brotliCompressSync, gzipSync, constants as zlibConstants } from 'node:zlib'
40
41
  import { extname, join } from 'node:path'
42
+ import { scanServerHtml } from '../server-common/scan-config.ts'
41
43
 
42
44
  // MIME types for extensions the production UI bundle currently
43
45
  // emits. Anything else falls through to `application/octet-stream`
@@ -102,8 +104,8 @@ export type StaticHandler = (req: HttpRequest, res: ServerResponse) => boolean
102
104
  // caller falls through to its next route. Missing `staticDir`
103
105
  // (pre-build case) logs a warning and returns a handler that always
104
106
  // answers false; the API/WS planes are unaffected.
105
- export function loadStatic(staticDir: string): StaticHandler {
106
- const files = readStaticFiles(staticDir)
107
+ export function loadStatic(staticDir: string, deepviewScanServer: string | null = null): StaticHandler {
108
+ const files = readStaticFiles(staticDir, deepviewScanServer)
107
109
  return function handleStatic(req: HttpRequest, res: ServerResponse): boolean {
108
110
  if (req.method !== 'GET' && req.method !== 'HEAD') return false
109
111
  if (typeof req.url !== 'string') return false
@@ -151,7 +153,7 @@ export function loadStatic(staticDir: string): StaticHandler {
151
153
  }
152
154
  }
153
155
 
154
- function readStaticFiles(staticDir: string): ReadonlyMap<string, StaticEntry> {
156
+ function readStaticFiles(staticDir: string, deepviewScanServer: string | null): ReadonlyMap<string, StaticEntry> {
155
157
  const files = new Map<string, StaticEntry>()
156
158
  let entries
157
159
  try { entries = readdirSync(staticDir, { withFileTypes: true }) } catch (err) {
@@ -165,18 +167,24 @@ function readStaticFiles(staticDir: string): ReadonlyMap<string, StaticEntry> {
165
167
  throw err
166
168
  }
167
169
  for (const entry of entries) {
168
- // `isFile()` filters subdirectories, symlinks, FIFOs etc. The
169
- // build emits a flat tree; dropping anything else keeps the
170
- // surface minimal even if a stray non-file sneaks in.
170
+ if (entry.isDirectory() && entry.name === 'provider-icons') {
171
+ for (const icon of readdirSync(join(staticDir, entry.name), { withFileTypes: true })) {
172
+ if (!icon.isFile() || extname(icon.name) !== '.svg') continue
173
+ const name = `${entry.name}/${icon.name}`
174
+ files.set(name, buildEntry(staticDir, name))
175
+ }
176
+ }
177
+ // Exclude other subdirectories, symlinks and non-file entries.
171
178
  if (!entry.isFile()) continue
172
- files.set(entry.name, buildEntry(staticDir, entry.name))
179
+ files.set(entry.name, buildEntry(staticDir, entry.name, deepviewScanServer))
173
180
  }
174
181
  return files
175
182
  }
176
183
 
177
- function buildEntry(staticDir: string, name: string): StaticEntry {
184
+ function buildEntry(staticDir: string, name: string, deepviewScanServer: string | null = null): StaticEntry {
178
185
  const ext = extname(name)
179
- const raw = readFileSync(join(staticDir, name))
186
+ const source = readFileSync(join(staticDir, name))
187
+ const raw = name === 'index.html' ? Buffer.from(scanServerHtml(source.toString('utf8'), deepviewScanServer)) : source
180
188
  const type = CONTENT_TYPE[ext] ?? 'application/octet-stream'
181
189
  // HTML: lift `<link rel="(module)preload" …>` into a Link header and
182
190
  // drop the tags from the served body. ETag + compression run against
@@ -26,6 +26,8 @@ type WireRevision = {
26
26
  signature: string
27
27
  }
28
28
 
29
+ type SubscriptionLookup = { resume: () => void }
30
+
29
31
  // Structured result of the shared save pipeline (`commitSave`), rendered
30
32
  // per transport: WS → protocol frames; REST → JSON + HTTP status.
31
33
  type SaveOutcome =
@@ -79,6 +81,7 @@ export type SyncHandlersDeps = {
79
81
  // payload budget. Optional: a SQLite deployment passes a no-op.
80
82
  publishRevision: (tag: string, revisionId: string) => void
81
83
  subscribe: (socket: WebSocket, tag: string) => void
84
+ pauseBroadcasts: (socket: WebSocket) => () => void
82
85
  getNonce: (socket: WebSocket) => string | undefined
83
86
  requiresAuth: (socket: WebSocket) => boolean
84
87
  // Whether an operator password is configured. The REST save plane's
@@ -112,6 +115,9 @@ export type SyncHandlers = {
112
115
 
113
116
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
114
117
  const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
118
+ // Only the newest lookup for a socket/tag may publish a snapshot.
119
+ // Versions cannot order different object incarnations (or deletions).
120
+ const subscriptionLookups = new WeakMap<WebSocket, Map<string, SubscriptionLookup>>()
115
121
 
116
122
  // Typed wrapper for the three `workspace-save-error` emit sites
117
123
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -348,34 +354,54 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
348
354
  return
349
355
  }
350
356
  const tag = msg.workspaceTag
351
- subscribe(socket, tag)
352
- // Explicit ack — distinguishes "the server processed my subscribe
353
- // and registered me as a peer" from "the WebSocket is open". A
354
- // client that sent a malformed / bad-sig subscribe never gets this;
355
- // a client that did gets one before the chain arrives. Lets the UI
356
- // surface a `connecting → online` transition based on real handshake
357
- // completion, not just socket state.
358
- //
359
- // The ack also carries the objstore inventory snapshot: the same
360
- // subscribe that registers this socket for objstore-put / -deleted
361
- // broadcasts seeds the client's initial inventory in one handshake.
362
- // The client keeps it live thereafter from those broadcasts.
363
- // Returns [] for a triage-only workspace. A failing inventory
364
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
365
- // (broadcasts will fill it in) so the ack + chain still go out.
366
- let resources: object[] = []
367
- try { resources = await objstoreResources(tag) }
368
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
369
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
370
- // `from` is the last revision id the client claims to have applied —
371
- // now a base64url string, not an integer. We send only revisions
372
- // after that. Client lying about `from` just means they get a
373
- // smaller catch-up — their subsequent saves will reveal stale state
374
- // on the usual base-mismatch path. Null / missing → full chain.
375
- const fromId = typeof msg.from === 'string' ? msg.from : null
376
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
377
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
378
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
357
+ const lookups = subscriptionLookups.get(socket) ?? new Map<string, SubscriptionLookup>()
358
+ subscriptionLookups.set(socket, lookups)
359
+ const previous = lookups.get(tag)
360
+ const resume = deps.pauseBroadcasts(socket)
361
+ const lookup = { resume }
362
+ lookups.set(tag, lookup)
363
+ // Acquire the replacement's pause before releasing its predecessor so
364
+ // buffered broadcasts cannot overtake the replacement snapshot. A hung
365
+ // obsolete read must not keep the whole socket paused. The hub's release
366
+ // is idempotent, so the old finally can still call it when the read ends.
367
+ previous?.resume()
368
+ try {
369
+ subscribe(socket, tag)
370
+ // Explicit ack — distinguishes "the server processed my subscribe
371
+ // and registered me as a peer" from "the WebSocket is open". A
372
+ // client that sent a malformed / bad-sig subscribe never gets this;
373
+ // a client that did gets one before the chain arrives. Lets the UI
374
+ // surface a `connecting → online` transition based on real handshake
375
+ // completion, not just socket state.
376
+ //
377
+ // The ack also carries the objstore inventory snapshot: the same
378
+ // subscribe that registers this socket for objstore-put / -deleted
379
+ // broadcasts seeds the client's initial inventory in one handshake.
380
+ // The client keeps it live thereafter from those broadcasts.
381
+ // Returns [] for a triage-only workspace. A lookup failure is NOT an
382
+ // empty inventory: clients interpret absent resources as deletions.
383
+ // Reconnect to retry the complete handshake on either transport.
384
+ const resources = await objstoreResources(tag)
385
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
386
+ // `from` is the last revision id the client claims to have applied —
387
+ // now a base64url string, not an integer. We send only revisions
388
+ // after that. Client lying about `from` just means they get a
389
+ // smaller catch-up — their subsequent saves will reveal stale state
390
+ // on the usual base-mismatch path. Null / missing → full chain.
391
+ const fromId = typeof msg.from === 'string' ? msg.from : null
392
+ const revisions = chainForWire(await chainFrom(handle, tag, fromId))
393
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
394
+ if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
395
+ send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
396
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
397
+ } catch (err) {
398
+ if (lookups.get(tag) !== lookup) return
399
+ if (debug) console.warn('subscribe: snapshot lookup failed', debugTag(tag), err)
400
+ socket.close(1011, 'subscription unavailable')
401
+ } finally {
402
+ if (lookups.get(tag) === lookup) lookups.delete(tag)
403
+ resume()
404
+ }
379
405
  }
380
406
 
381
407
  return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
@@ -1,28 +0,0 @@
1
- var N=globalThis,L=N.ShadowRoot&&(N.ShadyCSS===void 0||N.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,D=Symbol(),ie=new WeakMap,E=class{constructor(e,t,r){if(this._$cssResult$=!0,r!==D)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(L&&e===void 0){let r=t!==void 0&&t.length===1;r&&(e=ie.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),r&&ie.set(t,e))}return e}toString(){return this.cssText}},oe=i=>new E(typeof i=="string"?i:i+"",void 0,D),y=(i,...e)=>{let t=i.length===1?i[0]:e.reduce((r,s,o)=>r+(a=>{if(a._$cssResult$===!0)return a.cssText;if(typeof a=="number")return a;throw Error("Value passed to 'css' function must be a 'css' function result: "+a+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+i[o+1],i[0]);return new E(t,i,D)},ne=(i,e)=>{if(L)i.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let r=document.createElement("style"),s=N.litNonce;s!==void 0&&r.setAttribute("nonce",s),r.textContent=t.cssText,i.appendChild(r)}},j=L?i=>i:i=>i instanceof CSSStyleSheet?(e=>{let t="";for(let r of e.cssRules)t+=r.cssText;return oe(t)})(i):i;var{is:Re,defineProperty:Te,getOwnPropertyDescriptor:Ie,getOwnPropertyNames:Ue,getOwnPropertySymbols:Oe,getPrototypeOf:ze}=Object,H=globalThis,ae=H.trustedTypes,Ne=ae?ae.emptyScript:"",Le=H.reactiveElementPolyfillSupport,C=(i,e)=>i,B={toAttribute(i,e){switch(e){case Boolean:i=i?Ne:null;break;case Object:case Array:i=i==null?i:JSON.stringify(i)}return i},fromAttribute(i,e){let t=i;switch(e){case Boolean:t=i!==null;break;case Number:t=i===null?null:Number(i);break;case Object:case Array:try{t=JSON.parse(i)}catch{t=null}}return t}},de=(i,e)=>!Re(i,e),le={attribute:!0,type:String,converter:B,reflect:!1,useDefault:!1,hasChanged:de};Symbol.metadata??=Symbol("metadata"),H.litPropertyMetadata??=new WeakMap;var g=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=le){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let r=Symbol(),s=this.getPropertyDescriptor(e,r,t);s!==void 0&&Te(this.prototype,e,s)}}static getPropertyDescriptor(e,t,r){let{get:s,set:o}=Ie(this.prototype,e)??{get(){return this[t]},set(a){this[t]=a}};return{get:s,set(a){let h=s?.call(this);o?.call(this,a),this.requestUpdate(e,h,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??le}static _$Ei(){if(this.hasOwnProperty(C("elementProperties")))return;let e=ze(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(C("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(C("properties"))){let t=this.properties,r=[...Ue(t),...Oe(t)];for(let s of r)this.createProperty(s,t[s])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[r,s]of t)this.elementProperties.set(r,s)}this._$Eh=new Map;for(let[t,r]of this.elementProperties){let s=this._$Eu(t,r);s!==void 0&&this._$Eh.set(s,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let r=new Set(e.flat(1/0).reverse());for(let s of r)t.unshift(j(s))}else e!==void 0&&t.push(j(e));return t}static _$Eu(e,t){let r=t.attribute;return r===!1?void 0:typeof r=="string"?r:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let r of t.keys())this.hasOwnProperty(r)&&(e.set(r,this[r]),delete this[r]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return ne(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,r){this._$AK(e,r)}_$ET(e,t){let r=this.constructor.elementProperties.get(e),s=this.constructor._$Eu(e,r);if(s!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:B).toAttribute(t,r.type);this._$Em=e,o==null?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(e,t){let r=this.constructor,s=r._$Eh.get(e);if(s!==void 0&&this._$Em!==s){let o=r.getPropertyOptions(s),a=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:B;this._$Em=s;let h=a.fromAttribute(t,o.type);this[s]=h??this._$Ej?.get(s)??h,this._$Em=null}}requestUpdate(e,t,r,s=!1,o){if(e!==void 0){let a=this.constructor;if(s===!1&&(o=this[e]),r??=a.getPropertyOptions(e),!((r.hasChanged??de)(o,t)||r.useDefault&&r.reflect&&o===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,r))))return;this.C(e,t,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:r,reflect:s,wrapped:o},a){r&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,a??t??this[e]),o!==!0||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||r||(t=void 0),this._$AL.set(e,t)),s===!0&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[s,o]of this._$Ep)this[s]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[s,o]of r){let{wrapped:a}=o,h=this[s];a!==!0||this._$AL.has(s)||h===void 0||this.C(s,void 0,o,h)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(r=>r.hostUpdate?.()),this.update(t)):this._$EM()}catch(r){throw e=!1,this._$EM(),r}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(e){}firstUpdated(e){}};g.elementStyles=[],g.shadowRootOptions={mode:"open"},g[C("elementProperties")]=new Map,g[C("finalized")]=new Map,Le?.({ReactiveElement:g}),(H.reactiveElementVersions??=[]).push("2.1.2");var K=globalThis,ce=i=>i,M=K.trustedTypes,pe=M?M.createPolicy("lit-html",{createHTML:i=>i}):void 0,_e="$lit$",b=`lit$${Math.random().toFixed(9).slice(2)}$`,be="?"+b,He=`<${be}>`,x=document,R=()=>x.createComment(""),T=i=>i===null||typeof i!="object"&&typeof i!="function",G=Array.isArray,Me=i=>G(i)||typeof i?.[Symbol.iterator]=="function",V=`[
2
- \f\r]`,P=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,he=/-->/g,ue=/>/g,$=RegExp(`>|${V}(?:([^\\s"'>=/]+)(${V}*=${V}*(?:[^
3
- \f\r"'\`<>=]|("|')|))|$)`,"g"),me=/'/g,fe=/"/g,ve=/^(?:script|style|textarea|title)$/i,Y=i=>(e,...t)=>({_$litType$:i,strings:e,values:t}),n=Y(1),dt=Y(2),ct=Y(3),A=Symbol.for("lit-noChange"),c=Symbol.for("lit-nothing"),ge=new WeakMap,w=x.createTreeWalker(x,129);function ye(i,e){if(!G(i)||!i.hasOwnProperty("raw"))throw Error("invalid template strings array");return pe!==void 0?pe.createHTML(e):e}var De=(i,e)=>{let t=i.length-1,r=[],s,o=e===2?"<svg>":e===3?"<math>":"",a=P;for(let h=0;h<t;h++){let l=i[h],d,u,p=-1,f=0;for(;f<l.length&&(a.lastIndex=f,u=a.exec(l),u!==null);)f=a.lastIndex,a===P?u[1]==="!--"?a=he:u[1]!==void 0?a=ue:u[2]!==void 0?(ve.test(u[2])&&(s=RegExp("</"+u[2],"g")),a=$):u[3]!==void 0&&(a=$):a===$?u[0]===">"?(a=s??P,p=-1):u[1]===void 0?p=-2:(p=a.lastIndex-u[2].length,d=u[1],a=u[3]===void 0?$:u[3]==='"'?fe:me):a===fe||a===me?a=$:a===he||a===ue?a=P:(a=$,s=void 0);let _=a===$&&i[h+1].startsWith("/>")?" ":"";o+=a===P?l+He:p>=0?(r.push(d),l.slice(0,p)+_e+l.slice(p)+b+_):l+b+(p===-2?h:_)}return[ye(i,o+(i[t]||"<?>")+(e===2?"</svg>":e===3?"</math>":"")),r]},I=class i{constructor({strings:e,_$litType$:t},r){let s;this.parts=[];let o=0,a=0,h=e.length-1,l=this.parts,[d,u]=De(e,t);if(this.el=i.createElement(d,r),w.currentNode=this.el.content,t===2||t===3){let p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;(s=w.nextNode())!==null&&l.length<h;){if(s.nodeType===1){if(s.hasAttributes())for(let p of s.getAttributeNames())if(p.endsWith(_e)){let f=u[a++],_=s.getAttribute(p).split(b),z=/([.?@])?(.*)/.exec(f);l.push({type:1,index:o,name:z[2],strings:_,ctor:z[1]==="."?F:z[1]==="?"?Z:z[1]==="@"?W:S}),s.removeAttribute(p)}else p.startsWith(b)&&(l.push({type:6,index:o}),s.removeAttribute(p));if(ve.test(s.tagName)){let p=s.textContent.split(b),f=p.length-1;if(f>0){s.textContent=M?M.emptyScript:"";for(let _=0;_<f;_++)s.append(p[_],R()),w.nextNode(),l.push({type:2,index:++o});s.append(p[f],R())}}}else if(s.nodeType===8)if(s.data===be)l.push({type:2,index:o});else{let p=-1;for(;(p=s.data.indexOf(b,p+1))!==-1;)l.push({type:7,index:o}),p+=b.length-1}o++}}static createElement(e,t){let r=x.createElement("template");return r.innerHTML=e,r}};function k(i,e,t=i,r){if(e===A)return e;let s=r!==void 0?t._$Co?.[r]:t._$Cl,o=T(e)?void 0:e._$litDirective$;return s?.constructor!==o&&(s?._$AO?.(!1),o===void 0?s=void 0:(s=new o(i),s._$AT(i,t,r)),r!==void 0?(t._$Co??=[])[r]=s:t._$Cl=s),s!==void 0&&(e=k(i,s._$AS(i,e.values),s,r)),e}var q=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:r}=this._$AD,s=(e?.creationScope??x).importNode(t,!0);w.currentNode=s;let o=w.nextNode(),a=0,h=0,l=r[0];for(;l!==void 0;){if(a===l.index){let d;l.type===2?d=new U(o,o.nextSibling,this,e):l.type===1?d=new l.ctor(o,l.name,l.strings,this,e):l.type===6&&(d=new J(o,this,e)),this._$AV.push(d),l=r[++h]}a!==l?.index&&(o=w.nextNode(),a++)}return w.currentNode=x,s}p(e){let t=0;for(let r of this._$AV)r!==void 0&&(r.strings!==void 0?(r._$AI(e,r,t),t+=r.strings.length-2):r._$AI(e[t])),t++}},U=class i{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,r,s){this.type=2,this._$AH=c,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=r,this.options=s,this._$Cv=s?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=k(this,e,t),T(e)?e===c||e==null||e===""?(this._$AH!==c&&this._$AR(),this._$AH=c):e!==this._$AH&&e!==A&&this._(e):e._$litType$!==void 0?this.$(e):e.nodeType!==void 0?this.T(e):Me(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==c&&T(this._$AH)?this._$AA.nextSibling.data=e:this.T(x.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:r}=e,s=typeof r=="number"?this._$AC(e):(r.el===void 0&&(r.el=I.createElement(ye(r.h,r.h[0]),this.options)),r);if(this._$AH?._$AD===s)this._$AH.p(t);else{let o=new q(s,this),a=o.u(this.options);o.p(t),this.T(a),this._$AH=o}}_$AC(e){let t=ge.get(e.strings);return t===void 0&&ge.set(e.strings,t=new I(e)),t}k(e){G(this._$AH)||(this._$AH=[],this._$AR());let t=this._$AH,r,s=0;for(let o of e)s===t.length?t.push(r=new i(this.O(R()),this.O(R()),this,this.options)):r=t[s],r._$AI(o),s++;s<t.length&&(this._$AR(r&&r._$AB.nextSibling,s),t.length=s)}_$AR(e=this._$AA.nextSibling,t){for(this._$AP?.(!1,!0,t);e!==this._$AB;){let r=ce(e).nextSibling;ce(e).remove(),e=r}}setConnected(e){this._$AM===void 0&&(this._$Cv=e,this._$AP?.(e))}},S=class{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(e,t,r,s,o){this.type=1,this._$AH=c,this._$AN=void 0,this.element=e,this.name=t,this._$AM=s,this.options=o,r.length>2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=c}_$AI(e,t=this,r,s){let o=this.strings,a=!1;if(o===void 0)e=k(this,e,t,0),a=!T(e)||e!==this._$AH&&e!==A,a&&(this._$AH=e);else{let h=e,l,d;for(e=o[0],l=0;l<o.length-1;l++)d=k(this,h[r+l],t,l),d===A&&(d=this._$AH[l]),a||=!T(d)||d!==this._$AH[l],d===c?e=c:e!==c&&(e+=(d??"")+o[l+1]),this._$AH[l]=d}a&&!s&&this.j(e)}j(e){e===c?this.element.removeAttribute(this.name):this.element.setAttribute(this.name,e??"")}},F=class extends S{constructor(){super(...arguments),this.type=3}j(e){this.element[this.name]=e===c?void 0:e}},Z=class extends S{constructor(){super(...arguments),this.type=4}j(e){this.element.toggleAttribute(this.name,!!e&&e!==c)}},W=class extends S{constructor(e,t,r,s,o){super(e,t,r,s,o),this.type=5}_$AI(e,t=this){if((e=k(this,e,t,0)??c)===A)return;let r=this._$AH,s=e===c&&r!==c||e.capture!==r.capture||e.once!==r.once||e.passive!==r.passive,o=e!==c&&(r===c||s);s&&this.element.removeEventListener(this.name,this,r),o&&this.element.addEventListener(this.name,this,e),this._$AH=e}handleEvent(e){typeof this._$AH=="function"?this._$AH.call(this.options?.host??this.element,e):this._$AH.handleEvent(e)}},J=class{constructor(e,t,r){this.element=e,this.type=6,this._$AN=void 0,this._$AM=t,this.options=r}get _$AU(){return this._$AM._$AU}_$AI(e){k(this,e)}};var je=K.litHtmlPolyfillSupport;je?.(I,U),(K.litHtmlVersions??=[]).push("3.3.3");var $e=(i,e,t)=>{let r=t?.renderBefore??e,s=r._$litPart$;if(s===void 0){let o=t?.renderBefore??null;r._$litPart$=s=new U(e.insertBefore(R(),o),o,void 0,t??{})}return s._$AI(i),s};var Q=globalThis,m=class extends g{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=$e(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return A}};m._$litElement$=!0,m.finalized=!0,Q.litElementHydrateSupport?.({LitElement:m});var Be=Q.litElementPolyfillSupport;Be?.({LitElement:m});(Q.litElementVersions??=[]).push("4.2.2");var we=["admin","manage","triage","view","none"];var xe={dependencies:"Dependencies",security:"Security"};async function O(){let i=await fetch("/api/auth/session",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)return null;let e=await i.json();return{id:e?.user?.id??null,csrfToken:typeof e?.csrfToken=="string"?e.csrfToken:null}}async function Ve(){let i=await fetch("/api/admin/users",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)throw new Error(`HTTP ${i.status}`);let e=await i.json();return Array.isArray(e?.users)?e.users:[]}async function qe(i,e,t){let r={"content-type":"application/json"};t&&(r["x-csrf-token"]=t);let s=await fetch("/api/admin/set-role",{method:"POST",credentials:"same-origin",headers:r,body:JSON.stringify({userId:i,role:e})});if(!s.ok)throw new Error(`HTTP ${s.status}`)}var X=class extends m{static properties={_users:{state:!0},_error:{state:!0}};static styles=y`:host{display:block;padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--text)}.wrap{max-width:48rem;margin:0 auto}h1{font-size:1.15rem;font-weight:600;margin:0 0 1rem;user-select:none}.users{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}.users li{display:flex;align-items:center;gap:.7rem;padding:.55rem .85rem}.users li+li{border-top:1px solid var(--border)}.avatar{position:relative;flex-shrink:0;width:28px;height:28px;border-radius:50%;overflow:hidden;background:var(--accent);display:inline-grid;place-items:center;user-select:none}.avatar>span{font-size:.8rem;font-weight:600;color:var(--bg);text-transform:uppercase}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar img.broken{display:none}.who{display:flex;flex-direction:column;min-width:0}.login{font-weight:600;font-size:.9rem}.name{color:var(--muted);font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.role{margin-left:auto;flex-shrink:0;font:inherit;font-size:.82rem;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.2rem .4rem}.role:disabled{opacity:.55}.msg{color:var(--muted);font-size:.9rem}.msg.error{color:var(--critical,#c00)}`;constructor(){super(),this._users=null,this._error=null,this._me=null,this._csrf=null}connectedCallback(){super.connectedCallback(),this._load()}async _load(){this._error=null,this._users=null;try{let[e,t]=await Promise.all([O(),Ve()]);this._me=e?.id??null,this._csrf=e?.csrfToken??null,this._users=t}catch(e){this._error=String(e?.message??e)}}render(){return n`<div class="wrap"><h1>Users</h1>${this._error==null?this._users==null?n`<p class="msg">Loading…</p>`:this._list():n`<p class="msg error">Couldn't load users: ${this._error}</p>`}</div>`}_list(){return this._users.length===0?n`<p class="msg">No users yet.</p>`:n`<ul class="users">${this._users.map(e=>this._row(e))}</ul>`}_row(e){let t=(e.login?.[0]??"?").toUpperCase(),r=e.id===this._me;return n`<li><span class="avatar"><span>${t}</span> <img alt="" src="${`/api/avatar/${encodeURIComponent(e.id)}`}" @error="${s=>s.currentTarget.classList.add("broken")}"> </span><span class="who"><span class="login">${e.login}</span> ${e.name?n`<span class="name">${e.name}</span>`:c} </span><select class="role" ?disabled="${r}" title="${r?"You can\u2019t change your own role":"Change role"}" @change="${s=>this._changeRole(e,s.target.value,s.target)}">${we.map(s=>n`<option value="${s}" ?selected="${s===e.role}">${s}</option>`)}</select></li>`}async _changeRole(e,t,r){let s=e.role;if(t!==s){try{await qe(e.id,t,this._csrf),e.role=t}catch(o){console.warn("admin: set role failed:",o),r.value=s}this.requestUpdate()}}};customElements.define("managed-admin-users",X);async function Fe(){let i=await fetch("/api/admin/repositories",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)throw new Error(`HTTP ${i.status}`);return i.json()}async function Ze(i,e,t){let r={"content-type":"application/json"};t&&(r["x-csrf-token"]=t);let s=await fetch("/api/admin/repositories/select",{method:"POST",credentials:"same-origin",headers:r,body:JSON.stringify({repoId:i,selected:e})});if(!s.ok)throw new Error(`HTTP ${s.status}`)}var We=n`<svg class="repo-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M2 2.75A2.75 2.75 0 0 1 4.75 0h7.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75H4.5a1 1 0 0 0 0 2h8a.75.75 0 0 1 0 1.5h-8A2.5 2.5 0 0 1 2 13V2.75Zm2.75-.25a1.25 1.25 0 0 0-1.25 1.25v7.32c.317-.114.66-.07 1 .18V2.5h-.5a.25.25 0 0 0 .75 0Zm6.75 0H5.5v8.5h6V2.5Z"/></svg>`,ee=class extends m{static properties={_data:{state:!0},_error:{state:!0},_tab:{state:!0}};static styles=y`:host{display:block;padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--text)}.wrap{max-width:48rem;margin:0 auto}.head{display:flex;align-items:center;gap:1rem;margin:0 0 .75rem}h1{font-size:1.15rem;font-weight:600;margin:0;user-select:none}.connect{margin-left:auto;flex-shrink:0;font-size:.85rem;font-weight:600;color:var(--bg);background:var(--accent);text-decoration:none;border-radius:6px;padding:.35rem .7rem;white-space:nowrap}.connect:hover{opacity:.9}.tabs{display:flex;gap:.25rem;margin:0 0 1rem;border-bottom:1px solid var(--border)}.tab{font:inherit;font-size:.85rem;font-weight:600;color:var(--muted);background:0 0;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;padding:.4rem .55rem;cursor:pointer;user-select:none}.tab:hover{color:var(--text)}.tab.active{color:var(--text);border-bottom-color:var(--accent)}.hint{color:var(--muted);font-size:.82rem;margin:0 0 .8rem}.repos{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}.repos li{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem}.repos li+li{border-top:1px solid var(--border)}.select{flex-shrink:0;width:16px;height:16px;accent-color:var(--accent);cursor:pointer}.repo-icon{flex-shrink:0;color:var(--muted)}.full-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{flex-shrink:0;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:.05rem .4rem}.open{margin-left:auto;flex-shrink:0;font-size:.82rem;color:var(--accent);text-decoration:none}.open:hover{text-decoration:underline}.msg{color:var(--muted);font-size:.9rem;line-height:1.5}.msg.error{color:var(--critical,#c00)}`;constructor(){super(),this._data=null,this._error=null,this._csrf=null,this._tab="manage"}connectedCallback(){super.connectedCallback(),this._load()}async _load(){this._error=null,this._data=null;try{let[e,t]=await Promise.all([O(),Fe()]);this._csrf=e?.csrfToken??null,this._data=t}catch(e){this._error=String(e?.message??e)}}render(){let e=this._data?.installUrl;return n`<div class="wrap"><div class="head"><h1>Repositories</h1>${e?n`<a class="connect" href="${e}" target="_blank" rel="noopener noreferrer">Connect a repository</a>`:c}</div><div class="tabs" role="tablist"><button type="button" role="tab" class="tab ${this._tab==="manage"?"active":""}" aria-selected="${this._tab==="manage"}" @click="${()=>{this._tab="manage"}}">Manage repositories</button> <button type="button" role="tab" class="tab ${this._tab==="select"?"active":""}" aria-selected="${this._tab==="select"}" @click="${()=>{this._tab="select"}}">Select repositories</button></div>${this._body()}</div>`}_body(){if(this._error!=null)return n`<p class="msg error">Couldn't load repositories: ${this._error}</p>`;if(this._data==null)return n`<p class="msg">Loading…</p>`;let e=Array.isArray(this._data.repositories)?this._data.repositories:[];return e.length===0&&this._data.tokenMissing?n`<p class="msg">We don't have current GitHub access for your account. Please <strong>log out and log back in</strong> to grant repository access.</p>`:this._tab==="select"?this._selectView(e):this._manageView(e)}_manageView(e){let t=e.filter(r=>r.installed);return t.length===0?n`<p class="msg">No repositories connected yet. ${this._data.installUrl?n`Use “Connect a repository” to install the GitHub App on the repositories you want to read.`:n`Install the GitHub App on the repositories you want to read.`}</p>`:n`<p class="hint">Connected through the GitHub App — we can read these repositories.</p><ul class="repos">${t.map(r=>this._row(r,!1))}</ul>`}_selectView(e){return e.length===0?n`<p class="msg">No repositories found. ${this._data.installUrl?n`Use “Connect a repository” to install the GitHub App on the private repositories you want.`:n`Public repositories you own appear here.`}</p>`:n`${this._data.tokenMissing?n`<p class="hint">Some public repositories may be hidden — log out and back in to refresh GitHub access.</p>`:c}<p class="hint">Check a repository to operate on it — we'll read its contents.</p><ul class="repos">${e.map(t=>this._row(t,!0))}</ul>`}_row(e,t){return n`<li>${t?n`<input type="checkbox" class="select" .checked="${e.selected===!0}" title="${e.selected?"Selected \u2014 remove from the operate-on set":"Select this repository to operate on"}" @change="${r=>this._toggle(e,r.target)}">`:c} ${We} <span class="full-name">${e.fullName}</span> ${e.private?n`<span class="badge">private</span>`:c} ${e.htmlUrl?n`<a class="open" href="${e.htmlUrl}" target="_blank" rel="noopener noreferrer">open</a>`:c}</li>`}async _toggle(e,t){let r=t.checked;try{await Ze(e.id,r,this._csrf),e.selected=r}catch(s){console.warn("admin: repo selection failed:",s),t.checked=!r}this.requestUpdate()}};customElements.define("managed-admin-repos",ee);async function Je(){let i=await fetch("/api/admin/reports",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)throw new Error(`HTTP ${i.status}`);return i.json()}async function Ke(i,e,t){let r={"content-type":i.type||"application/json","x-report-filename":encodeURIComponent(i.name)};e&&(r["x-csrf-token"]=e),t!=null&&(r["x-repo-id"]=String(t));let s=await fetch("/api/admin/reports",{method:"POST",credentials:"same-origin",headers:r,body:i});if(!s.ok)throw new Error(s.status===413?"too large":`HTTP ${s.status}`);return s.json()}async function Ge(i,e){let t=e?{"x-csrf-token":e}:{},r=await fetch(`/api/admin/reports/${encodeURIComponent(i)}`,{method:"DELETE",credentials:"same-origin",headers:t});if(!r.ok)throw new Error(`HTTP ${r.status}`)}async function Ye(i,e,t){let r={"content-type":"application/json"};t&&(r["x-csrf-token"]=t);let s=await fetch("/api/admin/reports/set-repo",{method:"POST",credentials:"same-origin",headers:r,body:JSON.stringify({reportId:i,repoId:e})});if(!s.ok)throw new Error(`HTTP ${s.status}`)}function Ae(i){return typeof i!="number"||!Number.isFinite(i)?"":i<1024?`${i} B`:i<1048576?`${(i/1024).toFixed(1)} KB`:`${(i/1048576).toFixed(1)} MB`}function ke(i,e){return n`<option value="" ?selected="${e==null||e===""}">No repository</option>${i.map(t=>n`<option value="${t.repoId}" ?selected="${Number(e)===t.repoId}">${t.fullName}</option>`)}`}function Se(i,e,t){return!Array.isArray(i)||i.length===0?c:n`<label class="repo-picker"><span class="repo-picker-label">Attach uploads to</span> <select class="repo-select" @change="${r=>t(r.target.value===""?null:Number(r.target.value))}">${ke(i,e)}</select></label>`}function Ee(i,e,t){return!Array.isArray(i)||i.length===0?c:n`<select class="repo-attach" title="Attach to a repository" @change="${r=>t(r.target.value===""?null:Number(r.target.value))}">${ke(i,e)}</select>`}function Ce(i,e=!0){let t=document.createElement("input");t.type="file",t.multiple=e,t.addEventListener("change",()=>{i([...t.files])},{once:!0}),t.click()}function Pe(i,e,t){let r=0,s=d=>Array.from(d.dataTransfer?.types??[]).includes("Files"),o=d=>{s(d)&&(d.preventDefault(),r+=1,t(!0))},a=d=>{s(d)&&d.preventDefault()},h=d=>{s(d)&&(r=Math.max(0,r-1),r===0&&t(!1))},l=d=>{s(d)&&(d.preventDefault(),d.stopPropagation(),r=0,t(!1),e([...d.dataTransfer.files]))};return i.addEventListener("dragenter",o),i.addEventListener("dragover",a),i.addEventListener("dragleave",h),i.addEventListener("drop",l),()=>{i.removeEventListener("dragenter",o),i.removeEventListener("dragover",a),i.removeEventListener("dragleave",h),i.removeEventListener("drop",l)}}var Qe=n`<svg class="report-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5Zm7.75.689V4.25c0 .138.112.25.25.25h2.061ZM4.5 8.75A.75.75 0 0 1 5.25 8h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 4.5 8.75Zm0 2.5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1-.75-.75Z"/></svg>`,te=class extends m{static properties={_data:{state:!0},_error:{state:!0},_busy:{state:!0},_dragOver:{state:!0}};static styles=y`:host{display:block;position:relative;flex:1 1 auto;padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--text)}.wrap{max-width:48rem;margin:0 auto}.dropzone{position:absolute;inset:.6rem;z-index:5;display:grid;place-items:center;border:2px dashed var(--accent);border-radius:10px;background:rgb(from var(--bg) r g b / .9);color:var(--accent);font-size:1rem;font-weight:600;pointer-events:none}.head{display:flex;align-items:center;gap:1rem;margin:0 0 .75rem}h1{font-size:1.15rem;font-weight:600;margin:0;user-select:none}.head-actions{margin-left:auto;flex-shrink:0;display:flex;align-items:center;gap:.5rem}.repo-picker{display:inline-flex;align-items:center;gap:.4rem}.repo-picker-label{font-size:.8rem;color:var(--muted);white-space:nowrap}.repo-select{font:inherit;font-size:.82rem;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.2rem .4rem;max-width:14rem}.repo-attach{flex-shrink:0;font:inherit;font-size:.78rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.1rem .3rem;max-width:11rem}.upload{flex-shrink:0;font:inherit;font-size:.85rem;font-weight:600;color:var(--bg);background:var(--accent);border:none;cursor:pointer;border-radius:6px;padding:.35rem .7rem;white-space:nowrap}.upload:hover{opacity:.9}.upload:disabled{opacity:.55;cursor:default}.hint{color:var(--muted);font-size:.82rem;margin:0 0 .8rem}.reports{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}.reports li{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem}.reports li+li{border-top:1px solid var(--border)}.report-icon{flex-shrink:0;color:var(--muted)}.who{display:flex;flex-direction:column;min-width:0}.filename{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{margin-left:auto;flex-shrink:0;display:flex;gap:.7rem;align-items:center}.download{font-size:.82rem;color:var(--accent);text-decoration:none}.download:hover{text-decoration:underline}.delete{font:inherit;font-size:.82rem;color:var(--critical,#c00);background:0 0;border:none;cursor:pointer;padding:0}.delete:hover{text-decoration:underline}.msg{color:var(--muted);font-size:.9rem;line-height:1.5}.msg.error{color:var(--critical,#c00)}`;constructor(){super(),this._data=null,this._error=null,this._csrf=null,this._busy=!1,this._repoId=null,this._dragOver=!1,this._teardownDrop=null,this._queue=[]}connectedCallback(){super.connectedCallback(),this._load(),this._teardownDrop=Pe(this,e=>{this._upload(e)},e=>{this._dragOver=e})}disconnectedCallback(){super.disconnectedCallback(),this._teardownDrop?.()}async _load(){this._error=null,this._data=null;try{let[e,t]=await Promise.all([O(),Je()]);this._csrf=e?.csrfToken??null,this._data=t}catch(e){this._error=String(e?.message??e)}}render(){return n`${this._dragOver?n`<div class="dropzone">Drop reports to upload</div>`:c}<div class="wrap"><div class="head"><h1>Reports</h1><div class="head-actions">${Se(this._data?.repos,this._repoId,e=>{this._repoId=e})} <button type="button" class="upload" ?disabled="${this._busy}" @click="${()=>Ce(e=>{this._upload(e)})}">${this._busy?"Uploading\u2026":"Upload report"}</button></div></div>${this._body()}</div>`}_body(){if(this._error!=null)return n`<p class="msg error">Couldn't load reports: ${this._error}</p>`;if(this._data==null)return n`<p class="msg">Loading…</p>`;let e=Array.isArray(this._data.reports)?this._data.reports:[];return e.length===0?n`<p class="msg">No reports uploaded yet. Drag & drop files here, or use “Upload report”, to add a findings report (JSON, markdown, or CSV) for the server to operate on.</p>`:n`<p class="hint">Reports stored on the server.</p><ul class="reports">${e.map(t=>this._row(t))}</ul>`}_row(e){let t=e.uploadedByLogin?`by ${e.uploadedByLogin}`:"uploader removed",r=Number.isFinite(e.uploadedAt)?new Date(e.uploadedAt).toLocaleString():"",s=e.bundleFilename?`bundle: ${e.bundleFilename}`:e.bundleIntegrity?"bundle: (not uploaded)":null,o=[t,r,Ae(e.byteSize),s].filter(Boolean).join(" \xB7 ");return n`<li>${Qe} <span class="who"><span class="filename">${e.filename}</span> <span class="meta">${o}</span> </span><span class="actions">${Ee(this._data?.repos,e.repoId,a=>this._setRepo(e,a))} <a class="download" href="${`/api/admin/reports/${encodeURIComponent(e.id)}`}">download</a> <button type="button" class="delete" @click="${()=>this._delete(e)}">delete</button></span></li>`}async _setRepo(e,t){try{await Ye(e.id,t,this._csrf)}catch(r){this._error=`Couldn't change repo: ${String(r?.message??r)}`}await this._load()}async _upload(e){if(e.length!==0&&(this._queue.push(...e),!this._busy)){this._busy=!0,this._error=null;try{for(;this._queue.length>0;){let t=this._queue.shift();await Ke(t,this._csrf,this._repoId)}}catch(t){this._queue=[],this._error=`Upload failed: ${String(t?.message??t)}`}finally{this._busy=!1,await this._load()}}}async _delete(e){if(globalThis.confirm?.(`Delete \u201C${e.filename}\u201D? This can't be undone.`)){try{await Ge(e.id,this._csrf)}catch(t){this._error=`Delete failed: ${String(t?.message??t)}`}await this._load()}}};customElements.define("managed-admin-reports",te);async function Xe(){let i=await fetch("/api/admin/bundles",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)throw new Error(`HTTP ${i.status}`);return i.json()}async function et(i,e,t){let r={"content-type":"application/octet-stream","x-bundle-filename":encodeURIComponent(i.name)};e&&(r["x-csrf-token"]=e),t!=null&&(r["x-repo-id"]=String(t));let s=await fetch("/api/admin/bundles",{method:"POST",credentials:"same-origin",headers:r,body:i});if(!s.ok)throw new Error(s.status===413?"too large":`HTTP ${s.status}`);return s.json()}async function tt(i,e){let t=e?{"x-csrf-token":e}:{},r=await fetch(`/api/admin/bundles/${encodeURIComponent(i)}`,{method:"DELETE",credentials:"same-origin",headers:t});if(!r.ok)throw new Error(`HTTP ${r.status}`)}async function rt(i,e,t){let r={"content-type":"application/json"};t&&(r["x-csrf-token"]=t);let s=await fetch("/api/admin/bundles/set-repo",{method:"POST",credentials:"same-origin",headers:r,body:JSON.stringify({bundleId:i,repoId:e})});if(!s.ok)throw new Error(`HTTP ${s.status}`)}var st=n`<svg class="report-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M8.878.392a1.75 1.75 0 0 0-1.756 0l-5.25 3.045A1.75 1.75 0 0 0 1 4.951v6.098c0 .624.332 1.2.872 1.514l5.25 3.045a1.75 1.75 0 0 0 1.756 0l5.25-3.045c.54-.313.872-.89.872-1.514V4.951c0-.624-.332-1.2-.872-1.514ZM7.875 1.69a.25.25 0 0 1 .25 0l4.63 2.685L8 7.133 3.245 4.375Zm-5.125 4.4 4.5 2.61v5.317l-4.375-2.537a.25.25 0 0 1-.125-.216Zm6 7.927V8.7l4.5-2.61v3.96a.25.25 0 0 1-.125.216Z"/></svg>`,re=class extends m{static properties={_data:{state:!0},_error:{state:!0},_busy:{state:!0},_dragOver:{state:!0}};static styles=y`:host{display:block;position:relative;flex:1 1 auto;padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--text)}.wrap{max-width:48rem;margin:0 auto}.dropzone{position:absolute;inset:.6rem;z-index:5;display:grid;place-items:center;border:2px dashed var(--accent);border-radius:10px;background:rgb(from var(--bg) r g b / .9);color:var(--accent);font-size:1rem;font-weight:600;pointer-events:none}.head{display:flex;align-items:center;gap:1rem;margin:0 0 .75rem}h1{font-size:1.15rem;font-weight:600;margin:0;user-select:none}.head-actions{margin-left:auto;flex-shrink:0;display:flex;align-items:center;gap:.5rem}.repo-picker{display:inline-flex;align-items:center;gap:.4rem}.repo-picker-label{font-size:.8rem;color:var(--muted);white-space:nowrap}.repo-select{font:inherit;font-size:.82rem;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.2rem .4rem;max-width:14rem}.repo-attach{flex-shrink:0;font:inherit;font-size:.78rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.1rem .3rem;max-width:11rem}.upload{flex-shrink:0;font:inherit;font-size:.85rem;font-weight:600;color:var(--bg);background:var(--accent);border:none;cursor:pointer;border-radius:6px;padding:.35rem .7rem;white-space:nowrap}.upload:hover{opacity:.9}.upload:disabled{opacity:.55;cursor:default}.hint{color:var(--muted);font-size:.82rem;margin:0 0 .8rem}.reports{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}.reports li{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem}.reports li+li{border-top:1px solid var(--border)}.report-icon{flex-shrink:0;color:var(--muted)}.who{display:flex;flex-direction:column;min-width:0}.filename{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{flex-shrink:0;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:.05rem .4rem}.actions{margin-left:auto;flex-shrink:0;display:flex;gap:.7rem;align-items:center}.download{font-size:.82rem;color:var(--accent);text-decoration:none}.download:hover{text-decoration:underline}.delete{font:inherit;font-size:.82rem;color:var(--critical,#c00);background:0 0;border:none;cursor:pointer;padding:0}.delete:hover{text-decoration:underline}.msg{color:var(--muted);font-size:.9rem;line-height:1.5}.msg.error{color:var(--critical,#c00)}`;constructor(){super(),this._data=null,this._error=null,this._csrf=null,this._busy=!1,this._repoId=null,this._dragOver=!1,this._teardownDrop=null,this._queue=[]}connectedCallback(){super.connectedCallback(),this._load(),this._teardownDrop=Pe(this,e=>{this._upload(e)},e=>{this._dragOver=e})}disconnectedCallback(){super.disconnectedCallback(),this._teardownDrop?.()}async _load(){this._error=null,this._data=null;try{let[e,t]=await Promise.all([O(),Xe()]);this._csrf=e?.csrfToken??null,this._data=t}catch(e){this._error=String(e?.message??e)}}render(){return n`${this._dragOver?n`<div class="dropzone">Drop bundles to upload</div>`:c}<div class="wrap"><div class="head"><h1>Bundles</h1><div class="head-actions">${Se(this._data?.repos,this._repoId,e=>{this._repoId=e})} <button type="button" class="upload" ?disabled="${this._busy}" @click="${()=>Ce(e=>{this._upload(e)})}">${this._busy?"Uploading\u2026":"Upload bundle"}</button></div></div>${this._body()}</div>`}_body(){if(this._error!=null)return n`<p class="msg error">Couldn't load bundles: ${this._error}</p>`;if(this._data==null)return n`<p class="msg">Loading…</p>`;let e=Array.isArray(this._data.bundles)?this._data.bundles:[];return e.length===0?n`<p class="msg">No bundles uploaded yet. Drag & drop files here, or use “Upload bundle”, to add a sourcemap or stasis archive for the server to operate on; reports auto-link by content hash.</p>`:n`<p class="hint">Bundles stored on the server.</p><ul class="reports">${e.map(t=>this._row(t))}</ul>`}_row(e){let t=e.uploadedByLogin?`by ${e.uploadedByLogin}`:"uploader removed",r=Number.isFinite(e.uploadedAt)?new Date(e.uploadedAt).toLocaleString():"",s=[t,r,Ae(e.byteSize)].filter(Boolean).join(" \xB7 ");return n`<li title="${e.integrity??""}">${st} <span class="who"><span class="filename">${e.filename}</span> <span class="meta">${s}</span> </span>${e.kind?n`<span class="badge">${e.kind}</span>`:c} <span class="actions">${Ee(this._data?.repos,e.repoId,o=>this._setRepo(e,o))} <a class="download" href="${`/api/admin/bundles/${encodeURIComponent(e.id)}`}">download</a> <button type="button" class="delete" @click="${()=>this._delete(e)}">delete</button></span></li>`}async _setRepo(e,t){try{await rt(e.id,t,this._csrf)}catch(r){this._error=`Couldn't change repo: ${String(r?.message??r)}`}await this._load()}async _upload(e){if(e.length!==0&&(this._queue.push(...e),!this._busy)){this._busy=!0,this._error=null;try{for(;this._queue.length>0;){let t=this._queue.shift();await et(t,this._csrf,this._repoId)}}catch(t){this._queue=[],this._error=`Upload failed: ${String(t?.message??t)}`}finally{this._busy=!1,await this._load()}}}async _delete(e){if(globalThis.confirm?.(`Delete \u201C${e.filename}\u201D? Linked reports will keep their pending link.`)){try{await tt(e.id,this._csrf)}catch(t){this._error=`Delete failed: ${String(t?.message??t)}`}await this._load()}}};customElements.define("managed-admin-bundles",re);async function it(){let i=await fetch("/api/admin/teams",{credentials:"same-origin",headers:{accept:"application/json"}});if(!i.ok)throw new Error(`HTTP ${i.status}`);return i.json()}async function v(i,e,t){let r={"content-type":"application/json"};e&&(r["x-csrf-token"]=e);let s=await fetch(i,{method:"POST",credentials:"same-origin",headers:r,body:JSON.stringify(t)});if(!s.ok)throw new Error(s.status===409?"name already taken":`HTTP ${s.status}`)}var se=class extends m{static properties={_data:{state:!0},_error:{state:!0},_busy:{state:!0},_renamingId:{state:!0}};static styles=y`:host{display:block;padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--text)}.wrap{max-width:48rem;margin:0 auto}.head{display:flex;align-items:center;gap:.5rem;margin:0 0 1rem}h1{font-size:1.15rem;font-weight:600;margin:0 auto 0 0;user-select:none}input,select{font:inherit;font-size:.82rem;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.25rem .4rem}.new-name{width:12rem}.btn{font:inherit;font-size:.82rem;font-weight:600;color:var(--bg);background:var(--accent);border:none;border-radius:6px;padding:.3rem .6rem;cursor:pointer;white-space:nowrap}.btn:hover{opacity:.9}.btn:disabled{opacity:.55;cursor:default}.team{border:1px solid var(--border);border-radius:8px;padding:.85rem;margin:0 0 1rem}.team-head{display:flex;align-items:center;gap:.6rem;margin:0 0 .6rem}.team-name{font-weight:600;font-size:1rem}.rename-input{width:12rem}.sub{margin:.5rem 0 0}.sub-title{font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 .3rem}.links{list-style:none;margin:0 0 .4rem;padding:0}.links li{display:flex;align-items:center;gap:.6rem;padding:.25rem 0}.ln{font-size:.88rem}.path{color:var(--muted);font-size:.82rem}.perms{margin-left:auto;display:flex;gap:.8rem}.perm{display:inline-flex;align-items:center;gap:.25rem;font-size:.8rem;color:var(--muted);user-select:none}.add-row{display:flex;align-items:center;gap:.4rem;margin:.3rem 0 0;flex-wrap:wrap}.add-row select{max-width:16rem}.x{font:inherit;font-size:.8rem;color:var(--critical,#c00);background:0 0;border:none;cursor:pointer;padding:0}.x:hover{text-decoration:underline}.delete{margin-left:auto}.muted{color:var(--muted);font-size:.82rem;margin:0 0 .3rem}.msg{color:var(--muted);font-size:.9rem}.msg.error{color:var(--critical,#c00)}`;constructor(){super(),this._data=null,this._error=null,this._csrf=null,this._busy=!1,this._renamingId=null}connectedCallback(){super.connectedCallback(),this._load()}async _load(){this._error=null;try{let[e,t]=await Promise.all([O(),it()]);this._csrf=e?.csrfToken??null,this._data=t}catch(e){this._error=String(e?.message??e)}}async _do(e){if(!this._busy){this._busy=!0,this._error=null;try{await e()}catch(t){this._error=String(t?.message??t)}finally{this._busy=!1,await this._load()}}}render(){return n`<div class="wrap"><div class="head"><h1>Teams</h1><input class="new-name" type="text" placeholder="New team name" maxlength="100" @keydown="${e=>{e.key==="Enter"&&this._create()}}"> <button class="btn" ?disabled="${this._busy}" @click="${()=>this._create()}">Create</button></div>${this._body()}</div>`}_body(){if(this._error!=null&&this._data==null)return n`<p class="msg error">Couldn't load teams: ${this._error}</p>`;if(this._data==null)return n`<p class="msg">Loading…</p>`;let e=Array.isArray(this._data.teams)?this._data.teams:[];return n`${this._error==null?c:n`<p class="msg error">${this._error}</p>`} ${e.length===0?n`<p class="msg">No teams yet. Create one above.</p>`:e.map(t=>this._team(t))}`}_team(e){return n`<div class="team"><div class="team-head">${this._renamingId===e.id?n`<input class="rename-input" type="text" .value="${e.name}" maxlength="100" @keydown="${t=>this._renameKey(t,e)}"> <button class="btn" ?disabled="${this._busy}" @click="${t=>this._saveRename(e,t)}">save</button> <button class="x" @click="${()=>{this._renamingId=null}}">cancel</button>`:n`<span class="team-name">${e.name}</span> <button class="x" @click="${()=>{this._renamingId=e.id}}">rename</button> <button class="x delete" @click="${()=>this._deleteTeam(e)}">delete team</button>`}</div><div class="sub"><div class="sub-title">Repositories</div>${e.repos.length===0?n`<p class="muted">No repositories linked.</p>`:n`<ul class="links">${e.repos.map(t=>this._repoRow(e,t))}</ul>`} ${this._addRepoRow(e)}</div><div class="sub"><div class="sub-title">Members</div>${e.members.length===0?n`<p class="muted">No members.</p>`:n`<ul class="links">${e.members.map(t=>this._memberRow(e,t))}</ul>`} ${this._addMemberRow(e)}</div></div>`}_repoRow(e,t){return n`<li><span class="ln">${t.fullName}${t.path?n`<span class="path">/${t.path}</span>`:c}</span> <button class="x" @click="${()=>this._do(()=>v("/api/admin/teams/remove-repo",this._csrf,{teamId:e.id,repoId:t.repoId}))}">remove</button></li>`}_addRepoRow(e){let t=Array.isArray(this._data.repos)?this._data.repos:[];if(t.length===0)return n`<p class="muted">No selected repositories to link — pick some on “Manage repositories”.</p>`;let r=new Set(e.repos.map(s=>s.repoId));return n`<div class="add-row"><select class="add-repo-sel"><option value="">Add repository…</option>${t.map(s=>n`<option value="${s.repoId}">${s.fullName}${r.has(s.repoId)?" \u2014 update path":""}</option>`)}</select> <input class="add-repo-path" type="text" placeholder="subpath (optional)" maxlength="500"> <button class="btn" @click="${s=>this._addRepo(e,s)}">add</button></div>`}_memberRow(e,t){let r=Array.isArray(this._data.permissions)?this._data.permissions:[];return n`<li><span class="ln">${t.login}</span> <span class="perms">${r.map(s=>n`<label class="perm"><input type="checkbox" .checked="${t[s]===!0}" @change="${o=>this._togglePerm(e,t,s,o.target.checked)}"> ${xe[s]??s}</label>`)} </span><button class="x" @click="${()=>this._do(()=>v("/api/admin/teams/remove-member",this._csrf,{teamId:e.id,userId:t.userId}))}">remove</button></li>`}_addMemberRow(e){let t=Array.isArray(this._data.users)?this._data.users:[],r=new Set(e.members.map(s=>s.userId));return n`<div class="add-row"><select class="add-member-sel"><option value="">Add member…</option>${t.map(s=>n`<option value="${s.id}" ?disabled="${r.has(s.id)}">${s.login}${r.has(s.id)?" (member)":""}</option>`)}</select> <button class="btn" @click="${s=>this._addMember(e,s)}">add</button></div>`}_create(){let e=this.renderRoot.querySelector(".new-name"),t=e?.value.trim();t&&this._do(async()=>{await v("/api/admin/teams",this._csrf,{name:t}),e&&(e.value="")})}_deleteTeam(e){globalThis.confirm?.(`Delete team \u201C${e.name}\u201D? Its repo + member links are removed.`)&&this._do(()=>v("/api/admin/teams/delete",this._csrf,{teamId:e.id}))}_renameKey(e,t){e.key==="Enter"?this._saveRename(t,e):e.key==="Escape"&&(this._renamingId=null)}_saveRename(e,t){if(this._busy)return;let r=t.target.closest(".team-head")?.querySelector(".rename-input")?.value.trim()??"";this._renamingId=null,!(r===""||r===e.name)&&this._do(()=>v("/api/admin/teams/rename",this._csrf,{teamId:e.id,name:r}))}_addRepo(e,t){let r=t.target.closest(".add-row"),s=Number(r?.querySelector(".add-repo-sel")?.value);if(!Number.isSafeInteger(s)||s<=0)return;let o=r?.querySelector(".add-repo-path")?.value??"";this._do(()=>v("/api/admin/teams/set-repo",this._csrf,{teamId:e.id,repoId:s,path:o}))}_addMember(e,t){let r=t.target.closest(".add-row")?.querySelector(".add-member-sel")?.value;r&&this._do(()=>v("/api/admin/teams/set-member",this._csrf,{teamId:e.id,userId:r}))}_togglePerm(e,t,r,s){let o={};for(let a of this._data.permissions??[])o[a]=t[a]===!0;o[r]=s,this._do(()=>v("/api/admin/teams/set-member",this._csrf,{teamId:e.id,userId:t.userId,...o}))}};customElements.define("managed-admin-teams",se);export{re as ManagedAdminBundles,te as ManagedAdminReports,ee as ManagedAdminRepos,se as ManagedAdminTeams,X as ManagedAdminUsers};
4
- /*! Bundled license information:
5
-
6
- @lit/reactive-element/css-tag.js:
7
- (**
8
- * @license
9
- * Copyright 2019 Google LLC
10
- * SPDX-License-Identifier: BSD-3-Clause
11
- *)
12
-
13
- @lit/reactive-element/reactive-element.js:
14
- lit-html/lit-html.js:
15
- lit-element/lit-element.js:
16
- (**
17
- * @license
18
- * Copyright 2017 Google LLC
19
- * SPDX-License-Identifier: BSD-3-Clause
20
- *)
21
-
22
- lit-html/is-server.js:
23
- (**
24
- * @license
25
- * Copyright 2022 Google LLC
26
- * SPDX-License-Identifier: BSD-3-Clause
27
- *)
28
- */