@fias/create-fias-plugin 1.11.1 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fias/create-fias-plugin",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.19.0 -->
1
+ <!-- fias-sdk-guide-version: 2.20.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -706,7 +706,7 @@ const { url, expiresAt, contentType } = await vault.getDownloadUrl(documentId);
706
706
  // List documents this arche owns
707
707
  const { documents, nextCursor } = await vault.list({ search: 'invoice', limit: 50 });
708
708
 
709
- // Write a small text/JSON document (UTF-8, ≤ 1 MB)
709
+ // Write a small text/JSON document (UTF-8, ≤ 200 KB — use upload() for anything larger)
710
710
  const { documentId } = await vault.write({
711
711
  name: 'settings.json',
712
712
  content: JSON.stringify(settings),
@@ -735,11 +735,23 @@ const { bytes: saved } = await vault.readBytes(documentId);
735
735
  // Overwrite a document THIS arche created IN PLACE — same documentId, bytes
736
736
  // replaced, no version history. The autosave path (a spreadsheet saving as
737
737
  // the user works); to publish a distinct new version use write({
738
- // replacesDocumentId }) instead. UTF-8 text, ≤ 200 KB (OWN_DOCUMENT_SAVE_TOO_LARGE
739
- // names the cap). Debounce: a few seconds of idle plus blur/close — never per
740
- // keystroke — and surface RATE_LIMIT verbatim rather than retrying. A save
738
+ // replacesDocumentId }) instead. ONE method for any content up to 15 MB
739
+ // (OWN_DOCUMENT_SAVE_TOO_LARGE names the cap — the same one readBytes serves,
740
+ // so anything you save you can reopen): a string is saved as UTF-8 text (the
741
+ // document must be a text type); a Uint8Array / ArrayBuffer / Blob is saved
742
+ // as-is, for any type. Transport is automatic — never split, chunk or re-upload
743
+ // to work around size. Debounce: a few seconds of idle plus blur/close — never
744
+ // per keystroke — and surface RATE_LIMIT verbatim rather than retrying. A save
741
745
  // drops the document's search index and does not re-run extraction.
742
746
  await vault.saveContent({ documentId, content: JSON.stringify(sheet) });
747
+ await vault.saveContent({ documentId, content: pdfBytes }); // a PDF editor saving its PDF
748
+
749
+ // Don't let two tabs clobber each other: pass the `revision` you last saw
750
+ // (list() items, read(), readBytes().document and every saveContent result
751
+ // carry one — treat it as opaque). A stale one rejects with VERSION_CONFLICT
752
+ // and the document is untouched: re-read, merge or ask, retry with the new one.
753
+ let { revision } = (await vault.readBytes(documentId)).document;
754
+ ({ revision } = await vault.saveContent({ documentId, content, expectedRevision: revision }));
743
755
 
744
756
  // Semantic search across documents this arche owns
745
757
  // (burns user credits per the AI Markup Invariant — call sparingly)
@@ -760,17 +772,26 @@ const { referenceId } = await vault.attach(documentId, {
760
772
  await vault.detach(documentId, referenceId);
761
773
  ```
762
774
 
763
- **Saving into the user's OWN files** — `upload(bytes, { ..., userVisible: true })`. By default everything your app writes here is _arche-private_: yours to read and write, invisible in the user's Fias files, unusable by other arches. That is the right home for your app's own state (caches, session snapshots, working files). It is the wrong home for the artifact the user made and asked you to keep — an edited image, an exported document — because they will go looking for it and it will not be there.
775
+ **Saving into the user's Documents** — `upload(bytes, { ..., destination: 'documents' })` (`userVisible: true` is the same thing). By default everything your app writes here is _arche-private_: yours to read and write, invisible in the user's Fias files, unusable by other arches. That is the right home for your app's own state (caches, session snapshots, working files). It is the wrong home for the artifact the user made and asked you to keep — an edited image, an exported document — because they will go looking for it and it will not be there.
764
776
 
765
777
  ```tsx
766
778
  const saved = await vault.upload(bytes, {
767
- name: 'sunset.png',
779
+ name: 'sunset.png', // a suggestion — the user can change it
768
780
  mimeType: 'image/png',
769
- userVisible: true,
781
+ destination: 'documents',
770
782
  });
783
+ // saved.documentId, saved.name (what the user kept), saved.access ('none' | 'read' | 'write')
771
784
  ```
772
785
 
773
- Requires the `vault:user-documents:write` permission, and the HOST asks the user to confirm each save by name — you cannot script it, batch it, or pre-approve it. A decline rejects with `SAVE_DECLINED`; that is a normal outcome, not an error to retry. The file lands in `/My-Fias/<your arche's folder>/`, which the platform assigns — you cannot choose it, and `folderPath` is rejected alongside `userVisible`. Your arche must be registered for a user-visible folder platform-side (`ARCHE_NOT_REGISTERED_FOR_USER_VAULT` if not). Passing `replacesDocumentId` saves a new version in place; a version keeps its predecessor's visibility, so you cannot flip a user's visible file to private or vice versa (`VISIBILITY_MISMATCH`). Not available in preview or the dev harness — test it in the published plugin.
786
+ The host opens its **Save dialog**: the user picks a folder in their Documents, can rename the file, and settles a name clash (keep both, or replace a file of the same type). You cannot script it, batch it, or pre-approve it, and you never learn the folder. Requires `vault:user-documents:write`. Cancelling rejects with `SAVE_DECLINED`; that is a normal outcome, not an error to retry. `folderPath`, `replacesDocumentId`, `tags` and `sensitivity` are rejected alongside it (the user decides those). Not available in preview or the dev harness — test it in the published plugin.
787
+
788
+ **Afterwards the file is the user's.** `useVaultDocuments()` no longer lists it. What you keep is reported as `access`: with `vault:user-documents:read` you can open it again (`'read'`) through `useVaultUserDocuments()`, and with `vault:user-documents:edit` the dialog offers the user "keep editing" (`'write'`) so you can keep saving it with `useVaultUserDocuments().saveContent`. The user can change either in Arche Access.
789
+
790
+ **Arche Saves, automatically** — `upload(bytes, { ..., destination: 'arche-saves' })` saves into the user's `Arche Saves/<your label>/` with no dialog. Only for an arche a Fias admin has designated (that issues its label, which is yours for good) and that declares `vault:arche-saves:write` (human-reviewed). Otherwise it rejects with `ARCHE_NOT_DESIGNATED` or `PERMISSION_DENIED`. The files are the user's to keep, move or delete; you can keep reading them while they stay in Arche Saves.
791
+
792
+ **`saveCopy(documentId, { suggestedName? })`** — hand the user a copy of one of your own documents through the same dialog, without re-sending the bytes (needs `vault:documents:read` too). Your working file is unchanged; delete it afterwards if you no longer need it.
793
+
794
+ **A document's visibility never changes.** Your private documents stay private, and a file in the user's files stays theirs — there is no call that moves one into the other.
774
795
 
775
796
  **Sensitivity tiers** (`'standard'` | `'confidential'` | `'proprietary'`) gate downstream read access per the Vault's standard matrix. Defaults to `'standard'`.
776
797
 
@@ -809,11 +830,126 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
809
830
  - Grants are **per-document** and **standing**: the plugin can re-read granted documents in later sessions until the user removes access in **Vault → App Access**.
810
831
  - After revocation, `get`/`getDownloadUrl` reject with `DOCUMENT_NOT_FOUND` — handle that path gracefully (drop the document from your UI; do not retry in a loop).
811
832
  - `proprietary`-sensitivity documents are never grantable (they don't even appear in the picker).
833
+ - **Say what you can open:** `pick({ accept: ['application/pdf', 'image/*', '.docx'] })` takes MIME types, MIME families or extensions, up to 20. The host's Open dialog offers the matching files; the user can still reveal the rest, shown disabled. A malformed list rejects with `INVALID_PARAMS`. Omitted, every type is offered.
812
834
  - Every read is audited by the platform; a new document _version_ is a new documentId, so a re-pick is needed after the user replaces a document.
813
835
  - `getDownloadUrl` is for DISPLAY (`<img src>`); your iframe cannot fetch the URL (CSP). To process binary content (parse a PDF, transform an image), use `getBytes(id)` — size-capped at 15 MB (`CONSENTED_DOCUMENT_TOO_LARGE` names the cap; ask the user to open the file manually instead). `getBytes` has a tighter per-minute budget than the rest of the family because it can move megabytes: read sequentially, and surface a rate-limit error rather than retrying in a loop.
814
836
  - Revocation behaves differently per method: `getBytes` and `get` re-check the grant on every call, so they start failing immediately. A URL already handed out by `getDownloadUrl` keeps working until it expires (1 hour) — that is the documented contract, not a bug.
837
+ - **Workspaces.** The picker offers the files of the workspace the user is acting in — the workspace switcher in Fias: their Personal vault, or an org workspace they belong to — and `list()` returns the grants made in that workspace, so switching workspace changes what `list()` shows. A document you already hold a grant on stays readable by id (`get`, `getBytes`, `getDownloadUrl`, `saveContent`) whichever workspace the user is in: a grant belongs to the workspace the document lives in. A plugin never names a workspace itself. A member's role caps what they can share and read: a viewer can only share standard-sensitivity documents, and a document above a member's tier reads as `DOCUMENT_NOT_FOUND` even if another member granted it.
815
838
  - **Testable in the dev harness (mock mode).** `pick()` grants a small fixture set — a text file, a real PDF, and one deliberately over the 15 MB cap — and `list` / `get` / `getDownloadUrl` / `getBytes` all resolve against it, including the refusals (`BINARY_DOCUMENT` on a text read of a PDF, `CONSENTED_DOCUMENT_TOO_LARGE`, `DOCUMENT_NOT_FOUND`). Run the harness with `FIAS_HARNESS_VAULT_PICK=canceled` to exercise the cancel branch. Not available in the builder preview — `pick()` resolves `{ canceled: true }` there.
816
839
 
840
+ **Editing the user's files** — permission `vault:user-documents:edit`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). `:edit` does not include `:read`: the grants you save under are created and listed through the read surface (`pick`, `list`, `get`), so a manifest with `:edit` alone can ask for nothing and open nothing.
841
+
842
+ ```tsx
843
+ // Ask for EDIT access up front…
844
+ const picked = await userDocs.pick({ maxDocuments: 1, access: 'write' });
845
+ // …or later, for a document you can already read (e.g. one that arrived by
846
+ // handoff). The host skips the picker and asks about just that file.
847
+ const asked = await userDocs.requestEditAccess(documentId);
848
+ // CHECK it: a resolved promise is not "yes". If you held no grant on that
849
+ // document the host shows the ordinary picker instead, so what comes back may
850
+ // be a different file, or view-only.
851
+ const granted =
852
+ 'documents' in asked &&
853
+ asked.documents[0]?.documentId === documentId &&
854
+ asked.documents[0]?.access === 'write';
855
+
856
+ // Save in place: same documentId. A string (text types) or bytes, up to 15 MB.
857
+ const { document } = await userDocs.get(documentId);
858
+ // `revision` is optional in the type — a host that predates edit access does
859
+ // not send it, and without it there is nothing to save against.
860
+ if (!document.revision) throw new Error('This host cannot save to the user’s files.');
861
+ const saved = await userDocs.saveContent({
862
+ documentId,
863
+ content: bytes, // string | Uint8Array | ArrayBuffer | Blob
864
+ expectedRevision: document.revision, // REQUIRED — it is the user's file
865
+ });
866
+ // keep saved.revision for the next save
867
+ ```
868
+
869
+ - `document.access` is `'read'` or `'write'`. Open a `'read'` document read-only; a save to one rejects with `EDIT_ACCESS_REQUIRED`. The user can lower access to view-only or remove it at any time (Vault → App Access) — handle `EDIT_ACCESS_REQUIRED` and `DOCUMENT_NOT_FOUND` on any save, and stop autosaving when you see one.
870
+ - `expectedRevision` is required (`EXPECTED_REVISION_REQUIRED`). `VERSION_CONFLICT` means the file changed elsewhere — re-read, merge, retry; never loop blindly.
871
+ - The document's type cannot change (`MIME_CHANGE_NOT_ALLOWED`), and a `string` can only be saved onto a text document (or one with no recorded type, which stays untyped). Rename, tags and delete stay the user's.
872
+ - The platform keeps the user's earlier versions (at most one per 10 minutes per document). They count against the user's storage; `STORAGE_QUOTA_EXCEEDED` fails the save — tell the user, do not retry.
873
+ - Saves are rate-limited to 12 a minute per user, text and bytes together. Debounce autosave to at most one save every 10 seconds (plus a final save on blur or close) — never per keystroke. A refusal rejects with `err.code === 'RATE_LIMIT'` whether the host or the server made it; surface it rather than retrying in a loop.
874
+ - Content past 15 MB rejects locally with `OWN_DOCUMENT_SAVE_TOO_LARGE`, before anything is sent.
875
+ - A save does not re-index the file for search — the user does that from the file's page.
876
+ - Dev harness: **mock mode** implements all of this against the fixture documents (a `pick()` without `access: 'write'` leaves them read-only, so the refusal is testable). Live mode refuses — only the real user can grant edit access.
877
+
878
+ ### Sending a document for signature — Document Sign handoff
879
+
880
+ **Permission:** `navigation:open_arche`.
881
+
882
+ Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
883
+ They place the signature fields and press Send there — a handoff never sends
884
+ anything, and there is no way for your app to send on their behalf.
885
+
886
+ ```tsx
887
+ import { useFiasNavigation } from '@fias/arche-sdk';
888
+
889
+ const { openArche } = useFiasNavigation();
890
+
891
+ openArche('arche_document_sign', {
892
+ payload: {
893
+ kind: 'sign_document',
894
+ // One uuid per user intent, NOT per call. The platform keys idempotency
895
+ // on it: re-presenting one resolves to the same draft instead of
896
+ // creating a duplicate, and reusing a stale one reopens the old draft.
897
+ requestId: crypto.randomUUID(),
898
+ documentId, // a user-vault PDF
899
+ title: 'MSA — Acme Corp',
900
+ recipients: [{ email: 'alice@acme.com', name: 'Alice', role: 'signer' }],
901
+ correlationId: approvalRecordId, // opaque; echoed back, never interpreted
902
+ // (fixed at creation — see below)
903
+ },
904
+ });
905
+ ```
906
+
907
+ `correlationId` is fixed when the envelope is first created. Re-presenting a
908
+ `requestId` returns the SAME draft, and the result echoes the correlationId
909
+ from that FIRST call — a different one sent on the repeat is ignored, not
910
+ refused, for the same reason recipients are not re-seeded: a repeat must not
911
+ overwrite what the first call recorded. `documentId` is part of the request's
912
+ identity and IS refused when it differs (409); `correlationId` is metadata
913
+ riding along. If you want fresh metadata, mint a fresh `requestId`.
914
+
915
+ When the user leaves Document Sign they can come back to your app, and you
916
+ receive a `sign_document_result`: `{ requestId, outcome, envelopeId? }`, where
917
+ `outcome` is `sent`, `saved_draft` or `cancelled`. An `envelopeId` alone does
918
+ NOT mean anything was sent — an envelope id exists from the moment a draft is
919
+ created, which is why the outcome is there.
920
+
921
+ **Treat the result as a pointer, never as a record.** It tells you which
922
+ envelope to look at; it is not evidence of what happened to it. Match it to an
923
+ outstanding request by `requestId`, consume it once, and never re-point an
924
+ existing association because a `correlationId` matched.
925
+
926
+ **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
927
+ be written down before you call `openArche`.** The host navigates away and
928
+ tears your iframe down, so React state, refs, module variables — everything
929
+ in memory — are gone when the user comes back. Your app remounts from
930
+ nothing.
931
+
932
+ That is one rule, and it is easy to under-apply. It is not only the
933
+ outstanding `requestId`: it is the document the user selected, the record
934
+ they were working on, the form they half-filled, the step they were on.
935
+ Anything you would be annoyed to lose. Write it to `useFiasStorage()` or your
936
+ Data Store on the way out and restore it on the way in. Browser storage is
937
+ not an option — a plugin iframe is opaque-origin, so `localStorage` and
938
+ `sessionStorage` throw.
939
+
940
+ The `requestId` is the one that fails loudest: keep it in memory and every
941
+ reply comes back unmatchable, so you cannot tell a genuine result from a
942
+ forged one — which is the whole reason to match. The rest fail quietly, as a
943
+ user who returns to an app that has forgotten what they were doing.
944
+
945
+ Note the ordering this implies, and do not treat it as an edge case: the
946
+ result **normally arrives before your restore read resolves** — the handoff
947
+ is delivered as soon as your SDK reports ready, which is before your first
948
+ storage round trip comes back. Measured at about a second's head start in
949
+ local dev. Judging on arrival therefore rejects every genuine reply, not an
950
+ occasional one. Hold the result until you know what was outstanding, then
951
+ evaluate it.
952
+
817
953
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
818
954
 
819
955
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -886,7 +1022,7 @@ Rules that matter:
886
1022
  - **UI state only.** Actions manipulate what the user could do in your interface; anything touching server state stays in your own code paths.
887
1023
  - `requiresConfirmation: true` makes the assistant ask the user before dispatching. It escalates only — nothing can skip a confirmation the platform requires.
888
1024
  - Manifest actions win id collisions with runtime ones; the merged list is capped at 50.
889
- - The assistant is not present in the builder preview or the dev harness — declarations are accepted but nothing dispatches until the published plugin runs with the Fias AI window.
1025
+ - **Test it in the dev harness:** the **✦ Fias AI** toolbar button opens the real assistant over your plugin (Live mode; turns are real and use your credits). It describes your plugin exactly as the platform would: manifest actions always, runtime (`useFiasAIActions`) actions and `state` only for a trusted arche, and the window names any runtime action it is withholding. In the harness it can use your plugin's actions plus read-only Fias tools, never writes or sends. The builder preview has no assistant.
890
1026
  - **`openAssistant()`** (the hook's return) asks the HOST to open the Fias AI panel — use it for a contextual affordance (an empty-state "Ask Fias AI to get started" button). The host already shows its own "Works with Fias AI" chip, debounces your requests, and ignores them for a while after the user closes the panel — never call it in a loop or on mount. There is deliberately no way to pass text along: the assistant's input belongs to the user.
891
1027
 
892
1028
  ### `useArcheAssets()` — Contributor-published asset library
@@ -911,6 +1047,32 @@ const fresh = await getUrl(assetId);
911
1047
 
912
1048
  Cache the `assetId`; refresh `signedUrl` via `getUrl()` rather than persisting URLs across sessions.
913
1049
 
1050
+ ### `useFiasContent()` — This plugin's content pack
1051
+
1052
+ **Permission:** `entities:client_invoke`
1053
+ **Returns:** `FiasContentApi`
1054
+
1055
+ Ship large or numerous files — lessons, articles, data, images, audio, fonts — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1056
+
1057
+ ```tsx
1058
+ import { useFiasContent, FiasContentError } from '@fias/arche-sdk';
1059
+
1060
+ const content = useFiasContent();
1061
+
1062
+ const lessons = await content.list('lessons/'); // [{ path, sizeBytes, mimeType }]
1063
+ const markdown = await content.getText('lessons/01-intro.md');
1064
+ const course = await content.getJson<Course>('course.json');
1065
+ const batch = await content.getMany(['a.md', 'b.md'], 'text'); // up to 100 files per call
1066
+ const cover = await content.getObjectUrl('img/cover.webp'); // blob: URL for <img src>
1067
+ const { url } = await content.getUrl('audio/theme.ogg'); // signed URL, for long streaming audio
1068
+ ```
1069
+
1070
+ - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB). A pack holds at most 10,000 files, 2,000 images, 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1071
+ - Prefer `getObjectUrl` for images and fonts: repeat views come from the host cache. `getUrl` is for media that must stream or seek; it bypasses the cache, and a URL held past its `expiresAt` must be requested again.
1072
+ - Failures throw `FiasContentError` with a `code`: `CONTENT_NOT_FOUND`, `CONTENT_NOT_TEXT`, `CONTENT_TOO_LARGE`, `CONTENT_UNAVAILABLE`, `CONTENT_THROTTLED`, `CONTENT_UNAVAILABLE_IN_PREVIEW`, `PERMISSION_DENIED`, `RATE_LIMIT`. `getMany` returns the error for a failed path instead of throwing.
1073
+ - There is no published pack in the Arche Builder preview, so calls there fail with `CONTENT_UNAVAILABLE_IN_PREVIEW`; the dev harness serves your local `content/` directory.
1074
+ - Every publish reviews all of the content again. Reads are free for your users; the pack's storage and bandwidth are billed to you, the contributor.
1075
+
914
1076
  ### `useCommunityAssets()` — User-published images, visible to this arche's users
915
1077
 
916
1078
  **Permissions:** `assets:community:read` (browse/view) · `assets:community:publish` (publish/unpublish/listMine)
@@ -1020,18 +1182,29 @@ openArche('arc_0123456789abcdef0123456789abcdef', { newTab: true });
1020
1182
  openArche('arc_0123456789abcdef0123456789abcdef', { path: 'map' });
1021
1183
  ```
1022
1184
 
1023
- **Receiving a deep link.** There is nothing extra to opt into: `currentPath` in
1024
- the init payload is the full platform path the host loaded you at, so an arche
1025
- that wants to be a deep-link target just reads it once at boot.
1185
+ **Receiving a deep link.** There is nothing extra to opt into: `currentPath` is
1186
+ the host's path expressed in YOUR arche's route space — the same space
1187
+ `navigateTo` accepts — so an arche that wants to be a deep-link target just
1188
+ reads it at boot. The `/a/<arche>` prefix is stripped for you, and your home
1189
+ screen reads `/`.
1026
1190
 
1027
1191
  ```tsx
1028
1192
  const { currentPath } = useFiasNavigation();
1029
- // e.g. '/a/arc_0123…/map' → 'map'
1030
- const page = currentPath.split('/').slice(3).join('/') || 'home';
1193
+ // a deep link to '/a/arc_0123…/map' arrives here as '/map'
1194
+ const page = currentPath.replace(/^\//, '') || 'home';
1031
1195
  ```
1032
1196
 
1033
- It is set once, when your app mounts. A deep link from another arche always
1034
- mounts you fresh, so that is exactly when you need it.
1197
+ It is `''` until the host's `init` message lands — the bridge handshake is
1198
+ async, so your first render usually beats it. Treat `''` as "not known yet"
1199
+ rather than as your home screen; that distinction is what keeps a direct entry
1200
+ such as a share link from being lost to the race.
1201
+
1202
+ After that it TRACKS the host: it updates when you call `navigateTo`, and when
1203
+ the user moves the host themselves with browser back/forward. So a router
1204
+ driven off `currentPath` stays in step with the address bar, and the back
1205
+ button works the way your users expect — you do not have to mirror the path in
1206
+ your own state. The dev harness echoes navigations the same way, so what you
1207
+ see locally is what ships.
1035
1208
 
1036
1209
  ### Opening external links
1037
1210
 
@@ -1340,7 +1513,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1340
1513
 
1341
1514
  ### Size and File Limits
1342
1515
 
1343
- - **Bundle size:** Max 5 MB compressed
1516
+ - **Bundle size:** Max 5 MB, measured **uncompressed** — the raw bytes of the final single HTML file with your JS and CSS inlined. Files in `public/` don't count toward it (they have their own caps: 4 MB per file, 120 MB and 600 files total)
1344
1517
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1345
1518
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1346
1519
 
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.19.0 -->
1
+ <!-- fias-sdk-guide-version: 2.20.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -706,7 +706,7 @@ const { url, expiresAt, contentType } = await vault.getDownloadUrl(documentId);
706
706
  // List documents this arche owns
707
707
  const { documents, nextCursor } = await vault.list({ search: 'invoice', limit: 50 });
708
708
 
709
- // Write a small text/JSON document (UTF-8, ≤ 1 MB)
709
+ // Write a small text/JSON document (UTF-8, ≤ 200 KB — use upload() for anything larger)
710
710
  const { documentId } = await vault.write({
711
711
  name: 'settings.json',
712
712
  content: JSON.stringify(settings),
@@ -735,11 +735,23 @@ const { bytes: saved } = await vault.readBytes(documentId);
735
735
  // Overwrite a document THIS arche created IN PLACE — same documentId, bytes
736
736
  // replaced, no version history. The autosave path (a spreadsheet saving as
737
737
  // the user works); to publish a distinct new version use write({
738
- // replacesDocumentId }) instead. UTF-8 text, ≤ 200 KB (OWN_DOCUMENT_SAVE_TOO_LARGE
739
- // names the cap). Debounce: a few seconds of idle plus blur/close — never per
740
- // keystroke — and surface RATE_LIMIT verbatim rather than retrying. A save
738
+ // replacesDocumentId }) instead. ONE method for any content up to 15 MB
739
+ // (OWN_DOCUMENT_SAVE_TOO_LARGE names the cap — the same one readBytes serves,
740
+ // so anything you save you can reopen): a string is saved as UTF-8 text (the
741
+ // document must be a text type); a Uint8Array / ArrayBuffer / Blob is saved
742
+ // as-is, for any type. Transport is automatic — never split, chunk or re-upload
743
+ // to work around size. Debounce: a few seconds of idle plus blur/close — never
744
+ // per keystroke — and surface RATE_LIMIT verbatim rather than retrying. A save
741
745
  // drops the document's search index and does not re-run extraction.
742
746
  await vault.saveContent({ documentId, content: JSON.stringify(sheet) });
747
+ await vault.saveContent({ documentId, content: pdfBytes }); // a PDF editor saving its PDF
748
+
749
+ // Don't let two tabs clobber each other: pass the `revision` you last saw
750
+ // (list() items, read(), readBytes().document and every saveContent result
751
+ // carry one — treat it as opaque). A stale one rejects with VERSION_CONFLICT
752
+ // and the document is untouched: re-read, merge or ask, retry with the new one.
753
+ let { revision } = (await vault.readBytes(documentId)).document;
754
+ ({ revision } = await vault.saveContent({ documentId, content, expectedRevision: revision }));
743
755
 
744
756
  // Semantic search across documents this arche owns
745
757
  // (burns user credits per the AI Markup Invariant — call sparingly)
@@ -760,17 +772,26 @@ const { referenceId } = await vault.attach(documentId, {
760
772
  await vault.detach(documentId, referenceId);
761
773
  ```
762
774
 
763
- **Saving into the user's OWN files** — `upload(bytes, { ..., userVisible: true })`. By default everything your app writes here is _arche-private_: yours to read and write, invisible in the user's Fias files, unusable by other arches. That is the right home for your app's own state (caches, session snapshots, working files). It is the wrong home for the artifact the user made and asked you to keep — an edited image, an exported document — because they will go looking for it and it will not be there.
775
+ **Saving into the user's Documents** — `upload(bytes, { ..., destination: 'documents' })` (`userVisible: true` is the same thing). By default everything your app writes here is _arche-private_: yours to read and write, invisible in the user's Fias files, unusable by other arches. That is the right home for your app's own state (caches, session snapshots, working files). It is the wrong home for the artifact the user made and asked you to keep — an edited image, an exported document — because they will go looking for it and it will not be there.
764
776
 
765
777
  ```tsx
766
778
  const saved = await vault.upload(bytes, {
767
- name: 'sunset.png',
779
+ name: 'sunset.png', // a suggestion — the user can change it
768
780
  mimeType: 'image/png',
769
- userVisible: true,
781
+ destination: 'documents',
770
782
  });
783
+ // saved.documentId, saved.name (what the user kept), saved.access ('none' | 'read' | 'write')
771
784
  ```
772
785
 
773
- Requires the `vault:user-documents:write` permission, and the HOST asks the user to confirm each save by name — you cannot script it, batch it, or pre-approve it. A decline rejects with `SAVE_DECLINED`; that is a normal outcome, not an error to retry. The file lands in `/My-Fias/<your arche's folder>/`, which the platform assigns — you cannot choose it, and `folderPath` is rejected alongside `userVisible`. Your arche must be registered for a user-visible folder platform-side (`ARCHE_NOT_REGISTERED_FOR_USER_VAULT` if not). Passing `replacesDocumentId` saves a new version in place; a version keeps its predecessor's visibility, so you cannot flip a user's visible file to private or vice versa (`VISIBILITY_MISMATCH`). Not available in preview or the dev harness — test it in the published plugin.
786
+ The host opens its **Save dialog**: the user picks a folder in their Documents, can rename the file, and settles a name clash (keep both, or replace a file of the same type). You cannot script it, batch it, or pre-approve it, and you never learn the folder. Requires `vault:user-documents:write`. Cancelling rejects with `SAVE_DECLINED`; that is a normal outcome, not an error to retry. `folderPath`, `replacesDocumentId`, `tags` and `sensitivity` are rejected alongside it (the user decides those). Not available in preview or the dev harness — test it in the published plugin.
787
+
788
+ **Afterwards the file is the user's.** `useVaultDocuments()` no longer lists it. What you keep is reported as `access`: with `vault:user-documents:read` you can open it again (`'read'`) through `useVaultUserDocuments()`, and with `vault:user-documents:edit` the dialog offers the user "keep editing" (`'write'`) so you can keep saving it with `useVaultUserDocuments().saveContent`. The user can change either in Arche Access.
789
+
790
+ **Arche Saves, automatically** — `upload(bytes, { ..., destination: 'arche-saves' })` saves into the user's `Arche Saves/<your label>/` with no dialog. Only for an arche a Fias admin has designated (that issues its label, which is yours for good) and that declares `vault:arche-saves:write` (human-reviewed). Otherwise it rejects with `ARCHE_NOT_DESIGNATED` or `PERMISSION_DENIED`. The files are the user's to keep, move or delete; you can keep reading them while they stay in Arche Saves.
791
+
792
+ **`saveCopy(documentId, { suggestedName? })`** — hand the user a copy of one of your own documents through the same dialog, without re-sending the bytes (needs `vault:documents:read` too). Your working file is unchanged; delete it afterwards if you no longer need it.
793
+
794
+ **A document's visibility never changes.** Your private documents stay private, and a file in the user's files stays theirs — there is no call that moves one into the other.
774
795
 
775
796
  **Sensitivity tiers** (`'standard'` | `'confidential'` | `'proprietary'`) gate downstream read access per the Vault's standard matrix. Defaults to `'standard'`.
776
797
 
@@ -809,11 +830,126 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
809
830
  - Grants are **per-document** and **standing**: the plugin can re-read granted documents in later sessions until the user removes access in **Vault → App Access**.
810
831
  - After revocation, `get`/`getDownloadUrl` reject with `DOCUMENT_NOT_FOUND` — handle that path gracefully (drop the document from your UI; do not retry in a loop).
811
832
  - `proprietary`-sensitivity documents are never grantable (they don't even appear in the picker).
833
+ - **Say what you can open:** `pick({ accept: ['application/pdf', 'image/*', '.docx'] })` takes MIME types, MIME families or extensions, up to 20. The host's Open dialog offers the matching files; the user can still reveal the rest, shown disabled. A malformed list rejects with `INVALID_PARAMS`. Omitted, every type is offered.
812
834
  - Every read is audited by the platform; a new document _version_ is a new documentId, so a re-pick is needed after the user replaces a document.
813
835
  - `getDownloadUrl` is for DISPLAY (`<img src>`); your iframe cannot fetch the URL (CSP). To process binary content (parse a PDF, transform an image), use `getBytes(id)` — size-capped at 15 MB (`CONSENTED_DOCUMENT_TOO_LARGE` names the cap; ask the user to open the file manually instead). `getBytes` has a tighter per-minute budget than the rest of the family because it can move megabytes: read sequentially, and surface a rate-limit error rather than retrying in a loop.
814
836
  - Revocation behaves differently per method: `getBytes` and `get` re-check the grant on every call, so they start failing immediately. A URL already handed out by `getDownloadUrl` keeps working until it expires (1 hour) — that is the documented contract, not a bug.
837
+ - **Workspaces.** The picker offers the files of the workspace the user is acting in — the workspace switcher in Fias: their Personal vault, or an org workspace they belong to — and `list()` returns the grants made in that workspace, so switching workspace changes what `list()` shows. A document you already hold a grant on stays readable by id (`get`, `getBytes`, `getDownloadUrl`, `saveContent`) whichever workspace the user is in: a grant belongs to the workspace the document lives in. A plugin never names a workspace itself. A member's role caps what they can share and read: a viewer can only share standard-sensitivity documents, and a document above a member's tier reads as `DOCUMENT_NOT_FOUND` even if another member granted it.
815
838
  - **Testable in the dev harness (mock mode).** `pick()` grants a small fixture set — a text file, a real PDF, and one deliberately over the 15 MB cap — and `list` / `get` / `getDownloadUrl` / `getBytes` all resolve against it, including the refusals (`BINARY_DOCUMENT` on a text read of a PDF, `CONSENTED_DOCUMENT_TOO_LARGE`, `DOCUMENT_NOT_FOUND`). Run the harness with `FIAS_HARNESS_VAULT_PICK=canceled` to exercise the cancel branch. Not available in the builder preview — `pick()` resolves `{ canceled: true }` there.
816
839
 
840
+ **Editing the user's files** — permission `vault:user-documents:edit`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). `:edit` does not include `:read`: the grants you save under are created and listed through the read surface (`pick`, `list`, `get`), so a manifest with `:edit` alone can ask for nothing and open nothing.
841
+
842
+ ```tsx
843
+ // Ask for EDIT access up front…
844
+ const picked = await userDocs.pick({ maxDocuments: 1, access: 'write' });
845
+ // …or later, for a document you can already read (e.g. one that arrived by
846
+ // handoff). The host skips the picker and asks about just that file.
847
+ const asked = await userDocs.requestEditAccess(documentId);
848
+ // CHECK it: a resolved promise is not "yes". If you held no grant on that
849
+ // document the host shows the ordinary picker instead, so what comes back may
850
+ // be a different file, or view-only.
851
+ const granted =
852
+ 'documents' in asked &&
853
+ asked.documents[0]?.documentId === documentId &&
854
+ asked.documents[0]?.access === 'write';
855
+
856
+ // Save in place: same documentId. A string (text types) or bytes, up to 15 MB.
857
+ const { document } = await userDocs.get(documentId);
858
+ // `revision` is optional in the type — a host that predates edit access does
859
+ // not send it, and without it there is nothing to save against.
860
+ if (!document.revision) throw new Error('This host cannot save to the user’s files.');
861
+ const saved = await userDocs.saveContent({
862
+ documentId,
863
+ content: bytes, // string | Uint8Array | ArrayBuffer | Blob
864
+ expectedRevision: document.revision, // REQUIRED — it is the user's file
865
+ });
866
+ // keep saved.revision for the next save
867
+ ```
868
+
869
+ - `document.access` is `'read'` or `'write'`. Open a `'read'` document read-only; a save to one rejects with `EDIT_ACCESS_REQUIRED`. The user can lower access to view-only or remove it at any time (Vault → App Access) — handle `EDIT_ACCESS_REQUIRED` and `DOCUMENT_NOT_FOUND` on any save, and stop autosaving when you see one.
870
+ - `expectedRevision` is required (`EXPECTED_REVISION_REQUIRED`). `VERSION_CONFLICT` means the file changed elsewhere — re-read, merge, retry; never loop blindly.
871
+ - The document's type cannot change (`MIME_CHANGE_NOT_ALLOWED`), and a `string` can only be saved onto a text document (or one with no recorded type, which stays untyped). Rename, tags and delete stay the user's.
872
+ - The platform keeps the user's earlier versions (at most one per 10 minutes per document). They count against the user's storage; `STORAGE_QUOTA_EXCEEDED` fails the save — tell the user, do not retry.
873
+ - Saves are rate-limited to 12 a minute per user, text and bytes together. Debounce autosave to at most one save every 10 seconds (plus a final save on blur or close) — never per keystroke. A refusal rejects with `err.code === 'RATE_LIMIT'` whether the host or the server made it; surface it rather than retrying in a loop.
874
+ - Content past 15 MB rejects locally with `OWN_DOCUMENT_SAVE_TOO_LARGE`, before anything is sent.
875
+ - A save does not re-index the file for search — the user does that from the file's page.
876
+ - Dev harness: **mock mode** implements all of this against the fixture documents (a `pick()` without `access: 'write'` leaves them read-only, so the refusal is testable). Live mode refuses — only the real user can grant edit access.
877
+
878
+ ### Sending a document for signature — Document Sign handoff
879
+
880
+ **Permission:** `navigation:open_arche`.
881
+
882
+ Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
883
+ They place the signature fields and press Send there — a handoff never sends
884
+ anything, and there is no way for your app to send on their behalf.
885
+
886
+ ```tsx
887
+ import { useFiasNavigation } from '@fias/arche-sdk';
888
+
889
+ const { openArche } = useFiasNavigation();
890
+
891
+ openArche('arche_document_sign', {
892
+ payload: {
893
+ kind: 'sign_document',
894
+ // One uuid per user intent, NOT per call. The platform keys idempotency
895
+ // on it: re-presenting one resolves to the same draft instead of
896
+ // creating a duplicate, and reusing a stale one reopens the old draft.
897
+ requestId: crypto.randomUUID(),
898
+ documentId, // a user-vault PDF
899
+ title: 'MSA — Acme Corp',
900
+ recipients: [{ email: 'alice@acme.com', name: 'Alice', role: 'signer' }],
901
+ correlationId: approvalRecordId, // opaque; echoed back, never interpreted
902
+ // (fixed at creation — see below)
903
+ },
904
+ });
905
+ ```
906
+
907
+ `correlationId` is fixed when the envelope is first created. Re-presenting a
908
+ `requestId` returns the SAME draft, and the result echoes the correlationId
909
+ from that FIRST call — a different one sent on the repeat is ignored, not
910
+ refused, for the same reason recipients are not re-seeded: a repeat must not
911
+ overwrite what the first call recorded. `documentId` is part of the request's
912
+ identity and IS refused when it differs (409); `correlationId` is metadata
913
+ riding along. If you want fresh metadata, mint a fresh `requestId`.
914
+
915
+ When the user leaves Document Sign they can come back to your app, and you
916
+ receive a `sign_document_result`: `{ requestId, outcome, envelopeId? }`, where
917
+ `outcome` is `sent`, `saved_draft` or `cancelled`. An `envelopeId` alone does
918
+ NOT mean anything was sent — an envelope id exists from the moment a draft is
919
+ created, which is why the outcome is there.
920
+
921
+ **Treat the result as a pointer, never as a record.** It tells you which
922
+ envelope to look at; it is not evidence of what happened to it. Match it to an
923
+ outstanding request by `requestId`, consume it once, and never re-point an
924
+ existing association because a `correlationId` matched.
925
+
926
+ **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
927
+ be written down before you call `openArche`.** The host navigates away and
928
+ tears your iframe down, so React state, refs, module variables — everything
929
+ in memory — are gone when the user comes back. Your app remounts from
930
+ nothing.
931
+
932
+ That is one rule, and it is easy to under-apply. It is not only the
933
+ outstanding `requestId`: it is the document the user selected, the record
934
+ they were working on, the form they half-filled, the step they were on.
935
+ Anything you would be annoyed to lose. Write it to `useFiasStorage()` or your
936
+ Data Store on the way out and restore it on the way in. Browser storage is
937
+ not an option — a plugin iframe is opaque-origin, so `localStorage` and
938
+ `sessionStorage` throw.
939
+
940
+ The `requestId` is the one that fails loudest: keep it in memory and every
941
+ reply comes back unmatchable, so you cannot tell a genuine result from a
942
+ forged one — which is the whole reason to match. The rest fail quietly, as a
943
+ user who returns to an app that has forgotten what they were doing.
944
+
945
+ Note the ordering this implies, and do not treat it as an edge case: the
946
+ result **normally arrives before your restore read resolves** — the handoff
947
+ is delivered as soon as your SDK reports ready, which is before your first
948
+ storage round trip comes back. Measured at about a second's head start in
949
+ local dev. Judging on arrival therefore rejects every genuine reply, not an
950
+ occasional one. Hold the result until you know what was outstanding, then
951
+ evaluate it.
952
+
817
953
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
818
954
 
819
955
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -886,7 +1022,7 @@ Rules that matter:
886
1022
  - **UI state only.** Actions manipulate what the user could do in your interface; anything touching server state stays in your own code paths.
887
1023
  - `requiresConfirmation: true` makes the assistant ask the user before dispatching. It escalates only — nothing can skip a confirmation the platform requires.
888
1024
  - Manifest actions win id collisions with runtime ones; the merged list is capped at 50.
889
- - The assistant is not present in the builder preview or the dev harness — declarations are accepted but nothing dispatches until the published plugin runs with the Fias AI window.
1025
+ - **Test it in the dev harness:** the **✦ Fias AI** toolbar button opens the real assistant over your plugin (Live mode; turns are real and use your credits). It describes your plugin exactly as the platform would: manifest actions always, runtime (`useFiasAIActions`) actions and `state` only for a trusted arche, and the window names any runtime action it is withholding. In the harness it can use your plugin's actions plus read-only Fias tools, never writes or sends. The builder preview has no assistant.
890
1026
  - **`openAssistant()`** (the hook's return) asks the HOST to open the Fias AI panel — use it for a contextual affordance (an empty-state "Ask Fias AI to get started" button). The host already shows its own "Works with Fias AI" chip, debounces your requests, and ignores them for a while after the user closes the panel — never call it in a loop or on mount. There is deliberately no way to pass text along: the assistant's input belongs to the user.
891
1027
 
892
1028
  ### `useArcheAssets()` — Contributor-published asset library
@@ -911,6 +1047,32 @@ const fresh = await getUrl(assetId);
911
1047
 
912
1048
  Cache the `assetId`; refresh `signedUrl` via `getUrl()` rather than persisting URLs across sessions.
913
1049
 
1050
+ ### `useFiasContent()` — This plugin's content pack
1051
+
1052
+ **Permission:** `entities:client_invoke`
1053
+ **Returns:** `FiasContentApi`
1054
+
1055
+ Ship large or numerous files — lessons, articles, data, images, audio, fonts — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1056
+
1057
+ ```tsx
1058
+ import { useFiasContent, FiasContentError } from '@fias/arche-sdk';
1059
+
1060
+ const content = useFiasContent();
1061
+
1062
+ const lessons = await content.list('lessons/'); // [{ path, sizeBytes, mimeType }]
1063
+ const markdown = await content.getText('lessons/01-intro.md');
1064
+ const course = await content.getJson<Course>('course.json');
1065
+ const batch = await content.getMany(['a.md', 'b.md'], 'text'); // up to 100 files per call
1066
+ const cover = await content.getObjectUrl('img/cover.webp'); // blob: URL for <img src>
1067
+ const { url } = await content.getUrl('audio/theme.ogg'); // signed URL, for long streaming audio
1068
+ ```
1069
+
1070
+ - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB). A pack holds at most 10,000 files, 2,000 images, 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1071
+ - Prefer `getObjectUrl` for images and fonts: repeat views come from the host cache. `getUrl` is for media that must stream or seek; it bypasses the cache, and a URL held past its `expiresAt` must be requested again.
1072
+ - Failures throw `FiasContentError` with a `code`: `CONTENT_NOT_FOUND`, `CONTENT_NOT_TEXT`, `CONTENT_TOO_LARGE`, `CONTENT_UNAVAILABLE`, `CONTENT_THROTTLED`, `CONTENT_UNAVAILABLE_IN_PREVIEW`, `PERMISSION_DENIED`, `RATE_LIMIT`. `getMany` returns the error for a failed path instead of throwing.
1073
+ - There is no published pack in the Arche Builder preview, so calls there fail with `CONTENT_UNAVAILABLE_IN_PREVIEW`; the dev harness serves your local `content/` directory.
1074
+ - Every publish reviews all of the content again. Reads are free for your users; the pack's storage and bandwidth are billed to you, the contributor.
1075
+
914
1076
  ### `useCommunityAssets()` — User-published images, visible to this arche's users
915
1077
 
916
1078
  **Permissions:** `assets:community:read` (browse/view) · `assets:community:publish` (publish/unpublish/listMine)
@@ -1020,18 +1182,29 @@ openArche('arc_0123456789abcdef0123456789abcdef', { newTab: true });
1020
1182
  openArche('arc_0123456789abcdef0123456789abcdef', { path: 'map' });
1021
1183
  ```
1022
1184
 
1023
- **Receiving a deep link.** There is nothing extra to opt into: `currentPath` in
1024
- the init payload is the full platform path the host loaded you at, so an arche
1025
- that wants to be a deep-link target just reads it once at boot.
1185
+ **Receiving a deep link.** There is nothing extra to opt into: `currentPath` is
1186
+ the host's path expressed in YOUR arche's route space — the same space
1187
+ `navigateTo` accepts — so an arche that wants to be a deep-link target just
1188
+ reads it at boot. The `/a/<arche>` prefix is stripped for you, and your home
1189
+ screen reads `/`.
1026
1190
 
1027
1191
  ```tsx
1028
1192
  const { currentPath } = useFiasNavigation();
1029
- // e.g. '/a/arc_0123…/map' → 'map'
1030
- const page = currentPath.split('/').slice(3).join('/') || 'home';
1193
+ // a deep link to '/a/arc_0123…/map' arrives here as '/map'
1194
+ const page = currentPath.replace(/^\//, '') || 'home';
1031
1195
  ```
1032
1196
 
1033
- It is set once, when your app mounts. A deep link from another arche always
1034
- mounts you fresh, so that is exactly when you need it.
1197
+ It is `''` until the host's `init` message lands — the bridge handshake is
1198
+ async, so your first render usually beats it. Treat `''` as "not known yet"
1199
+ rather than as your home screen; that distinction is what keeps a direct entry
1200
+ such as a share link from being lost to the race.
1201
+
1202
+ After that it TRACKS the host: it updates when you call `navigateTo`, and when
1203
+ the user moves the host themselves with browser back/forward. So a router
1204
+ driven off `currentPath` stays in step with the address bar, and the back
1205
+ button works the way your users expect — you do not have to mirror the path in
1206
+ your own state. The dev harness echoes navigations the same way, so what you
1207
+ see locally is what ships.
1035
1208
 
1036
1209
  ### Opening external links
1037
1210
 
@@ -1340,7 +1513,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1340
1513
 
1341
1514
  ### Size and File Limits
1342
1515
 
1343
- - **Bundle size:** Max 5 MB compressed
1516
+ - **Bundle size:** Max 5 MB, measured **uncompressed** — the raw bytes of the final single HTML file with your JS and CSS inlined. Files in `public/` don't count toward it (they have their own caps: 4 MB per file, 120 MB and 600 files total)
1344
1517
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1345
1518
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1346
1519