@saadnvd1/agent-os 0.3.780 → 0.3.781
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/app/api/live-shares/[id]/route.ts +11 -0
- package/app/api/live-shares/route.test.ts +14 -0
- package/app/api/live-shares/route.ts +7 -1
- package/components/Live/InviteDialog.tsx +73 -2
- package/components/Live/LiveChip.tsx +68 -1
- package/components/Live/LivePanel.tsx +8 -0
- package/data/live/queries.ts +3 -0
- package/lib/connect/control-api.ts +30 -1
- package/lib/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
- package/lib/live/code-view.ts +56 -0
- package/lib/live/code.ts +25 -0
- package/lib/live/inbox.ts +9 -0
- package/lib/live/runtime.ts +2 -0
- package/lib/live/service.ts +63 -3
- package/lib/live/store.ts +22 -2
- package/lib/live/types.ts +27 -0
- package/lib/share/connect.ts +20 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
4
|
import {
|
|
5
5
|
admit,
|
|
6
|
+
changeCode,
|
|
6
7
|
changeLevel,
|
|
7
8
|
deny,
|
|
8
9
|
dismissSuggestion,
|
|
@@ -30,6 +31,13 @@ const Action = z.discriminatedUnion("action", [
|
|
|
30
31
|
action: z.literal("wheel"),
|
|
31
32
|
guestId: z.string().min(1).max(64).nullable(),
|
|
32
33
|
}),
|
|
34
|
+
// Sets the access code to the host's own, or turns it off (null).
|
|
35
|
+
z.object({
|
|
36
|
+
action: z.literal("code"),
|
|
37
|
+
code: z.string().max(40).nullable(),
|
|
38
|
+
}),
|
|
39
|
+
// Turns the access code on, or changes it, to a generated one.
|
|
40
|
+
z.object({ action: z.literal("new_code") }),
|
|
33
41
|
z.object({
|
|
34
42
|
action: z.literal("send"),
|
|
35
43
|
suggestionId: z.string().min(1).max(64),
|
|
@@ -63,6 +71,9 @@ export async function POST(
|
|
|
63
71
|
else if (a.action === "kick") kick(id, a.guestId);
|
|
64
72
|
else if (a.action === "level") changeLevel(id, a.level);
|
|
65
73
|
else if (a.action === "wheel") giveWheel(id, a.guestId);
|
|
74
|
+
else if (a.action === "code")
|
|
75
|
+
await changeCode(id, a.code === null ? null : { code: a.code });
|
|
76
|
+
else if (a.action === "new_code") await changeCode(id, { generate: true });
|
|
66
77
|
else if (a.action === "send")
|
|
67
78
|
await sendSuggestion(id, a.suggestionId, a.text);
|
|
68
79
|
else dismissSuggestion(id, a.suggestionId);
|
|
@@ -43,6 +43,20 @@ describe("live-shares routes", () => {
|
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("validate the access code shape (400) and accept the code actions", async () => {
|
|
47
|
+
expect(
|
|
48
|
+
(await POST(req({ sessionId: "s", code: { custom: 5 } }))).status
|
|
49
|
+
).toBe(400);
|
|
50
|
+
expect((await POST(req({ sessionId: "s", code: {} }))).status).toBe(409);
|
|
51
|
+
expect((await POST(req({ sessionId: "s", code: null }))).status).toBe(409);
|
|
52
|
+
// A bad own code on a share this machine holds nothing of: 404, so it validated.
|
|
53
|
+
expect((await act(req({ action: "code", code: "ab" }), ctx)).status).toBe(
|
|
54
|
+
404
|
|
55
|
+
);
|
|
56
|
+
expect((await act(req({ action: "code", code: 5 }), ctx)).status).toBe(400);
|
|
57
|
+
expect((await act(req({ action: "new_code" }), ctx)).status).toBe(404);
|
|
58
|
+
});
|
|
59
|
+
|
|
46
60
|
it("accept watch, suggest and drive, and history as a boolean", async () => {
|
|
47
61
|
// Past validation: not linked to Connect here, so 409, never 400.
|
|
48
62
|
expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
|
|
@@ -9,6 +9,11 @@ const Create = z.object({
|
|
|
9
9
|
expiresIn: z.enum(["1h", "session"]).default("1h"),
|
|
10
10
|
level: z.enum(["watch", "suggest", "drive"]).default("suggest"),
|
|
11
11
|
history: z.boolean().default(true),
|
|
12
|
+
// Absent or null: no code. {} generates one; { custom } uses the host's.
|
|
13
|
+
code: z
|
|
14
|
+
.object({ custom: z.string().max(40).optional() })
|
|
15
|
+
.nullable()
|
|
16
|
+
.optional(),
|
|
12
17
|
});
|
|
13
18
|
|
|
14
19
|
// POST /api/live-shares - mint a live link for one chat and start publishing.
|
|
@@ -25,7 +30,8 @@ export async function POST(request: Request) {
|
|
|
25
30
|
body.data.sessionId,
|
|
26
31
|
body.data.expiresIn,
|
|
27
32
|
body.data.level,
|
|
28
|
-
body.data.history
|
|
33
|
+
body.data.history,
|
|
34
|
+
body.data.code
|
|
29
35
|
);
|
|
30
36
|
return NextResponse.json(view, { status: 201 });
|
|
31
37
|
} catch (err) {
|
|
@@ -78,10 +78,13 @@ function Create({
|
|
|
78
78
|
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
79
79
|
const [level, setLevel] = useState<LiveLevel>("suggest");
|
|
80
80
|
const [history, setHistory] = useState<boolean | null>(null);
|
|
81
|
+
const [codeOn, setCodeOn] = useState<boolean | null>(null);
|
|
82
|
+
const [custom, setCustom] = useState("");
|
|
81
83
|
const create = useCreateLive();
|
|
82
84
|
const options = invite?.levels ?? [];
|
|
83
85
|
const chosen = options.find((o) => o.value === level);
|
|
84
86
|
const withHistory = history ?? invite?.history.default ?? true;
|
|
87
|
+
const withCode = codeOn ?? invite?.code.default ?? false;
|
|
85
88
|
return (
|
|
86
89
|
<>
|
|
87
90
|
<div className="flex flex-col gap-1.5">
|
|
@@ -118,6 +121,36 @@ function Create({
|
|
|
118
121
|
{invite.history.label}
|
|
119
122
|
</label>
|
|
120
123
|
)}
|
|
124
|
+
{invite && (
|
|
125
|
+
<div className="flex flex-col gap-1.5">
|
|
126
|
+
<label className="flex min-h-11 items-center gap-3 text-sm">
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
className="size-5"
|
|
130
|
+
checked={withCode}
|
|
131
|
+
onChange={(e) => setCodeOn(e.target.checked)}
|
|
132
|
+
/>
|
|
133
|
+
{invite.code.label}
|
|
134
|
+
</label>
|
|
135
|
+
{withCode && (
|
|
136
|
+
<>
|
|
137
|
+
<input
|
|
138
|
+
value={custom}
|
|
139
|
+
onChange={(e) => setCustom(e.target.value)}
|
|
140
|
+
maxLength={40}
|
|
141
|
+
autoComplete="off"
|
|
142
|
+
spellCheck={false}
|
|
143
|
+
placeholder={invite.code.customLabel}
|
|
144
|
+
aria-label={invite.code.customLabel}
|
|
145
|
+
className="bg-muted min-h-11 rounded-lg px-3 font-mono text-sm"
|
|
146
|
+
/>
|
|
147
|
+
<p className="text-muted-foreground text-xs">
|
|
148
|
+
{invite.code.hint} {invite.code.rules}
|
|
149
|
+
</p>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
121
154
|
<div className="flex flex-col gap-1.5">
|
|
122
155
|
<span className="text-muted-foreground text-xs font-medium">
|
|
123
156
|
The link works for
|
|
@@ -139,7 +172,17 @@ function Create({
|
|
|
139
172
|
className="min-h-11"
|
|
140
173
|
disabled={create.isPending}
|
|
141
174
|
onClick={() =>
|
|
142
|
-
create.mutate({
|
|
175
|
+
create.mutate({
|
|
176
|
+
sessionId,
|
|
177
|
+
expiresIn,
|
|
178
|
+
level,
|
|
179
|
+
history: withHistory,
|
|
180
|
+
code: withCode
|
|
181
|
+
? custom.trim()
|
|
182
|
+
? { custom: custom.trim() }
|
|
183
|
+
: {}
|
|
184
|
+
: null,
|
|
185
|
+
})
|
|
143
186
|
}
|
|
144
187
|
>
|
|
145
188
|
<Users className="h-4 w-4" />
|
|
@@ -153,6 +196,7 @@ function Link({ share }: { share: LiveView }) {
|
|
|
153
196
|
const { copied, copy } = useCopyToClipboard();
|
|
154
197
|
const end = useEndLive(share.id);
|
|
155
198
|
const qr = useQr(share.url);
|
|
199
|
+
const code = share.code;
|
|
156
200
|
const canShare = typeof navigator !== "undefined" && "share" in navigator;
|
|
157
201
|
return (
|
|
158
202
|
<>
|
|
@@ -175,6 +219,33 @@ function Link({ share }: { share: LiveView }) {
|
|
|
175
219
|
className="bg-muted min-h-11 w-full rounded-lg px-3 font-mono text-xs"
|
|
176
220
|
/>
|
|
177
221
|
</div>
|
|
222
|
+
{code.on && code.copyText && (
|
|
223
|
+
<div className="flex flex-col items-center gap-2">
|
|
224
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
225
|
+
{code.label}
|
|
226
|
+
</span>
|
|
227
|
+
<span
|
|
228
|
+
className="font-mono text-3xl font-semibold tracking-widest"
|
|
229
|
+
aria-label={code.label}
|
|
230
|
+
>
|
|
231
|
+
{code.display}
|
|
232
|
+
</span>
|
|
233
|
+
<Button
|
|
234
|
+
className="min-h-11 w-full"
|
|
235
|
+
onClick={() => copy(code.copyText!)}
|
|
236
|
+
>
|
|
237
|
+
{copied ? (
|
|
238
|
+
<Check className="h-4 w-4" />
|
|
239
|
+
) : (
|
|
240
|
+
<Copy className="h-4 w-4" />
|
|
241
|
+
)}
|
|
242
|
+
Copy link and code
|
|
243
|
+
</Button>
|
|
244
|
+
<p className="text-muted-foreground text-center text-xs">
|
|
245
|
+
{code.hint}
|
|
246
|
+
</p>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
178
249
|
<div className="flex flex-wrap gap-2">
|
|
179
250
|
<Button
|
|
180
251
|
variant="outline"
|
|
@@ -186,7 +257,7 @@ function Link({ share }: { share: LiveView }) {
|
|
|
186
257
|
) : (
|
|
187
258
|
<Copy className="h-4 w-4" />
|
|
188
259
|
)}
|
|
189
|
-
Copy
|
|
260
|
+
Copy link
|
|
190
261
|
</Button>
|
|
191
262
|
{canShare && (
|
|
192
263
|
<Button
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
4
|
+
import { Check, ChevronDown, Copy, Radio, UserMinus } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Segmented } from "@/components/ui/segmented";
|
|
7
7
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
8
8
|
import type { LiveLevel, LiveView } from "@/data/live";
|
|
9
|
+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
|
9
10
|
import { cn } from "@/lib/utils";
|
|
10
11
|
|
|
11
12
|
export function presenceText(guests: LiveView["guests"]): string {
|
|
@@ -21,6 +22,7 @@ export function LiveChip({
|
|
|
21
22
|
busy,
|
|
22
23
|
onAction,
|
|
23
24
|
onLevel,
|
|
25
|
+
onCode,
|
|
24
26
|
onEnd,
|
|
25
27
|
onInvite,
|
|
26
28
|
}: {
|
|
@@ -28,6 +30,7 @@ export function LiveChip({
|
|
|
28
30
|
busy: boolean;
|
|
29
31
|
onAction: (guestId: string, kind: string) => void;
|
|
30
32
|
onLevel: (level: LiveLevel) => void;
|
|
33
|
+
onCode: (kind: string, own?: string) => void;
|
|
31
34
|
onEnd: () => void;
|
|
32
35
|
onInvite: () => void;
|
|
33
36
|
}) {
|
|
@@ -76,6 +79,7 @@ export function LiveChip({
|
|
|
76
79
|
<p className="text-muted-foreground px-2 text-xs">
|
|
77
80
|
{share.levels.find((o) => o.value === share.level)?.hint}
|
|
78
81
|
</p>
|
|
82
|
+
<CodeRow share={share} busy={busy} onCode={onCode} />
|
|
79
83
|
{admitted.length === 0 && (
|
|
80
84
|
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
81
85
|
No one has joined yet.
|
|
@@ -128,3 +132,66 @@ export function LiveChip({
|
|
|
128
132
|
</div>
|
|
129
133
|
);
|
|
130
134
|
}
|
|
135
|
+
|
|
136
|
+
function CodeRow({
|
|
137
|
+
share,
|
|
138
|
+
busy,
|
|
139
|
+
onCode,
|
|
140
|
+
}: {
|
|
141
|
+
share: LiveView;
|
|
142
|
+
busy: boolean;
|
|
143
|
+
onCode: (kind: string, own?: string) => void;
|
|
144
|
+
}) {
|
|
145
|
+
const { copied, copy } = useCopyToClipboard();
|
|
146
|
+
const { code } = share;
|
|
147
|
+
const change = (kind: string) => {
|
|
148
|
+
if (kind === "copy") copy(code.copyText ?? share.url);
|
|
149
|
+
else if (kind === "copy_link") copy(share.url);
|
|
150
|
+
else if (kind === "set_code") {
|
|
151
|
+
const own = window.prompt(`${code.prompt.title}\n${code.prompt.message}`);
|
|
152
|
+
if (own?.trim()) onCode(kind, own.trim());
|
|
153
|
+
} else onCode(kind);
|
|
154
|
+
};
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex flex-col gap-1 px-2 py-1">
|
|
157
|
+
<div className="flex min-h-11 items-center gap-2">
|
|
158
|
+
<span className="text-muted-foreground text-xs">{code.label}</span>
|
|
159
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold tracking-wider">
|
|
160
|
+
{code.on ? code.display : "Off"}
|
|
161
|
+
</span>
|
|
162
|
+
{code.on && code.copyText && (
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
className="min-h-11"
|
|
166
|
+
onClick={() => copy(code.copyText!)}
|
|
167
|
+
aria-label="Copy link and code"
|
|
168
|
+
>
|
|
169
|
+
{copied ? (
|
|
170
|
+
<Check className="h-4 w-4" />
|
|
171
|
+
) : (
|
|
172
|
+
<Copy className="h-4 w-4" />
|
|
173
|
+
)}
|
|
174
|
+
</Button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
{code.notice && (
|
|
178
|
+
<p className="text-muted-foreground text-xs">{code.notice}</p>
|
|
179
|
+
)}
|
|
180
|
+
<div className="flex flex-wrap gap-1">
|
|
181
|
+
{code.actions
|
|
182
|
+
.filter((a) => a.kind !== "copy" && a.kind !== "copy_link")
|
|
183
|
+
.map((a) => (
|
|
184
|
+
<Button
|
|
185
|
+
key={a.kind}
|
|
186
|
+
variant="ghost"
|
|
187
|
+
className="min-h-11"
|
|
188
|
+
disabled={busy}
|
|
189
|
+
onClick={() => change(a.kind)}
|
|
190
|
+
>
|
|
191
|
+
{a.label}
|
|
192
|
+
</Button>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -47,6 +47,14 @@ export function LivePanel({
|
|
|
47
47
|
onLevel={(level) =>
|
|
48
48
|
act.mutate({ action: "level", level }, { onError: fail })
|
|
49
49
|
}
|
|
50
|
+
onCode={(kind, own) => {
|
|
51
|
+
if (kind === "code_off")
|
|
52
|
+
act.mutate({ action: "code", code: null }, { onError: fail });
|
|
53
|
+
else if (kind === "set_code" && own)
|
|
54
|
+
act.mutate({ action: "code", code: own }, { onError: fail });
|
|
55
|
+
else if (kind === "new_code" || kind === "code_on")
|
|
56
|
+
act.mutate({ action: "new_code" }, { onError: fail });
|
|
57
|
+
}}
|
|
50
58
|
onEnd={() => end.mutate(undefined, { onError: fail })}
|
|
51
59
|
onInvite={() => liveUiActions.open(sessionId, title)}
|
|
52
60
|
/>
|
package/data/live/queries.ts
CHANGED
|
@@ -57,6 +57,7 @@ export function useCreateLive() {
|
|
|
57
57
|
expiresIn: LiveExpiry;
|
|
58
58
|
level: LiveLevel;
|
|
59
59
|
history: boolean;
|
|
60
|
+
code?: { custom?: string } | null;
|
|
60
61
|
}) => json<LiveView>(await send("/api/live-shares", "POST", req)),
|
|
61
62
|
onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
62
63
|
});
|
|
@@ -67,6 +68,8 @@ export type LiveAction =
|
|
|
67
68
|
| { action: "deny" | "kick"; guestId: string }
|
|
68
69
|
| { action: "wheel"; guestId: string | null }
|
|
69
70
|
| { action: "level"; level: LiveLevel }
|
|
71
|
+
| { action: "code"; code: string | null }
|
|
72
|
+
| { action: "new_code" }
|
|
70
73
|
| { action: "send"; suggestionId: string; text?: string }
|
|
71
74
|
| { action: "dismiss"; suggestionId: string };
|
|
72
75
|
|
|
@@ -482,13 +482,20 @@ export interface LiveShare {
|
|
|
482
482
|
expires_at: string | null;
|
|
483
483
|
ends_with_session: boolean;
|
|
484
484
|
max_guests: number;
|
|
485
|
+
/** Connect confirmed it holds an access code for the share. */
|
|
486
|
+
access_code: boolean;
|
|
485
487
|
}
|
|
486
488
|
|
|
487
489
|
/** Mints a live link for one session; the answer carries the link, once. */
|
|
488
490
|
export async function createLiveShare(
|
|
489
491
|
config: ConnectConfig,
|
|
490
492
|
privateKey: string,
|
|
491
|
-
share: {
|
|
493
|
+
share: {
|
|
494
|
+
title: string;
|
|
495
|
+
expiresIn: LiveExpiry;
|
|
496
|
+
access?: string;
|
|
497
|
+
accessCode?: string;
|
|
498
|
+
}
|
|
492
499
|
): Promise<LiveShare> {
|
|
493
500
|
const res = await signed(config, privateKey)(
|
|
494
501
|
"POST",
|
|
@@ -497,6 +504,7 @@ export async function createLiveShare(
|
|
|
497
504
|
title: share.title,
|
|
498
505
|
expires_in: share.expiresIn,
|
|
499
506
|
...(share.access ? { access: share.access } : {}),
|
|
507
|
+
...(share.accessCode ? { access_code: share.accessCode } : {}),
|
|
500
508
|
}
|
|
501
509
|
);
|
|
502
510
|
const r = (await res.json().catch(() => null)) as Record<
|
|
@@ -517,5 +525,26 @@ export async function createLiveShare(
|
|
|
517
525
|
expires_at: typeof r.expires_at === "string" ? r.expires_at : null,
|
|
518
526
|
ends_with_session: r.ends_with_session === true,
|
|
519
527
|
max_guests: typeof r.max_guests === "number" ? r.max_guests : 0,
|
|
528
|
+
access_code: r.access_code === true,
|
|
520
529
|
};
|
|
521
530
|
}
|
|
531
|
+
|
|
532
|
+
/** Sets (a code) or clears (null) a live share's access code; Connect must say it did. */
|
|
533
|
+
export async function setLiveAccessCode(
|
|
534
|
+
config: ConnectConfig,
|
|
535
|
+
privateKey: string,
|
|
536
|
+
id: string,
|
|
537
|
+
code: string | null
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
540
|
+
const res = await signed(config, privateKey)(
|
|
541
|
+
"PUT",
|
|
542
|
+
`/api/v1/machines/${config.machineId}/live-shares/${id}/access-code`,
|
|
543
|
+
{ access_code: code }
|
|
544
|
+
);
|
|
545
|
+
const r = (await res.json().catch(() => null)) as {
|
|
546
|
+
access_code?: unknown;
|
|
547
|
+
} | null;
|
|
548
|
+
if (r?.access_code !== (code !== null))
|
|
549
|
+
throw new Error("the Connect service did not confirm the access code");
|
|
550
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261031T1200_live_share_access_code",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
const cols = (
|
|
7
|
+
db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
|
|
8
|
+
).map((c) => c.name);
|
|
9
|
+
// Null is off. The code is kept in plain text so the host can see it again;
|
|
10
|
+
// Connect holds only a keyed hash of it.
|
|
11
|
+
if (!cols.includes("access_code"))
|
|
12
|
+
db.exec(`ALTER TABLE live_shares ADD COLUMN access_code TEXT`);
|
|
13
|
+
},
|
|
14
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { LiveCodeView, LiveInvite } from "./types";
|
|
2
|
+
import { displayCode } from "./code";
|
|
3
|
+
import type { Runtime } from "./runtime";
|
|
4
|
+
|
|
5
|
+
const HINT =
|
|
6
|
+
"Guests type this before they reach your lobby. You still let each one in.";
|
|
7
|
+
|
|
8
|
+
const RULES = "6 to 32 letters or digits.";
|
|
9
|
+
|
|
10
|
+
/** The access code block of a share, with the actions it offers right now. */
|
|
11
|
+
export function codeView(rt: Runtime, now = Date.now()): LiveCodeView {
|
|
12
|
+
const code = rt.share.access_code;
|
|
13
|
+
const locked = rt.codeLockedUntil && rt.codeLockedUntil > now;
|
|
14
|
+
return {
|
|
15
|
+
on: !!code,
|
|
16
|
+
display: code ? displayCode(code) : null,
|
|
17
|
+
copyText: code
|
|
18
|
+
? `${rt.share.url}\nAccess code: ${displayCode(code)}`
|
|
19
|
+
: null,
|
|
20
|
+
label: "Access code",
|
|
21
|
+
hint: HINT,
|
|
22
|
+
notice:
|
|
23
|
+
code && locked
|
|
24
|
+
? `Too many wrong codes. New guests can join again at ${new Date(
|
|
25
|
+
rt.codeLockedUntil!
|
|
26
|
+
).toLocaleTimeString([], {
|
|
27
|
+
hour: "numeric",
|
|
28
|
+
minute: "2-digit",
|
|
29
|
+
})}, or change the code now.`
|
|
30
|
+
: null,
|
|
31
|
+
prompt: {
|
|
32
|
+
title: "Use my own code",
|
|
33
|
+
message: RULES,
|
|
34
|
+
placeholder: "Your code",
|
|
35
|
+
},
|
|
36
|
+
actions: code
|
|
37
|
+
? [
|
|
38
|
+
{ kind: "copy", label: "Copy link and code" },
|
|
39
|
+
{ kind: "copy_link", label: "Copy link" },
|
|
40
|
+
{ kind: "new_code", label: "New code" },
|
|
41
|
+
{ kind: "set_code", label: "Use my own…" },
|
|
42
|
+
{ kind: "code_off", label: "Turn off" },
|
|
43
|
+
]
|
|
44
|
+
: [{ kind: "code_on", label: "Turn on" }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inviteCode(): LiveInvite["code"] {
|
|
49
|
+
return {
|
|
50
|
+
default: false,
|
|
51
|
+
label: "Require an access code",
|
|
52
|
+
hint: HINT,
|
|
53
|
+
customLabel: "Use my own code",
|
|
54
|
+
rules: RULES,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/lib/live/code.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/** The code is never logged, so nothing here prints it. */
|
|
4
|
+
|
|
5
|
+
/** Six digits, zero-padded so 123 is "000123". */
|
|
6
|
+
export function generateCode(): string {
|
|
7
|
+
return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The shape Connect checks: spaces and dashes dropped, upper-cased. */
|
|
11
|
+
export function normalizeCode(code: string): string {
|
|
12
|
+
return code.replace(/[\s-]/g, "").toUpperCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Why a host's own code is refused, or null when it's fine. */
|
|
16
|
+
export function codeRefusal(code: string): string | null {
|
|
17
|
+
return /^[A-Z0-9]{6,32}$/.test(normalizeCode(code))
|
|
18
|
+
? null
|
|
19
|
+
: "The access code must be 6 to 32 letters or digits.";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the host sees and copies: "482-917" for six digits, else as typed in capitals. */
|
|
23
|
+
export function displayCode(code: string): string {
|
|
24
|
+
return /^\d{6}$/.test(code) ? `${code.slice(0, 3)}-${code.slice(3)}` : code;
|
|
25
|
+
}
|
package/lib/live/inbox.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const DOWNWARD = [
|
|
|
35
35
|
"access_failed",
|
|
36
36
|
"drive_prompt",
|
|
37
37
|
"drive_stop",
|
|
38
|
+
"code_locked",
|
|
38
39
|
] as const;
|
|
39
40
|
|
|
40
41
|
const id = z.string().min(1).max(64);
|
|
@@ -55,6 +56,7 @@ const SCHEMAS = {
|
|
|
55
56
|
access_failed: z.object({ reason: z.string().max(200).optional() }),
|
|
56
57
|
drive_prompt: z.object({ id, guest, text: z.string().max(16000) }),
|
|
57
58
|
drive_stop: z.object({ id, guest }),
|
|
59
|
+
code_locked: z.object({ until: z.string().max(40) }),
|
|
58
60
|
} satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
|
|
59
61
|
|
|
60
62
|
export interface InboxEffects {
|
|
@@ -225,6 +227,13 @@ export function handleFrame(
|
|
|
225
227
|
}
|
|
226
228
|
return true;
|
|
227
229
|
}
|
|
230
|
+
case "code_locked": {
|
|
231
|
+
const until = Date.parse(
|
|
232
|
+
(parsed.data as z.infer<typeof SCHEMAS.code_locked>).until
|
|
233
|
+
);
|
|
234
|
+
rt.codeLockedUntil = Number.isFinite(until) ? until : null;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
228
237
|
case "drive_prompt":
|
|
229
238
|
return acceptPrompt(
|
|
230
239
|
rt,
|
package/lib/live/runtime.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface Runtime {
|
|
|
48
48
|
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
49
49
|
slowUntil: number;
|
|
50
50
|
notice: string | null;
|
|
51
|
+
/** Connect locked the lobby until this time (ms) after wrong access codes. */
|
|
52
|
+
codeLockedUntil: number | null;
|
|
51
53
|
drops: number;
|
|
52
54
|
drive: DriveState;
|
|
53
55
|
}
|
package/lib/live/service.ts
CHANGED
|
@@ -10,7 +10,12 @@ import { addTap, getSession, registry } from "../chat/registry";
|
|
|
10
10
|
import { sendChatConfirmed } from "../chat/runner";
|
|
11
11
|
import { shareContext } from "../share/context";
|
|
12
12
|
import { redactForShare } from "../share/redact";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ShareError,
|
|
15
|
+
createLiveRemote,
|
|
16
|
+
revokeRemote,
|
|
17
|
+
setAccessCodeRemote,
|
|
18
|
+
} from "../share/connect";
|
|
14
19
|
import type { LiveExpiry } from "../connect/control-api";
|
|
15
20
|
import {
|
|
16
21
|
LEVELS,
|
|
@@ -19,6 +24,8 @@ import {
|
|
|
19
24
|
type LiveView,
|
|
20
25
|
} from "./types";
|
|
21
26
|
import { driveView, guestActions, levelOptions } from "./levels";
|
|
27
|
+
import { codeView, inviteCode } from "./code-view";
|
|
28
|
+
import { codeRefusal, generateCode, normalizeCode } from "./code";
|
|
22
29
|
import {
|
|
23
30
|
driveRefusal,
|
|
24
31
|
newDrive,
|
|
@@ -45,6 +52,7 @@ import {
|
|
|
45
52
|
logLive,
|
|
46
53
|
markLiveEnded,
|
|
47
54
|
openSuggestions,
|
|
55
|
+
setAccessCode,
|
|
48
56
|
setLevel,
|
|
49
57
|
type LiveShareRow,
|
|
50
58
|
} from "./store";
|
|
@@ -108,6 +116,7 @@ function attach(row: LiveShareRow): Runtime {
|
|
|
108
116
|
flushTimer: null,
|
|
109
117
|
slowUntil: 0,
|
|
110
118
|
notice: null,
|
|
119
|
+
codeLockedUntil: null,
|
|
111
120
|
drops: 0,
|
|
112
121
|
drive: newDrive(row.id),
|
|
113
122
|
};
|
|
@@ -162,7 +171,8 @@ export async function createLive(
|
|
|
162
171
|
sessionId: string,
|
|
163
172
|
expiresIn: LiveExpiry,
|
|
164
173
|
level: LiveLevel = "suggest",
|
|
165
|
-
history = true
|
|
174
|
+
history = true,
|
|
175
|
+
codeOpt?: { custom?: string } | null
|
|
166
176
|
): Promise<LiveView> {
|
|
167
177
|
startLive();
|
|
168
178
|
const session = getSession(sessionId);
|
|
@@ -170,13 +180,36 @@ export async function createLive(
|
|
|
170
180
|
throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
|
|
171
181
|
if (activeLiveShareFor(sessionId))
|
|
172
182
|
throw new ShareError("This session already has a live link.", 409);
|
|
183
|
+
let accessCode: string | null = null;
|
|
184
|
+
if (codeOpt) {
|
|
185
|
+
if (codeOpt.custom) {
|
|
186
|
+
const why = codeRefusal(codeOpt.custom);
|
|
187
|
+
if (why) throw new ShareError(why, 400);
|
|
188
|
+
}
|
|
189
|
+
accessCode = codeOpt.custom
|
|
190
|
+
? normalizeCode(codeOpt.custom)
|
|
191
|
+
: generateCode();
|
|
192
|
+
}
|
|
173
193
|
const ctx = shareContext();
|
|
174
194
|
const title =
|
|
175
195
|
redactForShare(session.name, ctx)
|
|
176
196
|
.text.replace(/\s+/g, " ")
|
|
177
197
|
.trim()
|
|
178
198
|
.slice(0, 120) || "Chat";
|
|
179
|
-
const made = await createLiveRemote({
|
|
199
|
+
const made = await createLiveRemote({
|
|
200
|
+
title,
|
|
201
|
+
expiresIn,
|
|
202
|
+
access: level,
|
|
203
|
+
...(accessCode ? { accessCode } : {}),
|
|
204
|
+
});
|
|
205
|
+
// An old Connect ignores the code and would let anyone reach the lobby.
|
|
206
|
+
if (accessCode && !made.access_code) {
|
|
207
|
+
await revokeRemote(made.id).catch(() => {});
|
|
208
|
+
throw new ShareError(
|
|
209
|
+
"Connect doesn't support access codes yet. Try again without one.",
|
|
210
|
+
502
|
|
211
|
+
);
|
|
212
|
+
}
|
|
180
213
|
const row: LiveShareRow = {
|
|
181
214
|
id: made.id,
|
|
182
215
|
session_id: sessionId,
|
|
@@ -189,6 +222,7 @@ export async function createLive(
|
|
|
189
222
|
ended_at: null,
|
|
190
223
|
level,
|
|
191
224
|
history: history ? 1 : 0,
|
|
225
|
+
access_code: accessCode,
|
|
192
226
|
};
|
|
193
227
|
insertLiveShare(row);
|
|
194
228
|
attach(row);
|
|
@@ -216,6 +250,30 @@ export function changeLevel(id: string, level: LiveLevel): void {
|
|
|
216
250
|
live.socket?.send(id, "access", { level });
|
|
217
251
|
}
|
|
218
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Turns the access code on (generate, or the host's own), changes it, or turns
|
|
255
|
+
* it off (null). Connect first: if it doesn't confirm, nothing changes here.
|
|
256
|
+
* Guests already in or waiting keep their seat; only new joins need the code.
|
|
257
|
+
*/
|
|
258
|
+
export async function changeCode(
|
|
259
|
+
id: string,
|
|
260
|
+
next: { generate: true } | { code: string } | null
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
const rt = runtimeFor(id);
|
|
263
|
+
let code: string | null = null;
|
|
264
|
+
if (next && "code" in next) {
|
|
265
|
+
const why = codeRefusal(next.code);
|
|
266
|
+
if (why) throw new ShareError(why, 400);
|
|
267
|
+
code = normalizeCode(next.code);
|
|
268
|
+
} else if (next) code = generateCode();
|
|
269
|
+
await setAccessCodeRemote(id, code);
|
|
270
|
+
const now = live.runtimes.get(id);
|
|
271
|
+
if (!now || now.share.ended_at) return;
|
|
272
|
+
now.share = { ...now.share, access_code: code };
|
|
273
|
+
setAccessCode(id, code);
|
|
274
|
+
now.codeLockedUntil = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
219
277
|
/** Gives the wheel to an admitted guest of a Drive share, or takes it back (null). */
|
|
220
278
|
export function giveWheel(id: string, guestId: string | null): void {
|
|
221
279
|
const rt = runtimeFor(id);
|
|
@@ -253,6 +311,7 @@ export function inviteFor(sessionId: string): LiveInvite {
|
|
|
253
311
|
default: true,
|
|
254
312
|
count,
|
|
255
313
|
},
|
|
314
|
+
code: inviteCode(),
|
|
256
315
|
};
|
|
257
316
|
}
|
|
258
317
|
|
|
@@ -413,6 +472,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
413
472
|
notice: rt.notice,
|
|
414
473
|
level: rt.share.level,
|
|
415
474
|
levels,
|
|
475
|
+
code: codeView(rt, now),
|
|
416
476
|
history: rt.share.history === 1,
|
|
417
477
|
...driveView(rt),
|
|
418
478
|
guests: shown
|
package/lib/live/store.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface LiveShareRow {
|
|
|
14
14
|
level: LiveLevel;
|
|
15
15
|
/** 1: guests get the chat from before the link was made. */
|
|
16
16
|
history: number;
|
|
17
|
+
/** The access code in plain text, or null when off. Never logged. */
|
|
18
|
+
access_code: string | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export type LogKind =
|
|
@@ -25,6 +27,8 @@ export type LogKind =
|
|
|
25
27
|
| "sent"
|
|
26
28
|
| "dismissed"
|
|
27
29
|
| "level"
|
|
30
|
+
// The code was turned on, off or changed; the log never holds the code.
|
|
31
|
+
| "code_changed"
|
|
28
32
|
// Drive: who holds the wheel (guest_id, ref = the wheel's own id; both
|
|
29
33
|
// null when taken back), a prompt they sent (ref = its id), and why a
|
|
30
34
|
// Drive's turn or queue was stopped (ref = the reason).
|
|
@@ -47,8 +51,8 @@ export interface LogRow {
|
|
|
47
51
|
export function insertLiveShare(row: LiveShareRow): void {
|
|
48
52
|
db.prepare(
|
|
49
53
|
`INSERT INTO live_shares
|
|
50
|
-
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
|
|
51
|
-
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
|
|
54
|
+
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history, access_code)
|
|
55
|
+
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history, @access_code)`
|
|
52
56
|
).run(row);
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -94,6 +98,22 @@ export function setLevel(id: string, level: LiveLevel): void {
|
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
export function setAccessCode(id: string, code: string | null): void {
|
|
102
|
+
db.prepare(`UPDATE live_shares SET access_code = ? WHERE id = ?`).run(
|
|
103
|
+
code,
|
|
104
|
+
id
|
|
105
|
+
);
|
|
106
|
+
logLive({
|
|
107
|
+
share_id: id,
|
|
108
|
+
kind: "code_changed",
|
|
109
|
+
guest_id: null,
|
|
110
|
+
guest_name: null,
|
|
111
|
+
ref: code ? "on" : "off",
|
|
112
|
+
text: null,
|
|
113
|
+
sent_text: null,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
export function logLive(
|
|
98
118
|
row: Omit<LogRow, "id" | "at"> & { at?: number }
|
|
99
119
|
): void {
|
package/lib/live/types.ts
CHANGED
|
@@ -29,6 +29,25 @@ export interface GuestAction {
|
|
|
29
29
|
label: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export type CodeActionKind =
|
|
33
|
+
"copy" | "copy_link" | "new_code" | "set_code" | "code_off" | "code_on";
|
|
34
|
+
|
|
35
|
+
/** The access code as the host's screens show it; built on the machine. */
|
|
36
|
+
export interface LiveCodeView {
|
|
37
|
+
on: boolean;
|
|
38
|
+
/** "482-917", or the host's own code in capitals. Null when off. */
|
|
39
|
+
display: string | null;
|
|
40
|
+
/** The link and the code on two lines, for one paste. Null when off. */
|
|
41
|
+
copyText: string | null;
|
|
42
|
+
label: string;
|
|
43
|
+
hint: string;
|
|
44
|
+
/** Set after too many wrong tries; a code change clears it. */
|
|
45
|
+
notice: string | null;
|
|
46
|
+
/** For "Use my own". */
|
|
47
|
+
prompt: { title: string; message: string; placeholder: string };
|
|
48
|
+
actions: { kind: CodeActionKind; label: string }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
export type DriverState = "idle" | "queued" | "working" | "waiting_on_host";
|
|
33
52
|
|
|
34
53
|
// What the host's screens show of a live share (served by /api/live-shares).
|
|
@@ -46,6 +65,7 @@ export interface LiveView {
|
|
|
46
65
|
levels: LevelOption[];
|
|
47
66
|
/** Whether the guests got the chat from before the link was made. */
|
|
48
67
|
history: boolean;
|
|
68
|
+
code: LiveCodeView;
|
|
49
69
|
/** The guest holding the wheel on a Drive share. */
|
|
50
70
|
driver: { guestId: string; name: string; state: DriverState } | null;
|
|
51
71
|
/** Shown above the composer while a guest drives. */
|
|
@@ -81,6 +101,13 @@ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
|
|
|
81
101
|
export interface LiveInvite {
|
|
82
102
|
levels: LevelOption[];
|
|
83
103
|
history: { label: string; default: boolean; count: number };
|
|
104
|
+
code: {
|
|
105
|
+
default: boolean;
|
|
106
|
+
label: string;
|
|
107
|
+
hint: string;
|
|
108
|
+
customLabel: string;
|
|
109
|
+
rules: string;
|
|
110
|
+
};
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
export interface LiveResponse {
|
package/lib/share/connect.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
listShares,
|
|
8
8
|
replaceShare,
|
|
9
9
|
createLiveShare,
|
|
10
|
+
setLiveAccessCode,
|
|
10
11
|
revokeShare,
|
|
11
12
|
uploadShare,
|
|
12
13
|
type LiveExpiry,
|
|
@@ -112,6 +113,7 @@ export async function createLiveRemote(share: {
|
|
|
112
113
|
title: string;
|
|
113
114
|
expiresIn: LiveExpiry;
|
|
114
115
|
access?: string;
|
|
116
|
+
accessCode?: string;
|
|
115
117
|
}) {
|
|
116
118
|
const { config, machineKey } = files();
|
|
117
119
|
try {
|
|
@@ -120,3 +122,21 @@ export async function createLiveRemote(share: {
|
|
|
120
122
|
explain(err);
|
|
121
123
|
}
|
|
122
124
|
}
|
|
125
|
+
|
|
126
|
+
/** Puts a new access code on Connect (null turns it off). Throws if Connect doesn't confirm. */
|
|
127
|
+
export async function setAccessCodeRemote(
|
|
128
|
+
id: string,
|
|
129
|
+
code: string | null
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const { config, machineKey } = files();
|
|
132
|
+
try {
|
|
133
|
+
await setLiveAccessCode(config, machineKey, id, code);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof ApiError && (err.status === 404 || err.status === 410))
|
|
136
|
+
throw new ShareError("This link has ended.", 404);
|
|
137
|
+
throw new ShareError(
|
|
138
|
+
"Connect didn't confirm the access code, so it is unchanged. Try again.",
|
|
139
|
+
502
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|