@openmarket/rooms-client 0.2.1 → 0.3.0

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RoomAccessMode, type RoomAttachment, type RoomAuditLogPayload, type RoomAuthSuccessPayload, type RoomBackfillPayload, type RoomDmHistoryResultPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomFriendPresence, type RoomInvitedPayload, type RoomLedgerEntry, type RoomMemberRemovedPayload, type RoomMemberRoleChangedPayload, type RoomMeta, type RoomPinsPayload, type RoomPresencePayload, type RoomReactorsPayload, type RoomReadStatePayload, type RoomReportedPayload, type RoomRole, type RoomSearchResultsPayload, type RoomSearchScope, type RoomSpace, type RoomThreadPayload, type RoomTopic, type RoomTopicAttention, type RoomTopicResultPayload, type RoomTopicsResultPayload, type RoomUserStatus, type RoomWhoPayload } from "./types.js";
1
+ import { type RoomAccessMode, type RoomAttachmentInput, type RoomAuditLogPayload, type RoomAuthSuccessPayload, type RoomBackfillPayload, type RoomDmHistoryResultPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomFriendPresence, type RoomInvitedPayload, type RoomLedgerEntry, type RoomMemberRemovedPayload, type RoomMemberRoleChangedPayload, type RoomMeta, type RoomPinsPayload, type RoomPresencePayload, type RoomReactorsPayload, type RoomReadStatePayload, type RoomReportedPayload, type RoomRole, type RoomSearchResultsPayload, type RoomSearchScope, type RoomSpace, type RoomThreadPayload, type RoomTopic, type RoomTopicAttention, type RoomTopicResultPayload, type RoomTopicsResultPayload, type RoomUserStatus, type RoomWhoPayload } from "./types.js";
2
2
  import { type RoomWebSocketCtor, RoomWsClient } from "./ws-client.js";
3
3
  export interface RoomClientConfig {
4
4
  url: string;
@@ -39,7 +39,7 @@ export interface RoomJoinOptions {
39
39
  }
40
40
  export interface RoomSayOptions extends RoomJoinOptions {
41
41
  text?: string;
42
- attachments?: RoomAttachment[];
42
+ attachments?: RoomAttachmentInput[];
43
43
  inReplyTo?: number;
44
44
  /** Post directly into a topic; the room's linear stream is unaffected. */
45
45
  topicId?: string;
@@ -283,7 +283,7 @@ export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOpti
283
283
  /** Re-join a room on an already-connected client (used after a reconnect). */
284
284
  export declare function rejoinRoomOnClient(client: RoomWsClient, options: RoomJoinOptions): Promise<RoomBackfillPayload>;
285
285
  export declare function attachRejoinOnReconnect(client: RoomWsClient, options: RoomJoinOptions, latestSeq: () => number | undefined, onRejoined?: (backfill: RoomBackfillPayload) => void, onError?: (err: unknown) => void): () => void;
286
- export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachment[], topicId?: string): string;
286
+ export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string): string;
287
287
  export declare function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void;
288
288
  export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null): void;
289
289
  /**
@@ -217,13 +217,27 @@ export interface RoomLedgerEntry {
217
217
  roomRole?: RoomRole | null | undefined;
218
218
  }
219
219
  export interface RoomAttachment {
220
- /** Object key under room-attachments/; clients sign it at render time. */
221
- key: string;
220
+ /**
221
+ * Object key under room-attachments/; clients sign it at render time.
222
+ * Absent while scanStatus is "pending" on entries served to members: the
223
+ * store withholds the signable key until the scan clears, so render the
224
+ * metadata as a placeholder and do not attempt signing without a key.
225
+ */
226
+ key?: string | undefined;
222
227
  name: string;
223
228
  size: number;
224
229
  mime: string;
225
230
  scanStatus?: "pending" | "clean" | "held" | undefined;
226
231
  }
232
+ /**
233
+ * A postable attachment reference. Outbound frames must always carry the
234
+ * stored key (senders reference their own uploads, which include it); only
235
+ * read views of pending entries may be keyless, and those must never be
236
+ * forwarded back into a post.
237
+ */
238
+ export type RoomAttachmentInput = RoomAttachment & {
239
+ key: string;
240
+ };
227
241
  /**
228
242
  * A registry-backed topic inside a channel. The id is permanent: rename
229
243
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -843,7 +857,7 @@ export type RoomClientMessage = {
843
857
  payload: {
844
858
  room: string;
845
859
  text?: string;
846
- attachments?: RoomAttachment[];
860
+ attachments?: RoomAttachmentInput[];
847
861
  inReplyTo?: number;
848
862
  /** Post directly into a topic; the room's linear stream is unaffected. */
849
863
  topicId?: string;
@@ -1,5 +1,8 @@
1
1
  import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
2
2
  export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
3
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
4
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
5
+ * constant only pre-flights uploads no tier could ever accept. */
3
6
  export declare const ROOM_ATTACHMENT_MAX_BYTES: number;
4
7
  export declare class RoomSocialError extends Error {
5
8
  readonly name = "RoomSocialError";
@@ -337,7 +340,10 @@ export declare function restoreRoomDoc(config: RoomSocialConfig, docId: string,
337
340
  note?: string;
338
341
  asAgent?: boolean;
339
342
  }): Promise<RoomDoc>;
340
- export declare function getRoomAttachmentUrl(config: RoomSocialConfig, room: string, key: string): Promise<{
343
+ export declare function getRoomAttachmentUrl(config: RoomSocialConfig, room: string, key: string, opts?: {
344
+ name?: string;
345
+ download?: boolean;
346
+ }): Promise<{
341
347
  url: string;
342
348
  expiresAt: string | null;
343
349
  }>;
@@ -363,6 +369,7 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
363
369
  body?: unknown;
364
370
  signal?: AbortSignal;
365
371
  formData?: FormData;
372
+ headers?: Record<string, string>;
366
373
  }): Promise<T>;
367
374
  export declare function cleanUsername(username: string): string;
368
375
  export declare function cleanRoomId(room: string): string;
@@ -1,6 +1,9 @@
1
1
  import { resolveRoomsWsUrl } from "./client.js";
2
2
  import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
3
- export const ROOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
3
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
4
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
5
+ * constant only pre-flights uploads no tier could ever accept. */
6
+ export const ROOM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
4
7
  export class RoomSocialError extends Error {
5
8
  name = "RoomSocialError";
6
9
  status;
@@ -517,9 +520,22 @@ export async function restoreRoomDoc(config, docId, input = {}) {
517
520
  const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/restore`, { method: "POST", body: input });
518
521
  return result.doc;
519
522
  }
520
- export async function getRoomAttachmentUrl(config, room, key) {
523
+ export async function getRoomAttachmentUrl(config, room, key, opts = {}) {
524
+ // The object key identifies the object and stays in the query. The
525
+ // download filename and force-download flag ride REQUEST HEADERS, never
526
+ // the URL: a private-room filename in the query string leaked into the
527
+ // relay's access logs and error telemetry (Morgan, Sentry).
521
528
  const params = new URLSearchParams({ key });
522
- const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`, { method: "GET" });
529
+ const headers = {};
530
+ // Percent-encode the name: Fetch/HTTP header values are byte strings, so a
531
+ // raw Unicode filename (报告.py, an emoji) throws a TypeError before the
532
+ // request is sent. The store decodes with decodeURIComponent.
533
+ if (opts.name)
534
+ headers["X-Om-Download-Name"] = encodeURIComponent(opts.name);
535
+ // Forces a Content-Disposition attachment even for inline mimes.
536
+ if (opts.download)
537
+ headers["X-Om-Download"] = "1";
538
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`, { method: "GET", headers });
523
539
  if (!result.url) {
524
540
  throw new RoomSocialError("Attachment sign failed", 502, result);
525
541
  }
@@ -587,6 +603,7 @@ export async function socialRequest(config, path, init = {}) {
587
603
  Authorization: `Bearer ${config.apiKey}`,
588
604
  Accept: "application/json",
589
605
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
606
+ ...(init.headers ?? {}),
590
607
  },
591
608
  ...(hasJsonBody
592
609
  ? { body: JSON.stringify(init.body) }
@@ -658,6 +675,63 @@ function requestPath(url) {
658
675
  function trimTrailingSlash(value) {
659
676
  return value.replace(/\/+$/, "");
660
677
  }
678
+ /** Source/config/text extensions declared as text/plain so the server's
679
+ * UTF-8 code-file lane accepts them. Markup extensions the server
680
+ * blocklists (html, svg, xml, ...) are deliberately absent: they fall
681
+ * through to application/octet-stream and the server rejects them. */
682
+ const CODE_TEXT_EXTENSIONS = new Set([
683
+ "js",
684
+ "jsx",
685
+ "ts",
686
+ "tsx",
687
+ "py",
688
+ "rb",
689
+ "go",
690
+ "rs",
691
+ "java",
692
+ "c",
693
+ "h",
694
+ "cc",
695
+ "cpp",
696
+ "hpp",
697
+ "cs",
698
+ "php",
699
+ "sh",
700
+ "bash",
701
+ "zsh",
702
+ "fish",
703
+ "ps1",
704
+ "sql",
705
+ "yaml",
706
+ "yml",
707
+ "toml",
708
+ "ini",
709
+ "cfg",
710
+ "conf",
711
+ "log",
712
+ "diff",
713
+ "patch",
714
+ "tex",
715
+ "r",
716
+ "jl",
717
+ "kt",
718
+ "kts",
719
+ "swift",
720
+ "scala",
721
+ "clj",
722
+ "cljs",
723
+ "ex",
724
+ "exs",
725
+ "erl",
726
+ "lua",
727
+ "pl",
728
+ "pm",
729
+ "dart",
730
+ "gradle",
731
+ "properties",
732
+ "bat",
733
+ "cmd",
734
+ ]);
661
735
  export function mimeFromFilename(filename) {
662
736
  const lower = filename.toLowerCase();
663
737
  if (lower.endsWith(".png"))
@@ -668,6 +742,10 @@ export function mimeFromFilename(filename) {
668
742
  return "image/webp";
669
743
  if (lower.endsWith(".gif"))
670
744
  return "image/gif";
745
+ if (lower.endsWith(".mp4"))
746
+ return "video/mp4";
747
+ if (lower.endsWith(".webm"))
748
+ return "video/webm";
671
749
  if (lower.endsWith(".pdf"))
672
750
  return "application/pdf";
673
751
  if (lower.endsWith(".txt"))
@@ -687,5 +765,8 @@ export function mimeFromFilename(filename) {
687
765
  if (lower.endsWith(".pptx")) {
688
766
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
689
767
  }
768
+ const dot = lower.lastIndexOf(".");
769
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1)))
770
+ return "text/plain";
690
771
  return "application/octet-stream";
691
772
  }
@@ -44,16 +44,26 @@ export interface RoomFollowResult {
44
44
  isNewFollow?: boolean;
45
45
  }
46
46
  export interface RoomAttachment {
47
- /** Object key under room-attachments/; clients sign it at render time. */
48
- key: string;
47
+ /**
48
+ * Object key under room-attachments/; clients sign it at render time.
49
+ * Absent while scanStatus is "pending" on entries served to members: the
50
+ * store withholds the signable key until the scan clears, so render the
51
+ * metadata as a placeholder and do not attempt signing without a key.
52
+ */
53
+ key?: string | undefined;
49
54
  name: string;
50
55
  size: number;
51
56
  mime: string;
52
57
  scanStatus?: "pending" | "clean" | "held" | undefined;
53
58
  }
54
59
  export interface RoomAttachmentUpload {
55
- /** The stored attachment plus its first signed URL, for immediate render. */
60
+ /**
61
+ * The stored attachment plus its first signed URL, for immediate render.
62
+ * The uploader always receives the key; only member views of PENDING
63
+ * entries withhold it.
64
+ */
56
65
  attachment: RoomAttachment & {
66
+ key: string;
57
67
  url: string;
58
68
  expiresAt: string | null;
59
69
  };
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { RoomAccessMode, RoomAttachment, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmReadPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
1
+ export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmReadPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
2
2
  export { applyRoomPresenceDelta, isSystemRoomLedgerEntryType, ROOM_PRESENCE_SAMPLE_LIMIT, RoomClientMessageType, RoomLedgerEntryType, RoomServerMessageType, SUPPORTED_ROOM_PROTOCOL_VERSION, } from "./shared/rooms-protocol.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/client.ts CHANGED
@@ -7,7 +7,7 @@ import { authFromApiKey } from "./jwt.js";
7
7
  import { consumeRoomSuggestionAttribution } from "./suggestion-attribution.js";
8
8
  import {
9
9
  type RoomAccessMode,
10
- type RoomAttachment,
10
+ type RoomAttachmentInput,
11
11
  type RoomAuditLogPayload,
12
12
  type RoomAuthSuccessPayload,
13
13
  type RoomBackfillPayload,
@@ -82,7 +82,7 @@ export interface RoomJoinOptions {
82
82
 
83
83
  export interface RoomSayOptions extends RoomJoinOptions {
84
84
  text?: string;
85
- attachments?: RoomAttachment[];
85
+ attachments?: RoomAttachmentInput[];
86
86
  inReplyTo?: number;
87
87
  /** Post directly into a topic; the room's linear stream is unaffected. */
88
88
  topicId?: string;
@@ -261,7 +261,9 @@ export interface RoomJoinedSession {
261
261
  client: RoomWsClient;
262
262
  }
263
263
 
264
- function toRoomAttachmentPayloads(attachments: readonly RoomAttachment[]): RoomAttachment[] {
264
+ function toRoomAttachmentPayloads(
265
+ attachments: readonly RoomAttachmentInput[],
266
+ ): RoomAttachmentInput[] {
265
267
  return attachments.map(({ key, name, size, mime }) => ({ key, name, size, mime }));
266
268
  }
267
269
 
@@ -1150,7 +1152,7 @@ export function postRoomMessage(
1150
1152
  room: string,
1151
1153
  text: string,
1152
1154
  inReplyTo?: number,
1153
- attachments?: RoomAttachment[],
1155
+ attachments?: RoomAttachmentInput[],
1154
1156
  topicId?: string,
1155
1157
  ): string {
1156
1158
  const clientMsgId = randomUUID();
@@ -246,14 +246,27 @@ export interface RoomLedgerEntry {
246
246
  }
247
247
 
248
248
  export interface RoomAttachment {
249
- /** Object key under room-attachments/; clients sign it at render time. */
250
- key: string;
249
+ /**
250
+ * Object key under room-attachments/; clients sign it at render time.
251
+ * Absent while scanStatus is "pending" on entries served to members: the
252
+ * store withholds the signable key until the scan clears, so render the
253
+ * metadata as a placeholder and do not attempt signing without a key.
254
+ */
255
+ key?: string | undefined;
251
256
  name: string;
252
257
  size: number;
253
258
  mime: string;
254
259
  scanStatus?: "pending" | "clean" | "held" | undefined;
255
260
  }
256
261
 
262
+ /**
263
+ * A postable attachment reference. Outbound frames must always carry the
264
+ * stored key (senders reference their own uploads, which include it); only
265
+ * read views of pending entries may be keyless, and those must never be
266
+ * forwarded back into a post.
267
+ */
268
+ export type RoomAttachmentInput = RoomAttachment & { key: string };
269
+
257
270
  /**
258
271
  * A registry-backed topic inside a channel. The id is permanent: rename
259
272
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -968,7 +981,7 @@ export type RoomClientMessage =
968
981
  payload: {
969
982
  room: string;
970
983
  text?: string;
971
- attachments?: RoomAttachment[];
984
+ attachments?: RoomAttachmentInput[];
972
985
  inReplyTo?: number;
973
986
  /** Post directly into a topic; the room's linear stream is unaffected. */
974
987
  topicId?: string;
@@ -62,7 +62,10 @@ export type {
62
62
  RoomSuggestionSource,
63
63
  } from "./social-types.js";
64
64
 
65
- export const ROOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
65
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
66
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
67
+ * constant only pre-flights uploads no tier could ever accept. */
68
+ export const ROOM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
66
69
 
67
70
  export class RoomSocialError extends Error {
68
71
  override readonly name = "RoomSocialError";
@@ -1097,12 +1100,24 @@ export async function getRoomAttachmentUrl(
1097
1100
  config: RoomSocialConfig,
1098
1101
  room: string,
1099
1102
  key: string,
1103
+ opts: { name?: string; download?: boolean } = {},
1100
1104
  ): Promise<{ url: string; expiresAt: string | null }> {
1105
+ // The object key identifies the object and stays in the query. The
1106
+ // download filename and force-download flag ride REQUEST HEADERS, never
1107
+ // the URL: a private-room filename in the query string leaked into the
1108
+ // relay's access logs and error telemetry (Morgan, Sentry).
1101
1109
  const params = new URLSearchParams({ key });
1110
+ const headers: Record<string, string> = {};
1111
+ // Percent-encode the name: Fetch/HTTP header values are byte strings, so a
1112
+ // raw Unicode filename (报告.py, an emoji) throws a TypeError before the
1113
+ // request is sent. The store decodes with decodeURIComponent.
1114
+ if (opts.name) headers["X-Om-Download-Name"] = encodeURIComponent(opts.name);
1115
+ // Forces a Content-Disposition attachment even for inline mimes.
1116
+ if (opts.download) headers["X-Om-Download"] = "1";
1102
1117
  const result = await socialRequest<{ url?: string; expiresAt?: string | null }>(
1103
1118
  config,
1104
1119
  `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`,
1105
- { method: "GET" },
1120
+ { method: "GET", headers },
1106
1121
  );
1107
1122
  if (!result.url) {
1108
1123
  throw new RoomSocialError("Attachment sign failed", 502, result);
@@ -1195,6 +1210,7 @@ export async function socialRequest<T>(
1195
1210
  body?: unknown;
1196
1211
  signal?: AbortSignal;
1197
1212
  formData?: FormData;
1213
+ headers?: Record<string, string>;
1198
1214
  } = {},
1199
1215
  ): Promise<T> {
1200
1216
  const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
@@ -1205,6 +1221,7 @@ export async function socialRequest<T>(
1205
1221
  Authorization: `Bearer ${config.apiKey}`,
1206
1222
  Accept: "application/json",
1207
1223
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
1224
+ ...(init.headers ?? {}),
1208
1225
  },
1209
1226
  ...(hasJsonBody
1210
1227
  ? { body: JSON.stringify(init.body) }
@@ -1284,12 +1301,72 @@ function trimTrailingSlash(value: string): string {
1284
1301
  return value.replace(/\/+$/, "");
1285
1302
  }
1286
1303
 
1304
+ /** Source/config/text extensions declared as text/plain so the server's
1305
+ * UTF-8 code-file lane accepts them. Markup extensions the server
1306
+ * blocklists (html, svg, xml, ...) are deliberately absent: they fall
1307
+ * through to application/octet-stream and the server rejects them. */
1308
+ const CODE_TEXT_EXTENSIONS = new Set([
1309
+ "js",
1310
+ "jsx",
1311
+ "ts",
1312
+ "tsx",
1313
+ "py",
1314
+ "rb",
1315
+ "go",
1316
+ "rs",
1317
+ "java",
1318
+ "c",
1319
+ "h",
1320
+ "cc",
1321
+ "cpp",
1322
+ "hpp",
1323
+ "cs",
1324
+ "php",
1325
+ "sh",
1326
+ "bash",
1327
+ "zsh",
1328
+ "fish",
1329
+ "ps1",
1330
+ "sql",
1331
+ "yaml",
1332
+ "yml",
1333
+ "toml",
1334
+ "ini",
1335
+ "cfg",
1336
+ "conf",
1337
+ "log",
1338
+ "diff",
1339
+ "patch",
1340
+ "tex",
1341
+ "r",
1342
+ "jl",
1343
+ "kt",
1344
+ "kts",
1345
+ "swift",
1346
+ "scala",
1347
+ "clj",
1348
+ "cljs",
1349
+ "ex",
1350
+ "exs",
1351
+ "erl",
1352
+ "lua",
1353
+ "pl",
1354
+ "pm",
1355
+ "dart",
1356
+ "gradle",
1357
+ "properties",
1358
+ "bat",
1359
+ "cmd",
1360
+ ]);
1361
+
1287
1362
  export function mimeFromFilename(filename: string): string {
1288
1363
  const lower = filename.toLowerCase();
1289
1364
  if (lower.endsWith(".png")) return "image/png";
1290
1365
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1291
1366
  if (lower.endsWith(".webp")) return "image/webp";
1292
1367
  if (lower.endsWith(".gif")) return "image/gif";
1368
+ if (lower.endsWith(".mp4")) return "video/mp4";
1369
+ if (lower.endsWith(".webm")) return "video/webm";
1293
1370
  if (lower.endsWith(".pdf")) return "application/pdf";
1294
1371
  if (lower.endsWith(".txt")) return "text/plain";
1295
1372
  if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "text/markdown";
@@ -1304,5 +1381,7 @@ export function mimeFromFilename(filename: string): string {
1304
1381
  if (lower.endsWith(".pptx")) {
1305
1382
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
1306
1383
  }
1384
+ const dot = lower.lastIndexOf(".");
1385
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1))) return "text/plain";
1307
1386
  return "application/octet-stream";
1308
1387
  }
@@ -53,8 +53,13 @@ export interface RoomFollowResult {
53
53
  }
54
54
 
55
55
  export interface RoomAttachment {
56
- /** Object key under room-attachments/; clients sign it at render time. */
57
- key: string;
56
+ /**
57
+ * Object key under room-attachments/; clients sign it at render time.
58
+ * Absent while scanStatus is "pending" on entries served to members: the
59
+ * store withholds the signable key until the scan clears, so render the
60
+ * metadata as a placeholder and do not attempt signing without a key.
61
+ */
62
+ key?: string | undefined;
58
63
  name: string;
59
64
  size: number;
60
65
  mime: string;
@@ -62,8 +67,16 @@ export interface RoomAttachment {
62
67
  }
63
68
 
64
69
  export interface RoomAttachmentUpload {
65
- /** The stored attachment plus its first signed URL, for immediate render. */
66
- attachment: RoomAttachment & { url: string; expiresAt: string | null };
70
+ /**
71
+ * The stored attachment plus its first signed URL, for immediate render.
72
+ * The uploader always receives the key; only member views of PENDING
73
+ * entries withhold it.
74
+ */
75
+ attachment: RoomAttachment & {
76
+ key: string;
77
+ url: string;
78
+ expiresAt: string | null;
79
+ };
67
80
  }
68
81
 
69
82
  export interface RoomPublicProfile {
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  RoomAccessMode,
3
3
  RoomAttachment,
4
+ RoomAttachmentInput,
4
5
  RoomAuditEntry,
5
6
  RoomAuditLogPayload,
6
7
  RoomAuditLogRequestPayload,