@mirrowel/opencode-openshare 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/ci/README.md +56 -0
- package/ci/openshare.config.json +10 -0
- package/ci/openshare.js +2270 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +440 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +524 -0
- package/dist/core.d.ts +117 -0
- package/dist/core.js +1944 -0
- package/dist/dialogs.d.ts +94 -0
- package/dist/dialogs.js +725 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.js +30 -0
- package/dist/settings.d.ts +24 -0
- package/dist/settings.js +1226 -0
- package/dist/share-flow.d.ts +287 -0
- package/dist/share-flow.js +516 -0
- package/dist/sidebar.d.ts +19 -0
- package/dist/sidebar.js +144 -0
- package/dist/tui-host.d.ts +158 -0
- package/dist/tui-host.js +6 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +4719 -0
- package/dist/ui-prefs.d.ts +16 -0
- package/dist/ui-prefs.js +27 -0
- package/dist/v2-server.d.ts +81 -0
- package/dist/v2-server.js +402 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +313 -0
- package/dist/v2-types.d.ts +134 -0
- package/dist/v2-types.js +8 -0
- package/openshare.config.json +16 -0
- package/package.json +98 -4
- package/src/banner.txt +25 -0
- package/src/commands.ts +452 -0
- package/src/config.ts +524 -0
- package/src/core.ts +1997 -0
- package/src/dialogs.tsx +971 -0
- package/src/server.ts +47 -0
- package/src/settings.ts +1239 -0
- package/src/share-flow.ts +551 -0
- package/src/sidebar.tsx +187 -0
- package/src/tui-host.ts +134 -0
- package/src/tui.tsx +318 -0
- package/src/ui-prefs.ts +36 -0
- package/src/v2-server.ts +438 -0
- package/src/v2-tui.ts +315 -0
- package/src/v2-types.ts +102 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// OpenShare share flows — host-agnostic logic behind /share 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
|
+
// stop-sharing 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
|
+
}
|