@fias/arche-sdk 2.32.0 → 2.34.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.
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.32.0 -->
1
+ <!-- fias-sdk-guide-version: 2.34.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -21,19 +21,19 @@ vite.config.ts # Vite dev server config (port 3100)
21
21
 
22
22
  Decide by who writes the data and when, before you write the code: moving data to a different home after you publish means a migration.
23
23
 
24
- | What it is | Where it goes | What you get |
25
- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
- | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
- | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
- | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
- | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
- | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
- | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
- | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
- | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
- | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
- | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
- | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
24
+ | What it is | Where it goes | What you get |
25
+ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
+ | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
+ | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts, 3D models | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
+ | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
+ | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
+ | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
+ | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
+ | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
+ | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
+ | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
+ | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
37
37
 
38
38
  **Common mistakes:**
39
39
 
@@ -92,6 +92,7 @@ function MyComponent() {
92
92
  - `spacing`: `{ xs, sm, md, lg, xl }` (CSS values like `'8px'`)
93
93
  - `fonts`: `{ body, heading, mono }` (font-family strings)
94
94
  - `components`: `{ borderRadius, buttonRadius, cardRadius, inputRadius, shadowSm, shadowMd, shadowLg, borderWidth }`
95
+ - `brand?`: `{ displayName, logoUrl, primaryColor, accentColor, headerTextColor }` — the Company's brand, present only when your app runs on that Company's portal and the Company has set one. Each field may be `null` (colours are `#RRGGBB`; `logoUrl` is for an `<img src>`). Absent on fias.io: keep your own palette as the fallback, and re-read it on every theme update (the brand can arrive after your first render). In the dev harness, set `"mockBrand": { … }` in `fias-dev.config.json` to see your app on a portal.
95
96
 
96
97
  ### `useFiasVendoredAssets(importSpecifier)` — Self-hosted vendored-library asset URLs
97
98
 
@@ -346,6 +347,30 @@ A collection can carry rules the platform checks on **every** write, so a buggy
346
347
 
347
348
  Rules that relate two documents (`references`, `ledgerSums`) are checked at the **end** of the write, so write the ledger row and the counter, or the target and the document pointing at it, together in one `batch` in any order. A violation rejects the whole write with `CONSTRAINT_VIOLATION`. Rules are **tighten-only**: you can add rules (at most 10 per kind), never remove or change one.
348
349
 
350
+ #### Attested logs (`attested`)
351
+
352
+ **Permission:** `data:attested` (requires `data:store`; human-reviewed)
353
+
354
+ An audit log whose "who did it" your code can't fake. Declare it in `fias-plugin.json` on a **new** collection, with `appendOnly` beside it:
355
+
356
+ ```json
357
+ "permissions": ["data:store", "data:workspace", "data:attested"],
358
+ "collections": [
359
+ { "name": "audit_log", "scope": "workspace", "constraints": { "appendOnly": true, "attested": true } }
360
+ ]
361
+ ```
362
+
363
+ - Fias stamps every row you add with `attestation`: `{ personRef, agentRef?, channel, recordedAt, appVersion?, buildHash? }` (`recordedAt` is when Fias stored the row; `appVersion` and `buildHash` name the release live when it was stored, on every row of your published app and never on a test row). It is never part of `data`, and nothing you send can set it. Read it from `getDocument()`, `query()` and `search()`; `put()` and each `batch()` write return the one they stamped.
364
+ - `personRef` is the app user ref of the person accountable for the row; name it with `resolvePeople`. `agentRef` is set only when an agent key wrote it (`channel: 'agent'`), and never resolves to a name: show "an agent acting for <person>".
365
+ - `channel` is `session`, `portal` (a Company portal), `assistant` (Fias AI), `agent` or `non_production`. Every row written in the dev harness or a Forge test is `non_production`, even after test data is carried into your published app — never present those as real.
366
+ - Rows can't be overwritten, or deleted one by one, by you, your users, agents or your contributor CLI (which can't write to an attested collection at all, and reads attestations without `personRef` / `agentRef`; agents and Fias AI read only the channel, time and release).
367
+ - In your published app only the publish creates it: `createCollection()` with the same constraints just finds it, and creating one there that the manifest didn't is refused (`ATTESTED_MANIFEST_ONLY`). In the dev harness and a Forge test your code creates it, as for any collection (the harness still checks your manifest declares it); a builder preview can't have one. Test data carried into your app at its first publish may hold only logs that release declares.
368
+ - It is refused on `shared` scope and on a collection that already exists.
369
+ - Fias turns attested logs on platform by platform, either for every app or for a short pilot list. Until it has for your app, a publish or a Forge test that creates a new log is refused (`ATTESTED_LOGS_NOT_AVAILABLE`); leave the log out of your manifest until then, never declare it without `attested` (that name could then never become a log). The dev harness's live mode is shared by all your apps, so it creates a new log only once logs are on for every app — a pilot app tests its log in Forge. A log your app already has keeps working whatever the switch says, and its Forge test channel can always recreate it.
370
+ - **Keep it declared.** Every later release must declare it, by name, with `attested`, and keep `data:attested`; a publish that drops either is refused (`ATTESTED_LOG_UNDECLARED`) — unless the log holds no real rows (empty, or only test rows carried in from a Forge test channel) and you leave it out of `collections` entirely; naming it without `attested` is always refused. Your app can write only to the logs its live release declares. A published log is never deleted, even empty (`COLLECTION_ATTESTED`), so its name stays a log. It lasts as long as your app: no workspace that holds a log — your app's own, a guild's or a Fias org's — can be archived (`WORKSPACE_ATTESTED`), so don't offer to archive one.
371
+ - In a Fias org workspace, adding a row also needs the workspace's approval of your app to cover `data:attested`; otherwise it is refused (`ARCHE_NOT_APPROVED`) and the workspace's admins see the app waiting to be approved again.
372
+ - Call it **platform-attested**. It proves who wrote the row, when and through which release, not that `data` is true; never call it "tamper-evident".
373
+
349
374
  ### Letting agents use your data (`agentAccess`)
350
375
 
351
376
  **Permission:** `data:agents` (requires `data:store`)
@@ -642,7 +667,7 @@ const [assignee] = await workspaces.resolvePeople([task.assigneeRef], shared.wor
642
667
  const label = assignee?.displayName ?? 'Former member';
643
668
  ```
644
669
 
645
- **Methods:** `create`, `openForWorkspace`, `list`, `get`, `archive` (owner only — final: an archived workspace cannot be reopened, and it is deleted with its documents 30 days later; say so before your user archives), `listMembers`, `addPeople`, `updateMember`, `removeMember`, `resolvePeople`. **Add people with `addPeople(workspaceId, { role? })`**: Fias draws a sheet in which the person picks co-members of their Fias workspace or types an exact username, and may change the role (you suggest `'admin'`, `'member'` or `'viewer'`); it resolves with `{ added: [{ ref, displayName, role }] }` — empty if they added no one. Your code never names who to add. (`addMember(workspaceId, username, …)` is deprecated: it works only for apps from before app user refs, and in the dev harness and Forge test channel; elsewhere it rejects with `ADD_MEMBER_USE_SHEET`.) Members come back named by your app's `ref` for them and their `displayName` — never a username or id — and `updateMember` / `removeMember` take that `{ ref }`. **Rate limits (per minute):** `create`/`archive` 20, member ops 30, `list`/`get`/`listMembers`/`resolvePeople` 120. **Error codes:** `NOT_A_MEMBER`, `FORBIDDEN`, `ROLE_CEILING`, `ADD_PEOPLE_UNAVAILABLE` (no sheet here: the Forge test channel, Company portals), `MEMBER_TARGET_USE_REF` (name members by `{ ref }`), `SHEET_BUSY`, `MUST_KEEP_ONE_OWNER`, `WORKSPACE_NOT_FOUND`, `HOLDER_BACKED_WORKSPACE`, `HOLDER_NOT_ELIGIBLE`. Read workspace documents with `useFiasDataStore().get`/`query`/`search` passing `{ workspaceId }` (every active member can read; make the collection `searchable` to use `search`). **Per-collection role floors:** raise a workspace collection's minimum role at creation — `createCollection('board_minutes', { userScope: 'workspace', writeMinRole: 'admin', readMinRole: 'member' })` makes that collection admin-write / member-read, above the default (write ≥ member, read ≥ viewer).
670
+ **Methods:** `create`, `openForWorkspace`, `list`, `get`, `archive` (owner only — final: an archived workspace cannot be reopened, and it is deleted with its documents 30 days later; say so before your user archives. A workspace holding an attested log can't be archived), `listMembers`, `addPeople`, `updateMember`, `removeMember`, `resolvePeople`. **Add people with `addPeople(workspaceId, { role? })`**: Fias draws a sheet in which the person picks co-members of their Fias workspace or types an exact username, and may change the role (you suggest `'admin'`, `'member'` or `'viewer'`); it resolves with `{ added: [{ ref, displayName, role }] }` — empty if they added no one. Your code never names who to add. (`addMember(workspaceId, username, …)` is deprecated: it works only for apps from before app user refs, and in the dev harness and Forge test channel; elsewhere it rejects with `ADD_MEMBER_USE_SHEET`.) Members come back named by your app's `ref` for them and their `displayName` — never a username or id — and `updateMember` / `removeMember` take that `{ ref }`. **Rate limits (per minute):** `create`/`archive` 20, member ops 30, `list`/`get`/`listMembers`/`resolvePeople` 120. **Error codes:** `NOT_A_MEMBER`, `FORBIDDEN`, `ROLE_CEILING`, `ADD_PEOPLE_UNAVAILABLE` (no sheet here: the Forge test channel, Company portals), `MEMBER_TARGET_USE_REF` (name members by `{ ref }`), `SHEET_BUSY`, `MUST_KEEP_ONE_OWNER`, `WORKSPACE_NOT_FOUND`, `WORKSPACE_ATTESTED`, `HOLDER_BACKED_WORKSPACE`, `HOLDER_NOT_ELIGIBLE`. Read workspace documents with `useFiasDataStore().get`/`query`/`search` passing `{ workspaceId }` (every active member can read; make the collection `searchable` to use `search`). **Per-collection role floors:** raise a workspace collection's minimum role at creation — `createCollection('board_minutes', { userScope: 'workspace', writeMinRole: 'admin', readMinRole: 'member' })` makes that collection admin-write / member-read, above the default (write ≥ member, read ≥ viewer).
646
671
 
647
672
  ### `useFiasAccess()` — The current user's own access in a workspace
648
673
 
@@ -1590,7 +1615,7 @@ for (const sound of entries) {
1590
1615
  **Permission:** `entities:client_invoke`
1591
1616
  **Returns:** `FiasContentApi`
1592
1617
 
1593
- 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.
1618
+ Ship large or numerous files — lessons, articles, data, images, audio, fonts, 3D models — 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.
1594
1619
 
1595
1620
  ```tsx
1596
1621
  import { useFiasContent, FiasContentError } from '@fias/arche-sdk';
@@ -1605,7 +1630,7 @@ const cover = await content.getObjectUrl('img/cover.webp'); // blob: URL for <im
1605
1630
  const { url } = await content.getUrl('audio/theme.ogg'); // signed URL, for long streaming audio
1606
1631
  ```
1607
1632
 
1608
- - 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.
1633
+ - 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); `.glb` (3D models, ≤ 25 MB, read with `getBytes` — see "VR and AR apps (WebXR)" for what a model may hold). A pack holds at most 10,000 files, 2,000 images (counting the textures inside models), 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1609
1634
  - 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.
1610
1635
  - 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.
1611
1636
  - 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.
@@ -1828,15 +1853,18 @@ const result = await web.extract({
1828
1853
  },
1829
1854
  },
1830
1855
  maxAgeSeconds: 24 * 3600,
1856
+ reads: 2, // money: read twice, keep only what both readings agree on
1831
1857
  });
1832
- // result.data.rates, result.site.name, result.fetchedAt, result.fromCache, result.discarded
1858
+ // result.data.rates, result.site.name, result.fetchedAt, result.fromCache, result.discarded, result.disagreed
1833
1859
  ```
1834
1860
 
1835
1861
  Your app reads pages only on **websites Fias approved** (regulated institutions such as credit unions and banks, and government sites) — any page on an approved site, so a redesigned or moved page keeps working. To have a site approved, ask Fias. `listSites()` lists them (free).
1836
1862
 
1837
- `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a product type, not a product name). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1863
+ `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a heading the page prints is fine as a choice if you listed it; a string the page chose never is). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. A model reading a page is not deterministic, so **where a wrong number matters (money), pass `reads: 2`**: the page is read twice, independently, and only a value both readings agree on is returned — a field they disagree on is `null` (a list keeps only the rows both produced) and is named in `disagreed`, for a person to confirm. A list named in `disagreed` may be missing rows the page prints: never conclude "absent on the page" (not offered) from a field in `disagreed` — treat it as unknown. Agreement says a value was stable across two readings, not that it is right; always show `site.name` and `fetchedAt` beside a figure so a person can check it. With two readings `discarded` names a list whole (`rates`), not a row. After the field names, `disagreed` names each row a list lost by its index in the in-order reading (`rates[3]`): the list you get is that reading with those rows removed. **Give a list a `key`** — the item fields that identify a row on the page (`key: ['product', 'months']`) — and a row both readings produced under one key is kept and agreed cell by cell: a cell they disagree on is `null` in the row and named `rates[3].apy` after the row entries, instead of the whole row being dropped because one cell differed (a row with a `null` key cell, or a key value a reading gave twice, is matched whole; the `3` is the row's index in the in-order reading, not in the list you received). `disagreedEntries(disagreed)` splits the entries into `fields`, `rows` and `cells`, each cell with its `position` in the list you received, and `disagreedFieldNames(disagreed)` gives the names alone; match entries by name or the `name[` prefix, never by equality or count. The reader never sees a key: a single reading Fias holds is paired, not bought again, when you add one; what two readings agreed on is read again. Two readings are two reader charges; the platform may raise the floor for every app, so check `disagreed` whatever you asked for. The reader's model, and so a reading's price, is chosen by the platform. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1864
+
1865
+ **Write the form from the page, not from what pages usually look like.** Read the page yourself first (you can; the reader cannot show it to you) and write the form from what that page prints, one per product you read off it (each is its own reading pair). List the page's own words — its table headings, its model-year bands, its term column headers — as `enum` choices on each row (up to 50 values of up to 60 characters) plus a fixed escape such as `(none of these)`, so the reader copies and never classifies; keep a small table in the app that turns each heading into the cells your code reads, and key each list by that cell and the printed term. Ask only for cells the page prints: a cell the page never prints is answered `null` by one reading and guessed by the other, whatever its description says. A page changes: rows under your escape choice, a withheld row whose key cell the readings could not agree on, the list named whole in `discarded` (with two readings it never names a row), an empty list where the page says the product is published, or far fewer rows than last time mean the form must be rewritten — tell the user which page, in plain words. On the Rate Research pilot a generic form that asked the reader to classify had 63 of 117 counted rows withheld and agreed twice on three plain misreadings; forms written from each page lost no rows on the next refresh and matched one institution's pages in 57 of 57 values. `reads: 2` stays on either way: it catches the misread digit, which no form can.
1838
1866
 
1839
- One copy of each page serves every app, and a page is fetched from its site **at most once an hour**. `maxAgeSeconds` (default one day, 3600–30 days) is the oldest copy you accept. A fresh fetch (about a tenth of a cent) and the reading (a fast model's tokens — well under a cent for a typical page) are charged to the user; a cached result costs nothing (`fromCache: true`). `stale: true` means the site could not be asked again just now and you got the last copy. Always show `site.name` and `fetchedAt` beside the figures. Not available in the builder preview or the test channel. **Error codes:** `WEB_SOURCES_DISABLED`, `INVALID_URL`, `SITE_NOT_APPROVED`, `PAGE_EXCLUDED` (a part of the site where the public posts), `ROBOTS_DISALLOWED`, `FETCH_FAILED`, `RESPONSE_TOO_LARGE`, `CONTENT_TYPE_NOT_ALLOWED`, `EXTRACTION_FAILED`, `SOURCE_BUSY`, `RATE_LIMIT` (surface it, never retry in a loop), `INSUFFICIENT_CREDITS`, `PERMISSION_DENIED`.
1867
+ One copy of each page serves every app, and a page is fetched from its site **at most once an hour**. `maxAgeSeconds` (default one day, 3600–30 days) is the oldest copy you accept. A fresh fetch (about a tenth of a cent) and each reading (a fast model's tokens — well under a cent for a typical page; two with `reads: 2`) are charged to the user; a cached result costs nothing (`fromCache: true`). `stale: true` means the site could not be asked again just now and you got the last copy. Always show `site.name` and `fetchedAt` beside the figures. Not available in the builder preview or the test channel. **Error codes:** `WEB_SOURCES_DISABLED`, `INVALID_URL`, `SITE_NOT_APPROVED`, `PAGE_EXCLUDED` (a part of the site where the public posts), `ROBOTS_DISALLOWED`, `FETCH_FAILED`, `RESPONSE_TOO_LARGE`, `CONTENT_TYPE_NOT_ALLOWED`, `EXTRACTION_FAILED`, `SOURCE_BUSY`, `RATE_LIMIT` (surface it, never retry in a loop), `INSUFFICIENT_CREDITS`, `PERMISSION_DENIED`.
1840
1868
 
1841
1869
  ### `useFiasX()` / `useFiasLinkedIn()` — Act as a connected account
1842
1870
 
@@ -2145,6 +2173,110 @@ Notes:
2145
2173
  - Only `http(s)` URLs make sense here; `javascript:`/`data:` URLs are blocked
2146
2174
  by the sandbox.
2147
2175
 
2176
+ ### VR and AR apps (WebXR)
2177
+
2178
+ A plugin can be a VR or AR app: a game, a 3D viewer, a space to walk around.
2179
+ On a headset such as a Meta Quest, the person opens your arche in the
2180
+ headset's browser and presses your "Enter VR" button. No SDK hook or
2181
+ permission is needed: the plugin frame is allowed WebXR, and you use the
2182
+ browser's own API or a library. With three.js, add `three` to the manifest's
2183
+ `dependencies` (the published arche loads it at runtime) and to `package.json`,
2184
+ with `@types/three` in `devDependencies`, all at the same exact version.
2185
+
2186
+ **Import from `three` itself, never `three/addons/...`.** The published build
2187
+ maps a dependency's bare name but not its subpaths, so `three/addons/...`
2188
+ (VRButton, OrbitControls, GLTFLoader) works under `npm run dev` and then fails
2189
+ the build with "no importmap entry". An Enter VR button is a few lines:
2190
+
2191
+ ```tsx
2192
+ import * as THREE from 'three';
2193
+
2194
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
2195
+ renderer.xr.enabled = true;
2196
+ renderer.xr.setReferenceSpaceType('local-floor'); // y = 0 is the room's floor
2197
+ container.appendChild(renderer.domElement);
2198
+
2199
+ // Offer VR only where it exists. On a phone or desktop this resolves false and
2200
+ // the same scene stays a normal 3D view.
2201
+ const vr = (await navigator.xr?.isSessionSupported('immersive-vr').catch(() => false)) ?? false;
2202
+
2203
+ // Call from the button's click handler, and request the session before any
2204
+ // other await: the click is what lets the browser start it.
2205
+ async function enterVr(xr: XRSystem) {
2206
+ const session = await xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] });
2207
+ await renderer.xr.setSession(session);
2208
+ }
2209
+
2210
+ // Not requestAnimationFrame: in VR the headset drives the frames.
2211
+ renderer.setAnimationLoop(() => renderer.render(scene, camera));
2212
+ ```
2213
+
2214
+ When you need an addon, copy its file from `node_modules/three/examples/jsm/`
2215
+ into `src/vendor/three/`, with the files it imports by relative path, keeping
2216
+ the folders. Its other imports are `three`, which the build maps. Beside it, add
2217
+ a one-line `.d.ts` so TypeScript uses `@types/three`'s types, for example
2218
+ `src/vendor/three/loaders/GLTFLoader.d.ts` holding
2219
+ `export * from 'three/examples/jsm/loaders/GLTFLoader.js';`.
2220
+
2221
+ **3D models.** Put each model in your content pack as a `.glb`
2222
+ (`content/models/chair.glb`) and read its bytes with `useFiasContent()` (see
2223
+ that section; it needs the `entities:client_invoke` permission). Your plugin
2224
+ cannot fetch, so `GLTFLoader.load(url)` fails, but `parseAsync` takes the
2225
+ bytes. Copy GLTFLoader in as above (`loaders/GLTFLoader.js` plus
2226
+ `utils/BufferGeometryUtils.js` and `utils/SkeletonUtils.js`) and swap its
2227
+ texture loader: three.js decodes embedded textures with `fetch()`, which the
2228
+ sandbox blocks, and the model then loads with NO textures and only a console
2229
+ warning says why.
2230
+
2231
+ ```tsx
2232
+ import * as THREE from 'three';
2233
+ import { useFiasContent } from '@fias/arche-sdk';
2234
+ import { GLTFLoader } from './vendor/three/loaders/GLTFLoader.js';
2235
+
2236
+ const content = useFiasContent();
2237
+ const loader = new GLTFLoader();
2238
+ loader.register((parser) => {
2239
+ parser.textureLoader = new THREE.TextureLoader(parser.options.manager); // loads through <img>
2240
+ return { name: 'fias_no_fetch_textures' };
2241
+ });
2242
+ const gltf = await loader.parseAsync(await content.getBytes('models/chair.glb'), '');
2243
+ scene.add(gltf.scene);
2244
+ ```
2245
+
2246
+ What a model may hold, and why:
2247
+
2248
+ - One self-contained binary glTF 2.0 file, at most 25 MB: everything packed
2249
+ inside, with no separate `.bin` or image files. A `.gltf` with side files is
2250
+ refused; Blender's glTF exporter makes the right kind with "glTF Binary (.glb)".
2251
+ - At most 64 textures, each a still PNG, JPEG, WebP or AVIF. Every texture is
2252
+ screened like an image file and counts toward the pack's 2,000 images; the
2253
+ names and `extras` in the model are reviewed like text.
2254
+ - Export without compression. KTX2 / Basis textures are refused, and Draco or
2255
+ meshopt geometry cannot be decoded in a plugin: their three.js decoders need
2256
+ WebAssembly, which the plugin's sandbox does not run.
2257
+
2258
+ A texture you apply in code can be its own image file in the pack: pass the URL
2259
+ from `content.getObjectUrl('textures/wall.webp')` to
2260
+ `new THREE.TextureLoader().loadAsync(url)`.
2261
+
2262
+ Notes:
2263
+
2264
+ - Entering VR or AR needs a click, and the browser may ask the person for
2265
+ consent. `immersive-ar` (passthrough on Quest) starts the same way: create
2266
+ the renderer with `alpha: true` and hide your sky so the room shows through.
2267
+ - Head and controller positions reveal things about the person, like their
2268
+ height. Use them to draw the scene, not as data you save or send.
2269
+ - To test on a headset with `fias-dev`, the page must be a secure context.
2270
+ `localhost` is one and a LAN address (`http://192.168.…`) is not, so connect
2271
+ the headset by USB with developer mode on, run the commands below, and open
2272
+ `http://localhost:3200` in the headset's browser. A published arche needs
2273
+ nothing extra.
2274
+
2275
+ ```bash
2276
+ adb reverse tcp:3100 tcp:3100 # your plugin's dev server
2277
+ adb reverse tcp:3200 tcp:3200 # the fias-dev harness
2278
+ ```
2279
+
2148
2280
  ### `useStepNavigation()` — Multi-step workflows
2149
2281
 
2150
2282
  **Permission:** None for in-memory use; `storage:sandbox` when `persistKey` is set (it reads/writes `__state/<persistKey>` via the storage bridge).
@@ -2342,7 +2474,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2342
2474
  | `expandedDescription` | No | Long-form marketplace copy |
2343
2475
  | `main` | Yes | Entry point source file |
2344
2476
  | `archeType` | Yes | `"tool"` or `"site"` |
2345
- | `categorySlug` | No | Marketplace category for discovery |
2477
+ | `categorySlug` | No | Deprecated and ignored — Arche Store categories are `store.categories` |
2346
2478
  | `icon` | No | Path to an icon image **under `public/`** (e.g. `public/icon.png`) — only `src/` and `public/` are packaged. Applied on every publish (local, staging and prod) and rehosted as your marketplace/store icon. PNG, JPEG, WebP or SVG, ≤5 MB; raster images must also be ≤1024×1024 px (SVG has no pixel limit). An invalid icon rejects the submission: a first-publish listing fee is refunded, but the AI review that already ran is still billed. To change it, replace the file (or the path) and republish; removing the field keeps the current icon. |
2347
2479
  | `tags` | No | Discovery tags |
2348
2480
  | `pricing` | Yes | Marketplace **listing** pricing (see "Pricing detail" below). NOT for in-arche charges — use `products` |
@@ -2355,6 +2487,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2355
2487
  | `isListed` | No | Default `true`. Visible in marketplace store |
2356
2488
  | `isGame` | No | Default `false`. `true` lists your arche on the Games page (`/a/games`) and badges it as a game in the Arche Store. Requires `game`. Omitting it on a republish takes the arche out of Games |
2357
2489
  | `game` | With `isGame` | `{ "genres": [...] }` — 1-4 (more only where a Fias admin allowed your arche) of `action`, `rpg`, `board`, `cards`, `casual`, `crafting`, `flying`, `match`, `platform`, `puzzle`, `strategy`, `word`, `racing`, `sports`, `simulation`, `trivia`, `music`, `educational`, `cozy`, `idle`, `tower-defense` (a kind of `strategy`: its games are listed there too). Refused without `isGame: true` |
2490
+ | `store` | No | How your arche presents itself in the Arche Store: `{ "subtitle", "categories", "releaseNotes", "languages", "screenshots" }`, every field optional (see "Your Arche Store page" below). Omitting a field on a republish clears it |
2358
2491
  | `sdk` | Yes | SDK version range |
2359
2492
  | `dependencies` | No | npm packages with **exact** versions (max 20) |
2360
2493
  | `archeIds` | Auto | Per-environment IDs written by the CLI after first publish — don't hand-edit |
@@ -2372,7 +2505,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2372
2505
 
2373
2506
  Other optional sub-fields: `currency: "usd"` (only USD supported today), `platformFeePercent` (override the default platform cut — non-standard).
2374
2507
 
2375
- **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `storage:local`, `data:store`, `data:search`, `data:workspace`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `vault:user-documents:read`, `vault:user-documents:write`, `vault:user-documents:edit`, `vault:workspace-documents:read`, `vault:creative:read`, `vault:creative:write`, `opener:send`, `assets:read`, `assets:community:read`, `assets:community:publish`, `litcraft:hub`, `navigation:open_arche`, `sandbox:vendored-libraries`, `ai:actions`, `connections:use`, `schedule:invoke`, `web:sources:read`
2508
+ **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `storage:local`, `data:store`, `data:search`, `data:workspace`, `data:attested`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `vault:user-documents:read`, `vault:user-documents:write`, `vault:user-documents:edit`, `vault:workspace-documents:read`, `vault:creative:read`, `vault:creative:write`, `opener:send`, `assets:read`, `assets:community:read`, `assets:community:publish`, `litcraft:hub`, `navigation:open_arche`, `sandbox:vendored-libraries`, `ai:actions`, `connections:use`, `schedule:invoke`, `web:sources:read`
2376
2509
 
2377
2510
  **Using AI:** Add `"entities:invoke"` to permissions, then use `useEntityInvocation()` with a model entity ID (or a `{ capability }` selector, or an entry from `useTextModels()`) and your own `systemPrompt`. Browse available models with `npx fias-dev entities`.
2378
2511
 
@@ -2434,6 +2567,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
2434
2567
  - **`window.alert()` / `confirm()` / `prompt()` do nothing** (no `allow-modals`): they don't throw — `confirm()` silently returns `false`, so gated actions never run. Build confirmation into your UI (a two-step button or an inline dialog) instead
2435
2568
  - **No `fetch()` or `XMLHttpRequest`** — all network access is blocked
2436
2569
  - **You _can_ open external links in a new tab** (e.g. `window.open('https://…', '_blank')`) — see "Opening external links" above
2570
+ - **You _can_ build VR and AR apps (WebXR)** for headsets like the Meta Quest — see "VR and AR apps (WebXR)" above
2437
2571
  - **No access** to parent DOM, cookies, or localStorage
2438
2572
  - **No external scripts or stylesheets** — everything must be bundled
2439
2573
  - All platform communication goes through the bridge (SDK hooks)
@@ -2563,6 +2697,33 @@ If the AI review rejects your submission, the listing-fee portion is
2563
2697
  refunded automatically (the per-review portion is not, since review work
2564
2698
  happened either way).
2565
2699
 
2700
+ ### Your Arche Store page
2701
+
2702
+ Every listed arche gets a public page in the Arche Store. The manifest's `store` block decides how it reads; every field is optional, and a republish that omits one clears it.
2703
+
2704
+ ```json
2705
+ {
2706
+ "store": {
2707
+ "subtitle": "Budgets that balance",
2708
+ "categories": ["finance", "productivity"],
2709
+ "releaseNotes": "New: monthly reports.\nFixed: rounding in CSV exports.",
2710
+ "languages": ["en", "pt-BR"],
2711
+ "screenshots": [
2712
+ { "src": "store/dashboard.png", "alt": "This month's spending by category" },
2713
+ { "src": "store/phone.png" }
2714
+ ]
2715
+ }
2716
+ }
2717
+ ```
2718
+
2719
+ - `subtitle` — the line under your app's name, up to 40 characters, one line.
2720
+ - `categories` — one or two of `ai-assistants`, `productivity`, `business`, `finance`, `communication`, `social`, `graphics-design`, `photo-video`, `music-audio`, `education`, `developer-tools`, `utilities`, `entertainment`, `lifestyle`, `health-fitness`, `travel`, `food-drink`, `shopping`, `news`, `reference`, `sports`. The first is your primary category: it shows under your app's name and is the one you are ranked in. **Games don't take categories** — a game is listed under its `game.genres`.
2721
+ - `releaseNotes` — this release's "What's New", up to 4,000 characters of plain text.
2722
+ - `languages` — the languages your app works in, as language tags (`"en"`, `"pt-BR"`), up to 20.
2723
+ - `screenshots` — up to 10, shown in order on your page. Each `src` is a file directly in a `store/` folder at your plugin's root (not `public/`, which is served as part of your app). PNG, JPEG or WebP, at most 2 MB, the shorter side at least 320 pixels and the longer at most 4096, between 0.4:1 (a tall phone screen) and 2.5:1 (a wide desktop one). `alt` (up to 120 characters) says what it shows. Only the files you name are sent. Each one is checked by an image safety screen when you publish, billed with your review; one the screen is unsure of is shown once a person at Fias approves it, and the rest of your release goes live meanwhile. A wide screenshot becomes your page's link preview.
2724
+
2725
+ Your app answers searches for its categories' words too — a `finance` app is found by "budget apps" even if its description never says "budget". `npx fias-dev validate` checks the block offline.
2726
+
2566
2727
  ### Managing the asset library (for `useArcheAssets`)
2567
2728
 
2568
2729
  `useArcheAssets()` reads a contributor-curated set of images pinned to the arche's published version. Populate that library with the `assets` subcommands. Each command accepts `--env <prod|staging|local>` (default `prod`) and `--arche-id <id>` (auto-resolved from the manifest when omitted).
@@ -2611,11 +2772,22 @@ Manage them on your arche's settings page ("Store listings"), or from a `store-l
2611
2772
  "isGame": true,
2612
2773
  "genres": ["word"],
2613
2774
  "icon": "store-art/wordtrace.png"
2775
+ },
2776
+ {
2777
+ "path": "/tools/budget",
2778
+ "title": "Budget Planner",
2779
+ "description": "Plan a month of spending in five minutes.",
2780
+ "isGame": false,
2781
+ "genres": [],
2782
+ "categories": ["finance"],
2783
+ "icon": "store-art/budget.png"
2614
2784
  }
2615
2785
  ]
2616
2786
  }
2617
2787
  ```
2618
2788
 
2789
+ A listing that isn't a game names up to two Arche Store `categories` instead of genres (the same ids as the manifest's `store.categories`, the first its primary one); a game's `categories` stay empty.
2790
+
2619
2791
  ```bash
2620
2792
  npx fias-dev listings list --env staging # Slots, live and retired listings
2621
2793
  npx fias-dev listings push --env staging # Show the plan and the charge, then ask