@paneui/core 0.0.8 → 0.0.10

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/dist/client.js CHANGED
@@ -87,7 +87,7 @@ export class PaneClient {
87
87
  }
88
88
  catch {
89
89
  // Body was not JSON (HTML error page, plain-text proxy error, …).
90
- // Don't discard it — surface the raw text so callers can diagnose.
90
+ // Don't discard it — pane the raw text so callers can diagnose.
91
91
  const snippet = text.length > MAX_RESPONSE_SNIPPET_LENGTH
92
92
  ? text.slice(0, MAX_RESPONSE_SNIPPET_LENGTH) + "…"
93
93
  : text;
@@ -121,34 +121,38 @@ export class PaneClient {
121
121
  docsUrl: err?.docs_url,
122
122
  });
123
123
  }
124
- /** POST /v1/surfaces — create a surface. */
125
- async createSession(req) {
126
- const r = await this.call("POST", "/v1/surfaces", {
124
+ /** POST /v1/panes — create a pane. */
125
+ async createPane(req) {
126
+ const r = await this.call("POST", "/v1/panes", {
127
127
  template: req.template,
128
128
  title: req.title,
129
+ preamble: req.preamble,
129
130
  input_data: req.input_data,
130
131
  participants: req.participants,
131
132
  ttl: req.ttl,
132
133
  metadata: req.metadata,
133
134
  callback: req.callback,
135
+ context_key: req.context_key,
136
+ icon_emoji: req.icon_emoji,
137
+ icon_attachment_id: req.icon_attachment_id,
134
138
  });
135
139
  if (!r.ok)
136
140
  this.fail(r);
137
141
  return this.asObject(r);
138
142
  }
139
- /** GET /v1/surfaces/:id — non-blocking surface metadata. */
140
- async getSession(surfaceId) {
141
- const r = await this.call("GET", `/v1/surfaces/${encodeURIComponent(surfaceId)}`);
143
+ /** GET /v1/panes/:id — non-blocking pane metadata. */
144
+ async getPane(paneId) {
145
+ const r = await this.call("GET", `/v1/panes/${encodeURIComponent(paneId)}`);
142
146
  if (!r.ok)
143
147
  this.fail(r);
144
148
  return this.asObject(r);
145
149
  }
146
150
  /**
147
- * GET /v1/surfaces/:id/events — fetch the event log.
151
+ * GET /v1/panes/:id/events — fetch the event log.
148
152
  * `since` is an opaque cursor; `waitSeconds` enables the relay long-poll
149
153
  * (0 = non-blocking, capped at 30 by the relay).
150
154
  */
151
- async getEvents(surfaceId, opts = {}) {
155
+ async getEvents(paneId, opts = {}) {
152
156
  const q = new URLSearchParams();
153
157
  if (opts.since != null && opts.since !== "")
154
158
  q.set("since", opts.since);
@@ -156,14 +160,175 @@ export class PaneClient {
156
160
  q.set("wait", String(Math.floor(opts.waitSeconds)));
157
161
  }
158
162
  const qs = q.toString();
159
- const r = await this.call("GET", `/v1/surfaces/${encodeURIComponent(surfaceId)}/events${qs ? "?" + qs : ""}`);
163
+ const r = await this.call("GET", `/v1/panes/${encodeURIComponent(paneId)}/events${qs ? "?" + qs : ""}`);
160
164
  if (!r.ok)
161
165
  this.fail(r);
162
166
  return this.asObject(r);
163
167
  }
164
- /** POST /v1/surfaces/:id/events append an agent event. */
165
- async sendEvent(surfaceId, ev) {
166
- const r = await this.call("POST", `/v1/surfaces/${encodeURIComponent(surfaceId)}/events`, {
168
+ // ----- #355: SQL query API --------------------------------------------
169
+ /**
170
+ * POST /v1/query — run a read-only SQL query against the agent's own
171
+ * scoped data (panes, records, events). The relay scopes the result at
172
+ * the view layer; the agent can never see rows whose pane.owner_human_id
173
+ * does not match the caller's scope. Three generic views (panes, records,
174
+ * events) are always exposed; per-collection / per-event-type views are
175
+ * also materialized for any schemas declared on the caller's templates.
176
+ *
177
+ * `opts.paneId` narrows the scope to a single pane — handy when two of
178
+ * the caller's panes declare the same collection with incompatible types
179
+ * and the materializer would otherwise raise view_conflict.
180
+ *
181
+ * `data` is a JSON column — use Postgres-style operators (->>, ->) to
182
+ * project into it. Cap: 10,000 result rows (response.truncated=true
183
+ * signals the cap was hit); statement timeout: 10 seconds.
184
+ */
185
+ async query(sql, opts = {}) {
186
+ const body = { sql };
187
+ if (opts.paneId !== undefined)
188
+ body.pane_id = opts.paneId;
189
+ const r = await this.call("POST", "/v1/query", body);
190
+ if (!r.ok)
191
+ this.fail(r);
192
+ return this.asObject(r);
193
+ }
194
+ // ----- #297: records CRUD ---------------------------------------------
195
+ /**
196
+ * GET /v1/panes/:id/records/:collection — cursor-paginated list.
197
+ * Includes tombstones (`deleted_at` set) so reconnecting clients can
198
+ * observe deletions.
199
+ */
200
+ async listRecords(paneId, collection, opts = {}) {
201
+ const q = new URLSearchParams();
202
+ if (opts.since != null)
203
+ q.set("since", String(opts.since));
204
+ if (opts.limit != null)
205
+ q.set("limit", String(opts.limit));
206
+ const qs = q.toString();
207
+ const r = await this.call("GET", `/v1/panes/${encodeURIComponent(paneId)}/records/${encodeURIComponent(collection)}${qs ? "?" + qs : ""}`);
208
+ if (!r.ok)
209
+ this.fail(r);
210
+ return this.asObject(r);
211
+ }
212
+ /**
213
+ * Convenience: walk listRecords until the named recordKey is found OR
214
+ * the collection is exhausted. The relay has no dedicated single-get
215
+ * route today; this client-side scan trades round trips for not adding
216
+ * a route. Fine for typical CLI use; not appropriate for hot paths.
217
+ */
218
+ async getRecord(paneId, collection, recordKey) {
219
+ let since;
220
+ for (;;) {
221
+ const page = await this.listRecords(paneId, collection, {
222
+ since,
223
+ limit: 200,
224
+ });
225
+ const hit = page.records.find((r) => r.key === recordKey);
226
+ if (hit)
227
+ return hit;
228
+ if (!page.has_more)
229
+ return null;
230
+ since = page.next_since;
231
+ }
232
+ }
233
+ /**
234
+ * POST /v1/panes/:id/records/:collection — create-or-return-existing.
235
+ * Duplicate `recordKey` returns the existing row with `deduped: true`.
236
+ */
237
+ async upsertRecord(paneId, collection, body) {
238
+ const r = await this.call("POST", `/v1/panes/${encodeURIComponent(paneId)}/records/${encodeURIComponent(collection)}`, body);
239
+ if (!r.ok)
240
+ this.fail(r);
241
+ const out = this.asObject(r);
242
+ return { record: out.record, deduped: out.deduped ?? false };
243
+ }
244
+ /**
245
+ * PATCH /v1/panes/:id/records/:collection/:recordKey — optimistic
246
+ * update. On 409 the relay returns the current row in `details.current`.
247
+ */
248
+ async updateRecord(paneId, collection, recordKey, body) {
249
+ const r = await this.call("PATCH", `/v1/panes/${encodeURIComponent(paneId)}/records/${encodeURIComponent(collection)}/${encodeURIComponent(recordKey)}`, body);
250
+ if (!r.ok)
251
+ this.fail(r);
252
+ return this.asObject(r);
253
+ }
254
+ /**
255
+ * DELETE /v1/panes/:id/records/:collection/:recordKey — soft-delete.
256
+ * Optional `if_match` returns 409 + `details.current` on mismatch.
257
+ */
258
+ async deleteRecord(paneId, collection, recordKey, opts = {}) {
259
+ const body = opts.ifMatch != null ? { if_match: opts.ifMatch } : undefined;
260
+ const r = await this.call("DELETE", `/v1/panes/${encodeURIComponent(paneId)}/records/${encodeURIComponent(collection)}/${encodeURIComponent(recordKey)}`, body);
261
+ if (!r.ok)
262
+ this.fail(r);
263
+ }
264
+ // ----- template-level records CRUD ------------------------------------
265
+ /**
266
+ * GET /v1/templates/:id/template-records/:collection — owner-only list of
267
+ * a template's curated records. Same wire shape as listRecords, separate
268
+ * route so the relay can route owner-vs-page access independently.
269
+ */
270
+ async listTemplateRecords(templateIdOrSlug, collection, opts = {}) {
271
+ const q = new URLSearchParams();
272
+ if (opts.since != null)
273
+ q.set("since", String(opts.since));
274
+ if (opts.limit != null)
275
+ q.set("limit", String(opts.limit));
276
+ const qs = q.toString();
277
+ const r = await this.call("GET", `/v1/templates/${encodeURIComponent(templateIdOrSlug)}/template-records/${encodeURIComponent(collection)}${qs ? "?" + qs : ""}`);
278
+ if (!r.ok)
279
+ this.fail(r);
280
+ return this.asObject(r);
281
+ }
282
+ /**
283
+ * Client-side scan helper — same shape as `getRecord` but for template
284
+ * records. Walks listTemplateRecords until the key matches.
285
+ */
286
+ async getTemplateRecord(templateIdOrSlug, collection, recordKey) {
287
+ let since;
288
+ for (;;) {
289
+ const page = await this.listTemplateRecords(templateIdOrSlug, collection, { since, limit: 200 });
290
+ const hit = page.records.find((r) => r.key === recordKey);
291
+ if (hit)
292
+ return hit;
293
+ if (!page.has_more)
294
+ return null;
295
+ since = page.next_since;
296
+ }
297
+ }
298
+ /**
299
+ * POST /v1/templates/:id/template-records/:collection — owner-only
300
+ * create-or-return-existing.
301
+ */
302
+ async upsertTemplateRecord(templateIdOrSlug, collection, body) {
303
+ const r = await this.call("POST", `/v1/templates/${encodeURIComponent(templateIdOrSlug)}/template-records/${encodeURIComponent(collection)}`, body);
304
+ if (!r.ok)
305
+ this.fail(r);
306
+ const out = this.asObject(r);
307
+ return { record: out.record, deduped: out.deduped ?? false };
308
+ }
309
+ /**
310
+ * PATCH /v1/templates/:id/template-records/:collection/:recordKey —
311
+ * optimistic-locked update.
312
+ */
313
+ async updateTemplateRecord(templateIdOrSlug, collection, recordKey, body) {
314
+ const r = await this.call("PATCH", `/v1/templates/${encodeURIComponent(templateIdOrSlug)}/template-records/${encodeURIComponent(collection)}/${encodeURIComponent(recordKey)}`, body);
315
+ if (!r.ok)
316
+ this.fail(r);
317
+ return this.asObject(r);
318
+ }
319
+ /**
320
+ * DELETE /v1/templates/:id/template-records/:collection/:recordKey —
321
+ * soft-delete.
322
+ */
323
+ async deleteTemplateRecord(templateIdOrSlug, collection, recordKey, opts = {}) {
324
+ const body = opts.ifMatch != null ? { if_match: opts.ifMatch } : undefined;
325
+ const r = await this.call("DELETE", `/v1/templates/${encodeURIComponent(templateIdOrSlug)}/template-records/${encodeURIComponent(collection)}/${encodeURIComponent(recordKey)}`, body);
326
+ if (!r.ok)
327
+ this.fail(r);
328
+ }
329
+ /** POST /v1/panes/:id/events — append an agent event. */
330
+ async sendEvent(paneId, ev) {
331
+ const r = await this.call("POST", `/v1/panes/${encodeURIComponent(paneId)}/events`, {
167
332
  type: ev.type,
168
333
  data: ev.data,
169
334
  causation_id: ev.causationId,
@@ -188,6 +353,7 @@ export class PaneClient {
188
353
  type: req.type,
189
354
  event_schema: req.event_schema,
190
355
  input_schema: req.input_schema,
356
+ icon_emoji: req.icon_emoji,
191
357
  });
192
358
  if (!r.ok)
193
359
  this.fail(r);
@@ -219,6 +385,10 @@ export class PaneClient {
219
385
  slug: metadata.slug,
220
386
  description: metadata.description,
221
387
  tags: metadata.tags,
388
+ // Forward null explicitly (clears the icon); undefined is dropped by
389
+ // JSON.stringify so an omitted field is a no-op server-side.
390
+ icon_emoji: metadata.icon_emoji,
391
+ icon_attachment_id: metadata.icon_attachment_id,
222
392
  });
223
393
  if (!r.ok)
224
394
  this.fail(r);
@@ -280,7 +450,7 @@ export class PaneClient {
280
450
  * POST /v1/agents/claim — bind this agent to a human via a one-shot
281
451
  * claim code the human generated in their settings UI. After a
282
452
  * successful claim the agent's existing API key continues to work,
283
- * but the agent (and its surfaces/templates) now belong to the
453
+ * but the agent (and its panes/templates) now belong to the
284
454
  * claiming human. One-way operation — there is no unclaim in v1.
285
455
  */
286
456
  async claimAgent(code) {
@@ -333,7 +503,7 @@ export class PaneClient {
333
503
  const r = await this.call("POST", "/v1/feedback", {
334
504
  type: req.type,
335
505
  message: req.message,
336
- surface_id: req.surfaceId,
506
+ pane_id: req.paneId,
337
507
  });
338
508
  if (!r.ok)
339
509
  this.fail(r);
@@ -356,14 +526,14 @@ export class PaneClient {
356
526
  return this.asObject(r);
357
527
  }
358
528
  /**
359
- * GET /v1/surfaces — list the calling agent's surfaces. Default filter is
529
+ * GET /v1/panes — list the calling agent's panes. Default filter is
360
530
  * `status=open` (effective status — respects expiresAt). Response items
361
531
  * carry NO secrets: no participant token plaintext, no callback URL, no
362
532
  * metadata or input_data. Use `participant_id` from the list as the handle
363
533
  * for {@link revokeParticipant}; use {@link mintParticipant} to issue a
364
534
  * fresh URL when the original was lost.
365
535
  */
366
- async listSessions(opts = {}) {
536
+ async listPanes(opts = {}) {
367
537
  const q = new URLSearchParams();
368
538
  if (opts.status !== undefined)
369
539
  q.set("status", opts.status);
@@ -374,69 +544,115 @@ export class PaneClient {
374
544
  if (opts.template_id !== undefined && opts.template_id !== "")
375
545
  q.set("template_id", opts.template_id);
376
546
  const qs = q.toString();
377
- const r = await this.call("GET", `/v1/surfaces${qs ? "?" + qs : ""}`);
547
+ const r = await this.call("GET", `/v1/panes${qs ? "?" + qs : ""}`);
378
548
  if (!r.ok)
379
549
  this.fail(r);
380
550
  return this.asObject(r);
381
551
  }
382
552
  /**
383
- * GET /v1/surfaces/:id/participants — list every participant on one
384
- * surface (active and revoked). Bounded by MAX_PARTICIPANTS_PER_SESSION
553
+ * GET /v1/panes/:id/participants — list every participant on one
554
+ * pane (active and revoked). Bounded by MAX_PARTICIPANTS_PER_PANE
385
555
  * on the relay, so the full list is returned with no pagination.
386
556
  * Use this to find the `participant_id` you need to pass to
387
557
  * {@link revokeParticipant}, or to audit revoked rows.
388
558
  */
389
- async listParticipants(surfaceId) {
390
- const r = await this.call("GET", `/v1/surfaces/${encodeURIComponent(surfaceId)}/participants`);
559
+ async listParticipants(paneId) {
560
+ const r = await this.call("GET", `/v1/panes/${encodeURIComponent(paneId)}/participants`);
391
561
  if (!r.ok)
392
562
  this.fail(r);
393
563
  return this.asObject(r);
394
564
  }
395
565
  /**
396
- * POST /v1/surfaces/:id/participants — mint a fresh participant URL for an
397
- * existing surface. The one-shot recovery primitive when the original URL
398
- * was dropped: the surface keeps its event log, template pin, and created_at.
566
+ * POST /v1/panes/:id/participants — mint a fresh participant URL for an
567
+ * existing pane. The one-shot recovery primitive when the original URL
568
+ * was dropped: the pane keeps its event log, template pin, and created_at.
399
569
  * v1 supports `kind: "human"` only.
400
570
  *
401
571
  * The plaintext token is returned EXACTLY ONCE in the response — the relay
402
572
  * stores only the hash. Save the response (e.g. pipe to a JSONL log) before
403
573
  * delivering the URL to the human.
404
574
  */
405
- async mintParticipant(surfaceId, opts = {}) {
406
- const r = await this.call("POST", `/v1/surfaces/${encodeURIComponent(surfaceId)}/participants`, { kind: opts.kind ?? "human" });
575
+ async mintParticipant(paneId, opts = {}) {
576
+ const r = await this.call("POST", `/v1/panes/${encodeURIComponent(paneId)}/participants`, { kind: opts.kind ?? "human" });
407
577
  if (!r.ok)
408
578
  this.fail(r);
409
579
  return this.asObject(r);
410
580
  }
411
581
  /**
412
- * DELETE /v1/surfaces/:id/participants/:participant_id — revoke a single
413
- * participant URL. The surface's other participants (and the agent's own
582
+ * DELETE /v1/panes/:id/participants/:participant_id — revoke a single
583
+ * participant URL. The pane's other participants (and the agent's own
414
584
  * WebSocket) are untouched. Idempotent: revoking an unknown or already-
415
585
  * revoked participant returns 204. The agent participant cannot be revoked
416
- * via this endpoint — use {@link deleteSession} instead.
586
+ * via this endpoint — use {@link deletePane} instead.
417
587
  *
418
588
  * Existing WebSocket connections held under the revoked token are NOT
419
589
  * actively kicked in v1; new HTTP and WS connections are refused.
420
590
  */
421
- async revokeParticipant(surfaceId, participantId) {
422
- const r = await this.call("DELETE", `/v1/surfaces/${encodeURIComponent(surfaceId)}/participants/${encodeURIComponent(participantId)}`);
591
+ async revokeParticipant(paneId, participantId) {
592
+ const r = await this.call("DELETE", `/v1/panes/${encodeURIComponent(paneId)}/participants/${encodeURIComponent(participantId)}`);
593
+ if (!r.ok)
594
+ this.fail(r);
595
+ }
596
+ /**
597
+ * DELETE /v1/panes/:id — close/delete a pane. Idempotent on the relay
598
+ * side (an already-closed pane still returns 204 with no body).
599
+ */
600
+ async deletePane(id) {
601
+ const r = await this.call("DELETE", `/v1/panes/${encodeURIComponent(id)}`);
602
+ if (!r.ok)
603
+ this.fail(r);
604
+ }
605
+ /**
606
+ * GET /v1/trash — list soft-deleted panes + templates in the caller's
607
+ * agent-scope. The trash UI / `pane trash list` lives off this. (#306)
608
+ */
609
+ async listTrash() {
610
+ const r = await this.call("GET", "/v1/trash");
611
+ if (!r.ok)
612
+ this.fail(r);
613
+ return r.data;
614
+ }
615
+ /**
616
+ * POST /v1/trash/panes/:id/restore — un-trash a soft-deleted pane (clear
617
+ * deletedAt + audit row). (#306)
618
+ */
619
+ async restorePane(id) {
620
+ const r = await this.call("POST", `/v1/trash/panes/${encodeURIComponent(id)}/restore`);
621
+ if (!r.ok)
622
+ this.fail(r);
623
+ }
624
+ /**
625
+ * POST /v1/trash/templates/:id/restore — un-trash a soft-deleted template. (#306)
626
+ */
627
+ async restoreTemplate(id) {
628
+ const r = await this.call("POST", `/v1/trash/templates/${encodeURIComponent(id)}/restore`);
629
+ if (!r.ok)
630
+ this.fail(r);
631
+ }
632
+ /**
633
+ * DELETE /v1/trash/panes/:id — permanently hard-delete a trashed pane
634
+ * (bypass retention window). (#306)
635
+ */
636
+ async permanentDeletePane(id) {
637
+ const r = await this.call("DELETE", `/v1/trash/panes/${encodeURIComponent(id)}`);
423
638
  if (!r.ok)
424
639
  this.fail(r);
425
640
  }
426
641
  /**
427
- * DELETE /v1/surfaces/:id — close/delete a surface. Idempotent on the relay
428
- * side (an already-closed surface still returns 204 with no body).
642
+ * DELETE /v1/trash/templates/:id — permanently hard-delete a trashed
643
+ * template. Refused 409 if a live pane still references one of its
644
+ * versions. (#306)
429
645
  */
430
- async deleteSession(id) {
431
- const r = await this.call("DELETE", `/v1/surfaces/${encodeURIComponent(id)}`);
646
+ async permanentDeleteTemplate(id) {
647
+ const r = await this.call("DELETE", `/v1/trash/templates/${encodeURIComponent(id)}`);
432
648
  if (!r.ok)
433
649
  this.fail(r);
434
650
  }
435
651
  /**
436
652
  * DELETE /v1/templates/:id — remove an template and (server-side) all its
437
653
  * versions. Strict cascade: the relay refuses with 409 conflict if any
438
- * surface in any state still references one of the template's versions —
439
- * surface that as a typed PaneApiError so the CLI can render a hint
654
+ * pane in any state still references one of the template's versions —
655
+ * pane that as a typed PaneApiError so the CLI can render a hint
440
656
  * instead of swallowing it.
441
657
  */
442
658
  async deleteArtifact(idOrSlug) {
@@ -444,6 +660,49 @@ export class PaneClient {
444
660
  if (!r.ok)
445
661
  this.fail(r);
446
662
  }
663
+ /**
664
+ * POST /v1/templates/:id/publish — enter the public catalog. The optional
665
+ * `scopes` list locks the permissions the template will request from
666
+ * installers at this version (see Phase F §4.5 / §8). Passing an empty
667
+ * array clears the stored scopes; omitting `scopes` keeps the existing
668
+ * ones.
669
+ */
670
+ async publishTemplate(idOrSlug, body = {}) {
671
+ const r = await this.call("POST", `/v1/templates/${encodeURIComponent(idOrSlug)}/publish`, body);
672
+ if (!r.ok)
673
+ this.fail(r);
674
+ return this.asObject(r);
675
+ }
676
+ /**
677
+ * POST /v1/templates/:id/unpublish — leave the public catalog. Existing
678
+ * installs are unaffected (humans keep their pinned version), but the
679
+ * template no longer appears in `searchPublicTemplates` results.
680
+ */
681
+ async unpublishTemplate(idOrSlug) {
682
+ const r = await this.call("POST", `/v1/templates/${encodeURIComponent(idOrSlug)}/unpublish`);
683
+ if (!r.ok)
684
+ this.fail(r);
685
+ return this.asObject(r);
686
+ }
687
+ /**
688
+ * GET /v1/templates/catalog?q=... — agent-side public catalog search.
689
+ * Lets an agent discover already-published apps before authoring a
690
+ * duplicate. Sorted by install_count desc, then publish recency.
691
+ */
692
+ async searchPublicTemplates(query, opts = {}) {
693
+ const params = new URLSearchParams();
694
+ if (query != null && query !== "")
695
+ params.set("q", query);
696
+ if (opts.limit != null)
697
+ params.set("limit", String(opts.limit));
698
+ if (opts.offset != null)
699
+ params.set("offset", String(opts.offset));
700
+ const qs = params.toString();
701
+ const r = await this.call("GET", `/v1/templates/catalog${qs ? "?" + qs : ""}`);
702
+ if (!r.ok)
703
+ this.fail(r);
704
+ return this.asObject(r);
705
+ }
447
706
  // ------------------------------------------------------------------------
448
707
  // Blobs (v0.1.0). Three-scope binary attachments with multipart upload.
449
708
  // See proposal pane#152 for the full design.
@@ -453,9 +712,9 @@ export class PaneClient {
453
712
  * in event payloads (the relay's `format: pane-attachment-id` schema vocab
454
713
  * validates the id) or in `pane create --input-data`.
455
714
  *
456
- * Scope defaults to "agent" (reusable across the agent's surfaces). For
457
- * `scope: "surface"` pass `surfaceId`; for `scope: "template"` pass
458
- * `templateId`. The agent must own the referenced surface / template;
715
+ * Scope defaults to "agent" (reusable across the agent's panes). For
716
+ * `scope: "pane"` pass `paneId`; for `scope: "template"` pass
717
+ * `templateId`. The agent must own the referenced pane / template;
459
718
  * cross-tenant attempts return attachment_not_found.
460
719
  *
461
720
  * MIME is inferred from `mime` if supplied; otherwise the relay sniffs
@@ -492,8 +751,8 @@ export class PaneClient {
492
751
  fd.set("file", attachment, opts.filename ?? "attachment");
493
752
  if (opts.scope)
494
753
  fd.set("scope", opts.scope);
495
- if (opts.surfaceId)
496
- fd.set("surface_id", opts.surfaceId);
754
+ if (opts.paneId)
755
+ fd.set("pane_id", opts.paneId);
497
756
  if (opts.templateId)
498
757
  fd.set("template_id", opts.templateId);
499
758
  if (opts.filename)
@@ -575,7 +834,7 @@ export class PaneClient {
575
834
  }
576
835
  /**
577
836
  * Mint a `/b/<token>` capability URL for `attachmentId`. Default TTL is set by
578
- * the relay (24h agent, surface-TTL surface, 30d template). `once: true`
837
+ * the relay (24h agent, pane-TTL pane, 30d template). `once: true`
579
838
  * tokens self-delete on first GET.
580
839
  */
581
840
  async mintBlobToken(attachmentId, opts = {}) {
@@ -637,7 +896,7 @@ export class PaneClient {
637
896
  size: opts.size,
638
897
  sha256: opts.sha256,
639
898
  scope: opts.scope,
640
- surface_id: opts.surfaceId,
899
+ pane_id: opts.paneId,
641
900
  template_id: opts.templateId,
642
901
  filename: opts.filename,
643
902
  });
@@ -0,0 +1,24 @@
1
+ export declare const RASTER_ICON_MIME_ALLOWLIST: readonly ["image/png", "image/jpeg", "image/webp", "image/gif"];
2
+ export type RasterIconMime = (typeof RASTER_ICON_MIME_ALLOWLIST)[number];
3
+ /** True iff `mime` is a raster image type allowed as an icon. */
4
+ export declare function isRasterImageMime(mime: string | null | undefined): boolean;
5
+ export declare const MAX_ICON_EMOJI_BYTES = 64;
6
+ /**
7
+ * Validate an icon emoji. Returns `{ ok: true }` for a single emoji grapheme,
8
+ * or `{ ok: false, error }` with a human-readable reason otherwise.
9
+ *
10
+ * Rules:
11
+ * - non-empty, ≤ MAX_ICON_EMOJI_BYTES bytes
12
+ * - no control characters
13
+ * - exactly ONE grapheme cluster (via Intl.Segmenter)
14
+ * - that grapheme contains at least one Extended_Pictographic code point
15
+ * (so plain ASCII letters/digits and bare symbols are rejected)
16
+ */
17
+ export declare function validateIconEmoji(raw: string): {
18
+ ok: true;
19
+ } | {
20
+ ok: false;
21
+ error: string;
22
+ };
23
+ /** Convenience boolean form of {@link validateIconEmoji}. */
24
+ export declare function isValidIconEmoji(raw: string): boolean;
package/dist/icons.js ADDED
@@ -0,0 +1,99 @@
1
+ // Icon helpers shared by the relay and CLI — emoji validation and the raster
2
+ // image MIME allowlist for template/pane icons.
3
+ //
4
+ // Icons are deliberately constrained: either ONE emoji grapheme (rendered
5
+ // inline as text) or an uploaded raster image (served from the relay's blob
6
+ // store). No external URLs, no SVG (XSS vector), no multi-character strings.
7
+ // Raster image MIME types accepted as an uploaded icon. SVG is deliberately
8
+ // EXCLUDED — it can carry script and is an XSS vector when rendered in an
9
+ // <img> from a same-origin route. Vector/animated-vector and non-image types
10
+ // are rejected.
11
+ export const RASTER_ICON_MIME_ALLOWLIST = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ ];
17
+ /** True iff `mime` is a raster image type allowed as an icon. */
18
+ export function isRasterImageMime(mime) {
19
+ if (!mime)
20
+ return false;
21
+ // Normalise: drop any `; charset=...` parameter and lowercase.
22
+ const base = mime.split(";")[0].trim().toLowerCase();
23
+ return RASTER_ICON_MIME_ALLOWLIST.includes(base);
24
+ }
25
+ // Upper bound on the raw byte length of an emoji string. A single rendered
26
+ // emoji can be a long ZWJ sequence (e.g. a family/flag with skin-tone
27
+ // modifiers), but 64 bytes is well beyond any real single grapheme and bounds
28
+ // the work the segmenter does.
29
+ export const MAX_ICON_EMOJI_BYTES = 64;
30
+ // Matches a code point with the Extended_Pictographic property — the Unicode
31
+ // property that flags "this is an emoji-ish pictograph". Used to ensure the
32
+ // grapheme actually contains an emoji and isn't a plain letter/digit/symbol.
33
+ const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
34
+ // Control characters (C0 + DEL + C1) are never valid in an icon emoji. The
35
+ // control chars in the class are intentional — that's exactly what we reject.
36
+ // eslint-disable-next-line no-control-regex
37
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
38
+ const byteLength = (s) => typeof TextEncoder !== "undefined"
39
+ ? new TextEncoder().encode(s).length
40
+ : Buffer.byteLength(s, "utf8");
41
+ /**
42
+ * Validate an icon emoji. Returns `{ ok: true }` for a single emoji grapheme,
43
+ * or `{ ok: false, error }` with a human-readable reason otherwise.
44
+ *
45
+ * Rules:
46
+ * - non-empty, ≤ MAX_ICON_EMOJI_BYTES bytes
47
+ * - no control characters
48
+ * - exactly ONE grapheme cluster (via Intl.Segmenter)
49
+ * - that grapheme contains at least one Extended_Pictographic code point
50
+ * (so plain ASCII letters/digits and bare symbols are rejected)
51
+ */
52
+ export function validateIconEmoji(raw) {
53
+ if (typeof raw !== "string" || raw.length === 0) {
54
+ return { ok: false, error: "icon_emoji must be a non-empty string" };
55
+ }
56
+ if (byteLength(raw) > MAX_ICON_EMOJI_BYTES) {
57
+ return {
58
+ ok: false,
59
+ error: `icon_emoji must be at most ${MAX_ICON_EMOJI_BYTES} bytes`,
60
+ };
61
+ }
62
+ if (CONTROL_CHAR_RE.test(raw)) {
63
+ return {
64
+ ok: false,
65
+ error: "icon_emoji must not contain control characters",
66
+ };
67
+ }
68
+ // Count grapheme clusters. Intl.Segmenter is available in Node 20+ and all
69
+ // evergreen browsers (our targets); guard defensively anyway.
70
+ let graphemeCount;
71
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
72
+ const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
73
+ // Iterate the segmenter to count grapheme clusters. `[...iterable].length`
74
+ // materialises the segments, but a single emoji is tiny so it's fine.
75
+ graphemeCount = [...seg.segment(raw)].length;
76
+ }
77
+ else {
78
+ // Fallback: count code points (over-counts ZWJ sequences, but the
79
+ // pictographic check below still gates non-emoji input).
80
+ graphemeCount = Array.from(raw).length;
81
+ }
82
+ if (graphemeCount !== 1) {
83
+ return {
84
+ ok: false,
85
+ error: "icon_emoji must be exactly one emoji (a single grapheme)",
86
+ };
87
+ }
88
+ if (!EXTENDED_PICTOGRAPHIC_RE.test(raw)) {
89
+ return {
90
+ ok: false,
91
+ error: "icon_emoji must be an emoji, not a letter, digit, or symbol",
92
+ };
93
+ }
94
+ return { ok: true };
95
+ }
96
+ /** Convenience boolean form of {@link validateIconEmoji}. */
97
+ export function isValidIconEmoji(raw) {
98
+ return validateIconEmoji(raw).ok;
99
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export { PaneClient, PaneApiError } from "./client.js";
2
- export type { ClientOptions, RelayResponse, CreateArtifactRequest, CreateArtifactVersionRequest, PatchArtifactMetadataRequest, AttachmentRef, UploadBlobOptions, PresignBlobOptions, AttachmentTokenMintResponse, ListBlobsOptions, AttachmentTokenAuditEntry, AttachmentTokenListResponse, } from "./client.js";
2
+ export type { ClientOptions, RelayResponse, CreateArtifactRequest, CreateArtifactVersionRequest, PatchArtifactMetadataRequest, AttachmentRef, UploadBlobOptions, PresignBlobOptions, AttachmentTokenMintResponse, ListBlobsOptions, AttachmentTokenAuditEntry, AttachmentTokenListResponse, QueryResponse, } from "./client.js";
3
3
  export { openStream } from "./stream.js";
4
4
  export type { OpenStreamOptions, StreamHandlers, StreamHandle, } from "./stream.js";
5
5
  export { registerAgent } from "./register.js";
6
6
  export type { RegisterAgentOptions, RegisterAgentResult } from "./register.js";
7
- export { artifactSchema, callbackSchema, createSessionSchema, artifactTypeSchema, createArtifactSchema, createArtifactVersionSchema, patchArtifactMetadataSchema, feedbackTypeSchema, submitFeedbackSchema, listSessionsStatusSchema, listSessionsQuerySchema, mintParticipantSchema, } from "./schemas.js";
8
- export type { CreateSessionInput, ListSessionsStatus, ListSessionsQuery, MintParticipantInput, } from "./schemas.js";
7
+ export { artifactSchema, callbackSchema, createPaneSchema, artifactTypeSchema, createArtifactSchema, createArtifactVersionSchema, patchArtifactMetadataSchema, feedbackTypeSchema, submitFeedbackSchema, listPanesStatusSchema, listPanesQuerySchema, mintParticipantSchema, upgradePaneSchema, } from "./schemas.js";
8
+ export type { CreatePaneInput, ListPanesStatus, ListPanesQuery, MintParticipantInput, UpgradePaneInput, } from "./schemas.js";
9
+ export { validateIconEmoji, isValidIconEmoji, isRasterImageMime, RASTER_ICON_MIME_ALLOWLIST, MAX_ICON_EMOJI_BYTES, } from "./icons.js";
10
+ export type { RasterIconMime } from "./icons.js";
9
11
  export { MAX_EVENT_TYPE_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, MAX_RESPONSE_SNIPPET_LENGTH, MAX_FRAME_SNIPPET_LENGTH, } from "./limits.js";
10
- export type { AuthorKind, PaneEvent, Template, TemplateType, TemplateVersion, TemplateRecord, TemplateSummary, CreateArtifactResponse, KeyInfo, TasteInfo, FeedbackType, FeedbackSubmission, FeedbackRecord, FeedbackPage, Callback, CreateSessionRequest, CreateSessionResponse, SurfaceState, EventsPage, ParticipantSummary, ParticipantsList, SurfaceSummary, SurfacesPage, MintParticipantResponse, RelayError, } from "./types.js";
12
+ export type { AuthorKind, PaneEvent, SerializedRecord, DeletedRecordRef, RecordDeltaMessage, Template, TemplateType, TemplateVersion, TemplateRecord, TemplateSummary, CreateArtifactResponse, KeyInfo, TasteInfo, FeedbackType, FeedbackSubmission, FeedbackRecord, FeedbackPage, Callback, CreatePaneRequest, CreatePaneResponse, PaneState, EventsPage, ParticipantSummary, ParticipantsList, PaneSummary, PanesPage, MintParticipantResponse, TrashedPaneEntry, TrashedTemplateEntry, TrashListResponse, RelayError, } from "./types.js";