@mirrowel/opencode-openshare 0.0.0-stage → 0.1.0

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +5 -0
  5. package/ci/openshare.js +2265 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +134 -0
  9. package/dist/config.js +517 -0
  10. package/dist/core.d.ts +117 -0
  11. package/dist/core.js +1944 -0
  12. package/dist/dialogs.d.ts +94 -0
  13. package/dist/dialogs.js +725 -0
  14. package/dist/server.d.ts +64 -0
  15. package/dist/server.js +30 -0
  16. package/dist/settings.d.ts +24 -0
  17. package/dist/settings.js +1226 -0
  18. package/dist/share-flow.d.ts +287 -0
  19. package/dist/share-flow.js +516 -0
  20. package/dist/sidebar.d.ts +19 -0
  21. package/dist/sidebar.js +144 -0
  22. package/dist/tui-host.d.ts +158 -0
  23. package/dist/tui-host.js +6 -0
  24. package/dist/tui.d.ts +17 -0
  25. package/dist/tui.js +4762 -0
  26. package/dist/ui-prefs.d.ts +16 -0
  27. package/dist/ui-prefs.js +27 -0
  28. package/dist/v2-server.d.ts +81 -0
  29. package/dist/v2-server.js +402 -0
  30. package/dist/v2-tui.d.ts +24 -0
  31. package/dist/v2-tui.js +313 -0
  32. package/dist/v2-types.d.ts +134 -0
  33. package/dist/v2-types.js +8 -0
  34. package/openshare.config.json +16 -0
  35. package/package.json +98 -4
  36. package/src/banner.txt +22 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +518 -0
  39. package/src/core.ts +1997 -0
  40. package/src/dialogs.tsx +971 -0
  41. package/src/server.ts +47 -0
  42. package/src/settings.ts +1239 -0
  43. package/src/share-flow.ts +551 -0
  44. package/src/sidebar.tsx +187 -0
  45. package/src/tui-host.ts +134 -0
  46. package/src/tui.tsx +318 -0
  47. package/src/ui-prefs.ts +36 -0
  48. package/src/v2-server.ts +438 -0
  49. package/src/v2-tui.ts +315 -0
  50. package/src/v2-types.ts +102 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,551 @@
1
+ // OpenShare share flows — host-agnostic logic behind /share, /unshare and
2
+ // the settings dialog's "this session" actions. Runs against the
3
+ // ShareHostApi facade (tui-host.ts) and the shared config module, so the
4
+ // same compiled code serves the v1 TUI and the v2 adapter.
5
+ //
6
+ // Flow contracts (mirrored by scripts/manual-tests.mjs):
7
+ // /share resolve root session -> config check (onboard to settings
8
+ // when unconfigured) -> consent once -> set the enabled flag
9
+ // (with per-session opts when present) -> wait for the server
10
+ // plugin to publish the binding URL into session metadata
11
+ // (~seconds; worker-down surfaces as a timeout toast) -> copy.
12
+ // /unshare resolve root -> confirm -> read binding -> clear flag ->
13
+ // DELETE the share directly via the worker (belt and braces:
14
+ // works even if the server plugin never saw the flag event) ->
15
+ // the server-side flag-removal path cleans up on its own.
16
+
17
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
18
+ import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY, type ShareConfig, type ServerEntry } from "./config.js"
19
+ import { dbg, dbgLog } from "./sidebar.js"
20
+ import type { ClientApi, SessionInfo, SessionOpts } from "./core.js"
21
+ import type { ShareHostApi } from "./tui-host.js"
22
+
23
+ export type SessionBinding = {
24
+ id: string
25
+ secret: string
26
+ rev: number
27
+ url: string
28
+ enabled?: boolean
29
+ opts?: SessionOpts
30
+ }
31
+
32
+ export const CONSENT_KV_KEY = "openshare_consent"
33
+
34
+ export function pluginConfigPaths(baseDir: string = moduleDir()): string[] {
35
+ return configPaths(baseDir)
36
+ }
37
+
38
+ export function currentConfig(baseDir: string = moduleDir()): ShareConfig {
39
+ return loadConfig(baseDir)
40
+ }
41
+
42
+ /** Writes the config file. Target rule (owner-directed): the global
43
+ * settings home by default, or an explicit existing file chosen in the
44
+ * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
45
+ * into the tracked adjacent template, live-caught). $comment and foreign
46
+ * keys preserved. */
47
+ export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string = moduleDir()): { path: string } | undefined {
48
+ const path = target ?? configHomePath()
49
+ if (!path) return undefined
50
+ try {
51
+ const previous: Record<string, unknown> = existsSync(path)
52
+ ? (() => {
53
+ try {
54
+ return JSON.parse(readFileSync(path, "utf8"))
55
+ } catch {
56
+ return {}
57
+ }
58
+ })()
59
+ : {}
60
+ // preserve $comment and any foreign keys, override with our values
61
+ const next: Record<string, unknown> = { ...previous }
62
+ for (const key of [
63
+ "workerUrl",
64
+ "createToken",
65
+ "servers",
66
+ "debounceMs",
67
+ "completedDelayMs",
68
+ "modelLabel",
69
+ "ttlDays",
70
+ "openTasksInNewTab",
71
+ "authLogin",
72
+ "authPassword",
73
+ "spoolDir",
74
+ "urlFile",
75
+ "shareMode",
76
+ "announceStderr",
77
+ ] as Array<keyof ShareConfig>) {
78
+ next[key] = cfg[key]
79
+ }
80
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n")
81
+ return { path }
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ export function isConfigured(cfg: ShareConfig): boolean {
88
+ // token optional: with none, the engine (or the TUI connect flow)
89
+ // enrolls on first contact — public servers take anyone, private ones
90
+ // need servers.{url}.code
91
+ return Boolean(resolveServer(cfg)?.url)
92
+ }
93
+
94
+ /** The resolved active server (url + entry), for the TUI server rows. */
95
+ export function activeServer(cfg: ShareConfig = currentConfig()): { url: string; token: string; entry: ServerEntry } | undefined {
96
+ return resolveServer(cfg)
97
+ }
98
+
99
+ export function serverEntries(cfg: ShareConfig = currentConfig()): Array<{ url: string; entry: ServerEntry; active: boolean }> {
100
+ const merged: Record<string, ServerEntry> = {}
101
+ if (cfg.workerUrl) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
102
+ if (cfg.servers) for (const [url, entry] of Object.entries(cfg.servers)) merged[url] = { ...merged[url], ...entry }
103
+ const urls = Object.keys(merged)
104
+ const activeUrl = urls.find((u) => merged[u].active) ?? urls[0]
105
+ return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }))
106
+ }
107
+
108
+ export { saveServerEntry, deleteServerEntry }
109
+
110
+ // --- session metadata helpers -------------------------------------------------
111
+
112
+ /** All live per-server bindings from a v2 (or legacy v1) metadata record. */
113
+ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding & { server: string; collided?: string }> {
114
+ const raw = info?.metadata?.[METADATA_KEY]
115
+ if (!raw || typeof raw !== "object") return []
116
+ const record = raw as Record<string, unknown>
117
+ const opts = (record.opts as SessionOpts | undefined) ?? undefined
118
+ const enabled = record.enabled === true ? true : undefined
119
+ const out: Array<SessionBinding & { server: string; collided?: string }> = []
120
+ if (record.servers && typeof record.servers === "object") {
121
+ for (const [server, entry] of Object.entries(record.servers as Record<string, unknown>)) {
122
+ if (!entry || typeof entry !== "object") continue
123
+ const e = entry as Record<string, unknown>
124
+ if (typeof e.id !== "string" || typeof e.secret !== "string" || !e.id) continue
125
+ const collided = typeof e.collided === "string" ? e.collided : undefined
126
+ out.push({
127
+ id: e.id,
128
+ secret: e.secret,
129
+ rev: typeof e.rev === "number" ? e.rev : 0,
130
+ url: typeof e.url === "string" ? e.url : "",
131
+ enabled,
132
+ opts,
133
+ server,
134
+ collided,
135
+ })
136
+ }
137
+ return out
138
+ }
139
+ // legacy flat binding
140
+ if (typeof record.id === "string" && typeof record.secret === "string" && record.id) {
141
+ const collided = typeof record.collided === "string" ? record.collided : undefined
142
+ out.push({
143
+ id: record.id,
144
+ secret: record.secret,
145
+ rev: typeof record.rev === "number" ? record.rev : 0,
146
+ url: typeof record.url === "string" ? record.url : "",
147
+ enabled,
148
+ opts,
149
+ server: "",
150
+ collided,
151
+ })
152
+ }
153
+ return out
154
+ }
155
+
156
+ /** Read every live binding for a session (fan-out aware). */
157
+ export async function readBindings(client: ClientApi | undefined, sid: string): Promise<Array<SessionBinding & { server: string; collided?: string }>> {
158
+ return bindingsOf(await getSession(client, sid))
159
+ }
160
+
161
+ export function hostOf(url: string): string {
162
+ try {
163
+ return new URL(url).host
164
+ } catch {
165
+ return url
166
+ }
167
+ }
168
+
169
+ /** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
170
+ * server actions: opt-out entries, collision resolution). Reads-modifies-
171
+ * writes the whole metadata to keep foreign keys intact. */
172
+ export async function patchSessionBinding(
173
+ client: ClientApi | undefined,
174
+ sid: string,
175
+ mutator: (servers: Record<string, unknown>, binding: Record<string, unknown>) => void,
176
+ ): Promise<boolean> {
177
+ const info = await getSession(client, sid)
178
+ if (!info) return false
179
+ const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
180
+ const raw = info.metadata?.[METADATA_KEY]
181
+ const binding: Record<string, unknown> =
182
+ raw && typeof raw === "object" ? { ...(raw as Record<string, unknown>) } : { v: 2, servers: {} }
183
+ const servers: Record<string, unknown> =
184
+ binding.servers && typeof binding.servers === "object" ? { ...(binding.servers as Record<string, unknown>) } : {}
185
+ mutator(servers, binding)
186
+ binding.servers = servers
187
+ metadata[METADATA_KEY] = binding
188
+ return patchMetadata(client, sid, metadata)
189
+ }
190
+
191
+ /** The display/primary binding: the active server's entry when bound
192
+ * there, else the first live entry (single-server configs are unchanged). */
193
+ export function bindingOf(info: SessionInfo | undefined): SessionBinding | undefined {
194
+ const all = bindingsOf(info)
195
+ if (!all.length) return undefined
196
+ const active = activeServer()?.url
197
+ return all.find((b) => b.server === active) ?? all[0]
198
+ }
199
+
200
+ async function getSession(client: ClientApi | undefined, sid: string): Promise<SessionInfo | undefined> {
201
+ if (!client?.session?.get) return undefined
202
+ try {
203
+ const res = await client.session.get({ path: { id: sid } })
204
+ const info = (res as { data?: SessionInfo })?.data ?? (res as SessionInfo)
205
+ return info && typeof info === "object" ? info : undefined
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ async function patchMetadata(client: ClientApi | undefined, sid: string, metadata: Record<string, unknown>): Promise<boolean> {
212
+ if (!client?.session?.update) {
213
+ dbgLog(`patchMetadata(${sid}): no client.session.update`)
214
+ return false
215
+ }
216
+ try {
217
+ const res = await client.session.update({ path: { id: sid }, body: { metadata } })
218
+ if (res && typeof res === "object" && "error" in res && (res as { error?: unknown }).error) {
219
+ const err = (res as { error?: { name?: string; data?: { message?: string; ref?: string } } }).error
220
+ dbgLog(`patchMetadata(${sid}) ERROR: ${err?.name}: ${err?.data?.message ?? ""} ref=${err?.data?.ref ?? ""}`)
221
+ return false
222
+ }
223
+ dbgLog(`patchMetadata(${sid}): ok res=${dbg(res)}`)
224
+ return true
225
+ } catch (e) {
226
+ dbgLog(`patchMetadata(${sid}) THREW: ${(e as Error)?.stack ?? e}`)
227
+ return false
228
+ }
229
+ }
230
+
231
+ /** Walks parentID up to the root session (the share owns the whole tree). */
232
+ export async function resolveRoot(client: ClientApi | undefined, sid: string): Promise<string> {
233
+ let current = sid
234
+ for (let hops = 0; hops < 10; hops++) {
235
+ const info = await getSession(client, current)
236
+ const parent = info?.parentID
237
+ if (typeof parent !== "string" || !parent || parent === current) return current
238
+ current = parent
239
+ }
240
+ return current
241
+ }
242
+
243
+ export async function readBinding(client: ClientApi | undefined, sid: string): Promise<SessionBinding | undefined> {
244
+ return bindingOf(await getSession(client, sid))
245
+ }
246
+
247
+ /** Sets metadata.openshare = { ...existing(v2 structure preserved),
248
+ * enabled: true, opts? }. The per-server binding entries are the ENGINE's
249
+ * to manage; the flag just opts the session in. */
250
+ export async function setShareFlag(
251
+ client: ClientApi | undefined,
252
+ sid: string,
253
+ opts?: SessionOpts,
254
+ ): Promise<boolean> {
255
+ const info = await getSession(client, sid)
256
+ if (!info) return false
257
+ const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
258
+ const existing = info.metadata?.[METADATA_KEY]
259
+ const value: Record<string, unknown> =
260
+ existing && typeof existing === "object"
261
+ ? { ...(existing as Record<string, unknown>) }
262
+ : { v: 2, servers: {} }
263
+ value.enabled = true
264
+ if (opts && Object.keys(opts).length) value.opts = opts
265
+ metadata[METADATA_KEY] = value
266
+ return patchMetadata(client, sid, metadata)
267
+ }
268
+
269
+ export async function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean> {
270
+ const info = await getSession(client, sid)
271
+ if (!info) return false
272
+ const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
273
+ delete metadata[METADATA_KEY]
274
+ return patchMetadata(client, sid, metadata)
275
+ }
276
+
277
+ // --- worker direct access -------------------------------------------------------
278
+
279
+ export async function workerDeleteShare(workerUrl: string, id: string, secret: string): Promise<boolean> {
280
+ try {
281
+ const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
282
+ method: "DELETE",
283
+ headers: { "content-type": "application/json" },
284
+ body: JSON.stringify({ secret }),
285
+ signal: AbortSignal.timeout(8000),
286
+ })
287
+ return res.ok
288
+ } catch {
289
+ return false
290
+ }
291
+ }
292
+
293
+ /** Health probe for the settings dialog's connection test. */
294
+ export async function probeWorker(workerUrl: string): Promise<{ ok: boolean; detail: string }> {
295
+ if (!workerUrl) return { ok: false, detail: "workerUrl is empty" }
296
+ try {
297
+ const res = await fetch(`${workerUrl.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(6000) })
298
+ if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` }
299
+ const body = (await res.json().catch(() => ({}))) as { service?: string }
300
+ return { ok: true, detail: body.service ? `service: ${body.service}` : "reachable" }
301
+ } catch (error) {
302
+ return { ok: false, detail: (error as Error).message }
303
+ }
304
+ }
305
+
306
+ /** Share liveness probe — the "session says shared but the link is dead"
307
+ * case (purged, TTL-expired, or deleted while the binding stayed in
308
+ * session metadata). GET /data answers 404 {status:"gone"} BEFORE the
309
+ * read-auth gate, so this is a credential-free existence check that
310
+ * works identically on the CF worker and the Bun server. Network errors
311
+ * return "unknown" — never auto-disable on unknown. */
312
+ export async function verifyShare(workerUrl: string, shareId: string): Promise<"live" | "gone" | "unknown"> {
313
+ const base = normServerUrl(workerUrl)
314
+ if (!base || !shareId) return "unknown"
315
+ try {
316
+ const res = await fetch(`${base}/api/share/${shareId}/data`, { signal: AbortSignal.timeout(4000) })
317
+ if (res.status === 404) {
318
+ const body = (await res.json().catch(() => ({}))) as { status?: string }
319
+ return body.status === "gone" || Object.keys(body).length === 0 ? "gone" : "unknown"
320
+ }
321
+ // 200 = readable; 401 = read-gated but EXISTS. Both mean the page is there.
322
+ return "live"
323
+ } catch {
324
+ return "unknown"
325
+ }
326
+ }
327
+
328
+ /** Remove the binding entries for shares that no longer exist server-side
329
+ * (the auto-disable half of gone detection). Entries are deleted outright —
330
+ * not optedOut — so a later prompt in the session can create a fresh share. */
331
+ export async function clearGoneBindingEntries(host: ShareHostApi, root: string, servers: string[]): Promise<boolean> {
332
+ if (!servers.length) return false
333
+ return patchSessionBinding(host.client, root, (map) => {
334
+ for (const server of servers) delete map[server]
335
+ })
336
+ }
337
+
338
+ // --- server identity ops (TUI) -----------------------------------------------
339
+
340
+ export function normServerUrl(url: string): string {
341
+ return url.trim().replace(/\/+$/, "")
342
+ }
343
+
344
+ /** /health probe including the auth mode (public enrolls anyone). */
345
+ export async function probeAuthMode(workerUrl: string): Promise<{ ok: boolean; mode?: "public" | "private"; detail: string }> {
346
+ const base = normServerUrl(workerUrl)
347
+ if (!base) return { ok: false, detail: "no URL" }
348
+ try {
349
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
350
+ if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` }
351
+ const body = (await res.json().catch(() => ({}))) as { auth?: { mode?: string } }
352
+ const mode = body.auth?.mode === "public" ? "public" : body.auth?.mode === "private" ? "private" : undefined
353
+ return { ok: true, mode, detail: mode ? `auth: ${mode}` : "reachable (pre-auth server?)" }
354
+ } catch (error) {
355
+ return { ok: false, detail: (error as Error).message }
356
+ }
357
+ }
358
+
359
+ export async function enrollAtServer(workerUrl: string, code: string): Promise<{ ok: boolean; token?: string; userId?: string; label?: string; error?: string }> {
360
+ const base = normServerUrl(workerUrl)
361
+ try {
362
+ const res = await fetch(`${base}/api/enroll`, {
363
+ method: "POST",
364
+ headers: { "content-type": "application/json" },
365
+ body: JSON.stringify({ code: code || undefined }),
366
+ signal: AbortSignal.timeout(10000),
367
+ })
368
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string }
369
+ if (res.ok && body.ok && typeof body.token === "string") {
370
+ return { ok: true, token: body.token, userId: body.userId, label: body.label }
371
+ }
372
+ return { ok: false, error: body.error ?? `HTTP ${res.status}` }
373
+ } catch (error) {
374
+ return { ok: false, error: (error as Error).message }
375
+ }
376
+ }
377
+
378
+ export async function rotateAtServer(workerUrl: string, token: string): Promise<{ ok: boolean; token?: string; error?: string }> {
379
+ const base = normServerUrl(workerUrl)
380
+ try {
381
+ const res = await fetch(`${base}/api/token/rotate`, {
382
+ method: "POST",
383
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
384
+ signal: AbortSignal.timeout(10000),
385
+ })
386
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; token?: string; error?: string }
387
+ if (res.ok && body.ok && typeof body.token === "string") return { ok: true, token: body.token }
388
+ return { ok: false, error: body.error ?? `HTTP ${res.status}` }
389
+ } catch (error) {
390
+ return { ok: false, error: (error as Error).message }
391
+ }
392
+ }
393
+
394
+ export async function disconnectAtServer(workerUrl: string, token: string): Promise<{ ok: boolean; error?: string }> {
395
+ const base = normServerUrl(workerUrl)
396
+ try {
397
+ const res = await fetch(`${base}/api/token`, {
398
+ method: "DELETE",
399
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
400
+ signal: AbortSignal.timeout(10000),
401
+ })
402
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
403
+ return { ok: res.ok && body.ok !== false, error: body.error }
404
+ } catch (error) {
405
+ return { ok: false, error: (error as Error).message }
406
+ }
407
+ }
408
+
409
+ // --- admin console (root CREATE_TOKEN) ----------------------------------------
410
+
411
+ export type AdminUser = { id: string; label: string; createdAt: number; frozen: boolean; shares: number; tokens: number; tokensActive: number }
412
+ export type AdminToken = { id: string; userId: string; label: string; limits: Record<string, number>; createdAt: number; lastUsedAt: number; expiresAt: number; revoked: boolean }
413
+ export type AdminCode = { id: string; label: string; activationsLeft: number; used: number; createdAt: number; expiresAt: number; disabled: boolean }
414
+ export type AdminShare = { id: string; createdAt: number; sessionID?: string; ownerId?: string | null }
415
+
416
+ /** Thin typed client for the worker's /api/admin surface. `token` must be
417
+ * the server's root CREATE_TOKEN (user osk_ tokens get 403). */
418
+ export function adminClient(workerUrl: string, token: string) {
419
+ const base = normServerUrl(workerUrl)
420
+ const call = async <T>(method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; data?: T; error?: string }> => {
421
+ try {
422
+ const res = await fetch(`${base}${path}`, {
423
+ method,
424
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
425
+ body: body === undefined ? undefined : JSON.stringify(body),
426
+ signal: AbortSignal.timeout(12000),
427
+ })
428
+ const parsed = (await res.json().catch(() => ({}))) as T & { ok?: boolean; error?: string }
429
+ if (!res.ok) return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` }
430
+ return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error }
431
+ } catch (error) {
432
+ return { ok: false, status: 0, error: (error as Error).message }
433
+ }
434
+ }
435
+ return {
436
+ users: () => call<{ users: AdminUser[] }>("GET", "/api/admin/users"),
437
+ createUser: (label: string) => call<{ userId: string; label: string }>("POST", "/api/admin/users", { label }),
438
+ freeze: (userId: string) => call<{ frozen: boolean; shares: number }>("POST", `/api/admin/users/${userId}/freeze`),
439
+ unfreeze: (userId: string) => call<{ frozen: boolean; shares: number }>("POST", `/api/admin/users/${userId}/unfreeze`),
440
+ deleteUser: (userId: string, mode: "freeze" | "delete") => call<{ mode: string; shares: number }>("DELETE", `/api/admin/users/${userId}`, { mode }),
441
+ tokens: () => call<{ tokens: AdminToken[] }>("GET", "/api/admin/tokens"),
442
+ mintToken: (userId: string, label?: string, limits?: Record<string, number>) => call<{ token: string; id: string }>("POST", "/api/admin/tokens", { userId, label, limits }),
443
+ revokeToken: (tokenId: string) => call("DELETE", `/api/admin/tokens/${tokenId}`),
444
+ codes: () => call<{ codes: AdminCode[] }>("GET", "/api/admin/codes"),
445
+ createCode: (label: string, activations: number) => call<{ code: string; id: string; activationsLeft: number }>("POST", "/api/admin/codes", { label, activations }),
446
+ disableCode: (codeId: string) => call("DELETE", `/api/admin/codes/${codeId}`),
447
+ shares: () => call<{ shares: AdminShare[] }>("GET", "/api/admin/shares"),
448
+ deleteShare: (shareId: string) => call("DELETE", `/api/share/${shareId}`),
449
+ reassign: (shareId: string, userId: string) => call("POST", `/api/admin/shares/${shareId}/reassign`, { userId }),
450
+ purge: () => call<{ removed: number }>("POST", "/api/admin/purge"),
451
+ health: () => call<{ ok: boolean; service: string; policy: Record<string, unknown>; auth: { mode: string } }>("GET", "/health"),
452
+ }
453
+ }
454
+
455
+ /** Resolve the admin credential for a server: the entry's adminToken, the
456
+ * legacy createToken (when it points at this server), or prompt. */
457
+ export function adminTokenFor(url: string, cfg: ShareConfig = currentConfig()): string {
458
+ const base = normServerUrl(url)
459
+ const entry = serverEntries(cfg).find((s) => s.url === base)
460
+ return entry?.entry.adminToken ?? (cfg.workerUrl && normServerUrl(cfg.workerUrl) === base ? cfg.createToken : "") ?? ""
461
+ }
462
+
463
+ // --- the flows --------------------------------------------------------------------
464
+
465
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
466
+
467
+ /** Waits for the server plugin to publish the binding URL (poll; robust on
468
+ * both hosts). Resolves the binding, or undefined on timeout. */
469
+ export async function waitForBinding(
470
+ client: ClientApi | undefined,
471
+ sid: string,
472
+ timeoutMs = 25000,
473
+ ): Promise<SessionBinding | undefined> {
474
+ const start = Date.now()
475
+ while (Date.now() - start < timeoutMs) {
476
+ const binding = await readBinding(client, sid)
477
+ if (binding?.url) return binding
478
+ await sleep(600)
479
+ }
480
+ return undefined
481
+ }
482
+
483
+ export type FlowResult = { ok: boolean; message: string; url?: string; unconfigured?: boolean }
484
+
485
+ export async function runShareFlow(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult> {
486
+ if (!sessionID) return { ok: false, message: "No active session to share." }
487
+ const cfg = currentConfig()
488
+ if (!isConfigured(cfg)) {
489
+ return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true }
490
+ }
491
+ const root = await resolveRoot(host.client, sessionID)
492
+ const existing = await readBinding(host.client, root)
493
+ if (existing?.url && existing.enabled !== false) {
494
+ // already sharing: copy the link
495
+ await host.clipboard.write(existing.url)
496
+ return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url }
497
+ }
498
+ if (!host.kv.get(CONSENT_KV_KEY, false)) {
499
+ // caller shows the consent dialog before invoking confirmShare()
500
+ return { ok: false, message: "consent required" }
501
+ }
502
+ return confirmShare(host, root)
503
+ }
504
+
505
+ /** Post-consent share: set the flag, wait for the URL, copy it. */
506
+ export async function confirmShare(host: ShareHostApi, rootSessionID: string): Promise<FlowResult> {
507
+ dbgLog(`confirmShare: setShareFlag(${rootSessionID})`)
508
+ const set = await setShareFlag(host.client, rootSessionID)
509
+ dbgLog(`confirmShare: setShareFlag -> ${set}`)
510
+ if (!set) return { ok: false, message: "Could not write the share flag into session metadata." }
511
+ const binding = await waitForBinding(host.client, rootSessionID)
512
+ dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`)
513
+ if (!binding?.url) {
514
+ return {
515
+ ok: false,
516
+ message:
517
+ "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
518
+ }
519
+ }
520
+ await host.clipboard.write(binding.url)
521
+ return { ok: true, message: `Share URL copied to clipboard: ${binding.url}`, url: binding.url }
522
+ }
523
+
524
+ export async function runUnshareFlow(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult> {
525
+ if (!sessionID) return { ok: false, message: "No active session." }
526
+ const root = await resolveRoot(host.client, sessionID)
527
+ const info = await getSession(host.client, root)
528
+ const all = bindingsOf(info)
529
+ if (!all.length) return { ok: false, message: "This session is not shared." }
530
+ // clear flag first (stops the forwarder), then delete every server's
531
+ // page directly — the direct delete also covers server plugins that
532
+ // never saw the event
533
+ await clearShareFlag(host.client, root)
534
+ let deleted = 0
535
+ for (const b of all) {
536
+ // the binding URL carries the origin the share lives on (with
537
+ // multi-server that can differ from today's active entry)
538
+ const bindingOrigin = b.url ? b.url.slice(0, b.url.indexOf("/s/")) : ""
539
+ const deleteBase = bindingOrigin || b.server
540
+ if (deleteBase && b.id && b.secret) {
541
+ if (await workerDeleteShare(deleteBase, b.id, b.secret)) deleted++
542
+ }
543
+ }
544
+ return {
545
+ ok: true,
546
+ message:
547
+ deleted === all.length
548
+ ? `Sharing stopped; ${deleted} share page${deleted === 1 ? "" : "s"} deleted.`
549
+ : `Sharing stopped (${deleted}/${all.length} pages deleted; the rest will follow via the sweeper).`,
550
+ }
551
+ }