@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.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/src/dialogs.tsx CHANGED
@@ -163,11 +163,11 @@ function estimatedVisualRows(message: string, width: number) {
163
163
  return message.split(/\r?\n/).reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / Math.max(1, width))), 0)
164
164
  }
165
165
 
166
- /** Deterministic character-level pre-wrap (parity lesson: opentui's
167
- * word wrap cannot break unbreakable tokens like a JSON policy blob, so
168
- * "wrapped" text still escaped the dialog border — live-caught). Both the
169
- * [i] pane and InfoDialog render THESE lines; the row estimate uses the
170
- * same output, so heights match what renders. */
166
+ /** Deterministic character-level pre-wrap: opentui's word wrap cannot
167
+ * break unbreakable tokens like a JSON policy blob, so "wrapped" text still
168
+ * escapes the dialog border. Both the [i] pane and InfoDialog render THESE
169
+ * lines; the row estimate uses the same output, so heights match what
170
+ * renders. */
171
171
  export function preWrap(text: string, width: number): string[] {
172
172
  const out: string[] = []
173
173
  for (const raw of text.split(/\r?\n/)) {
@@ -229,10 +229,9 @@ function InfoLines(props: { api: TuiPluginApi; lines: string[] }): JSX.Element {
229
229
  )
230
230
  }
231
231
 
232
- /** title column cap (family menuTitleWidth, live-caught need: a server URL
233
- * as a row title stretched the column toward half the dialog — the owner
234
- * capped it further: full URLs belong in the [i] pane, not the row).
235
- * Capped + ellipsized. */
232
+ /** Title column cap (family menuTitleWidth): a server URL as a row title
233
+ * would otherwise stretch the column toward half the dialog. Full URLs
234
+ * belong in the [i] pane, not the row. Capped and ellipsized. */
236
235
  function menuTitleWidth(width: DialogWidth, options: readonly { title: string }[]) {
237
236
  const longest = Math.max(0, ...options.map((option) => option.title.length)) + 2
238
237
  if (width === "xlarge") return Math.min(Math.max(20, longest), 28)
@@ -246,13 +245,12 @@ function ellipsize(text: string, width: number) {
246
245
 
247
246
  /** OpenShare visual frame: rounded accent border + accent title row. The
248
247
  * logic inside the menus stays the ported family pattern; this wrapper is
249
- * purely presentation (owner-preferred styling). Natural width — the host
250
- * dialog panel provides the container; the border hugs the content. */
251
- // Frame note (live-caught): the HOST dialog panel renders plugin content
252
- // with paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always
253
- // showed a dead panel strip above its top border. Borderless family
254
- // shape + a solid accent rule under the title: zero dead space, accent
255
- // identity kept.
248
+ * purely presentation. Natural width: the host dialog panel provides the
249
+ * container and the border hugs the content. */
250
+ // Frame note: the HOST dialog panel renders plugin content with
251
+ // paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always shows a
252
+ // dead panel strip above its top border. Borderless family shape plus a
253
+ // solid accent rule under the title: zero dead space, accent identity kept.
256
254
  function DialogFrame(props: { api: TuiPluginApi; title: string; children: JSX.Element }): JSX.Element {
257
255
  const theme = () => props.api.theme.current
258
256
  return (
@@ -323,8 +321,8 @@ export function MenuDialog<Value>(props: {
323
321
  const dimensions = useTerminalDimensions()
324
322
  const listHeight = createMemo(() => cappedHeight(props.options.length, maxRows(props.api, dimensions().height, 12, 4), 4))
325
323
  // fixed title column shared by every row (family menuTitleWidth): flex
326
- // columns stagger per-row because flex splits by content — a computed
327
- // width is what makes the value/description column align (live-caught).
324
+ // columns stagger per row because flex splits by content, so a computed
325
+ // width is what makes the value/description column align.
328
326
  const width = dialogWidthOf(props.api)
329
327
  const titleWidth = createMemo(() => menuTitleWidth(width, props.options))
330
328
  const hasHelp = createMemo(() => props.options.some((option) => option.help))
@@ -352,8 +350,8 @@ export function MenuDialog<Value>(props: {
352
350
  })
353
351
  // [i] pane sections: "## Header" lines in help split sections; the pane
354
352
  // height comes from the CONTENT (pre-wrapped rows), capped by the same
355
- // dialog metrics — no longer bound to the spawning menu's list height
356
- // (live-caught: a 3-row menu gave a 3-row info pane).
353
+ // dialog metrics rather than the spawning menu's list height (which gave
354
+ // a 3-row menu a 3-row info pane).
357
355
  const helpSections = createMemo(() => {
358
356
  const raw = current()?.help ?? ""
359
357
  const sections: Array<{ title: string; lines: string[] }> = [{ title: "", lines: [] }]
@@ -602,11 +600,10 @@ function claimDialog(api: TuiPluginApi): () => void {
602
600
  pendingDialog = release
603
601
  return release
604
602
  }
605
- /** showMenuOnce, family discipline with the flicker fix (2026-10-19):
606
- * done() does NOT clear the host stack — the next dialog's replace() is an
607
- * atomic swap, so clearing first paints one empty frame (the esc-to-parent
608
- * flicker, live-caught). Chain ends clear explicitly (command exits,
609
- * settings exit). */
603
+ /** showMenuOnce, family discipline: done() does NOT clear the host stack,
604
+ * because the next dialog's replace() is an atomic swap and clearing first
605
+ * paints one empty frame (the esc-to-parent flicker). Chain ends clear
606
+ * explicitly (command exits, settings exit). */
610
607
  export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
611
608
  return new Promise((resolve) => {
612
609
  let settled = false
@@ -627,11 +624,11 @@ export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; o
627
624
  }
628
625
 
629
626
  // --- InfoDialog (config-studio port): a real message body ----------------------
630
- // Content-derived height, pre-wrapped (char-level — word wrap cannot break
627
+ // Content-derived height, pre-wrapped (char-level: word wrap cannot break
631
628
  // JSON blobs), semantically styled lines, page/home/end keys, and an action
632
- // footer with buttons + key shortcuts. Replaces the hint-as-message menus
633
- // for confirm/alert (whose single overflow-hidden line clipped destructive
634
- // prompts mid-URL — live-caught).
629
+ // footer with buttons + key shortcuts. Replaces the hint-as-message menus for
630
+ // confirm/alert, whose single overflow-hidden line clipped destructive
631
+ // prompts mid-URL.
635
632
 
636
633
  export type DialogAction = { title: string; value: string; key?: string; danger?: boolean }
637
634
 
@@ -772,9 +769,9 @@ export function showAlertMenu(api: TuiPluginApi, props: { title: string; message
772
769
 
773
770
  // --- prompt (text entry — the one custom piece) -------------------------------
774
771
 
775
- // Full printable ASCII (parity fix, live-caught class: the lowercase-only
776
- // set made uppercase CREATE_TOKENs and codes UNTYPEABLE). Space is bound
777
- // separately below; escape is the back binding, so it's excluded.
772
+ // Full printable ASCII: a lowercase-only set makes uppercase
773
+ // CREATE_TOKENs and codes UNTYPEABLE. Space is bound separately below;
774
+ // escape is the back binding, so it is excluded.
778
775
  const PROMPT_CHARS = [
779
776
  ..."abcdefghijklmnopqrstuvwxyz",
780
777
  ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ",
package/src/redact.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Client-only redaction + light path transform (Wave 3 trust).
3
+ *
4
+ * Owner verdict: the server stays dumb - the client controls what leaves
5
+ * the machine. This pass runs once at enqueue (the single choke point
6
+ * every item crosses before any transport) on UNGATED shares only: a
7
+ * share with login+pass is private from casual observers already, and
8
+ * redacting there would corrupt content the server owner can see anyway
9
+ * (documented caveat: NOT private from the server owner until Wave 3b
10
+ * encryption lands).
11
+ *
12
+ * Extending: add a detector to BUILTINS (fork-friendly registry) or set
13
+ * `redact.patterns` in the config (regex source strings, global flag).
14
+ */
15
+
16
+ /** A detector: stable `kind` names the marker, e.g. `[redacted:aws-key]`. */
17
+ export type RedactRule = { kind: string; re: RegExp }
18
+
19
+ export const BUILTINS: RedactRule[] = [
20
+ { kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
21
+ { kind: "gh-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
22
+ { kind: "gh-token", re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g },
23
+ { kind: "api-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
24
+ { kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
25
+ { kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
26
+ { kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
27
+ // PEM private-key blocks (whole block)
28
+ { kind: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
29
+ // .env style assignments for secret-shaped names
30
+ { kind: "env-secret", re: /(?<![A-Za-z0-9_])(?:export\s+)?[A-Z0-9_]*(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Z0-9_]*\s*=\s*\S+/g },
31
+ // URL connection strings with embedded passwords (password masked)
32
+ { kind: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^:@/\s"']+:[^@/\s"']{4,}@/gi },
33
+ ]
34
+
35
+ /** Config-supplied rules (regex source strings, applied globally). */
36
+ function compile(patterns: unknown): RedactRule[] {
37
+ if (!Array.isArray(patterns)) return []
38
+ const out: RedactRule[] = []
39
+ for (const p of patterns) {
40
+ if (typeof p !== "string" || !p) continue
41
+ try {
42
+ out.push({ kind: "user", re: new RegExp(p, "g") })
43
+ } catch {
44
+ // a broken user regex must never kill sharing - skip it
45
+ }
46
+ }
47
+ return out
48
+ }
49
+
50
+ export type RedactionHit = { kind: string; sample: string }
51
+
52
+ /** Home-directory prefix -> `~`; applied consistently to session.directory
53
+ * AND tool path fields so viewer-side relativization still works (both
54
+ * ends share the ~-prefixed form). */
55
+ export function tildePath(p: string, home: string): string {
56
+ if (!p || !home || p.length <= home.length) return p
57
+ const norm = (s: string) => s.replace(/[\\/]+/g, "/").replace(/\/$/, "")
58
+ const np = norm(p)
59
+ const nh = norm(home)
60
+ if (np === nh) return "~"
61
+ if (np.startsWith(nh + "/")) return "~" + np.slice(nh.length)
62
+ return p
63
+ }
64
+
65
+ const PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"])
66
+
67
+ type Opts = { rules: RedactRule[]; home: string; enabled: boolean }
68
+ type Stats = { hits: RedactionHit[]; changed: boolean }
69
+
70
+ /**
71
+ * Apply redaction (when enabled) + the light path transform to one
72
+ * contract item. Returns the ORIGINAL untouched when nothing matched
73
+ * (zero-copy fast path for clean sessions); otherwise a mutated deep
74
+ * clone - the host's live object is never touched.
75
+ */
76
+ export function redactItem(item: unknown, opts: Opts): { item: unknown; hits: RedactionHit[] } {
77
+ const flat = JSON.stringify(item) ?? "null"
78
+ const needRedact = opts.enabled && opts.rules.some((r) => { r.re.lastIndex = 0; return r.re.test(flat) })
79
+ const needPaths = !!opts.home && flat.includes(opts.home)
80
+ if (!needRedact && !needPaths) return { item, hits: [] }
81
+ const clone = JSON.parse(flat) as unknown
82
+ const stats: Stats = { hits: [], changed: false }
83
+ walk(clone, opts, stats)
84
+ return { item: clone, hits: stats.hits }
85
+ }
86
+
87
+ function walk(node: unknown, opts: Opts, stats: Stats, key?: string): void {
88
+ if (Array.isArray(node)) {
89
+ for (let i = 0; i < node.length; i++) {
90
+ const v = node[i]
91
+ if (typeof v === "string") node[i] = applyString(v, opts, stats, key)
92
+ else walk(v, opts, stats, key)
93
+ }
94
+ return
95
+ }
96
+ if (node && typeof node === "object") {
97
+ for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
98
+ if (typeof v === "string") (node as Record<string, unknown>)[k] = applyString(v, opts, stats, k)
99
+ else walk(v, opts, stats, k)
100
+ }
101
+ }
102
+ }
103
+
104
+ function applyString(v: string, opts: Opts, stats: Stats, key?: string): string {
105
+ let out = v
106
+ if (opts.enabled) {
107
+ for (const r of opts.rules) {
108
+ r.re.lastIndex = 0
109
+ out = out.replace(r.re, (full) => {
110
+ if (stats.hits.length < 8 && !stats.hits.some((h) => h.kind === r.kind)) {
111
+ stats.hits.push({ kind: r.kind, sample: full.slice(0, 60) })
112
+ }
113
+ stats.changed = true
114
+ if (r.kind === "conn-string") {
115
+ // keep scheme://user, mask the password, keep @host
116
+ const at = full.lastIndexOf("@")
117
+ const colon = full.indexOf(":", full.indexOf("://") + 3) + 1
118
+ if (at > colon && colon > 0) return full.slice(0, colon) + "[redacted:conn-string]" + full.slice(at)
119
+ }
120
+ return `[redacted:${r.kind}]`
121
+ })
122
+ }
123
+ }
124
+ if (key && PATH_KEYS.has(key) && opts.home && out.includes(opts.home)) {
125
+ out = tildePath(out, opts.home)
126
+ stats.changed = true
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** All active rules (built-ins + compiled user patterns). */
132
+ export function activeRules(userPatterns: unknown): RedactRule[] {
133
+ return [...BUILTINS, ...compile(userPatterns)]
134
+ }
package/src/settings.ts CHANGED
@@ -15,12 +15,15 @@ import {
15
15
  deleteServerEntry,
16
16
  disconnectAtServer,
17
17
  enrollAtServer,
18
+ fmtHours,
19
+ parseTtlHours,
18
20
  hostOf,
19
21
  isConfigured,
20
22
  normServerUrl,
21
23
  probeAuthMode,
22
24
  probeWorker,
23
25
  patchSessionBinding,
26
+ patchShareOpts,
24
27
  readBinding,
25
28
  readBindings,
26
29
  resolveRoot,
@@ -146,13 +149,13 @@ const LABELS: Record<string, LabelMeta> = {
146
149
  info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
147
150
  def: '"$Provider/$Model"',
148
151
  },
149
- ttlDays: {
150
- label: "TTL (days)",
151
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
152
+ ttlHours: {
153
+ label: "TTL (hours)",
154
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
152
155
  kind: "number",
153
156
  section: "Sharing",
154
- info: "How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
155
- def: "worker cap (90 on the reference deployment)",
157
+ info: "How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.\n\nA per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.",
158
+ def: "worker cap (2160h = 90d on the reference deployment)",
156
159
  },
157
160
  authLogin: {
158
161
  label: "Read auth: login",
@@ -219,8 +222,8 @@ function displayValue(key: string, cfg: ShareConfig): string {
219
222
  async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promise<ShareConfig | undefined> {
220
223
  const meta = LABELS[key]
221
224
  if (!meta) return undefined
222
- // selection-backed settings (owner: "setting share mode i need to enter it
223
- // manually? No selection?") — fixed vocabularies become menus
225
+ // selection-backed settings: fixed vocabularies become menus instead of
226
+ // asking for a typed value
224
227
  if (meta.options) {
225
228
  const current = String((cfg as unknown as Record<string, unknown>)[key] ?? "")
226
229
  const picked = await showSelect(host.ui, {
@@ -262,39 +265,91 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
262
265
  return next as ShareConfig
263
266
  }
264
267
 
265
- /** Connect (add + enroll) a server — the /share first-run flow and the
268
+ /** Connect (add + enroll) a server - the /share first-run flow and the
266
269
  * settings "Connect to a server" row share it. Prompts the URL, probes
267
- * the auth mode, prompts a code for private servers, enrolls, persists
268
- * the token and makes it the active server. */
270
+ * the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
271
+ * (public servers only; anonymous shares sit under the server's global
272
+ * caps). Onboarding detects whether a code is needed and only then prompts
273
+ * for one (a code already in config is used without asking). The outcome is
274
+ * remembered per server: a token means onboarded, an anonymous flag means
275
+ * declined. */
269
276
  export async function connectServerFlow(host: ShareHostApi): Promise<{ ok: boolean; message: string; url?: string }> {
270
277
  const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" })
271
278
  if (raw === undefined || raw === "") return { ok: false, message: "Cancelled." }
272
279
  const url = normServerUrl(raw)
273
280
  if (!/^https?:\/\//.test(url)) return { ok: false, message: "URL must start with http:// or https://" }
274
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}…` })
281
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` })
275
282
  const probe = await probeAuthMode(url)
276
283
  if (!probe.ok) return { ok: false, message: `${url}: ${probe.detail}` }
277
- let code = ""
278
- if (probe.mode === "private") {
279
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" })
280
- if (typed === undefined) return { ok: false, message: "Cancelled — private servers need a code." }
284
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {}
285
+ const deactivateOthers = () => {
286
+ for (const other of serverEntries()) {
287
+ if (other.url !== url) saveServerEntry(other.url, { active: undefined })
288
+ }
289
+ }
290
+ if (probe.mode === "public") {
291
+ // the ask: an account is a real thing (own shares, own limits,
292
+ // revocable credentials) - but anonymous sharing works too
293
+ const picked = await showSelect(host.ui, {
294
+ title: `${url} - account?`,
295
+ options: [
296
+ {
297
+ value: "onboard",
298
+ title: "Onboard (get an account)",
299
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
300
+ help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required.",
301
+ },
302
+ {
303
+ value: "anon",
304
+ title: "Stay anonymous",
305
+ description: "share without an account; counts against the server-wide anonymous limits",
306
+ help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now.",
307
+ },
308
+ ],
309
+ })
310
+ if (picked === "anon") {
311
+ saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true })
312
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url }
313
+ }
314
+ if (picked === undefined || picked === null) return { ok: false, message: "Cancelled." }
315
+ // onboard: try without a code (codeless servers), fall through to
316
+ // the code prompt exactly when the server demands one
317
+ let code = existing.code ?? ""
318
+ if (!code) {
319
+ const free = await enrollAtServer(url, "")
320
+ if (free.ok) {
321
+ deactivateOthers()
322
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true })
323
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url }
324
+ }
325
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
326
+ if (typed === undefined) return { ok: false, message: "Cancelled - this server needs a code to onboard." }
327
+ code = typed.trim()
328
+ }
329
+ const res = await enrollAtServer(url, code)
330
+ if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
331
+ deactivateOthers()
332
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true })
333
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
334
+ }
335
+ // private server: a code is required, full stop
336
+ let code = existing.code ?? ""
337
+ if (!code) {
338
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
339
+ if (typed === undefined) return { ok: false, message: "Cancelled - private servers need a code." }
281
340
  code = typed.trim()
282
341
  }
283
342
  const res = await enrollAtServer(url, code)
284
343
  if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
285
- // single active server: deactivate the others in the settings home,
286
- // then persist the new membership as active
287
- for (const other of serverEntries()) {
288
- if (other.url !== url) saveServerEntry(other.url, { active: undefined })
289
- }
344
+ deactivateOthers()
290
345
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true })
291
- return { ok: true, message: `Connected to ${url} as "${res.label}" — sharing goes there now.`, url }
346
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
292
347
  }
293
348
 
294
349
  /** Per-server submenu: identity status + membership actions. Root-token
295
- * holders get "Get a user token" (admin mints their own user identity —
296
- * root stays as adminToken; being admin and being a user are NOT mutually
297
- * exclusive, live-caught). */
350
+ * holders get "Get a user token" (the admin mints their own user identity;
351
+ * root stays as adminToken, since being admin and being a user are not
352
+ * mutually exclusive). */
298
353
  async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
299
354
  while (true) {
300
355
  const entries = serverEntries()
@@ -307,7 +362,9 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
307
362
  ? `token ${mask(mine.entry.token)}`
308
363
  : mine.entry.code
309
364
  ? "no token (code saved, enrolls on demand)"
310
- : "no token (public server, enrolls on demand)"
365
+ : mine.entry.anonymous
366
+ ? "anonymous (declined onboarding)"
367
+ : "no token (public server, shares anonymously)"
311
368
  const options: ShareDialogSelectOption<string>[] = [
312
369
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
313
370
  ]
@@ -381,15 +438,27 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
381
438
  continue
382
439
  }
383
440
  let code = mine.entry.code ?? ""
384
- if (probe.mode === "private" && !code) {
385
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" })
441
+ if (!code) {
442
+ // codeless first: only prompt when the server actually demands
443
+ // a code (public + codeless-on boards silently, like connect)
444
+ const free = await enrollAtServer(url, "")
445
+ if (free.ok) {
446
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined })
447
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` })
448
+ continue
449
+ }
450
+ if (!/code required/i.test(free.error ?? "")) {
451
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" })
452
+ continue
453
+ }
454
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
386
455
  if (typed === undefined) continue
387
456
  code = typed.trim()
388
457
  if (code) saveServerEntry(url, { code })
389
458
  }
390
459
  const res = await enrollAtServer(url, code)
391
460
  if (res.ok) {
392
- saveServerEntry(url, { token: res.token, label: res.label })
461
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined })
393
462
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` })
394
463
  } else {
395
464
  await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" })
@@ -599,9 +668,9 @@ async function withBusy(host: ShareHostApi, title: string, work: Promise<void> |
599
668
  doneBusy?.()
600
669
  }
601
670
 
602
- /** Server info, the readable version (live-caught: the raw JSON policy line
603
- * escaped the dialog border): formatted policy lines, auth explained in
604
- * words, YOUR standing on this server, and follow-up actions. */
671
+ /** Server info, the readable version (the raw JSON policy line escapes
672
+ * the dialog border): formatted policy lines, auth explained in words,
673
+ * YOUR standing on this server, and follow-up actions. */
605
674
  async function serverInfoScreen(host: ShareHostApi, url: string, client: ReturnType<typeof adminClient>): Promise<void> {
606
675
  const h = await client.health()
607
676
  if (!h.ok) {
@@ -844,16 +913,15 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
844
913
  }
845
914
 
846
915
  /** The settings dialog: a compact ROOT of group entries (with live
847
- * summaries) opening focused submenus. Two-level by design — one flat
848
- * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
849
- * Save semantics unchanged: root Exit saves when dirty; submenu Back
850
- * just returns. */
916
+ * summaries) opening focused submenus. Two-level by design: one flat ~25-row
917
+ * dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
918
+ * submenu Back just returns. */
851
919
  export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
852
920
  let cfg = currentConfig()
853
921
  let dirty = false
854
- // Write target (owner rule): global settings home by default; any other
855
- // existing file in the read chain may be chosen explicitly. The TUI
856
- // never creates deploy-time override files.
922
+ // Write target: global settings home by default; any other existing
923
+ // file in the read chain may be chosen explicitly. The TUI never
924
+ // creates deploy-time override files.
857
925
  const candidates = writeCandidates(moduleDir())
858
926
  let writeTarget: WriteCandidate = candidates[0]
859
927
  const sources = configSourceMap(moduleDir())
@@ -1017,8 +1085,8 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1017
1085
  rows.push({
1018
1086
  title: "Session TTL override (days)",
1019
1087
  value: "session:ttl",
1020
- description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
1021
- help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
1088
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
1089
+ help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none — config default)"}`,
1022
1090
  })
1023
1091
  rows.push({
1024
1092
  title: "Session read auth",
@@ -1233,9 +1301,9 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1233
1301
  for (const b of allB) {
1234
1302
  if (!b.server) continue
1235
1303
  // keep id/secret on the opt-out marker: the snapshot stays
1236
- // deletable later and re-sharing reuses the binding instead
1237
- // of stalling on a secret-less marker (live-caught as "can
1238
- // never unshare or re-share after leave-as-is")
1304
+ // deletable later, and re-sharing reuses the binding instead
1305
+ // of stalling on a secret-less marker that can never unshare
1306
+ // or re-share again.
1239
1307
  servers[b.server] = { ...(servers[b.server] as Record<string, unknown> | undefined), id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true }
1240
1308
  }
1241
1309
  })
@@ -1254,26 +1322,32 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1254
1322
  return
1255
1323
  }
1256
1324
  if (choice === "session:ttl") {
1257
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" })
1325
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" })
1258
1326
  if (raw === undefined) return
1259
1327
  const opts: SessionOpts = { ...(current?.opts ?? {}) }
1260
- if (raw === "") delete opts.ttlDays
1328
+ if (raw === "") delete opts.ttlHours
1261
1329
  else {
1262
- const days = Number(raw)
1263
- if (!Number.isFinite(days) || days <= 0) {
1264
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." })
1330
+ const hours = parseTtlHours(raw)
1331
+ if (hours === undefined) {
1332
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." })
1265
1333
  return
1266
1334
  }
1267
- opts.ttlDays = Math.floor(days)
1335
+ opts.ttlHours = hours
1268
1336
  }
1269
1337
  await setShareOpts(host.client, root, opts)
1270
1338
  const liveNow = live.length > 0
1339
+ if (liveNow) {
1340
+ // REST push (#13): immediate on every server, socket or not
1341
+ const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })))
1342
+ const failed = results.filter((r) => !r.ok).length
1343
+ if (failed) host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` })
1344
+ }
1271
1345
  host.ui.toast({
1272
1346
  variant: "success",
1273
1347
  title: "OpenShare",
1274
1348
  message: liveNow
1275
- ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1276
- : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
1349
+ ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1350
+ : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
1277
1351
  })
1278
1352
  return
1279
1353
  }
@@ -1298,6 +1372,12 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1298
1372
  }
1299
1373
  await setShareOpts(host.client, root, opts)
1300
1374
  const liveNow2 = live.length > 0
1375
+ if (liveNow2) {
1376
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password }
1377
+ const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })))
1378
+ const failed2 = results2.filter((r) => !r.ok).length
1379
+ if (failed2) host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` })
1380
+ }
1301
1381
  host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` })
1302
1382
  return
1303
1383
  }
@@ -1350,7 +1430,7 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1350
1430
  })
1351
1431
  groups.push({
1352
1432
  id: "sharing",
1353
- summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.ttlDays ?? "server cap"}`,
1433
+ summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
1354
1434
  help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.\n\nCadence values are clamped by the server's policy window.`,
1355
1435
  })
1356
1436
  groups.push({