Skip to content
Source
Sign inSign up

guestShare.ts

148 lines6.0 KB

1import * as Schema from "effect/Schema";
2​
3import { ChatAttachment } from "./chatAttachment.ts";
4import {
5 GuestMessageId,
6 GuestShareId,
7 IsoDateTime,
8 ThreadId,
9 TrimmedNonEmptyString,
10 TrimmedString,
11} from "./baseSchemas.ts";
12​
13/**
14 * A guest share lets one unauthenticated browser watch a thread over T3 Connect and propose
15 * messages the owner must send or dismiss. The thread projection holds this public state; the
16 * credentials are derived from the share id with a server key and never stored.
17 */
18export const GUEST_SHARE_TTL_MS = 24 * 60 * 60 * 1000;
19export const GUEST_MESSAGE_MAX_LENGTH = 4_000;
20export const GUEST_NAME_MAX_LENGTH = 40;
21export const GUEST_PENDING_MESSAGE_LIMIT = 3;
22/** Resolved messages kept so the guest can see what happened to what they sent. */
23export const GUEST_RESOLVED_MESSAGE_LIMIT = 20;
24/** Files a guest can attach to one message, and their total size. */
25export const GUEST_MESSAGE_MAX_ATTACHMENTS = 4;
26export const GUEST_MESSAGE_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
27/** Total size of the files in a share's waiting messages, so a guest cannot fill the disk. */
28export const GUEST_PENDING_ATTACHMENT_BYTES_LIMIT = 20 * 1024 * 1024;
29​
30export const ThreadGuestMessageStatus = Schema.Literals(["pending", "sent", "dismissed"]);
31export type ThreadGuestMessageStatus = typeof ThreadGuestMessageStatus.Type;
32​
33export const ThreadGuestMessage = Schema.Struct({
34 id: GuestMessageId,
35 text: Schema.String,
36 status: ThreadGuestMessageStatus,
37 submittedAt: IsoDateTime,
38 /** The thread's attachments, deleted if the message is dismissed. Absent on older messages. */
39 attachments: Schema.optional(Schema.Array(ChatAttachment)),
40});
41export type ThreadGuestMessage = typeof ThreadGuestMessage.Type;
42​
43export const ThreadGuestShare = Schema.Struct({
44 id: GuestShareId,
45 /** The T3 Connect tunnel hostname the link points at. Guest routes require it to still serve. */
46 host: TrimmedNonEmptyString,
47 createdAt: IsoDateTime,
48 expiresAt: IsoDateTime,
49 /** Set once, when the guest redeems the link. */
50 guestName: Schema.NullOr(TrimmedNonEmptyString),
51 messages: Schema.Array(ThreadGuestMessage),
52});
53export type ThreadGuestShare = typeof ThreadGuestShare.Type;
54​
55export const pendingGuestMessageCount = (share: ThreadGuestShare | null | undefined): number =>
56 share?.messages.filter((message) => message.status === "pending").length ?? 0;
57​
58export const GuestShareCreateInput = Schema.Struct({ threadId: ThreadId });
59export type GuestShareCreateInput = typeof GuestShareCreateInput.Type;
60​
61export const GuestShareCreateResult = Schema.Struct({
62 url: TrimmedNonEmptyString,
63 share: ThreadGuestShare,
64});
65export type GuestShareCreateResult = typeof GuestShareCreateResult.Type;
66​
67const GUEST_SHARE_CREATE_MESSAGES = {
68 disabled: "Guest links are off. Turn them on in Settings > Labs.",
69 connect_unavailable: "Guest links need a running T3 Connect tunnel.",
70 thread_unavailable: "This thread cannot be shared.",
71 read_failed: "Failed to read the shared thread.",
72 changed: "The guest link changed while it was being created.",
73} as const;
74​
75export class GuestShareCreateError extends Schema.TaggedError<GuestShareCreateError>()(
76 "GuestShareCreateError",
77 {
78 reason: Schema.Literals([
79 "disabled",
80 "connect_unavailable",
81 "thread_unavailable",
82 "read_failed",
83 "changed",
84 ]),
85 },
86) {
87 override get message(): string {
88 return GUEST_SHARE_CREATE_MESSAGES[this.reason];
89 }
91​
92// The name lands in the agent's attribution line, so it must stay one plain line, and the owner
93// reads it, so direction overrides cannot make it look like another name.
94const NAME_BREAKING_CHARACTER = /[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u;
95​
96export const GuestRedeemRequest = Schema.Struct({
97 credential: TrimmedNonEmptyString,
98 name: TrimmedNonEmptyString.check(
99 Schema.isMaxLength(GUEST_NAME_MAX_LENGTH),
100 Schema.makeFilter((name) => !NAME_BREAKING_CHARACTER.test(name), {
101 description: "a single line of text",
102 }),
103 ),
104});
105export type GuestRedeemRequest = typeof GuestRedeemRequest.Type;
106​
107/**
108 * The fields of a guest message. The page posts them as multipart form data, with each file as a
109 * `file` part; text can be empty when files are attached.
110 */
111export const GuestSubmitRequest = Schema.Struct({
112 /** The share the page shows, so a message from a page for a replaced link is refused instead
113 of reaching the new one. */
114 shareId: GuestShareId,
115 text: TrimmedString.check(Schema.isMaxLength(GUEST_MESSAGE_MAX_LENGTH)),
116});
117export type GuestSubmitRequest = typeof GuestSubmitRequest.Type;
118​
119export const GUEST_ROUTE_PREFIX = "/api/guest";
120/** The guest page and guest routes name the share they mean with this query parameter. */
121export const GUEST_SHARE_PARAM = "share";
122​
123/**
124 * One session cookie per share, so a guest keeps every link they joined on a host, and a page
125 * for one share never acts with another share's session.
126 */
127export const guestSessionCookieName = (shareId: string) => `t3_guest_${shareId}`;
128/** Serves one HTML render in the shared thread: `?share=<share id>&item=<turn item id>`. */
129export const GUEST_RENDER_ROUTE = `${GUEST_ROUTE_PREFIX}/render`;
130/** One item of the shared thread, as an expanded row loads it: `?share=<share id>&item=<id>`. */
131export const GUEST_ITEM_ROUTE = `${GUEST_ROUTE_PREFIX}/item`;
132/**
133 * Older turns of the shared thread, as the owner's "Load earlier turns" pages them:
134 * `?share=<share id>&cursor=<history cursor>`.
135 */
136export const GUEST_HISTORY_ROUTE = `${GUEST_ROUTE_PREFIX}/history`;
137/** Server-sent output frames of one command in the shared thread: `?share=<share id>&item=<id>`. */
138export const GUEST_COMMAND_OUTPUT_ROUTE = `${GUEST_ROUTE_PREFIX}/command-output`;
139/**
140 * A file in the shared thread or a guest's waiting message: `?share=<share id>&id=<attachment id>`.
141 * Images load inline; other files download.
142 */
143export const GUEST_ATTACHMENT_ROUTE = `${GUEST_ROUTE_PREFIX}/attachment`;
144/**
145 * An image file on the host that an agent message in the shared thread embeds:
146 * `?share=<share id>&path=<absolute path>`.
147 */
148export const GUEST_MEDIA_ROUTE = `${GUEST_ROUTE_PREFIX}/media`;