@polymorfa/nextjs 0.1.0-dev.20260922094209

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.
@@ -0,0 +1,229 @@
1
+ import { type MediaDownloadGrant, type MediaDownloadRouteOptions, type MediaDownloadRouteResource } from "./media-route.js";
2
+ import { type TemplateRouteResource } from "./template-builder-route.js";
3
+ import type { WebhookConstructor } from "./webhook.js";
4
+ /**
5
+ * What a grant can allow. The API enforces the client-token actions
6
+ * (`send_*`, `read_presence`, `subscribe_presence`, `read_contact`,
7
+ * `voip_*`); this handler enforces the rest on its own routes.
8
+ */
9
+ export type PolymorfaPermission = "read_messages" | "subscribe_events" | "send_message" | "send_reaction" | "send_typing" | "send_seen" | "read_presence" | "subscribe_presence" | "read_contact" | "voip_place" | "voip_answer" | "voip_signal" | "connect_whatsapp" | "manage_templates";
10
+ /**
11
+ * What `mint()` returns: the smallest grant this user needs. There is no
12
+ * default that grants everything; every field that widens access is
13
+ * explicit.
14
+ */
15
+ export type PolymorfaGrantInput = ({
16
+ readonly session: string;
17
+ readonly customer?: never;
18
+ } | {
19
+ /** Polymorfa Customer ID (beta). Never your own external ID. */
20
+ readonly customer: string;
21
+ readonly session?: never;
22
+ }) & {
23
+ /** Conversation IDs the user may read, or `"all"` for every one. */
24
+ readonly conversations: "all" | readonly string[];
25
+ /** Permissions for this user. At least one. */
26
+ readonly allow: readonly PolymorfaPermission[];
27
+ /** Token lifetime. Default 600 (10 minutes). */
28
+ readonly ttlSeconds?: number;
29
+ /** Stable per-user ID for rate limits. Defaults to `user.id`. */
30
+ readonly ephemeralId?: string;
31
+ };
32
+ /** The grant as the handler resolved it. */
33
+ export interface PolymorfaGrant {
34
+ readonly session?: string;
35
+ readonly customer?: string;
36
+ readonly conversations: "all" | readonly string[];
37
+ readonly allow: readonly PolymorfaPermission[];
38
+ readonly ttlSeconds: number;
39
+ readonly ephemeralId: string;
40
+ }
41
+ /** Everything a route callback knows about the caller. */
42
+ export interface PolymorfaRouteContext<User> {
43
+ readonly user: User;
44
+ readonly grant: PolymorfaGrant;
45
+ readonly request: Request;
46
+ readonly signal: AbortSignal;
47
+ }
48
+ /** One conversation list row, as `<Inbox/>` renders it. */
49
+ export interface HistoryConversation {
50
+ readonly id: string;
51
+ readonly session?: string;
52
+ readonly name?: string;
53
+ readonly phoneNumber?: string;
54
+ readonly avatarUrl?: string;
55
+ readonly lastMessage?: {
56
+ readonly text: string;
57
+ readonly createdAt: number;
58
+ readonly direction: "inbound" | "outbound";
59
+ };
60
+ readonly lastActivity: number;
61
+ readonly unreadCount: number;
62
+ }
63
+ /** One message, as `<Inbox/>` renders it. */
64
+ export interface HistoryMessage {
65
+ readonly id: string;
66
+ readonly text: string;
67
+ readonly createdAt: number;
68
+ readonly direction: "inbound" | "outbound";
69
+ readonly status: "pending" | "sent" | "failed";
70
+ readonly replyTo?: string;
71
+ readonly attachments?: readonly {
72
+ readonly id: string;
73
+ readonly name: string;
74
+ readonly size: number;
75
+ readonly contentType: string;
76
+ readonly url?: string;
77
+ readonly previewUrl?: string;
78
+ }[];
79
+ }
80
+ export interface HistoryContact {
81
+ readonly id: string;
82
+ readonly name?: string;
83
+ readonly phoneNumber?: string;
84
+ readonly avatarUrl?: string;
85
+ readonly about?: string;
86
+ readonly email?: string;
87
+ readonly fields?: readonly {
88
+ readonly label: string;
89
+ readonly value: string;
90
+ }[];
91
+ }
92
+ export interface HistoryPage<T> {
93
+ readonly data: readonly T[];
94
+ readonly nextCursor?: string | null;
95
+ }
96
+ /** A webhook event envelope for the events relay. */
97
+ export interface RelayEvent {
98
+ readonly id: string;
99
+ readonly session: string;
100
+ readonly event: string;
101
+ readonly timestamp: string;
102
+ readonly payload: unknown;
103
+ }
104
+ /** What the handler sends to `POST /platform/client-tokens`. */
105
+ export interface ClientTokenMintInput {
106
+ readonly session?: string;
107
+ readonly customer?: string;
108
+ readonly allow?: readonly string[];
109
+ readonly ephemeralId: string;
110
+ readonly ttlSeconds?: number;
111
+ }
112
+ /** What `connect()` returns: the QuickLink to create for this user. */
113
+ export interface QuickLinkInput {
114
+ readonly projectId?: string;
115
+ readonly customerId?: string;
116
+ readonly externalId?: string;
117
+ readonly configuration?: object;
118
+ }
119
+ /** Structural subset of `MessagingClient` the handler uses. */
120
+ export interface PolymorfaHandlerClient {
121
+ readonly clientTokens: {
122
+ mint(input: ClientTokenMintInput, options: {
123
+ readonly signal: AbortSignal;
124
+ }): Promise<{
125
+ readonly data: {
126
+ readonly data: {
127
+ readonly token: string;
128
+ readonly expiresAt: string;
129
+ };
130
+ };
131
+ }>;
132
+ };
133
+ readonly quickLinks?: {
134
+ create(input: QuickLinkInput, options: {
135
+ readonly signal: AbortSignal;
136
+ }): Promise<{
137
+ readonly data: {
138
+ readonly data: {
139
+ readonly id: string;
140
+ readonly url: string;
141
+ readonly expiresAt: string | null;
142
+ };
143
+ };
144
+ }>;
145
+ };
146
+ readonly media?: MediaDownloadRouteResource;
147
+ readonly templates?: TemplateRouteResource;
148
+ }
149
+ export interface PolymorfaHandlerOptions<User extends {
150
+ readonly id: string;
151
+ }> {
152
+ /** A server-side `MessagingClient` from `@polymorfa/sdk`. */
153
+ readonly polymorfa: PolymorfaHandlerClient;
154
+ /** Your auth: the signed-in user, or `null` (answered with `401`). */
155
+ readonly authenticate: (request: Request) => User | null | Promise<User | null>;
156
+ /**
157
+ * Required. The grant for this user, or `null` to refuse (`403`). Called
158
+ * on every request, so revoking access takes effect on the next call.
159
+ */
160
+ readonly mint: (user: User, request: Request) => PolymorfaGrantInput | null | Promise<PolymorfaGrantInput | null>;
161
+ /** Path the handler is mounted at. Default `/api/polymorfa`. */
162
+ readonly basePath?: string;
163
+ /** `POST /webhooks`: verified before `onEvent` runs. */
164
+ readonly webhooks?: {
165
+ readonly secret: string;
166
+ readonly onEvent: (event: unknown) => void | Promise<void>;
167
+ /** Pass `constructWebhookEvent` from `@polymorfa/sdk`. */
168
+ readonly constructEvent: WebhookConstructor<unknown>;
169
+ readonly signatureHeader?: string;
170
+ };
171
+ /** `GET /media/:id`, gated by `read_messages`. */
172
+ readonly media?: {
173
+ readonly mode: MediaDownloadRouteOptions["mode"];
174
+ /** Resolve the media from your records; never trust the URL alone. */
175
+ readonly authorize: (context: PolymorfaRouteContext<User>, mediaId: string) => MediaDownloadGrant | null | Promise<MediaDownloadGrant | null>;
176
+ };
177
+ /** `GET /history/...` from your own store, gated by `read_messages`. */
178
+ readonly history?: {
179
+ readonly conversations: (context: PolymorfaRouteContext<User>, page: {
180
+ readonly cursor?: string;
181
+ }) => Promise<HistoryPage<HistoryConversation>>;
182
+ readonly messages: (context: PolymorfaRouteContext<User>, conversationId: string, page: {
183
+ readonly cursor?: string;
184
+ }) => Promise<HistoryPage<HistoryMessage>>;
185
+ /** Contact panel data, gated by `read_contact`. */
186
+ readonly contact?: (context: PolymorfaRouteContext<User>, conversationId: string) => Promise<HistoryContact | null>;
187
+ };
188
+ /**
189
+ * `GET /events` as server-sent events, gated by `subscribe_events`.
190
+ * Events outside the grant's session and conversations are dropped.
191
+ */
192
+ readonly events?: (context: PolymorfaRouteContext<User> & {
193
+ readonly lastEventId?: string;
194
+ }) => AsyncIterable<RelayEvent>;
195
+ /**
196
+ * `POST /connect`, gated by `connect_whatsapp`: the QuickLink to create.
197
+ * The browser receives only the hosted URL.
198
+ */
199
+ readonly connect?: (context: PolymorfaRouteContext<User>) => QuickLinkInput | Promise<QuickLinkInput>;
200
+ /** `POST /templates`, gated by `manage_templates`. */
201
+ readonly templates?: {
202
+ readonly projectSlug: (context: PolymorfaRouteContext<User>) => string | Promise<string>;
203
+ readonly submissionSession: (context: PolymorfaRouteContext<User>) => string | Promise<string>;
204
+ };
205
+ /** SSE heartbeat interval. Default 25 s. */
206
+ readonly heartbeatMs?: number;
207
+ /** Receives unexpected errors. Responses never include their details. */
208
+ readonly onError?: (error: unknown, route: string) => void;
209
+ }
210
+ export type PolymorfaRouteHandler = (request: Request) => Promise<Response>;
211
+ export interface PolymorfaHandler {
212
+ readonly GET: PolymorfaRouteHandler;
213
+ readonly POST: PolymorfaRouteHandler;
214
+ /** Any method; the adapters call this. */
215
+ readonly handle: PolymorfaRouteHandler;
216
+ }
217
+ /**
218
+ * One catch-all route for the drop-in components. Mount it at
219
+ * `app/api/polymorfa/[...route]/route.ts` and export `GET` and `POST`.
220
+ *
221
+ * The API key stays on the server. The browser never sends permissions:
222
+ * `mint()` decides them for the signed-in user on every request.
223
+ */
224
+ export declare function createPolymorfaHandler<User extends {
225
+ readonly id: string;
226
+ }>(options: PolymorfaHandlerOptions<User>): PolymorfaHandler;
227
+ /** The conversation an event belongs to, as `@polymorfa/store` reads it. */
228
+ export declare function conversationOf(event: RelayEvent): string | undefined;
229
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,434 @@
1
+ import { createMediaDownloadRoute, } from "./media-route.js";
2
+ import { createTemplateBuilderRoute, } from "./template-builder-route.js";
3
+ const PERMISSIONS = new Set([
4
+ "read_messages",
5
+ "subscribe_events",
6
+ "send_message",
7
+ "send_reaction",
8
+ "send_typing",
9
+ "send_seen",
10
+ "read_presence",
11
+ "subscribe_presence",
12
+ "read_contact",
13
+ "voip_place",
14
+ "voip_answer",
15
+ "voip_signal",
16
+ "connect_whatsapp",
17
+ "manage_templates",
18
+ ]);
19
+ /** Actions the API narrows on a Customer-scoped token (`allow`). */
20
+ const CUSTOMER_TOKEN_ACTIONS = new Set([
21
+ "send_message",
22
+ "send_reaction",
23
+ "send_typing",
24
+ "send_seen",
25
+ "read_presence",
26
+ "subscribe_presence",
27
+ "read_contact",
28
+ ]);
29
+ const privateHeaders = {
30
+ "Cache-Control": "no-store, private",
31
+ Vary: "Cookie, Authorization",
32
+ "X-Content-Type-Options": "nosniff",
33
+ };
34
+ class Refusal extends Error {
35
+ status;
36
+ code;
37
+ headers;
38
+ constructor(status, code, message, headers = {}) {
39
+ super(message);
40
+ this.status = status;
41
+ this.code = code;
42
+ this.headers = headers;
43
+ }
44
+ }
45
+ /**
46
+ * One catch-all route for the drop-in components. Mount it at
47
+ * `app/api/polymorfa/[...route]/route.ts` and export `GET` and `POST`.
48
+ *
49
+ * The API key stays on the server. The browser never sends permissions:
50
+ * `mint()` decides them for the signed-in user on every request.
51
+ */
52
+ export function createPolymorfaHandler(options) {
53
+ if (typeof options.authenticate !== "function")
54
+ throw new TypeError("createPolymorfaHandler: authenticate is required.");
55
+ if (typeof options.mint !== "function")
56
+ throw new TypeError("createPolymorfaHandler: mint is required. Return the smallest grant each user needs.");
57
+ if (typeof options.polymorfa?.clientTokens?.mint !== "function")
58
+ throw new TypeError("createPolymorfaHandler: polymorfa must be a server MessagingClient.");
59
+ if (options.webhooks !== undefined &&
60
+ (typeof options.webhooks.secret !== "string" ||
61
+ options.webhooks.secret.length === 0 ||
62
+ typeof options.webhooks.constructEvent !== "function" ||
63
+ typeof options.webhooks.onEvent !== "function"))
64
+ throw new TypeError("createPolymorfaHandler: webhooks needs secret, constructEvent and onEvent.");
65
+ if (options.connect !== undefined &&
66
+ options.polymorfa.quickLinks === undefined)
67
+ throw new TypeError("createPolymorfaHandler: connect needs polymorfa.quickLinks.");
68
+ if (options.templates !== undefined &&
69
+ options.polymorfa.templates === undefined)
70
+ throw new TypeError("createPolymorfaHandler: templates needs polymorfa.templates.");
71
+ const basePath = (options.basePath ?? "/api/polymorfa").replace(/\/+$/, "");
72
+ const heartbeatMs = options.heartbeatMs ?? 25_000;
73
+ const report = (error, route) => {
74
+ try {
75
+ options.onError?.(error, route);
76
+ }
77
+ catch {
78
+ // A failing reporter must not change the response.
79
+ }
80
+ };
81
+ const resolve = async (request, permission) => {
82
+ const user = await options.authenticate(request);
83
+ if (user === null || user === undefined)
84
+ throw new Refusal(401, "unauthenticated", "Sign in to continue.");
85
+ if (typeof user.id !== "string" || user.id.length === 0)
86
+ throw new TypeError("authenticate must return a user with a string id.");
87
+ const input = await options.mint(user, request);
88
+ if (input === null || input === undefined)
89
+ throw new Refusal(403, "forbidden", "This user has no Polymorfa access.");
90
+ const grant = normalizeGrant(input, user.id);
91
+ if (permission !== undefined && !grant.allow.includes(permission))
92
+ throw new Refusal(403, "permission_missing", `The grant lacks "${permission}".`);
93
+ return { user, grant, request, signal: request.signal };
94
+ };
95
+ const mediaRoute = options.media === undefined || options.polymorfa.media === undefined
96
+ ? undefined
97
+ : createMediaDownloadRoute({
98
+ client: { media: options.polymorfa.media },
99
+ mode: options.media.mode,
100
+ authorize: async (request) => {
101
+ const context = await resolve(request, "read_messages");
102
+ const mediaId = routeOf(request, basePath)?.[1];
103
+ if (mediaId === undefined)
104
+ return null;
105
+ return options.media.authorize(context, mediaId);
106
+ },
107
+ });
108
+ const templateRoute = options.templates === undefined
109
+ ? undefined
110
+ : (() => {
111
+ const contexts = new WeakMap();
112
+ const route = createTemplateBuilderRoute({
113
+ templates: options.polymorfa.templates,
114
+ authorize: (request) => {
115
+ const context = contexts.get(request);
116
+ return context === undefined ? null : { userId: context.user.id };
117
+ },
118
+ resolveProjectSlug: (_subject, request) => options.templates.projectSlug(contexts.get(request)),
119
+ resolveSubmissionSession: (_subject, request) => options.templates.submissionSession(contexts.get(request)),
120
+ });
121
+ return async (request) => {
122
+ contexts.set(request, await resolve(request, "manage_templates"));
123
+ return route(request);
124
+ };
125
+ })();
126
+ const handle = async (request) => {
127
+ const segments = routeOf(request, basePath);
128
+ const route = segments?.join("/") ?? "";
129
+ try {
130
+ if (segments === undefined || segments.length === 0)
131
+ return error(404, "not_found", "Unknown Polymorfa route.");
132
+ const [head, second, third, fourth] = segments;
133
+ const method = request.method.toUpperCase();
134
+ if (head === "token" && segments.length === 1) {
135
+ requireMethod(method, "POST");
136
+ return await mintToken(request);
137
+ }
138
+ if (head === "webhooks" && segments.length === 1 && options.webhooks) {
139
+ requireMethod(method, "POST");
140
+ return await receiveWebhook(request);
141
+ }
142
+ if (head === "media" && segments.length === 2 && mediaRoute) {
143
+ requireMethod(method, "GET");
144
+ return await mediaRoute(request);
145
+ }
146
+ if (head === "history" && second === "conversations" && options.history) {
147
+ requireMethod(method, "GET");
148
+ return await history(request, third, fourth, segments.length);
149
+ }
150
+ if (head === "events" && segments.length === 1 && options.events) {
151
+ requireMethod(method, "GET");
152
+ return await events(request);
153
+ }
154
+ if (head === "connect" && segments.length === 1 && options.connect) {
155
+ requireMethod(method, "POST");
156
+ return await connect(request);
157
+ }
158
+ if (head === "templates" && segments.length === 1 && templateRoute) {
159
+ requireMethod(method, "POST");
160
+ return await templateRoute(request);
161
+ }
162
+ return error(404, "not_found", "Unknown Polymorfa route.");
163
+ }
164
+ catch (cause) {
165
+ if (cause instanceof Refusal)
166
+ return error(cause.status, cause.code, cause.message, cause.headers);
167
+ report(cause, route);
168
+ return error(500, "handler_failed", "The Polymorfa route failed.");
169
+ }
170
+ };
171
+ async function mintToken(request) {
172
+ const { grant } = await resolve(request);
173
+ let body;
174
+ if (grant.session !== undefined)
175
+ body = {
176
+ session: grant.session,
177
+ ephemeralId: grant.ephemeralId,
178
+ ttlSeconds: grant.ttlSeconds,
179
+ };
180
+ else {
181
+ // The API narrows Customer tokens to these actions. Omitting allow
182
+ // would fall back to the session rules, so an empty list is refused.
183
+ const allow = grant.allow.filter((action) => CUSTOMER_TOKEN_ACTIONS.has(action));
184
+ if (allow.length === 0)
185
+ throw new TypeError("A customer grant needs at least one API action (send_*, read_presence, subscribe_presence or read_contact).");
186
+ body = {
187
+ customer: grant.customer,
188
+ allow,
189
+ ephemeralId: grant.ephemeralId,
190
+ ttlSeconds: grant.ttlSeconds,
191
+ };
192
+ }
193
+ const response = await options.polymorfa.clientTokens.mint(body, {
194
+ signal: request.signal,
195
+ });
196
+ const token = response?.data?.data?.token;
197
+ const expiresAt = Date.parse(response?.data?.data?.expiresAt ?? "");
198
+ if (typeof token !== "string" ||
199
+ !token.startsWith("pmfa_ct_") ||
200
+ token.length <= "pmfa_ct_".length ||
201
+ !Number.isFinite(expiresAt))
202
+ throw new TypeError("The Polymorfa API returned an invalid client token.");
203
+ return json({
204
+ value: token,
205
+ audience: "browser",
206
+ expiresAt,
207
+ scopes: grant.allow,
208
+ grant: {
209
+ ...(grant.session === undefined ? {} : { session: grant.session }),
210
+ ...(grant.customer === undefined ? {} : { customer: grant.customer }),
211
+ conversations: grant.conversations,
212
+ allow: grant.allow,
213
+ },
214
+ });
215
+ }
216
+ async function receiveWebhook(request) {
217
+ const webhooks = options.webhooks;
218
+ const header = webhooks.signatureHeader ?? "x-webhook-signature";
219
+ const signature = request.headers.get(header);
220
+ if (signature === null || signature.length === 0)
221
+ return error(400, "signature_missing", `Missing ${header}.`);
222
+ const constructEvent = webhooks.constructEvent;
223
+ let event;
224
+ try {
225
+ event = await constructEvent(await request.arrayBuffer(), signature, webhooks.secret);
226
+ }
227
+ catch {
228
+ return error(400, "signature_invalid", "The webhook signature is invalid.");
229
+ }
230
+ await webhooks.onEvent(event);
231
+ return json({ received: true });
232
+ }
233
+ async function history(request, conversationId, child, length) {
234
+ const context = await resolve(request, "read_messages");
235
+ const cursor = new URL(request.url).searchParams.get("cursor") ?? undefined;
236
+ const page = cursor === undefined ? {} : { cursor };
237
+ const allowed = (id) => context.grant.conversations === "all" ||
238
+ context.grant.conversations.includes(id);
239
+ if (length === 2) {
240
+ const result = await options.history.conversations(context, page);
241
+ return json({
242
+ data: result.data.filter((row) => allowed(row.id)),
243
+ nextCursor: result.nextCursor ?? null,
244
+ });
245
+ }
246
+ // A conversation outside the grant answers 404, like a missing one.
247
+ if (conversationId === undefined ||
248
+ !allowed(conversationId) ||
249
+ length !== 4)
250
+ throw new Refusal(404, "not_found", "Conversation not found.");
251
+ if (child === "messages") {
252
+ const result = await options.history.messages(context, conversationId, page);
253
+ return json({ data: result.data, nextCursor: result.nextCursor ?? null });
254
+ }
255
+ if (child === "contact" && options.history.contact) {
256
+ if (!context.grant.allow.includes("read_contact"))
257
+ throw new Refusal(403, "permission_missing", 'The grant lacks "read_contact".');
258
+ const contact = await options.history.contact(context, conversationId);
259
+ if (contact === null)
260
+ throw new Refusal(404, "not_found", "Contact not found.");
261
+ return json({ data: contact });
262
+ }
263
+ throw new Refusal(404, "not_found", "Unknown Polymorfa route.");
264
+ }
265
+ async function events(request) {
266
+ const context = await resolve(request, "subscribe_events");
267
+ const lastEventId = request.headers.get("last-event-id") ??
268
+ new URL(request.url).searchParams.get("lastEventId") ??
269
+ undefined;
270
+ const source = options.events({
271
+ ...context,
272
+ ...(lastEventId === undefined ? {} : { lastEventId }),
273
+ });
274
+ const { grant } = context;
275
+ const encoder = new TextEncoder();
276
+ let iterator;
277
+ let heartbeat;
278
+ const stop = () => {
279
+ if (heartbeat !== undefined)
280
+ clearInterval(heartbeat);
281
+ heartbeat = undefined;
282
+ void iterator?.return?.();
283
+ };
284
+ const body = new ReadableStream({
285
+ start(controller) {
286
+ controller.enqueue(encoder.encode("retry: 2000\n\n"));
287
+ heartbeat = setInterval(() => {
288
+ try {
289
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
290
+ }
291
+ catch {
292
+ stop();
293
+ }
294
+ }, heartbeatMs);
295
+ request.signal.addEventListener("abort", () => {
296
+ stop();
297
+ try {
298
+ controller.close();
299
+ }
300
+ catch {
301
+ // Already closed.
302
+ }
303
+ });
304
+ },
305
+ async pull(controller) {
306
+ iterator ??= source[Symbol.asyncIterator]();
307
+ try {
308
+ for (;;) {
309
+ const next = await iterator.next();
310
+ if (next.done) {
311
+ stop();
312
+ controller.close();
313
+ return;
314
+ }
315
+ if (!eventAllowed(next.value, grant))
316
+ continue;
317
+ controller.enqueue(encoder.encode(`id: ${next.value.id.replace(/[\r\n]/g, "")}\ndata: ${JSON.stringify(next.value)}\n\n`));
318
+ return;
319
+ }
320
+ }
321
+ catch (cause) {
322
+ stop();
323
+ report(cause, "events");
324
+ controller.error(cause);
325
+ }
326
+ },
327
+ cancel() {
328
+ stop();
329
+ },
330
+ });
331
+ return new Response(body, {
332
+ status: 200,
333
+ headers: {
334
+ ...privateHeaders,
335
+ "Content-Type": "text/event-stream; charset=utf-8",
336
+ "Cache-Control": "no-store, private, no-transform",
337
+ Connection: "keep-alive",
338
+ "X-Accel-Buffering": "no",
339
+ },
340
+ });
341
+ }
342
+ async function connect(request) {
343
+ const context = await resolve(request, "connect_whatsapp");
344
+ const input = await options.connect(context);
345
+ const response = await options.polymorfa.quickLinks.create(input, {
346
+ signal: request.signal,
347
+ });
348
+ const link = response?.data?.data;
349
+ if (typeof link?.url !== "string" || typeof link.id !== "string")
350
+ throw new TypeError("The Polymorfa API returned an invalid QuickLink.");
351
+ return json({
352
+ data: { id: link.id, url: link.url, expiresAt: link.expiresAt ?? null },
353
+ });
354
+ }
355
+ return { GET: handle, POST: handle, handle };
356
+ }
357
+ function requireMethod(method, expected) {
358
+ if (method !== expected)
359
+ throw new Refusal(405, "method_not_allowed", `Use ${expected}.`, {
360
+ Allow: expected,
361
+ });
362
+ }
363
+ /** Route segments after `basePath`, or `undefined` outside it. */
364
+ function routeOf(request, basePath) {
365
+ const pathname = new URL(request.url).pathname.replace(/\/+$/, "");
366
+ if (pathname !== basePath && !pathname.startsWith(`${basePath}/`))
367
+ return undefined;
368
+ const rest = pathname.slice(basePath.length);
369
+ const segments = rest.split("/").filter((segment) => segment.length > 0);
370
+ try {
371
+ return segments.map((segment) => decodeURIComponent(segment));
372
+ }
373
+ catch {
374
+ return undefined;
375
+ }
376
+ }
377
+ function normalizeGrant(input, userId) {
378
+ const hasSession = typeof input.session === "string" && input.session.length > 0;
379
+ const hasCustomer = typeof input.customer === "string" && input.customer.length > 0;
380
+ if (hasSession === hasCustomer)
381
+ throw new TypeError("mint must return exactly one of session or customer.");
382
+ if (!Array.isArray(input.allow) ||
383
+ input.allow.length === 0 ||
384
+ input.allow.some((permission) => !PERMISSIONS.has(permission)))
385
+ throw new TypeError("mint must return allow: a non-empty list of Polymorfa permissions.");
386
+ const conversations = input.conversations;
387
+ if (conversations !== "all" &&
388
+ !(Array.isArray(conversations) &&
389
+ conversations.every((id) => typeof id === "string" && id.length > 0)))
390
+ throw new TypeError('mint must return conversations: "all" or a list of conversation IDs.');
391
+ const ttlSeconds = input.ttlSeconds ?? 600;
392
+ if (!Number.isSafeInteger(ttlSeconds) ||
393
+ ttlSeconds < 60 ||
394
+ ttlSeconds > 86_400)
395
+ throw new TypeError("ttlSeconds must be an integer from 60 to 86400.");
396
+ const ephemeralId = input.ephemeralId ?? userId;
397
+ if (typeof ephemeralId !== "string" || ephemeralId.length === 0)
398
+ throw new TypeError("ephemeralId must be a non-empty string.");
399
+ return Object.freeze({
400
+ ...(hasSession
401
+ ? { session: input.session }
402
+ : { customer: input.customer }),
403
+ conversations: conversations === "all" ? "all" : Object.freeze([...conversations]),
404
+ allow: Object.freeze([...new Set(input.allow)]),
405
+ ttlSeconds,
406
+ ephemeralId,
407
+ });
408
+ }
409
+ function eventAllowed(event, grant) {
410
+ if (typeof event !== "object" ||
411
+ event === null ||
412
+ typeof event.id !== "string" ||
413
+ typeof event.event !== "string")
414
+ return false;
415
+ if (grant.session !== undefined && event.session !== grant.session)
416
+ return false;
417
+ if (grant.conversations === "all")
418
+ return true;
419
+ const conversationId = conversationOf(event);
420
+ // Session-level events carry no conversation and pass the session check.
421
+ return (conversationId === undefined || grant.conversations.includes(conversationId));
422
+ }
423
+ /** The conversation an event belongs to, as `@polymorfa/store` reads it. */
424
+ export function conversationOf(event) {
425
+ const payload = event.payload;
426
+ const id = payload?.conversation?.id ?? payload?.from?.id;
427
+ return typeof id === "string" ? id : undefined;
428
+ }
429
+ function json(value, status = 200) {
430
+ return Response.json(value, { status, headers: privateHeaders });
431
+ }
432
+ function error(status, code, message, headers = {}) {
433
+ return Response.json({ error: { code, message } }, { status, headers: { ...privateHeaders, ...headers } });
434
+ }