@dxos/plugin-file 0.12.0-next-73daef452f1464b62efdbca75fb0067c1066f264 → 0.14.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.
Files changed (229) hide show
  1. package/PLUGIN.mdl +140 -35
  2. package/dist/lib/FileCapabilities.mjs +1 -2
  3. package/dist/lib/FileCapabilities.mjs.map +1 -1
  4. package/dist/lib/FileLimits.mjs +30 -23
  5. package/dist/lib/FileLimits.mjs.map +1 -1
  6. package/dist/lib/FileOperation.mjs +95 -2
  7. package/dist/lib/FileOperation.mjs.map +1 -1
  8. package/dist/lib/FileSkill.mjs +65 -0
  9. package/dist/lib/FileSkill.mjs.map +1 -0
  10. package/dist/lib/StagedUpload.mjs +68 -0
  11. package/dist/lib/StagedUpload.mjs.map +1 -0
  12. package/dist/lib/assets/plugin-file-CT0bdZfZ.css +86 -0
  13. package/dist/lib/capabilities.browser.mjs +78 -0
  14. package/dist/lib/capabilities.browser.mjs.map +1 -0
  15. package/dist/lib/capabilities.mjs +26 -10
  16. package/dist/lib/capabilities.mjs.map +1 -1
  17. package/dist/lib/capabilities.node.mjs +19 -10
  18. package/dist/lib/capabilities.node.mjs.map +1 -1
  19. package/dist/lib/capabilities.tauri.mjs +78 -0
  20. package/dist/lib/capabilities.tauri.mjs.map +1 -0
  21. package/dist/lib/capabilities.workerd.mjs +19 -10
  22. package/dist/lib/capabilities.workerd.mjs.map +1 -1
  23. package/dist/lib/chunk-FileArticle.mjs +19 -50
  24. package/dist/lib/chunk-FileArticle.mjs.map +1 -1
  25. package/dist/lib/chunk-FileCard.mjs +56 -0
  26. package/dist/lib/chunk-FileCard.mjs.map +1 -0
  27. package/dist/lib/chunk-FileProperties.mjs +95 -0
  28. package/dist/lib/chunk-FileProperties.mjs.map +1 -0
  29. package/dist/lib/chunk-PLUGIN.mjs +6 -0
  30. package/dist/lib/chunk-PLUGIN.mjs.map +1 -0
  31. package/dist/lib/chunk-create-from-source.mjs +80 -0
  32. package/dist/lib/chunk-create-from-source.mjs.map +1 -0
  33. package/dist/lib/chunk-create-from-upload.mjs +36 -0
  34. package/dist/lib/chunk-create-from-upload.mjs.map +1 -0
  35. package/dist/lib/chunk-create-object.mjs +1 -1
  36. package/dist/lib/chunk-create.mjs +18 -30
  37. package/dist/lib/chunk-create.mjs.map +1 -1
  38. package/dist/lib/chunk-edge-backend.mjs +2 -2
  39. package/dist/lib/chunk-edge-backend.mjs.map +1 -1
  40. package/dist/lib/chunk-file-uploader.mjs +4 -4
  41. package/dist/lib/chunk-file-uploader.mjs.map +1 -1
  42. package/dist/lib/chunk-inline-backend.mjs +2 -2
  43. package/dist/lib/chunk-inline-backend.mjs.map +1 -1
  44. package/dist/lib/chunk-markdown-extension.mjs +9 -8
  45. package/dist/lib/chunk-markdown-extension.mjs.map +1 -1
  46. package/dist/lib/chunk-operation-handler.mjs +1 -1
  47. package/dist/lib/chunk-react-surface.mjs +23 -13
  48. package/dist/lib/chunk-react-surface.mjs.map +1 -1
  49. package/dist/lib/chunk-read.mjs +1 -1
  50. package/dist/lib/chunk-resolve-download.mjs +33 -0
  51. package/dist/lib/chunk-resolve-download.mjs.map +1 -0
  52. package/dist/lib/chunk-settings.mjs +3 -3
  53. package/dist/lib/chunk-settings.mjs.map +1 -1
  54. package/dist/lib/chunk-skill-definition.mjs +1 -1
  55. package/dist/lib/chunk-useFileUrl.mjs +56 -0
  56. package/dist/lib/chunk-useFileUrl.mjs.map +1 -0
  57. package/dist/lib/components.mjs +820 -31
  58. package/dist/lib/components.mjs.map +1 -1
  59. package/dist/lib/containers.mjs +25 -15
  60. package/dist/lib/containers.mjs.map +1 -1
  61. package/dist/lib/index.mjs +2 -2
  62. package/dist/lib/meta.mjs.map +1 -1
  63. package/dist/lib/operations.mjs +7 -1
  64. package/dist/lib/operations.mjs.map +1 -1
  65. package/dist/lib/skills.mjs +2 -30
  66. package/dist/lib/translations.mjs +26 -3
  67. package/dist/lib/translations.mjs.map +1 -1
  68. package/dist/types/src/FilePlugin.workerd.test.d.ts +2 -0
  69. package/dist/types/src/FilePlugin.workerd.test.d.ts.map +1 -0
  70. package/dist/types/src/StagedUpload.d.ts +82 -0
  71. package/dist/types/src/StagedUpload.d.ts.map +1 -0
  72. package/dist/types/src/capabilities/FileUploadField.d.ts +2 -2
  73. package/dist/types/src/capabilities/FileUploadField.d.ts.map +1 -1
  74. package/dist/types/src/capabilities/gen/browser.d.ts +14 -0
  75. package/dist/types/src/capabilities/gen/browser.d.ts.map +1 -0
  76. package/dist/types/src/capabilities/gen/node.d.ts +1 -1
  77. package/dist/types/src/capabilities/gen/node.d.ts.map +1 -1
  78. package/dist/types/src/capabilities/gen/tauri.d.ts +14 -0
  79. package/dist/types/src/capabilities/gen/tauri.d.ts.map +1 -0
  80. package/dist/types/src/capabilities/gen/workerd.d.ts +1 -1
  81. package/dist/types/src/capabilities/gen/workerd.d.ts.map +1 -1
  82. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  83. package/dist/types/src/components/FileInput/FileInput.d.ts +1 -3
  84. package/dist/types/src/components/FileInput/FileInput.d.ts.map +1 -1
  85. package/dist/types/src/components/FileInput/index.d.ts +1 -1
  86. package/dist/types/src/components/FileInput/index.d.ts.map +1 -1
  87. package/dist/types/src/components/PdfCanvas/PdfCanvas.d.ts +44 -0
  88. package/dist/types/src/components/PdfCanvas/PdfCanvas.d.ts.map +1 -0
  89. package/dist/types/src/components/PdfCanvas/PdfCanvas.stories.d.ts +111 -0
  90. package/dist/types/src/components/PdfCanvas/PdfCanvas.stories.d.ts.map +1 -0
  91. package/dist/types/src/components/PdfCanvas/index.d.ts +2 -0
  92. package/dist/types/src/components/PdfCanvas/index.d.ts.map +1 -0
  93. package/dist/types/src/components/PdfCanvas/pdf-search.d.ts +38 -0
  94. package/dist/types/src/components/PdfCanvas/pdf-search.d.ts.map +1 -0
  95. package/dist/types/src/components/Preview/Preview.d.ts +39 -0
  96. package/dist/types/src/components/Preview/Preview.d.ts.map +1 -0
  97. package/dist/types/src/components/Preview/Preview.stories.d.ts +110 -0
  98. package/dist/types/src/components/Preview/Preview.stories.d.ts.map +1 -0
  99. package/dist/types/src/components/Preview/PreviewContext.d.ts +24 -0
  100. package/dist/types/src/components/Preview/PreviewContext.d.ts.map +1 -0
  101. package/dist/types/src/components/Preview/index.d.ts +3 -0
  102. package/dist/types/src/components/Preview/index.d.ts.map +1 -0
  103. package/dist/types/src/components/index.d.ts +3 -2
  104. package/dist/types/src/components/index.d.ts.map +1 -1
  105. package/dist/types/src/containers/FileArticle/FileArticle.d.ts +3 -3
  106. package/dist/types/src/containers/FileArticle/FileArticle.d.ts.map +1 -1
  107. package/dist/types/src/containers/FileArticle/FileArticle.stories.d.ts +104 -0
  108. package/dist/types/src/containers/FileArticle/FileArticle.stories.d.ts.map +1 -0
  109. package/dist/types/src/containers/FileArticle/index.d.ts +1 -1
  110. package/dist/types/src/containers/FileArticle/index.d.ts.map +1 -1
  111. package/dist/types/src/containers/FileCard/FileCard.d.ts +10 -0
  112. package/dist/types/src/containers/FileCard/FileCard.d.ts.map +1 -0
  113. package/dist/types/src/containers/FileCard/index.d.ts +2 -0
  114. package/dist/types/src/containers/FileCard/index.d.ts.map +1 -0
  115. package/dist/types/src/containers/FileProperties/FileProperties.d.ts +9 -0
  116. package/dist/types/src/containers/FileProperties/FileProperties.d.ts.map +1 -0
  117. package/dist/types/src/containers/FileProperties/index.d.ts +2 -0
  118. package/dist/types/src/containers/FileProperties/index.d.ts.map +1 -0
  119. package/dist/types/src/containers/FileSettings/FileSettings.d.ts +1 -1
  120. package/dist/types/src/containers/FileSettings/FileSettings.d.ts.map +1 -1
  121. package/dist/types/src/containers/FileSettings/index.d.ts +1 -1
  122. package/dist/types/src/containers/FileSettings/index.d.ts.map +1 -1
  123. package/dist/types/src/containers/index.d.ts +3 -1
  124. package/dist/types/src/containers/index.d.ts.map +1 -1
  125. package/dist/types/src/extensions/index.d.ts +1 -1
  126. package/dist/types/src/extensions/index.d.ts.map +1 -1
  127. package/dist/types/src/hooks/index.d.ts +2 -0
  128. package/dist/types/src/hooks/index.d.ts.map +1 -0
  129. package/dist/types/src/hooks/useFileUrl.d.ts +13 -0
  130. package/dist/types/src/hooks/useFileUrl.d.ts.map +1 -0
  131. package/dist/types/src/index.d.ts +2 -2
  132. package/dist/types/src/index.d.ts.map +1 -1
  133. package/dist/types/src/operations/create-from-source.d.ts +13 -0
  134. package/dist/types/src/operations/create-from-source.d.ts.map +1 -0
  135. package/dist/types/src/operations/create-from-source.test.d.ts +2 -0
  136. package/dist/types/src/operations/create-from-source.test.d.ts.map +1 -0
  137. package/dist/types/src/operations/create-from-upload.d.ts +38 -0
  138. package/dist/types/src/operations/create-from-upload.d.ts.map +1 -0
  139. package/dist/types/src/operations/create.d.ts +85 -9
  140. package/dist/types/src/operations/create.d.ts.map +1 -1
  141. package/dist/types/src/operations/index.d.ts.map +1 -1
  142. package/dist/types/src/operations/resolve-download.d.ts +42 -0
  143. package/dist/types/src/operations/resolve-download.d.ts.map +1 -0
  144. package/dist/types/src/operations/resolve-download.test.d.ts +2 -0
  145. package/dist/types/src/operations/resolve-download.test.d.ts.map +1 -0
  146. package/dist/types/src/skills/FileSkill.d.ts +6 -0
  147. package/dist/types/src/skills/FileSkill.d.ts.map +1 -0
  148. package/dist/types/src/skills/index.d.ts +1 -1
  149. package/dist/types/src/skills/index.d.ts.map +1 -1
  150. package/dist/types/src/translations.d.ts +49 -3
  151. package/dist/types/src/translations.d.ts.map +1 -1
  152. package/dist/types/src/types/FileCapabilities.d.ts +3 -2
  153. package/dist/types/src/types/FileCapabilities.d.ts.map +1 -1
  154. package/dist/types/src/types/FileLimits.d.ts +9 -2
  155. package/dist/types/src/types/FileLimits.d.ts.map +1 -1
  156. package/dist/types/src/types/FileOperation.d.ts +73 -0
  157. package/dist/types/src/types/FileOperation.d.ts.map +1 -1
  158. package/dist/types/src/types/index.d.ts +5 -5
  159. package/dist/types/src/types/index.d.ts.map +1 -1
  160. package/dist/types/tsconfig.tsbuildinfo +1 -1
  161. package/package.json +60 -34
  162. package/src/FilePlugin.workerd.test.ts +22 -0
  163. package/src/StagedUpload.ts +100 -0
  164. package/src/capabilities/FileUploadField.tsx +3 -3
  165. package/src/capabilities/edge-backend.ts +1 -1
  166. package/src/capabilities/file-uploader.ts +1 -1
  167. package/src/capabilities/gen/browser.ts +104 -0
  168. package/src/capabilities/gen/node.ts +14 -21
  169. package/src/capabilities/gen/tauri.ts +104 -0
  170. package/src/capabilities/gen/workerd.ts +14 -21
  171. package/src/capabilities/index.ts +33 -15
  172. package/src/capabilities/inline-backend.ts +1 -1
  173. package/src/capabilities/markdown-extension.ts +1 -1
  174. package/src/capabilities/react-surface.ts +22 -8
  175. package/src/capabilities/settings.ts +2 -2
  176. package/src/components/FileInput/FileInput.tsx +5 -14
  177. package/src/components/FileInput/index.ts +1 -1
  178. package/src/components/PdfCanvas/PdfCanvas.stories.tsx +128 -0
  179. package/src/components/PdfCanvas/PdfCanvas.tsx +631 -0
  180. package/src/components/{FilePreview → PdfCanvas}/index.ts +1 -1
  181. package/src/components/PdfCanvas/pdf-search.ts +89 -0
  182. package/src/components/PdfCanvas/text-layer.css +85 -0
  183. package/src/components/Preview/Preview.stories.tsx +118 -0
  184. package/src/components/Preview/Preview.tsx +315 -0
  185. package/src/components/Preview/PreviewContext.ts +42 -0
  186. package/src/components/Preview/index.ts +6 -0
  187. package/src/components/index.ts +3 -2
  188. package/src/containers/FileArticle/FileArticle.stories.tsx +147 -0
  189. package/src/containers/FileArticle/FileArticle.tsx +28 -69
  190. package/src/containers/FileArticle/index.ts +1 -1
  191. package/src/containers/FileCard/FileCard.tsx +73 -0
  192. package/src/containers/FileCard/index.ts +5 -0
  193. package/src/containers/FileProperties/FileProperties.tsx +108 -0
  194. package/src/containers/FileProperties/index.ts +5 -0
  195. package/src/containers/FileSettings/FileSettings.tsx +30 -25
  196. package/src/containers/FileSettings/index.ts +1 -1
  197. package/src/containers/index.ts +4 -2
  198. package/src/extensions/image.tsx +6 -6
  199. package/src/extensions/index.ts +1 -1
  200. package/src/hooks/index.ts +5 -0
  201. package/src/hooks/useFileUrl.ts +86 -0
  202. package/src/index.ts +2 -2
  203. package/src/meta.ts +1 -1
  204. package/src/operations/create-from-source.test.ts +227 -0
  205. package/src/operations/create-from-source.ts +98 -0
  206. package/src/operations/create-from-upload.ts +53 -0
  207. package/src/operations/create.test.ts +25 -14
  208. package/src/operations/create.ts +8 -29
  209. package/src/operations/index.ts +5 -2
  210. package/src/operations/resolve-download.test.ts +68 -0
  211. package/src/operations/resolve-download.ts +49 -0
  212. package/src/plugin.test.ts +2 -2
  213. package/src/skills/FileSkill.ts +71 -0
  214. package/src/skills/index.ts +1 -1
  215. package/src/translations.ts +27 -4
  216. package/src/types/FileCapabilities.ts +4 -4
  217. package/src/types/FileLimits.ts +23 -13
  218. package/src/types/FileOperation.ts +110 -1
  219. package/src/types/index.ts +5 -5
  220. package/src/vite-env.d.ts +15 -0
  221. package/dist/lib/skills.mjs.map +0 -1
  222. package/dist/types/src/components/FilePreview/FilePreview.d.ts +0 -12
  223. package/dist/types/src/components/FilePreview/FilePreview.d.ts.map +0 -1
  224. package/dist/types/src/components/FilePreview/index.d.ts +0 -2
  225. package/dist/types/src/components/FilePreview/index.d.ts.map +0 -1
  226. package/dist/types/src/skills/file-skill.d.ts +0 -5
  227. package/dist/types/src/skills/file-skill.d.ts.map +0 -1
  228. package/src/components/FilePreview/FilePreview.tsx +0 -55
  229. package/src/skills/file-skill.ts +0 -40
package/PLUGIN.mdl CHANGED
@@ -21,9 +21,10 @@ Each extension is defined in the Appendix or resolved via its URI.
21
21
  |-------------|--------------------------------|
22
22
  | `type` | `org.dxos.mdl.type@1.0` |
23
23
  | `feat` | `org.dxos.mdl.feat@1.0` |
24
- | `test` | `org.dxos.mdl.test@1.0` |
24
+ | `scenario` | `org.dxos.mdl.scenario@1.0` |
25
25
  | `component` | `org.dxos.mdl.component@1.0` |
26
26
  | `op` | `org.dxos.mdl.op@1.0` |
27
+ | `test` | `org.dxos.mdl.test@1.0` |
27
28
 
28
29
  ## Types
29
30
 
@@ -59,11 +60,22 @@ type Settings
59
60
  ## Components
60
61
 
61
62
  ```mdl
62
- component FilePreview
63
+ component Preview
63
64
  desc: |
64
- Renders a File object using the appropriate HTML element for its MIME type.
65
- Images use <img>, videos use <video controls>, PDFs use <iframe>, and
66
- all other types fall back to a download link.
65
+ Composite preview of a file's content, in Radix parts. Preview.Root holds
66
+ the source and whatever the content discovers about it, Preview.Content
67
+ renders by MIME type, and Preview.Toolbar sits above with controls. Split
68
+ this way because the toolbar needs facts only the content discovers — a
69
+ PDF's page count is known after pdf.js parses the document.
70
+
71
+ Root renders no element of its own: its parts are placed into slots that
72
+ belong to the surrounding container (Panel.Toolbar / Panel.Content), so a
73
+ wrapper would break the panel's grid.
74
+
75
+ Images, video and audio go through react-ui's MediaPlayer. PDFs render
76
+ through pdf.js to stacked canvases — one per page — rather than an
77
+ <iframe>, whose built-in viewer lives in another origin and cannot be
78
+ themed. Everything else falls back to a download link.
67
79
  props:
68
80
  type: string # MIME type
69
81
  url: string # renderable data:, blob:, or http(s): URL
@@ -108,18 +120,21 @@ component FileArticle
108
120
  Full-viewport article container rendered for article, section, and slide
109
121
  roles. Loads the File's referenced Blob and resolves a renderable URL via
110
122
  Blob.url; if the backend has no getUrl, falls back to Blob.read and
111
- constructs a blob: object URL (revoked on unmount). Delegates to
112
- FilePreview. Shows an empty state while the URL is resolving or if the
113
- File is not attached to a database.
123
+ constructs a blob: object URL (revoked on unmount). Composes Preview's
124
+ parts into the panel's toolbar and content slots. Shows an empty state
125
+ while the URL is resolving or if the File is not attached to a database.
126
+
127
+ Full plank width, not the reading column: `dx-document` is right for prose
128
+ and wrong for a preview.
114
129
  props:
115
130
  role: AppSurface.Role
116
131
  subject: File
117
132
  layout: |
118
133
  ┌──────────────────────────────┐
119
- │ │
120
- │ FilePreview │
134
+ │ Preview.Toolbar (n pages) │
135
+ ├──────────────────────────────┤
136
+ │ Preview.Content │
121
137
  │ (fills container) │
122
- │ │
123
138
  └──────────────────────────────┘
124
139
  ```
125
140
 
@@ -129,8 +144,8 @@ component FileArticle
129
144
  op Create
130
145
  key: org.dxos.operation.file.create
131
146
  desc: |
132
- Validates a browser File object's MIME type, resolves the active
133
- BackendDescriptor, and composes File.fromBytes (which hashes/uploads the
147
+ Resolves the MIME type to store (FileLimits.toStoredMimeType), resolves the
148
+ active BackendDescriptor, and composes File.fromBytes (which hashes/uploads the
134
149
  bytes via the Blob API and adds the resulting Blob to the database) to
135
150
  construct a File ECHO object. Does not add the File object itself to the
136
151
  space — callers do that separately.
@@ -139,7 +154,6 @@ op Create
139
154
  db: Database
140
155
  output: { object: File }
141
156
  errors:
142
- UnsupportedFileTypeError: MIME type is not image/*, video/*, or application/pdf
143
157
  FileTooLargeError: file exceeds the 4 MB inline limit (only possible when the
144
158
  resolved backend's storage is 'inline'; other backends scale beyond it)
145
159
  NoBackendError: no BackendDescriptor capability is registered
@@ -150,8 +164,9 @@ op Create
150
164
  ```mdl
151
165
  feat F-1: File upload
152
166
 
153
- req F-1.1: Accepted MIME types MUST be image/* (jpg, jpeg, png, gif, webp, svg),
154
- video/* (mp4, webm, mov), and application/pdf only.
167
+ req F-1.1: Every MIME type MUST be accepted, including an absent one. A type
168
+ that is absent or that a browser executes (text/html, application/xhtml+xml,
169
+ text/xml, application/xml) MUST be stored as application/octet-stream.
155
170
 
156
171
  req F-1.2: Maximum upload size MUST be 4 MB when the resolved backend's storage
157
172
  is 'inline'. Other backends (e.g. edge, wnfs) are not subject to this cap.
@@ -165,8 +180,8 @@ feat F-1: File upload
165
180
  renderable url resolved via Blob.url, if one is available)
166
181
 
167
182
  req F-1.4:
168
- when: file MIME type is not accepted
169
- then: UnsupportedFileTypeError returned; no ECHO write occurs
183
+ when: file MIME type is unrecognized or empty (e.g. a .ndjson.gz log bundle)
184
+ then: the File is created; Preview shows the no-preview message and a download
170
185
 
171
186
  req F-1.5:
172
187
  when: file size exceeds the limit and the resolved backend's storage is 'inline'
@@ -200,14 +215,23 @@ feat F-2: Backend capability system
200
215
  feat F-3: File preview
201
216
 
202
217
  req F-3.1: FileArticle MUST resolve a renderable URL via Blob.url before
203
- rendering FilePreview. For inline files this is always a data: URL
218
+ rendering Preview. For inline files this is always a data: URL
204
219
  (constructed synchronously from the stored bytes, no Blob.read needed). For
205
220
  external files with no registered getUrl, FileArticle falls back to
206
221
  Blob.read and constructs a blob: URL, revoked on unmount.
207
222
 
208
- req F-3.2: FilePreview MUST render the appropriate element for each MIME class:
209
- image/* → <img>, video/* → <video controls>, application/pdf → <iframe>,
210
- other → <a download>.
223
+ req F-3.2: Preview.Content MUST render the appropriate element for each MIME
224
+ class: image/*, video/* and audio/* → MediaPlayer, application/pdf → pdf.js
225
+ canvases, other → <a download>.
226
+
227
+ req F-3.4: A PDF MUST render every page, not one at a time. The <iframe> this
228
+ replaced scrolled the whole document, so paginating would silently hide
229
+ content behind navigation that does not exist.
230
+
231
+ req F-3.5: Rendering MUST be cancelled before the document is torn down.
232
+ pdf.js rejects in-flight render and getPage promises with
233
+ RenderingCancelledException / "Transport destroyed" once the transport is
234
+ destroyed, and those rejections are unhandled if nothing cancels first.
211
235
 
212
236
  req F-3.3: FileArticle surfaces are registered for article, section,
213
237
  and slide roles so files embed correctly in Markdown and Presenter layouts.
@@ -235,7 +259,7 @@ feat F-5: Settings
235
259
  ## Acceptance
236
260
 
237
261
  ```mdl
238
- test T-1: Upload image inline
262
+ scenario T-1: Upload image inline
239
263
  given: only the inline BackendDescriptor is registered
240
264
  when: user uploads a 1 MB PNG via the Create Object form
241
265
  then:
@@ -245,7 +269,7 @@ test T-1: Upload image inline
245
269
  ```
246
270
 
247
271
  ```mdl
248
- test T-2: Reject oversized file
272
+ scenario T-2: Reject oversized file
249
273
  given: inline backend selected, 4 MB limit
250
274
  when: user uploads a 5 MB JPEG
251
275
  then:
@@ -255,16 +279,15 @@ test T-2: Reject oversized file
255
279
  ```
256
280
 
257
281
  ```mdl
258
- test T-3: Reject unsupported MIME type
282
+ scenario T-3: Store an unrecognized MIME type
259
283
  given: inline backend registered
260
284
  when: user uploads a .zip file
261
285
  then:
262
- - UnsupportedFileTypeError is returned
263
- - no File object is created
286
+ - a File object is created whose Blob type is application/zip
264
287
  ```
265
288
 
266
289
  ```mdl
267
- test T-4: Preview inline image
290
+ scenario T-4: Preview inline image
268
291
  given: a File whose data Ref resolves to a Blob with data._tag === 'inline' and type === 'image/png'
269
292
  when: FileArticle is rendered
270
293
  then:
@@ -273,21 +296,21 @@ test T-4: Preview inline image
273
296
  ```
274
297
 
275
298
  ```mdl
276
- test T-5: Preview PDF
299
+ scenario T-5: Preview PDF
277
300
  given: a File with type === 'application/pdf' and a Blob resolved to an HTTP or data URL
278
301
  when: FilePreview is rendered
279
302
  then: an <iframe> element is rendered with the file URL as src
280
303
  ```
281
304
 
282
305
  ```mdl
283
- test T-6: Backend fallback to inline
306
+ scenario T-6: Backend fallback to inline
284
307
  given: Settings.backend is 'wnfs' but no BackendDescriptor with storage 'wnfs' is registered
285
308
  when: op:Create is invoked
286
309
  then: the inline backend is used and the file is stored inline
287
310
  ```
288
311
 
289
312
  ```mdl
290
- test T-7: External blob resolved via a registered BlobBackend's getUrl
313
+ scenario T-7: External blob resolved via a registered BlobBackend's getUrl
291
314
  given: a File whose data Ref resolves to a Blob with data._tag === 'external' and a
292
315
  BlobBackend registered on the ECHO Hypergraph that resolves the Blob's URI scheme
293
316
  and implements getUrl
@@ -299,7 +322,7 @@ test T-7: External blob resolved via a registered BlobBackend's getUrl
299
322
  ```
300
323
 
301
324
  ```mdl
302
- test T-8: External blob without getUrl falls back to reading bytes
325
+ scenario T-8: External blob without getUrl falls back to reading bytes
303
326
  given: a File whose data Ref resolves to an external Blob whose registered
304
327
  BlobBackend implements get but not getUrl
305
328
  when: FileArticle is rendered
@@ -310,7 +333,7 @@ test T-8: External blob without getUrl falls back to reading bytes
310
333
  ```
311
334
 
312
335
  ```mdl
313
- test T-9: No backend error
336
+ scenario T-9: No backend error
314
337
  given: no BackendDescriptor capability is registered
315
338
  when: op:Create is invoked
316
339
  then: NoBackendError is returned
@@ -318,6 +341,88 @@ test T-9: No backend error
318
341
 
319
342
  ---
320
343
 
344
+ ## QA
345
+
346
+ Executable tests. Each step carries a human instruction (`do`) and, where the step maps to an
347
+ invocable operation, an agent binding (`invoke`). Report failures by test and step number.
348
+
349
+ plugin-file is on by default (`FilePlugin.meta.profile.key` is in `getDefaults`; see
350
+ `packages/apps/composer-app/src/plugin-defs.tsx`).
351
+
352
+ ```mdl
353
+ test QA-1: Upload an image and preview it
354
+ desc: The core file round-trip — upload an inline image via the Create Object form, place it, and read the preview back.
355
+ actors: both
356
+ covers: [F-1.1, F-1.3, F-3.1, F-3.2, T-1, T-4]
357
+ status: unverified
358
+ given:
359
+ - an identity exists
360
+ - space: a space is open
361
+ - collection: a collection in that space, to create into
362
+ - only the inline BackendDescriptor is registered (default)
363
+ - no File named "qa-image.png" exists — ABORT rather than reusing one this test did not create
364
+ steps:
365
+ - name: Upload the image
366
+ do: In the navtree, click + on a collection and choose File. Drop a small PNG (e.g. 10 KB) named "qa-image.png".
367
+ invoke: |
368
+ org.dxos.operation.file.create {
369
+ file: new File([Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="), (c) => c.charCodeAt(0))], "qa-image.png", { type: "image/png" }),
370
+ db: $given.space.db
371
+ }
372
+ capture: created
373
+ expect: a File object is created with type "image/png", the given size, and a Blob whose
374
+ data resolves inline
375
+ assert: return $created.object.type === 'image/png' && !!$created.object.data
376
+ note: |
377
+ `file` is a browser `File` object, not a plain literal — the runner constructs it inline
378
+ with `new File(...)` in the invoke snippet rather than passing JSON, since `BrowserFile`
379
+ has no serializable shape.
380
+ - name: Place it in the collection
381
+ do: (the Create Object form places it for you)
382
+ invoke: org.dxos.operation.space.addObject { object: $created.object, target: $given.collection }
383
+ expect: "qa-image.png" appears as a row under the collection
384
+ assert: |
385
+ return $given.collection.objects.some((ref) => ref.target?.id === $created.object.id);
386
+ note: Coalesced with the previous step — `created.object` is live and cannot cross a snippet boundary.
387
+ - name: Open it and view the preview
388
+ do: Click "qa-image.png" in the navtree.
389
+ invoke: org.dxos.operation.appToolkit.open { subject: ["root/$given.space.id/content/collections/$created.object.id"] }
390
+ expect: FileArticle renders an <img> element showing the uploaded image, resolved via a data: URL
391
+ assert: |
392
+ const img = document.querySelector('img[src^="data:"]');
393
+ return !!img && img.complete && img.naturalWidth > 0;
394
+ after:
395
+ - name: Delete the File
396
+ do: Right-click "qa-image.png" in the navtree and choose Delete.
397
+ invoke: org.dxos.operation.space.removeObjects { objects: [$created.object], target: $given.collection }
398
+ expect: the row leaves the navtree and its plank closes
399
+ ```
400
+
401
+ ```mdl
402
+ test QA-2: Reject an oversized upload
403
+ desc: The rejection path (F-1.5) — a validation round-trip that must leave no ECHO write behind.
404
+ actors: agent
405
+ covers: [F-1.5, T-2]
406
+ status: unverified
407
+ given:
408
+ - an identity exists
409
+ - space: a space is open
410
+ - only the inline BackendDescriptor is registered (4 MB cap applies)
411
+ steps:
412
+ - name: Reject an oversized file
413
+ do: Attempt to upload a PNG larger than 4 MB.
414
+ invoke: |
415
+ org.dxos.operation.file.create {
416
+ file: new File([new Uint8Array(5 * 1024 * 1024)], "qa-big.png", { type: "image/png" }),
417
+ db: $given.space.db
418
+ }
419
+ expect: the upload is refused with FileTooLargeError and no File object is created
420
+ assert: |
421
+ return /FileTooLarge/.test($error?.message ?? $error?._tag ?? '');
422
+ ```
423
+
424
+ ---
425
+
321
426
  ## Appendix: Extension Definitions
322
427
 
323
428
  ```mdl
@@ -342,8 +447,8 @@ ext feat
342
447
  ```
343
448
 
344
449
  ```mdl
345
- ext test
346
- uri: org.dxos.mdl.test@1.0
450
+ ext scenario
451
+ uri: org.dxos.mdl.scenario@1.0
347
452
  desc: An acceptance scenario expressed as given / when / then steps.
348
453
  fields:
349
454
  given?: Step | Step[]
@@ -1,5 +1,4 @@
1
1
  import { t as __exportAll } from "./chunk-rolldown-runtime.mjs";
2
- import { ACCEPTED_MIME } from "./FileLimits.mjs";
3
2
  import * as Capability from "@dxos/app-framework/Capability";
4
3
  import { meta } from "#meta";
5
4
  import * as Schema from "effect/Schema";
@@ -14,7 +13,7 @@ var SettingsAtom = Capability.makeSingleton()(`${meta.profile.key}.capability.se
14
13
  var FileAction;
15
14
  (function(_FileAction) {
16
15
  const UploadAnnotationId = _FileAction.UploadAnnotationId = `${meta.profile.key}.annotation.upload`;
17
- _FileAction.CreateFileSchema = Schema.Struct({ file: Schema.instanceOf(File).annotate({ [UploadAnnotationId]: ACCEPTED_MIME }) });
16
+ _FileAction.CreateFileSchema = Schema.Struct({ file: Schema.instanceOf(File).annotate({ [UploadAnnotationId]: true }) });
18
17
  })(FileAction || (FileAction = {}));
19
18
  //#endregion
20
19
  export { Backend, FileAction, SettingsAtom, FileCapabilities_exports as t };
@@ -1 +1 @@
1
- {"version":3,"file":"FileCapabilities.mjs","names":[],"sources":["../../src/types/FileCapabilities.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Schema from 'effect/Schema';\nimport type * as Atom from 'effect/unstable/reactivity/Atom';\n\nimport * as Capability from '@dxos/app-framework/Capability';\n\nimport { meta } from '#meta';\n\nimport { ACCEPTED_MIME } from './FileLimits';\nimport * as Settings from './Settings';\n\n/**\n * Descriptor for a registered `BlobBackend` storage option, surfaced in the file plugin's\n * settings UI. Actual upload/read logic lives in the Blob backend registered on the Hypergraph\n * under `storage` (see `client.graph.registerBlobBackend`) — this type carries no behavior.\n */\nexport type Backend = {\n /** Display label shown in the settings UI. */\n readonly name: string;\n /** Description shown next to the backend in settings. */\n readonly description?: string;\n /**\n * `Blob.fromBytes`'s `storage` option to use when this backend is selected. Also the settings\n * key identifying this backend — one `BlobBackend` is registered per storage name, so the two\n * concepts always coincide.\n */\n readonly storage: string;\n};\n\n// Multi capability: inline-backend and edge-backend each contribute one entry.\nexport const Backend = Capability.make<Backend>()(`${meta.profile.key}.capability.backend`);\n\nexport const SettingsAtom = Capability.makeSingleton<Atom.Writable<Settings.Settings>>()(\n `${meta.profile.key}.capability.settings`,\n);\n\nexport namespace FileAction {\n export const UploadAnnotationId = `${meta.profile.key}.annotation.upload`;\n\n export const CreateFileSchema = Schema.Struct({\n file: Schema.instanceOf(File).annotate({\n [UploadAnnotationId]: ACCEPTED_MIME,\n }),\n });\n\n export type CreateFileForm = Schema.Schema.Type<typeof CreateFileSchema>;\n}\n"],"mappings":";;;;;;;;;;;AAiCA,IAAa,UAAU,WAAW,KAAc,CAAC,CAAC,GAAG,KAAK,QAAQ,IAAI,oBAAoB;AAE1F,IAAa,eAAe,WAAW,cAAgD,CAAC,CACtF,GAAG,KAAK,QAAQ,IAAI,qBACtB;AAEO,IAAU;CAAV,SAAA,aAAA;CACE,MAAM,qBAAA,YAAA,qBAAqB,GAAG,KAAK,QAAQ,IAAI;CAEzC,YAAA,mBAAmB,OAAO,OAAO,EAC5C,MAAM,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,GACpC,qBAAqB,cACxB,CAAC,EACH,CAAC;AAGH,EAAA,CAAA,eAAA,aAAA,CAAA,EAAA"}
1
+ {"version":3,"file":"FileCapabilities.mjs","names":[],"sources":["../../src/types/FileCapabilities.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport type * as Atom from 'effect/reactivity/Atom';\nimport * as Schema from 'effect/Schema';\n\nimport * as Capability from '@dxos/app-framework/Capability';\n\nimport { meta } from '#meta';\n\nimport * as Settings from './Settings.ts';\n\n/**\n * Descriptor for a registered `BlobBackend` storage option, surfaced in the file plugin's\n * settings UI. Actual upload/read logic lives in the Blob backend registered on the Hypergraph\n * under `storage` (see `client.graph.registerBlobBackend`) — this type carries no behavior.\n */\nexport type Backend = {\n /** Display label shown in the settings UI. */\n readonly name: string;\n /** Description shown next to the backend in settings. */\n readonly description?: string;\n /**\n * `Blob.fromBytes`'s `storage` option to use when this backend is selected. Also the settings\n * key identifying this backend — one `BlobBackend` is registered per storage name, so the two\n * concepts always coincide.\n */\n readonly storage: string;\n};\n\n// Multi capability: inline-backend and edge-backend each contribute one entry.\nexport const Backend = Capability.make<Backend>()(`${meta.profile.key}.capability.backend`);\n\nexport const SettingsAtom = Capability.makeSingleton<Atom.Writable<Settings.Settings>>()(\n `${meta.profile.key}.capability.settings`,\n);\n\nexport namespace FileAction {\n /** Marks a field as a file upload, so the form renders the drop zone for it. Any type is accepted. */\n export const UploadAnnotationId = `${meta.profile.key}.annotation.upload`;\n\n export const CreateFileSchema = Schema.Struct({\n file: Schema.instanceOf(File).annotate({\n [UploadAnnotationId]: true,\n }),\n });\n\n export type CreateFileForm = Schema.Schema.Type<typeof CreateFileSchema>;\n}\n"],"mappings":";;;;;;;;;;AAgCA,IAAa,UAAU,WAAW,KAAc,CAAC,CAAC,GAAG,KAAK,QAAQ,IAAI,oBAAoB;AAE1F,IAAa,eAAe,WAAW,cAAgD,CAAC,CACtF,GAAG,KAAK,QAAQ,IAAI,qBACtB;AAEO,IAAU;CAAV,SAAA,aAAA;CAEE,MAAM,qBAAA,YAAA,qBAAqB,GAAG,KAAK,QAAQ,IAAI;CAEzC,YAAA,mBAAmB,OAAO,OAAO,EAC5C,MAAM,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,GACpC,qBAAqB,KACxB,CAAC,EACH,CAAC;AAGH,EAAA,CAAA,eAAA,aAAA,CAAA,EAAA"}
@@ -1,31 +1,38 @@
1
1
  import { t as __exportAll } from "./chunk-rolldown-runtime.mjs";
2
2
  //#region src/types/FileLimits.ts
3
3
  var FileLimits_exports = /* @__PURE__ */ __exportAll({
4
- ACCEPTED_MIME: () => ACCEPTED_MIME,
5
- isAcceptedMimeType: () => isAcceptedMimeType
4
+ FALLBACK_MIME_TYPE: () => FALLBACK_MIME_TYPE,
5
+ toStoredMimeType: () => toStoredMimeType
6
6
  });
7
- var ACCEPTED_MIME = {
8
- "image/*": [
9
- ".jpg",
10
- ".jpeg",
11
- ".png",
12
- ".gif",
13
- ".webp",
14
- ".svg"
15
- ],
16
- "video/*": [
17
- ".mp4",
18
- ".webm",
19
- ".mov"
20
- ],
21
- "application/pdf": [".pdf"]
22
- };
23
- var isAcceptedMimeType = (type) => {
24
- if (type === "application/pdf") return true;
25
- if (type.startsWith("image/") || type.startsWith("video/")) return true;
26
- return false;
7
+ /** Stored in place of a type that is absent or would execute; the bytes are kept, only the label changes. */
8
+ var FALLBACK_MIME_TYPE = "application/octet-stream";
9
+ /**
10
+ * Media types a browser executes when it renders them.
11
+ *
12
+ * A stored file is handed back through a presigned, public or `blob:` URL on the app's origin, and
13
+ * HTML served from there runs with that origin — making an upload a stored-XSS primitive. Such
14
+ * files are still accepted, but labelled {@link FALLBACK_MIME_TYPE} so they download rather than
15
+ * render. Matches the blob service's own `ACTIVE_CONTENT_TYPES`, minus SVG, which image previews
16
+ * rely on.
17
+ */
18
+ var ACTIVE_CONTENT_TYPES = /* @__PURE__ */ new Set([
19
+ "text/html",
20
+ "application/xhtml+xml",
21
+ "text/xml",
22
+ "application/xml"
23
+ ]);
24
+ /**
25
+ * The media type to store for an upload. Every type is accepted — the file plugin keeps bytes it
26
+ * cannot preview — so this never rejects; it only neutralizes types that are missing or executable.
27
+ * Compared case-insensitively and without parameters: RFC 2045 defines type and subtype as
28
+ * case-insensitive, and `text/html; charset=utf-8` is still HTML.
29
+ */
30
+ var toStoredMimeType = (type) => {
31
+ const declared = type?.trim() ?? "";
32
+ const base = declared.split(";")[0].trim().toLowerCase();
33
+ return base.length === 0 || ACTIVE_CONTENT_TYPES.has(base) ? FALLBACK_MIME_TYPE : declared;
27
34
  };
28
35
  //#endregion
29
- export { ACCEPTED_MIME, isAcceptedMimeType, FileLimits_exports as t };
36
+ export { FALLBACK_MIME_TYPE, FileLimits_exports as t, toStoredMimeType };
30
37
 
31
38
  //# sourceMappingURL=FileLimits.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileLimits.mjs","names":[],"sources":["../../src/types/FileLimits.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nexport const ACCEPTED_MIME: Record<string, string[]> = {\n 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],\n 'video/*': ['.mp4', '.webm', '.mov'],\n 'application/pdf': ['.pdf'],\n};\n\nexport const isAcceptedMimeType = (type: string): boolean => {\n if (type === 'application/pdf') {\n return true;\n }\n if (type.startsWith('image/') || type.startsWith('video/')) {\n return true;\n }\n return false;\n};\n"],"mappings":";;;;;;AAIA,IAAa,gBAA0C;CACrD,WAAW;EAAC;EAAQ;EAAS;EAAQ;EAAQ;EAAS;CAAM;CAC5D,WAAW;EAAC;EAAQ;EAAS;CAAM;CACnC,mBAAmB,CAAC,MAAM;AAC5B;AAEA,IAAa,sBAAsB,SAA0B;CAC3D,IAAI,SAAS,mBACX,OAAO;CAET,IAAI,KAAK,WAAW,QAAQ,KAAK,KAAK,WAAW,QAAQ,GACvD,OAAO;CAET,OAAO;AACT"}
1
+ {"version":3,"file":"FileLimits.mjs","names":[],"sources":["../../src/types/FileLimits.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\n/** Stored in place of a type that is absent or would execute; the bytes are kept, only the label changes. */\nexport const FALLBACK_MIME_TYPE = 'application/octet-stream';\n\n/**\n * Media types a browser executes when it renders them.\n *\n * A stored file is handed back through a presigned, public or `blob:` URL on the app's origin, and\n * HTML served from there runs with that origin — making an upload a stored-XSS primitive. Such\n * files are still accepted, but labelled {@link FALLBACK_MIME_TYPE} so they download rather than\n * render. Matches the blob service's own `ACTIVE_CONTENT_TYPES`, minus SVG, which image previews\n * rely on.\n */\nconst ACTIVE_CONTENT_TYPES = new Set(['text/html', 'application/xhtml+xml', 'text/xml', 'application/xml']);\n\n/**\n * The media type to store for an upload. Every type is accepted — the file plugin keeps bytes it\n * cannot preview — so this never rejects; it only neutralizes types that are missing or executable.\n * Compared case-insensitively and without parameters: RFC 2045 defines type and subtype as\n * case-insensitive, and `text/html; charset=utf-8` is still HTML.\n */\nexport const toStoredMimeType = (type: string | undefined): string => {\n const declared = type?.trim() ?? '';\n const base = declared.split(';')[0].trim().toLowerCase();\n return base.length === 0 || ACTIVE_CONTENT_TYPES.has(base) ? FALLBACK_MIME_TYPE : declared;\n};\n"],"mappings":";;;;;;;AAKA,IAAa,qBAAqB;;;;;;;;;;AAWlC,IAAM,uCAAuB,IAAI,IAAI;CAAC;CAAa;CAAyB;CAAY;AAAiB,CAAC;;;;;;;AAQ1G,IAAa,oBAAoB,SAAqC;CACpE,MAAM,WAAW,MAAM,KAAK,KAAK;CACjC,MAAM,OAAO,SAAS,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,YAAY;CACvD,OAAO,KAAK,WAAW,KAAK,qBAAqB,IAAI,IAAI,IAAI,qBAAqB;AACpF"}
@@ -9,7 +9,11 @@ import * as Struct from "effect/Struct";
9
9
  //#region src/types/FileOperation.ts
10
10
  var FileOperation_exports = /* @__PURE__ */ __exportAll({
11
11
  Create: () => Create,
12
- Read: () => Read
12
+ CreateFromSource: () => CreateFromSource,
13
+ CreateFromUpload: () => CreateFromUpload,
14
+ FileSource: () => FileSource,
15
+ Read: () => Read,
16
+ ResolveDownload: () => ResolveDownload
13
17
  });
14
18
  var Create = Operation.make({
15
19
  meta: {
@@ -21,6 +25,71 @@ var Create = Operation.make({
21
25
  input: FileAction.CreateFileSchema.mapFields(Struct.assign({ db: Database.Database })),
22
26
  output: Schema.Struct({ object: Type.getSchema(File.File) })
23
27
  });
28
+ /**
29
+ * Where the bytes come from. Mirrors `ContentBlock.ImageSource` rather than inventing a shape — it
30
+ * is how this repo already models "bytes, or a pointer to bytes".
31
+ *
32
+ * Two arms because their costs differ sharply: `base64` rides in the tool-call arguments and so
33
+ * occupies the caller's context, while `http` is fetched by the host and never crosses it.
34
+ */
35
+ var FileSource = Schema.Union([Schema.Struct({
36
+ type: Schema.tag("base64"),
37
+ mediaType: Schema.String.annotate({ description: "MIME type of the encoded bytes, e.g. image/png." }),
38
+ data: Schema.String.annotate({ description: "Base64-encoded file contents." })
39
+ }), Schema.Struct({
40
+ type: Schema.tag("http"),
41
+ url: Schema.String.annotate({ description: "HTTPS URL the host downloads. Must not be a private address." })
42
+ })]);
43
+ /**
44
+ * Creates a file from serializable input, so an agent can reach it.
45
+ *
46
+ * Separate from {@link Create} rather than replacing it: `Create` takes a live browser `File`, which
47
+ * is right for the upload UI and cannot render as JSON Schema — an operation whose input cannot is
48
+ * dropped from the registry silently, and so is invisible to any tool caller.
49
+ *
50
+ * Takes the database as a service rather than an input field, which is both what makes the input
51
+ * serializable and what forces the caller to name a space.
52
+ */
53
+ var CreateFromSource = Operation.make({
54
+ meta: {
55
+ key: DXN.make("org.dxos.operation.file.createFromSource"),
56
+ name: "Create File From Source",
57
+ description: "Creates a file in the space from base64 content or an HTTPS URL, storing it via the configured backend.",
58
+ icon: "ph--file-plus--regular"
59
+ },
60
+ input: Schema.Struct({
61
+ source: FileSource,
62
+ name: Schema.optional(Schema.String.annotate({ description: "Filename to record on the file object." }))
63
+ }),
64
+ output: Schema.Struct({ object: Type.getSchema(File.File) }),
65
+ services: [Database.Service, Capability.Service]
66
+ });
67
+ /**
68
+ * Creates a file from bytes already uploaded to blob storage over a signed URL.
69
+ *
70
+ * The third arm alongside {@link CreateFromSource}'s two, and the one for a file that exists only
71
+ * on the caller's disk: `base64` would have a model emit the bytes token by token, and `http`
72
+ * requires them to already be served somewhere public. Here the caller uploads directly (see the
73
+ * MCP server's `createUpload` tool) and passes only the id, so a screen recording costs the same
74
+ * few tokens as a screenshot.
75
+ *
76
+ * No media type or size input: nothing the caller says about the bytes is trusted or needed, since
77
+ * the blob service measured them as they arrived.
78
+ */
79
+ var CreateFromUpload = Operation.make({
80
+ meta: {
81
+ key: DXN.make("org.dxos.operation.file.createFromUpload"),
82
+ name: "Create File From Upload",
83
+ description: "Creates a file in the space from a completed direct upload, given the uploadId returned by the createUpload tool. Use after the upload command has succeeded.",
84
+ icon: "ph--upload--regular"
85
+ },
86
+ input: Schema.Struct({
87
+ uploadId: Schema.String.annotate({ description: "The uploadId returned by createUpload, whose bytes have already been uploaded." }),
88
+ name: Schema.optional(Schema.String.annotate({ description: "Filename to record on the file object." }))
89
+ }),
90
+ output: Schema.Struct({ object: Type.getSchema(File.File) }),
91
+ services: [Database.Service, Capability.Service]
92
+ });
24
93
  var Read = Operation.make({
25
94
  meta: {
26
95
  key: DXN.make("org.dxos.operation.file.read"),
@@ -32,7 +101,31 @@ var Read = Operation.make({
32
101
  output: ContentBlock.ContentBlockResult,
33
102
  services: [Database.Service]
34
103
  });
104
+ /**
105
+ * Names a file's bytes to the host that will sign a download URL for them — the reverse of
106
+ * {@link CreateFromUpload}.
107
+ *
108
+ * Host-internal: the MCP hosts' `createDownload` tool invokes it and signs the URL, since the
109
+ * signing secret (EDGE) or the loopback listener (`dx mcp serve`) lives in the host, not in the
110
+ * operation runtime. No skill lists it, so it is never offered to a model directly.
111
+ */
112
+ var ResolveDownload = Operation.make({
113
+ meta: {
114
+ key: DXN.make("org.dxos.operation.file.resolveDownload"),
115
+ name: "Resolve File Download",
116
+ description: "Resolves a file to the id its host signs a download URL for.",
117
+ icon: "ph--file-arrow-down--regular"
118
+ },
119
+ input: Schema.Struct({ file: Ref.Ref(File.File).annotate({ description: "The file to download." }) }),
120
+ output: Schema.Struct({
121
+ downloadId: Schema.String.annotate({ description: "Opaque to the caller; names the bytes to the host that signs the download URL." }),
122
+ name: Schema.optional(Schema.String),
123
+ type: Schema.String,
124
+ size: Schema.Number
125
+ }),
126
+ services: [Database.Service]
127
+ });
35
128
  //#endregion
36
- export { Create, Read, FileOperation_exports as t };
129
+ export { Create, CreateFromSource, CreateFromUpload, FileSource, Read, ResolveDownload, FileOperation_exports as t };
37
130
 
38
131
  //# sourceMappingURL=FileOperation.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileOperation.mjs","names":[],"sources":["../../src/types/FileOperation.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\n// @import-as-namespace\n\nimport * as Schema from 'effect/Schema';\nimport * as Struct from 'effect/Struct';\n\nimport * as Capability from '@dxos/app-framework/Capability';\nimport * as Operation from '@dxos/compute/Operation';\nimport { Database, DXN, Ref, Type } from '@dxos/echo';\nimport { ContentBlock, File } from '@dxos/types';\n\nimport * as FileCapabilities from './FileCapabilities';\n\nexport const Create = Operation.make({\n meta: { key: DXN.make('org.dxos.operation.file.create'), name: 'Create File', icon: 'ph--file--regular' },\n services: [Capability.Service],\n input: FileCapabilities.FileAction.CreateFileSchema.mapFields(Struct.assign({ db: Database.Database })),\n output: Schema.Struct({\n object: Type.getSchema(File.File),\n }),\n});\n\nexport const Read = Operation.make({\n meta: {\n key: DXN.make('org.dxos.operation.file.read'),\n name: 'Read File',\n description:\n 'Reads the contents of a file and returns them as a File content block (data URL for inline files, original URL for external files).',\n icon: 'ph--file-arrow-down--regular',\n },\n input: Schema.Struct({\n file: Ref.Ref(File.File).annotate({\n description: 'The file to read.',\n }),\n }),\n output: ContentBlock.ContentBlockResult,\n services: [Database.Service],\n});\n"],"mappings":";;;;;;;;;;;;;AAgBA,IAAa,SAAS,UAAU,KAAK;CACnC,MAAM;EAAE,KAAK,IAAI,KAAK,gCAAgC;EAAG,MAAM;EAAe,MAAM;CAAoB;CACxG,UAAU,CAAC,WAAW,OAAO;CAC7B,OAAA,WAAmC,iBAAiB,UAAU,OAAO,OAAO,EAAE,IAAI,SAAS,SAAS,CAAC,CAAC;CACtG,QAAQ,OAAO,OAAO,EACpB,QAAQ,KAAK,UAAU,KAAK,IAAI,EAClC,CAAC;AACH,CAAC;AAED,IAAa,OAAO,UAAU,KAAK;CACjC,MAAM;EACJ,KAAK,IAAI,KAAK,8BAA8B;EAC5C,MAAM;EACN,aACE;EACF,MAAM;CACR;CACA,OAAO,OAAO,OAAO,EACnB,MAAM,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,SAAS,EAChC,aAAa,oBACf,CAAC,EACH,CAAC;CACD,QAAQ,aAAa;CACrB,UAAU,CAAC,SAAS,OAAO;AAC7B,CAAC"}
1
+ {"version":3,"file":"FileOperation.mjs","names":[],"sources":["../../src/types/FileOperation.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\n// @import-as-namespace\n\nimport * as Schema from 'effect/Schema';\nimport * as Struct from 'effect/Struct';\n\nimport * as Capability from '@dxos/app-framework/Capability';\nimport * as Operation from '@dxos/compute/Operation';\nimport { Database, DXN, Ref, Type } from '@dxos/echo';\nimport { ContentBlock, File } from '@dxos/types';\n\nimport * as FileCapabilities from './FileCapabilities.ts';\n\nexport const Create = Operation.make({\n meta: { key: DXN.make('org.dxos.operation.file.create'), name: 'Create File', icon: 'ph--file--regular' },\n services: [Capability.Service],\n input: FileCapabilities.FileAction.CreateFileSchema.mapFields(Struct.assign({ db: Database.Database })),\n output: Schema.Struct({\n object: Type.getSchema(File.File),\n }),\n});\n\n/**\n * Where the bytes come from. Mirrors `ContentBlock.ImageSource` rather than inventing a shape — it\n * is how this repo already models \"bytes, or a pointer to bytes\".\n *\n * Two arms because their costs differ sharply: `base64` rides in the tool-call arguments and so\n * occupies the caller's context, while `http` is fetched by the host and never crosses it.\n */\nexport const FileSource = Schema.Union([\n Schema.Struct({\n type: Schema.tag('base64'),\n mediaType: Schema.String.annotate({ description: 'MIME type of the encoded bytes, e.g. image/png.' }),\n data: Schema.String.annotate({ description: 'Base64-encoded file contents.' }),\n }),\n Schema.Struct({\n type: Schema.tag('http'),\n url: Schema.String.annotate({ description: 'HTTPS URL the host downloads. Must not be a private address.' }),\n }),\n]);\n\n/**\n * Creates a file from serializable input, so an agent can reach it.\n *\n * Separate from {@link Create} rather than replacing it: `Create` takes a live browser `File`, which\n * is right for the upload UI and cannot render as JSON Schema — an operation whose input cannot is\n * dropped from the registry silently, and so is invisible to any tool caller.\n *\n * Takes the database as a service rather than an input field, which is both what makes the input\n * serializable and what forces the caller to name a space.\n */\nexport const CreateFromSource = Operation.make({\n meta: {\n key: DXN.make('org.dxos.operation.file.createFromSource'),\n name: 'Create File From Source',\n description:\n 'Creates a file in the space from base64 content or an HTTPS URL, storing it via the configured backend.',\n icon: 'ph--file-plus--regular',\n },\n input: Schema.Struct({\n source: FileSource,\n name: Schema.optional(Schema.String.annotate({ description: 'Filename to record on the file object.' })),\n }),\n output: Schema.Struct({\n object: Type.getSchema(File.File),\n }),\n services: [Database.Service, Capability.Service],\n});\n\n/**\n * Creates a file from bytes already uploaded to blob storage over a signed URL.\n *\n * The third arm alongside {@link CreateFromSource}'s two, and the one for a file that exists only\n * on the caller's disk: `base64` would have a model emit the bytes token by token, and `http`\n * requires them to already be served somewhere public. Here the caller uploads directly (see the\n * MCP server's `createUpload` tool) and passes only the id, so a screen recording costs the same\n * few tokens as a screenshot.\n *\n * No media type or size input: nothing the caller says about the bytes is trusted or needed, since\n * the blob service measured them as they arrived.\n */\nexport const CreateFromUpload = Operation.make({\n meta: {\n key: DXN.make('org.dxos.operation.file.createFromUpload'),\n name: 'Create File From Upload',\n description:\n 'Creates a file in the space from a completed direct upload, given the uploadId returned by ' +\n 'the createUpload tool. Use after the upload command has succeeded.',\n icon: 'ph--upload--regular',\n },\n input: Schema.Struct({\n uploadId: Schema.String.annotate({\n description: 'The uploadId returned by createUpload, whose bytes have already been uploaded.',\n }),\n name: Schema.optional(Schema.String.annotate({ description: 'Filename to record on the file object.' })),\n }),\n output: Schema.Struct({\n object: Type.getSchema(File.File),\n }),\n services: [Database.Service, Capability.Service],\n});\n\nexport const Read = Operation.make({\n meta: {\n key: DXN.make('org.dxos.operation.file.read'),\n name: 'Read File',\n description:\n 'Reads the contents of a file and returns them as a File content block (data URL for inline files, original URL for external files).',\n icon: 'ph--file-arrow-down--regular',\n },\n input: Schema.Struct({\n file: Ref.Ref(File.File).annotate({\n description: 'The file to read.',\n }),\n }),\n output: ContentBlock.ContentBlockResult,\n services: [Database.Service],\n});\n\n/**\n * Names a file's bytes to the host that will sign a download URL for them — the reverse of\n * {@link CreateFromUpload}.\n *\n * Host-internal: the MCP hosts' `createDownload` tool invokes it and signs the URL, since the\n * signing secret (EDGE) or the loopback listener (`dx mcp serve`) lives in the host, not in the\n * operation runtime. No skill lists it, so it is never offered to a model directly.\n */\nexport const ResolveDownload = Operation.make({\n meta: {\n key: DXN.make('org.dxos.operation.file.resolveDownload'),\n name: 'Resolve File Download',\n description: 'Resolves a file to the id its host signs a download URL for.',\n icon: 'ph--file-arrow-down--regular',\n },\n input: Schema.Struct({\n file: Ref.Ref(File.File).annotate({ description: 'The file to download.' }),\n }),\n output: Schema.Struct({\n downloadId: Schema.String.annotate({\n description: 'Opaque to the caller; names the bytes to the host that signs the download URL.',\n }),\n name: Schema.optional(Schema.String),\n type: Schema.String,\n size: Schema.Number,\n }),\n services: [Database.Service],\n});\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,IAAa,SAAS,UAAU,KAAK;CACnC,MAAM;EAAE,KAAK,IAAI,KAAK,gCAAgC;EAAG,MAAM;EAAe,MAAM;CAAoB;CACxG,UAAU,CAAC,WAAW,OAAO;CAC7B,OAAA,WAAmC,iBAAiB,UAAU,OAAO,OAAO,EAAE,IAAI,SAAS,SAAS,CAAC,CAAC;CACtG,QAAQ,OAAO,OAAO,EACpB,QAAQ,KAAK,UAAU,KAAK,IAAI,EAClC,CAAC;AACH,CAAC;;;;;;;;AASD,IAAa,aAAa,OAAO,MAAM,CACrC,OAAO,OAAO;CACZ,MAAM,OAAO,IAAI,QAAQ;CACzB,WAAW,OAAO,OAAO,SAAS,EAAE,aAAa,kDAAkD,CAAC;CACpG,MAAM,OAAO,OAAO,SAAS,EAAE,aAAa,gCAAgC,CAAC;AAC/E,CAAC,GACD,OAAO,OAAO;CACZ,MAAM,OAAO,IAAI,MAAM;CACvB,KAAK,OAAO,OAAO,SAAS,EAAE,aAAa,+DAA+D,CAAC;AAC7G,CAAC,CACH,CAAC;;;;;;;;;;;AAYD,IAAa,mBAAmB,UAAU,KAAK;CAC7C,MAAM;EACJ,KAAK,IAAI,KAAK,0CAA0C;EACxD,MAAM;EACN,aACE;EACF,MAAM;CACR;CACA,OAAO,OAAO,OAAO;EACnB,QAAQ;EACR,MAAM,OAAO,SAAS,OAAO,OAAO,SAAS,EAAE,aAAa,yCAAyC,CAAC,CAAC;CACzG,CAAC;CACD,QAAQ,OAAO,OAAO,EACpB,QAAQ,KAAK,UAAU,KAAK,IAAI,EAClC,CAAC;CACD,UAAU,CAAC,SAAS,SAAS,WAAW,OAAO;AACjD,CAAC;;;;;;;;;;;;;AAcD,IAAa,mBAAmB,UAAU,KAAK;CAC7C,MAAM;EACJ,KAAK,IAAI,KAAK,0CAA0C;EACxD,MAAM;EACN,aACE;EAEF,MAAM;CACR;CACA,OAAO,OAAO,OAAO;EACnB,UAAU,OAAO,OAAO,SAAS,EAC/B,aAAa,iFACf,CAAC;EACD,MAAM,OAAO,SAAS,OAAO,OAAO,SAAS,EAAE,aAAa,yCAAyC,CAAC,CAAC;CACzG,CAAC;CACD,QAAQ,OAAO,OAAO,EACpB,QAAQ,KAAK,UAAU,KAAK,IAAI,EAClC,CAAC;CACD,UAAU,CAAC,SAAS,SAAS,WAAW,OAAO;AACjD,CAAC;AAED,IAAa,OAAO,UAAU,KAAK;CACjC,MAAM;EACJ,KAAK,IAAI,KAAK,8BAA8B;EAC5C,MAAM;EACN,aACE;EACF,MAAM;CACR;CACA,OAAO,OAAO,OAAO,EACnB,MAAM,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,SAAS,EAChC,aAAa,oBACf,CAAC,EACH,CAAC;CACD,QAAQ,aAAa;CACrB,UAAU,CAAC,SAAS,OAAO;AAC7B,CAAC;;;;;;;;;AAUD,IAAa,kBAAkB,UAAU,KAAK;CAC5C,MAAM;EACJ,KAAK,IAAI,KAAK,yCAAyC;EACvD,MAAM;EACN,aAAa;EACb,MAAM;CACR;CACA,OAAO,OAAO,OAAO,EACnB,MAAM,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,SAAS,EAAE,aAAa,wBAAwB,CAAC,EAC5E,CAAC;CACD,QAAQ,OAAO,OAAO;EACpB,YAAY,OAAO,OAAO,SAAS,EACjC,aAAa,iFACf,CAAC;EACD,MAAM,OAAO,SAAS,OAAO,MAAM;EACnC,MAAM,OAAO;EACb,MAAM,OAAO;CACf,CAAC;CACD,UAAU,CAAC,SAAS,OAAO;AAC7B,CAAC"}
@@ -0,0 +1,65 @@
1
+ import { t as __exportAll } from "./chunk-rolldown-runtime.mjs";
2
+ import { FileOperation } from "#types";
3
+ import * as Operation from "@dxos/compute/Operation";
4
+ import * as Skill from "@dxos/compute/Skill";
5
+ import * as Template from "@dxos/compute/Template";
6
+ import { trim } from "@dxos/util";
7
+ //#region src/skills/FileSkill.ts
8
+ var FileSkill_exports = /* @__PURE__ */ __exportAll({
9
+ key: () => key,
10
+ make: () => make,
11
+ operations: () => operations
12
+ });
13
+ var key = "org.dxos.skill.file";
14
+ var operations = [
15
+ FileOperation.Read,
16
+ FileOperation.CreateFromSource,
17
+ FileOperation.CreateFromUpload
18
+ ];
19
+ var make = () => Skill.make({
20
+ key,
21
+ name: "File",
22
+ description: "Read the contents of files (images, videos, PDFs), and add new files to a space.",
23
+ tools: Skill.toolDefinitions({ operations }),
24
+ instructions: Template.make({ source: trim`
25
+ {{! File }}
26
+
27
+ You can read the contents of files.
28
+ Calling the ${Operation.toolName(FileOperation.Read)} tool returns the file contents as a File content block (a data URL for
29
+ inline files, the original URL for external files). The model receives the file natively
30
+ and can describe, transcribe, or otherwise reason over its contents.
31
+
32
+ You can also add a file to the space with the ${Operation.toolName(FileOperation.CreateFromSource)} tool, from one of two sources:
33
+ - \`{ "type": "http", "url": "https://..." }\` when the content is already reachable at a URL.
34
+ Prefer this whenever it applies: the bytes are fetched by the host and never pass through
35
+ the conversation. The URL must be https and must not point at a private address.
36
+ - \`{ "type": "base64", "mediaType": "image/png", "data": "..." }\` when you hold the bytes
37
+ yourself, such as an image you generated. Keep this under 1MB — the encoded payload counts
38
+ against the conversation, so a large file is slow and expensive before it is anything else.
39
+
40
+ For a file that is already on your own disk -- a screenshot you just took, a screen
41
+ recording, a log bundle -- use neither arm above. Upload it directly:
42
+ 1. Call the host's \`createUpload\` tool (with the file's \`name\`, and its \`size\` if known).
43
+ 2. Run the \`command\` it returns in a shell; it transfers the bytes with \`curl\`.
44
+ 3. Call ${Operation.toolName(FileOperation.CreateFromUpload)} with the returned \`uploadId\` to create the file object.
45
+ The bytes go from disk to storage without passing through this conversation, so the cost is
46
+ the same whether the file is 40KB or 90MB. Base64 is never the right choice for a video.
47
+
48
+ To save a file from the space to your own disk, download it directly:
49
+ 1. Call the host's \`createDownload\` tool with the file's reference.
50
+ 2. Run the \`command\` it returns in a shell; it saves the file in the working directory.
51
+ Use ${Operation.toolName(FileOperation.Read)} only when you need to look at a file yourself; never read one
52
+ just to write its bytes out. \`createUpload\` and \`createDownload\` are tools of the MCP host, not
53
+ operations; a host without them (the in-app chat) has no direct transfer.
54
+
55
+ Any file type is accepted; HTML and XML are stored as \`application/octet-stream\` so they
56
+ download rather than render. Always pass the true media type of the content; do not infer it
57
+ from a file extension.
58
+ ` }),
59
+ agentCanEnable: true,
60
+ mcpPrompt: true
61
+ });
62
+ //#endregion
63
+ export { key, make, operations, FileSkill_exports as t };
64
+
65
+ //# sourceMappingURL=FileSkill.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileSkill.mjs","names":[],"sources":["../../src/skills/FileSkill.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Operation from '@dxos/compute/Operation';\nimport * as Skill from '@dxos/compute/Skill';\nimport * as Template from '@dxos/compute/Template';\nimport { trim } from '@dxos/util';\n\nimport { FileOperation } from '#types';\n\nexport const key = 'org.dxos.skill.file';\n\n// Carried on the definition so a host that serves it over MCP can map its tool ids back to\n// operations without the plugin being activated.\nexport const operations: readonly Operation.Definition.Any[] = [\n FileOperation.Read,\n FileOperation.CreateFromSource,\n FileOperation.CreateFromUpload,\n];\n\nexport const make = () =>\n Skill.make({\n key: key,\n name: 'File',\n description: 'Read the contents of files (images, videos, PDFs), and add new files to a space.',\n tools: Skill.toolDefinitions({\n operations,\n }),\n instructions: Template.make({\n source: trim`\n {{! File }}\n\n You can read the contents of files.\n Calling the ${Operation.toolName(FileOperation.Read)} tool returns the file contents as a File content block (a data URL for\n inline files, the original URL for external files). The model receives the file natively\n and can describe, transcribe, or otherwise reason over its contents.\n\n You can also add a file to the space with the ${Operation.toolName(FileOperation.CreateFromSource)} tool, from one of two sources:\n - \\`{ \"type\": \"http\", \"url\": \"https://...\" }\\` when the content is already reachable at a URL.\n Prefer this whenever it applies: the bytes are fetched by the host and never pass through\n the conversation. The URL must be https and must not point at a private address.\n - \\`{ \"type\": \"base64\", \"mediaType\": \"image/png\", \"data\": \"...\" }\\` when you hold the bytes\n yourself, such as an image you generated. Keep this under 1MB — the encoded payload counts\n against the conversation, so a large file is slow and expensive before it is anything else.\n\n For a file that is already on your own disk -- a screenshot you just took, a screen\n recording, a log bundle -- use neither arm above. Upload it directly:\n 1. Call the host's \\`createUpload\\` tool (with the file's \\`name\\`, and its \\`size\\` if known).\n 2. Run the \\`command\\` it returns in a shell; it transfers the bytes with \\`curl\\`.\n 3. Call ${Operation.toolName(FileOperation.CreateFromUpload)} with the returned \\`uploadId\\` to create the file object.\n The bytes go from disk to storage without passing through this conversation, so the cost is\n the same whether the file is 40KB or 90MB. Base64 is never the right choice for a video.\n\n To save a file from the space to your own disk, download it directly:\n 1. Call the host's \\`createDownload\\` tool with the file's reference.\n 2. Run the \\`command\\` it returns in a shell; it saves the file in the working directory.\n Use ${Operation.toolName(FileOperation.Read)} only when you need to look at a file yourself; never read one\n just to write its bytes out. \\`createUpload\\` and \\`createDownload\\` are tools of the MCP host, not\n operations; a host without them (the in-app chat) has no direct transfer.\n\n Any file type is accepted; HTML and XML are stored as \\`application/octet-stream\\` so they\n download rather than render. Always pass the true media type of the content; do not infer it\n from a file extension.\n `,\n }),\n agentCanEnable: true,\n // Projected by the MCP hosts (`dx mcp serve`, EDGE's worker), so `file.createFromUpload` — the\n // other half of their `createUpload` tool — is discoverable and invocable there.\n mcpPrompt: true,\n });\n"],"mappings":";;;;;;;;;;;;AAWA,IAAa,MAAM;AAInB,IAAa,aAAkD;CAC7D,cAAc;CACd,cAAc;CACd,cAAc;AAChB;AAEA,IAAa,aACX,MAAM,KAAK;CACJ;CACL,MAAM;CACN,aAAa;CACb,OAAO,MAAM,gBAAgB,EAC3B,WACF,CAAC;CACD,cAAc,SAAS,KAAK,EAC1B,QAAQ,IAAI;;;;sBAII,UAAU,SAAS,cAAc,IAAI,EAAE;;;;wDAIL,UAAU,SAAS,cAAc,gBAAgB,EAAE;;;;;;;;;;;;kBAYzF,UAAU,SAAS,cAAc,gBAAgB,EAAE;;;;;;;cAOvD,UAAU,SAAS,cAAc,IAAI,EAAE;;;;;;;QAQjD,CAAC;CACD,gBAAgB;CAGhB,WAAW;AACb,CAAC"}