@fias/create-fias-plugin 1.11.0 → 1.11.2

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.0",
3
+ "version": "1.11.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.18.0 -->
1
+ <!-- fias-sdk-guide-version: 2.19.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -545,11 +545,34 @@ const { entities, isLoading } = useImageEntities({
545
545
  supportsReferenceImage: true,
546
546
  });
547
547
  // entities[i]: { entityId, displayName, provider, modes, supportedSizes,
548
- // supportsReferenceImage, isPlatformRecommended, ... }
548
+ // supportsReferenceImage, isPlatformRecommended,
549
+ // creditsPerImage, estimatedDurationMs, company, strengths,
550
+ // providerParams, ... }
549
551
  ```
550
552
 
551
553
  Each entry is scrubbed — only fields a UI legitimately needs (no provider model strings, no endpoints, no execution config).
552
554
 
555
+ `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
556
+
557
+ **Tunable engine parameters.** `providerParams` describes what a model exposes for tuning — enough to render a settings panel without hardcoding a thing:
558
+
559
+ ```tsx
560
+ // param: { key, label, description, controlType, defaultValue,
561
+ // min?, max?, step?, options?, category? }
562
+ // controlType: 'slider' | 'number' | 'select' | 'checkbox' | 'textarea'
563
+ const seed = entity.providerParams?.find((p) => p.key === 'seed');
564
+ await generate({ entityId, prompt, providerParams: { seed: 42 } });
565
+ ```
566
+
567
+ **The `category` trap.** A param carrying `category: 'quality'` or `'style'` does NOT belong in `providerParams` — send it on the matching top-level field. Providers read those two from the top level only, and an unrecognized `providerParams` key is dropped without an error, so the image generates at the model's default **and you are billed for it anyway**:
568
+
569
+ ```tsx
570
+ generate({ entityId, prompt, quality: 'high' }); // honoured
571
+ generate({ entityId, prompt, providerParams: { gpt_quality: 'high' } }); // SILENTLY IGNORED
572
+ ```
573
+
574
+ Its `options` are still how you build the control — only the destination differs.
575
+
553
576
  ### Image-editing capability hooks (auto-generated)
554
577
 
555
578
  **Permission:** `entities:image_edit`
@@ -683,7 +706,7 @@ const { url, expiresAt, contentType } = await vault.getDownloadUrl(documentId);
683
706
  // List documents this arche owns
684
707
  const { documents, nextCursor } = await vault.list({ search: 'invoice', limit: 50 });
685
708
 
686
- // 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)
687
710
  const { documentId } = await vault.write({
688
711
  name: 'settings.json',
689
712
  content: JSON.stringify(settings),
@@ -712,11 +735,23 @@ const { bytes: saved } = await vault.readBytes(documentId);
712
735
  // Overwrite a document THIS arche created IN PLACE — same documentId, bytes
713
736
  // replaced, no version history. The autosave path (a spreadsheet saving as
714
737
  // the user works); to publish a distinct new version use write({
715
- // replacesDocumentId }) instead. UTF-8 text, ≤ 200 KB (OWN_DOCUMENT_SAVE_TOO_LARGE
716
- // names the cap). Debounce: a few seconds of idle plus blur/close — never per
717
- // 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
718
745
  // drops the document's search index and does not re-run extraction.
719
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 }));
720
755
 
721
756
  // Semantic search across documents this arche owns
722
757
  // (burns user credits per the AI Markup Invariant — call sparingly)
@@ -749,6 +784,19 @@ const saved = await vault.upload(bytes, {
749
784
 
750
785
  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.
751
786
 
787
+ **Handing over a file you already made** — `promoteToUserFiles(documentId)`. If the artifact already exists as one of your private documents, do NOT re-upload it with `userVisible` — that creates a second copy, doubles the user's storage, and orphans anything pointing at the original. Promote it instead: the same document moves into the user's files, keeping its `documentId`.
788
+
789
+ ```typescript
790
+ try {
791
+ await vault.promoteToUserFiles(documentId); // the HOST asks the user; you cannot script it
792
+ } catch (err) {
793
+ if (err instanceof FiasBridgeError && err.code === 'SAVE_DECLINED') return; // a normal outcome
794
+ throw err;
795
+ }
796
+ ```
797
+
798
+ Same requirements as a user-visible upload (`vault:user-documents:write`, a registered folder — the platform picks the destination). It is **one-way and it changes what you can do**: once the file is the user's, this hook's `saveContent`, `update` and `delete` reject, and there is no way to take a file back out of the user's files. To keep saving it, declare `vault:user-documents:edit` — the host's confirmation then offers the user "let this app keep editing", the result carries `editAccess`, and when it is `true` you carry on with `useVaultUserDocuments().saveContent` (below). Without it, promote when the user is DONE — never while your editor still needs to save. After a `SAVE_DECLINED`, re-`list()` before assuming nothing moved: the host performs the move itself, and if its page is torn down mid-move you are told "declined" for a promote that completed — the document's `visibility` is the truth. Re-asking is capped at 5 a minute, like the picker. Not available in preview or the dev harness.
799
+
752
800
  **Sensitivity tiers** (`'standard'` | `'confidential'` | `'proprietary'`) gate downstream read access per the Vault's standard matrix. Defaults to `'standard'`.
753
801
 
754
802
  **`fetchVaultDocumentDownloadUrl(documentId)`** — non-React function that shares the hook's client-side URL cache. Use from PDF exporters, image preloaders, or other non-component code paths.
@@ -789,8 +837,122 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
789
837
  - 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.
790
838
  - `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.
791
839
  - 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.
840
+ - **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.
792
841
  - **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.
793
842
 
843
+ **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.
844
+
845
+ ```tsx
846
+ // Ask for EDIT access up front…
847
+ const picked = await userDocs.pick({ maxDocuments: 1, access: 'write' });
848
+ // …or later, for a document you can already read (e.g. one that arrived by
849
+ // handoff). The host skips the picker and asks about just that file.
850
+ const asked = await userDocs.requestEditAccess(documentId);
851
+ // CHECK it: a resolved promise is not "yes". If you held no grant on that
852
+ // document the host shows the ordinary picker instead, so what comes back may
853
+ // be a different file, or view-only.
854
+ const granted =
855
+ 'documents' in asked &&
856
+ asked.documents[0]?.documentId === documentId &&
857
+ asked.documents[0]?.access === 'write';
858
+
859
+ // Save in place: same documentId. A string (text types) or bytes, up to 15 MB.
860
+ const { document } = await userDocs.get(documentId);
861
+ // `revision` is optional in the type — a host that predates edit access does
862
+ // not send it, and without it there is nothing to save against.
863
+ if (!document.revision) throw new Error('This host cannot save to the user’s files.');
864
+ const saved = await userDocs.saveContent({
865
+ documentId,
866
+ content: bytes, // string | Uint8Array | ArrayBuffer | Blob
867
+ expectedRevision: document.revision, // REQUIRED — it is the user's file
868
+ });
869
+ // keep saved.revision for the next save
870
+ ```
871
+
872
+ - `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.
873
+ - `expectedRevision` is required (`EXPECTED_REVISION_REQUIRED`). `VERSION_CONFLICT` means the file changed elsewhere — re-read, merge, retry; never loop blindly.
874
+ - 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.
875
+ - 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.
876
+ - 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.
877
+ - Content past 15 MB rejects locally with `OWN_DOCUMENT_SAVE_TOO_LARGE`, before anything is sent.
878
+ - A save does not re-index the file for search — the user does that from the file's page.
879
+ - 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.
880
+
881
+ ### Sending a document for signature — Document Sign handoff
882
+
883
+ **Permission:** `navigation:open_arche`.
884
+
885
+ Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
886
+ They place the signature fields and press Send there — a handoff never sends
887
+ anything, and there is no way for your app to send on their behalf.
888
+
889
+ ```tsx
890
+ import { useFiasNavigation } from '@fias/arche-sdk';
891
+
892
+ const { openArche } = useFiasNavigation();
893
+
894
+ openArche('arche_document_sign', {
895
+ payload: {
896
+ kind: 'sign_document',
897
+ // One uuid per user intent, NOT per call. The platform keys idempotency
898
+ // on it: re-presenting one resolves to the same draft instead of
899
+ // creating a duplicate, and reusing a stale one reopens the old draft.
900
+ requestId: crypto.randomUUID(),
901
+ documentId, // a user-vault PDF
902
+ title: 'MSA — Acme Corp',
903
+ recipients: [{ email: 'alice@acme.com', name: 'Alice', role: 'signer' }],
904
+ correlationId: approvalRecordId, // opaque; echoed back, never interpreted
905
+ // (fixed at creation — see below)
906
+ },
907
+ });
908
+ ```
909
+
910
+ `correlationId` is fixed when the envelope is first created. Re-presenting a
911
+ `requestId` returns the SAME draft, and the result echoes the correlationId
912
+ from that FIRST call — a different one sent on the repeat is ignored, not
913
+ refused, for the same reason recipients are not re-seeded: a repeat must not
914
+ overwrite what the first call recorded. `documentId` is part of the request's
915
+ identity and IS refused when it differs (409); `correlationId` is metadata
916
+ riding along. If you want fresh metadata, mint a fresh `requestId`.
917
+
918
+ When the user leaves Document Sign they can come back to your app, and you
919
+ receive a `sign_document_result`: `{ requestId, outcome, envelopeId? }`, where
920
+ `outcome` is `sent`, `saved_draft` or `cancelled`. An `envelopeId` alone does
921
+ NOT mean anything was sent — an envelope id exists from the moment a draft is
922
+ created, which is why the outcome is there.
923
+
924
+ **Treat the result as a pointer, never as a record.** It tells you which
925
+ envelope to look at; it is not evidence of what happened to it. Match it to an
926
+ outstanding request by `requestId`, consume it once, and never re-point an
927
+ existing association because a `correlationId` matched.
928
+
929
+ **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
930
+ be written down before you call `openArche`.** The host navigates away and
931
+ tears your iframe down, so React state, refs, module variables — everything
932
+ in memory — are gone when the user comes back. Your app remounts from
933
+ nothing.
934
+
935
+ That is one rule, and it is easy to under-apply. It is not only the
936
+ outstanding `requestId`: it is the document the user selected, the record
937
+ they were working on, the form they half-filled, the step they were on.
938
+ Anything you would be annoyed to lose. Write it to `useFiasStorage()` or your
939
+ Data Store on the way out and restore it on the way in. Browser storage is
940
+ not an option — a plugin iframe is opaque-origin, so `localStorage` and
941
+ `sessionStorage` throw.
942
+
943
+ The `requestId` is the one that fails loudest: keep it in memory and every
944
+ reply comes back unmatchable, so you cannot tell a genuine result from a
945
+ forged one — which is the whole reason to match. The rest fail quietly, as a
946
+ user who returns to an app that has forgotten what they were doing.
947
+
948
+ Note the ordering this implies, and do not treat it as an edge case: the
949
+ result **normally arrives before your restore read resolves** — the handoff
950
+ is delivered as soon as your SDK reports ready, which is before your first
951
+ storage round trip comes back. Measured at about a second's head start in
952
+ local dev. Judging on arrival therefore rejects every genuine reply, not an
953
+ occasional one. Hold the result until you know what was outstanding, then
954
+ evaluate it.
955
+
794
956
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
795
957
 
796
958
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -863,7 +1025,7 @@ Rules that matter:
863
1025
  - **UI state only.** Actions manipulate what the user could do in your interface; anything touching server state stays in your own code paths.
864
1026
  - `requiresConfirmation: true` makes the assistant ask the user before dispatching. It escalates only — nothing can skip a confirmation the platform requires.
865
1027
  - Manifest actions win id collisions with runtime ones; the merged list is capped at 50.
866
- - 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.
1028
+ - **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.
867
1029
  - **`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.
868
1030
 
869
1031
  ### `useArcheAssets()` — Contributor-published asset library
@@ -1317,7 +1479,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1317
1479
 
1318
1480
  ### Size and File Limits
1319
1481
 
1320
- - **Bundle size:** Max 5 MB compressed
1482
+ - **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)
1321
1483
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1322
1484
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1323
1485
 
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.18.0 -->
1
+ <!-- fias-sdk-guide-version: 2.19.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -545,11 +545,34 @@ const { entities, isLoading } = useImageEntities({
545
545
  supportsReferenceImage: true,
546
546
  });
547
547
  // entities[i]: { entityId, displayName, provider, modes, supportedSizes,
548
- // supportsReferenceImage, isPlatformRecommended, ... }
548
+ // supportsReferenceImage, isPlatformRecommended,
549
+ // creditsPerImage, estimatedDurationMs, company, strengths,
550
+ // providerParams, ... }
549
551
  ```
550
552
 
551
553
  Each entry is scrubbed — only fields a UI legitimately needs (no provider model strings, no endpoints, no execution config).
552
554
 
555
+ `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
556
+
557
+ **Tunable engine parameters.** `providerParams` describes what a model exposes for tuning — enough to render a settings panel without hardcoding a thing:
558
+
559
+ ```tsx
560
+ // param: { key, label, description, controlType, defaultValue,
561
+ // min?, max?, step?, options?, category? }
562
+ // controlType: 'slider' | 'number' | 'select' | 'checkbox' | 'textarea'
563
+ const seed = entity.providerParams?.find((p) => p.key === 'seed');
564
+ await generate({ entityId, prompt, providerParams: { seed: 42 } });
565
+ ```
566
+
567
+ **The `category` trap.** A param carrying `category: 'quality'` or `'style'` does NOT belong in `providerParams` — send it on the matching top-level field. Providers read those two from the top level only, and an unrecognized `providerParams` key is dropped without an error, so the image generates at the model's default **and you are billed for it anyway**:
568
+
569
+ ```tsx
570
+ generate({ entityId, prompt, quality: 'high' }); // honoured
571
+ generate({ entityId, prompt, providerParams: { gpt_quality: 'high' } }); // SILENTLY IGNORED
572
+ ```
573
+
574
+ Its `options` are still how you build the control — only the destination differs.
575
+
553
576
  ### Image-editing capability hooks (auto-generated)
554
577
 
555
578
  **Permission:** `entities:image_edit`
@@ -683,7 +706,7 @@ const { url, expiresAt, contentType } = await vault.getDownloadUrl(documentId);
683
706
  // List documents this arche owns
684
707
  const { documents, nextCursor } = await vault.list({ search: 'invoice', limit: 50 });
685
708
 
686
- // 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)
687
710
  const { documentId } = await vault.write({
688
711
  name: 'settings.json',
689
712
  content: JSON.stringify(settings),
@@ -712,11 +735,23 @@ const { bytes: saved } = await vault.readBytes(documentId);
712
735
  // Overwrite a document THIS arche created IN PLACE — same documentId, bytes
713
736
  // replaced, no version history. The autosave path (a spreadsheet saving as
714
737
  // the user works); to publish a distinct new version use write({
715
- // replacesDocumentId }) instead. UTF-8 text, ≤ 200 KB (OWN_DOCUMENT_SAVE_TOO_LARGE
716
- // names the cap). Debounce: a few seconds of idle plus blur/close — never per
717
- // 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
718
745
  // drops the document's search index and does not re-run extraction.
719
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 }));
720
755
 
721
756
  // Semantic search across documents this arche owns
722
757
  // (burns user credits per the AI Markup Invariant — call sparingly)
@@ -749,6 +784,19 @@ const saved = await vault.upload(bytes, {
749
784
 
750
785
  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.
751
786
 
787
+ **Handing over a file you already made** — `promoteToUserFiles(documentId)`. If the artifact already exists as one of your private documents, do NOT re-upload it with `userVisible` — that creates a second copy, doubles the user's storage, and orphans anything pointing at the original. Promote it instead: the same document moves into the user's files, keeping its `documentId`.
788
+
789
+ ```typescript
790
+ try {
791
+ await vault.promoteToUserFiles(documentId); // the HOST asks the user; you cannot script it
792
+ } catch (err) {
793
+ if (err instanceof FiasBridgeError && err.code === 'SAVE_DECLINED') return; // a normal outcome
794
+ throw err;
795
+ }
796
+ ```
797
+
798
+ Same requirements as a user-visible upload (`vault:user-documents:write`, a registered folder — the platform picks the destination). It is **one-way and it changes what you can do**: once the file is the user's, this hook's `saveContent`, `update` and `delete` reject, and there is no way to take a file back out of the user's files. To keep saving it, declare `vault:user-documents:edit` — the host's confirmation then offers the user "let this app keep editing", the result carries `editAccess`, and when it is `true` you carry on with `useVaultUserDocuments().saveContent` (below). Without it, promote when the user is DONE — never while your editor still needs to save. After a `SAVE_DECLINED`, re-`list()` before assuming nothing moved: the host performs the move itself, and if its page is torn down mid-move you are told "declined" for a promote that completed — the document's `visibility` is the truth. Re-asking is capped at 5 a minute, like the picker. Not available in preview or the dev harness.
799
+
752
800
  **Sensitivity tiers** (`'standard'` | `'confidential'` | `'proprietary'`) gate downstream read access per the Vault's standard matrix. Defaults to `'standard'`.
753
801
 
754
802
  **`fetchVaultDocumentDownloadUrl(documentId)`** — non-React function that shares the hook's client-side URL cache. Use from PDF exporters, image preloaders, or other non-component code paths.
@@ -789,8 +837,122 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
789
837
  - 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.
790
838
  - `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.
791
839
  - 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.
840
+ - **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.
792
841
  - **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.
793
842
 
843
+ **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.
844
+
845
+ ```tsx
846
+ // Ask for EDIT access up front…
847
+ const picked = await userDocs.pick({ maxDocuments: 1, access: 'write' });
848
+ // …or later, for a document you can already read (e.g. one that arrived by
849
+ // handoff). The host skips the picker and asks about just that file.
850
+ const asked = await userDocs.requestEditAccess(documentId);
851
+ // CHECK it: a resolved promise is not "yes". If you held no grant on that
852
+ // document the host shows the ordinary picker instead, so what comes back may
853
+ // be a different file, or view-only.
854
+ const granted =
855
+ 'documents' in asked &&
856
+ asked.documents[0]?.documentId === documentId &&
857
+ asked.documents[0]?.access === 'write';
858
+
859
+ // Save in place: same documentId. A string (text types) or bytes, up to 15 MB.
860
+ const { document } = await userDocs.get(documentId);
861
+ // `revision` is optional in the type — a host that predates edit access does
862
+ // not send it, and without it there is nothing to save against.
863
+ if (!document.revision) throw new Error('This host cannot save to the user’s files.');
864
+ const saved = await userDocs.saveContent({
865
+ documentId,
866
+ content: bytes, // string | Uint8Array | ArrayBuffer | Blob
867
+ expectedRevision: document.revision, // REQUIRED — it is the user's file
868
+ });
869
+ // keep saved.revision for the next save
870
+ ```
871
+
872
+ - `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.
873
+ - `expectedRevision` is required (`EXPECTED_REVISION_REQUIRED`). `VERSION_CONFLICT` means the file changed elsewhere — re-read, merge, retry; never loop blindly.
874
+ - 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.
875
+ - 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.
876
+ - 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.
877
+ - Content past 15 MB rejects locally with `OWN_DOCUMENT_SAVE_TOO_LARGE`, before anything is sent.
878
+ - A save does not re-index the file for search — the user does that from the file's page.
879
+ - 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.
880
+
881
+ ### Sending a document for signature — Document Sign handoff
882
+
883
+ **Permission:** `navigation:open_arche`.
884
+
885
+ Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
886
+ They place the signature fields and press Send there — a handoff never sends
887
+ anything, and there is no way for your app to send on their behalf.
888
+
889
+ ```tsx
890
+ import { useFiasNavigation } from '@fias/arche-sdk';
891
+
892
+ const { openArche } = useFiasNavigation();
893
+
894
+ openArche('arche_document_sign', {
895
+ payload: {
896
+ kind: 'sign_document',
897
+ // One uuid per user intent, NOT per call. The platform keys idempotency
898
+ // on it: re-presenting one resolves to the same draft instead of
899
+ // creating a duplicate, and reusing a stale one reopens the old draft.
900
+ requestId: crypto.randomUUID(),
901
+ documentId, // a user-vault PDF
902
+ title: 'MSA — Acme Corp',
903
+ recipients: [{ email: 'alice@acme.com', name: 'Alice', role: 'signer' }],
904
+ correlationId: approvalRecordId, // opaque; echoed back, never interpreted
905
+ // (fixed at creation — see below)
906
+ },
907
+ });
908
+ ```
909
+
910
+ `correlationId` is fixed when the envelope is first created. Re-presenting a
911
+ `requestId` returns the SAME draft, and the result echoes the correlationId
912
+ from that FIRST call — a different one sent on the repeat is ignored, not
913
+ refused, for the same reason recipients are not re-seeded: a repeat must not
914
+ overwrite what the first call recorded. `documentId` is part of the request's
915
+ identity and IS refused when it differs (409); `correlationId` is metadata
916
+ riding along. If you want fresh metadata, mint a fresh `requestId`.
917
+
918
+ When the user leaves Document Sign they can come back to your app, and you
919
+ receive a `sign_document_result`: `{ requestId, outcome, envelopeId? }`, where
920
+ `outcome` is `sent`, `saved_draft` or `cancelled`. An `envelopeId` alone does
921
+ NOT mean anything was sent — an envelope id exists from the moment a draft is
922
+ created, which is why the outcome is there.
923
+
924
+ **Treat the result as a pointer, never as a record.** It tells you which
925
+ envelope to look at; it is not evidence of what happened to it. Match it to an
926
+ outstanding request by `requestId`, consume it once, and never re-point an
927
+ existing association because a `correlationId` matched.
928
+
929
+ **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
930
+ be written down before you call `openArche`.** The host navigates away and
931
+ tears your iframe down, so React state, refs, module variables — everything
932
+ in memory — are gone when the user comes back. Your app remounts from
933
+ nothing.
934
+
935
+ That is one rule, and it is easy to under-apply. It is not only the
936
+ outstanding `requestId`: it is the document the user selected, the record
937
+ they were working on, the form they half-filled, the step they were on.
938
+ Anything you would be annoyed to lose. Write it to `useFiasStorage()` or your
939
+ Data Store on the way out and restore it on the way in. Browser storage is
940
+ not an option — a plugin iframe is opaque-origin, so `localStorage` and
941
+ `sessionStorage` throw.
942
+
943
+ The `requestId` is the one that fails loudest: keep it in memory and every
944
+ reply comes back unmatchable, so you cannot tell a genuine result from a
945
+ forged one — which is the whole reason to match. The rest fail quietly, as a
946
+ user who returns to an app that has forgotten what they were doing.
947
+
948
+ Note the ordering this implies, and do not treat it as an edge case: the
949
+ result **normally arrives before your restore read resolves** — the handoff
950
+ is delivered as soon as your SDK reports ready, which is before your first
951
+ storage round trip comes back. Measured at about a second's head start in
952
+ local dev. Judging on arrival therefore rejects every genuine reply, not an
953
+ occasional one. Hold the result until you know what was outstanding, then
954
+ evaluate it.
955
+
794
956
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
795
957
 
796
958
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -863,7 +1025,7 @@ Rules that matter:
863
1025
  - **UI state only.** Actions manipulate what the user could do in your interface; anything touching server state stays in your own code paths.
864
1026
  - `requiresConfirmation: true` makes the assistant ask the user before dispatching. It escalates only — nothing can skip a confirmation the platform requires.
865
1027
  - Manifest actions win id collisions with runtime ones; the merged list is capped at 50.
866
- - 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.
1028
+ - **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.
867
1029
  - **`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.
868
1030
 
869
1031
  ### `useArcheAssets()` — Contributor-published asset library
@@ -1317,7 +1479,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1317
1479
 
1318
1480
  ### Size and File Limits
1319
1481
 
1320
- - **Bundle size:** Max 5 MB compressed
1482
+ - **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)
1321
1483
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1322
1484
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1323
1485