@fias/create-fias-plugin 1.23.0 → 1.25.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.23.0",
3
+ "version": "1.25.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.31.0 -->
1
+ <!-- fias-sdk-guide-version: 2.33.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -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
 
@@ -173,9 +174,13 @@ pairings instead of the flat catalog, see `FONT_PAIRINGS`.
173
174
  import { useFiasUser } from '@fias/arche-sdk';
174
175
 
175
176
  const user = useFiasUser();
176
- // { userId: string, displayName: string, avatar: string | null }
177
+ // { ref: string, displayName: string, avatar: string | null }
177
178
  ```
178
179
 
180
+ `ref` is your app's identifier for this person (`aur_…`): the same everywhere in your app, different
181
+ in every other app. Store it wherever your data names a person. Apps that existed before refs also
182
+ get a deprecated `userId`; new apps never do. In the dev harness, Mock mode gives the mock user a stable ref, and Live mode the ref the platform allocated for you; set `"mockUser": { "legacyUserId": true }` in `fias-dev.config.json` to also get `userId`, as an app from before refs does.
183
+
179
184
  ### `useFiasHolder()` — The Fias workspace the app was opened in
180
185
 
181
186
  **Permission:** `user:profile:read` or `data:workspace`
@@ -336,11 +341,36 @@ A collection can carry rules the platform checks on **every** write, so a buggy
336
341
  - **`nonNegative`**: these numeric fields must be ≥ 0 when present.
337
342
  - **`fieldBounds`**: `field <op> bound`, where `op` is `eq` / `ne` / `lt` / `lte` / `gt` / `gte` and the bound is a constant `value` or a sibling field `valueField` (exactly one).
338
343
  - **`immutableFields`**: set once; an overwrite must carry the same value.
344
+ - **`personFields`**: these fields name people. Each holds one app user ref (`useFiasUser().ref`) or a list of up to 20, never a name, username or id. Declare every field your data uses for a person: it is what lets Fias relate records to people and carry refs safely between apps.
339
345
  - **`references`**: `field` holds the key of a document that must exist in `collection`, in the same scope (same workspace, same user, or shared). An absent or null field is allowed. The writer must be able to read the target collection. Deleting a document that something still references is refused; there is no cascade.
340
346
  - **`ledgerSums`**: a counter `field` may change only by exactly the sum of the rows appended to `ledger` in the **same write**. A ledger row counts toward the counter whose key its term's `key` field holds, adding its `amount` (subtracted with `negate: true`, filtered with `when: { field, in: [...] }`). A counter that moves without rows, and rows whose counter did not move by their sum, are both refused. The ledger must be `appendOnly` and have the counter's scope; an absent counter counts as 0. At most 4 terms per rule.
341
347
 
342
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.
343
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
+
344
374
  ### Letting agents use your data (`agentAccess`)
345
375
 
346
376
  **Permission:** `data:agents` (requires `data:store`)
@@ -599,7 +629,8 @@ function Team() {
599
629
 
600
630
  async function setup() {
601
631
  const ws = await workspaces.create('Acme Corp'); // caller becomes owner
602
- await workspaces.addMember(ws.workspaceId, 'alice', 'member'); // members are addressed by username
632
+ // Fias shows the person a sheet; they choose who to add. You get refs back.
633
+ const { added } = await workspaces.addPeople(ws.workspaceId, { role: 'member' });
603
634
 
604
635
  // Workspace-scoped collection + document (role-gated server-side).
605
636
  await dataStore.createCollection('ledger', { userScope: 'workspace' });
@@ -613,7 +644,8 @@ function Team() {
613
644
  );
614
645
 
615
646
  const mine = await workspaces.list(); // workspaces I belong to
616
- const members = await workspaces.listMembers(ws.workspaceId);
647
+ const members = await workspaces.listMembers(ws.workspaceId); // [{ ref, displayName, role, … }]
648
+ await workspaces.updateMember(ws.workspaceId, { ref: members[1].ref }, 'admin');
617
649
  }
618
650
  }
619
651
  ```
@@ -628,7 +660,14 @@ if (holder?.type === 'org') {
628
660
  }
629
661
  ```
630
662
 
631
- **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`, `addMember`, `updateMember`, `removeMember`. `addMember(workspaceId, username, role, expiresAt?)` accepts an optional ISO-8601 `expiresAt` to grant **time-bounded** access (e.g. a contractor seat) — owners cannot expire; an expired member is treated as no longer a member. **Rate limits (per minute):** `create`/`archive` 20, member ops 30, `list`/`get`/`listMembers` 120. **Error codes:** `NOT_A_MEMBER`, `FORBIDDEN`, `ROLE_CEILING`, `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).
663
+ **Naming people.** Store `useFiasUser().ref` wherever your data names a person (an assignee, an "uploaded by"), and turn refs back into names with `resolvePeople(refs, workspaceId)`: up to 100 at a time, in order, each `{ ref, displayName, avatar }` or `null`. A ref is named only while it belongs to a current member of that workspace — someone who left, an unknown ref or another app's ref is `null`, and you can't tell which. Without `workspaceId` only the user's own ref resolves. In a Fias org workspace, names also need the `data:workspace-people:read` permission (human-reviewed) **and** the workspace's approval of your app covering it; without both every ref is `null`, so show a neutral label. Names never come with an id or an email.
664
+
665
+ ```tsx
666
+ const [assignee] = await workspaces.resolvePeople([task.assigneeRef], shared.workspaceId);
667
+ const label = assignee?.displayName ?? 'Former member';
668
+ ```
669
+
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).
632
671
 
633
672
  ### `useFiasAccess()` — The current user's own access in a workspace
634
673
 
@@ -1785,6 +1824,46 @@ await connections.release(connectionId); // give up your own access (owners and
1785
1824
 
1786
1825
  A person connects an outside account (X: `'x'`, LinkedIn: `'linkedin'`) to a Fias workspace — their personal one, or an organization's, where only owners and admins can — and lets your app use it. Your app **never sees a credential**: `connect(provider)` opens a screen the platform draws (it offers the workspace's existing accounts for that provider, or connects a new one in a popup) and resolves with the connection the person chose, or `{ canceled: true }`. `list()` returns only the accounts the workspace the app is open in lets it use; one with `status: 'needs_reconnect'` needs the person to connect it again. People manage connections and every app's access in Workspace Controls. `release` gives up the app's access for the whole workspace, so only the workspace's owners and admins may call it (`ROLE_NOT_ALLOWED` otherwise). Not available in the builder preview or the test channel. **Error codes:** `CONNECTION_NOT_FOUND`, `PERMISSION_DENIED`, `CONNECT_UNAVAILABLE`, `ROLE_NOT_ALLOWED`.
1787
1826
 
1827
+ ### `useFiasWebSources()` — Read pages on Fias-approved websites, as data
1828
+
1829
+ **Permission:** `web:sources:read` (human-reviewed at publish)
1830
+ **Returns:** `FiasWebSourcesApi`
1831
+
1832
+ ```tsx
1833
+ import { useFiasWebSources } from '@fias/arche-sdk';
1834
+
1835
+ const web = useFiasWebSources();
1836
+ const { sites } = await web.listSites({ category: 'credit_union', search: 'becu' });
1837
+ // [{ host: 'www.becu.org', name: 'BECU', category: 'credit_union' }]
1838
+ const result = await web.extract({
1839
+ url: 'https://www.becu.org/everyday-banking/certificates',
1840
+ form: {
1841
+ description: 'Share certificate rates',
1842
+ fields: {
1843
+ asOf: { kind: 'date', description: 'Date the rates are effective, if stated' },
1844
+ rates: {
1845
+ kind: 'list',
1846
+ maxItems: 50,
1847
+ item: {
1848
+ product: { kind: 'enum', values: ['certificate', 'ira_certificate', 'other'] },
1849
+ months: { kind: 'integer', min: 1, max: 120, description: 'Term in months' },
1850
+ apy: { kind: 'number', min: 0, max: 25, description: 'APY in percent' },
1851
+ },
1852
+ },
1853
+ },
1854
+ },
1855
+ maxAgeSeconds: 24 * 3600,
1856
+ reads: 2, // money: read twice, keep only what both readings agree on
1857
+ });
1858
+ // result.data.rates, result.site.name, result.fetchedAt, result.fromCache, result.discarded, result.disagreed
1859
+ ```
1860
+
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).
1862
+
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 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`. 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. 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
+ 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`.
1866
+
1788
1867
  ### `useFiasX()` / `useFiasLinkedIn()` — Act as a connected account
1789
1868
 
1790
1869
  **Permission:** `connections:use` (human-reviewed at publish)
@@ -1844,7 +1923,7 @@ import { useCompanyEmail } from '@fias/arche-sdk';
1844
1923
  const email = useCompanyEmail();
1845
1924
  const { results, sent } = await email.send({
1846
1925
  requestKey: `digest-${weekId}`, // reuse on retry — never double-sends
1847
- to: members.map((m) => ({ userId: m.userId })),
1926
+ to: assigneeRefs.map((ref) => ({ ref })), // refs your app stored (useFiasUser().ref)
1848
1927
  subject: 'Your weekly digest',
1849
1928
  text: digestText, // required
1850
1929
  html: digestHtml, // optional, sanitized
@@ -1853,7 +1932,7 @@ const { results, sent } = await email.send({
1853
1932
  ```
1854
1933
 
1855
1934
  - **You never choose the From address** — it is the address the Company assigned your app. You choose recipients and content.
1856
- - **Name members by `userId`**; you never see their addresses. Raw `{ email }` recipients work only if the Company let your app email anyone. One recipient outside what the Company allowed refuses the whole call (without saying which).
1935
+ - **Name members by their ref** (`{ ref }` — see Naming people under `useFiasWorkspaces()`); you never see their addresses. A ref that names no one is refused exactly like a member outside what the Company allowed. `{ userId }` is deprecated and accepted only from apps that existed before refs. Raw `{ email }` recipients work only if the Company let your app email anyone. One recipient outside what the Company allowed refuses the whole call (without saying which).
1857
1936
  - **The user must be working in one of the Company's workspaces, with edit access.** Each recipient is charged to that user, like any paid call; up to 50 recipients per call, and a daily limit per app per Company.
1858
1937
  - **Retry with the same `requestKey`.** A recipient already sent under that key is not sent again. `unknown` means it may have gone out — do not resend it under a new key.
1859
1938
  - **HTML is sanitized** to the email allow-list: tables, inline presentational styles, `https` images and `https`/`mailto` links are kept; `<script>`, `<form>`, `<iframe>` and similar refuse the send (`EMAIL_CONTENT_REJECTED`).
@@ -2289,7 +2368,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2289
2368
  | `expandedDescription` | No | Long-form marketplace copy |
2290
2369
  | `main` | Yes | Entry point source file |
2291
2370
  | `archeType` | Yes | `"tool"` or `"site"` |
2292
- | `categorySlug` | No | Marketplace category for discovery |
2371
+ | `categorySlug` | No | Deprecated and ignored — Arche Store categories are `store.categories` |
2293
2372
  | `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. |
2294
2373
  | `tags` | No | Discovery tags |
2295
2374
  | `pricing` | Yes | Marketplace **listing** pricing (see "Pricing detail" below). NOT for in-arche charges — use `products` |
@@ -2302,6 +2381,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2302
2381
  | `isListed` | No | Default `true`. Visible in marketplace store |
2303
2382
  | `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 |
2304
2383
  | `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` |
2384
+ | `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 |
2305
2385
  | `sdk` | Yes | SDK version range |
2306
2386
  | `dependencies` | No | npm packages with **exact** versions (max 20) |
2307
2387
  | `archeIds` | Auto | Per-environment IDs written by the CLI after first publish — don't hand-edit |
@@ -2319,7 +2399,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2319
2399
 
2320
2400
  Other optional sub-fields: `currency: "usd"` (only USD supported today), `platformFeePercent` (override the default platform cut — non-standard).
2321
2401
 
2322
- **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`
2402
+ **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`
2323
2403
 
2324
2404
  **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`.
2325
2405
 
@@ -2510,6 +2590,33 @@ If the AI review rejects your submission, the listing-fee portion is
2510
2590
  refunded automatically (the per-review portion is not, since review work
2511
2591
  happened either way).
2512
2592
 
2593
+ ### Your Arche Store page
2594
+
2595
+ 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.
2596
+
2597
+ ```json
2598
+ {
2599
+ "store": {
2600
+ "subtitle": "Budgets that balance",
2601
+ "categories": ["finance", "productivity"],
2602
+ "releaseNotes": "New: monthly reports.\nFixed: rounding in CSV exports.",
2603
+ "languages": ["en", "pt-BR"],
2604
+ "screenshots": [
2605
+ { "src": "store/dashboard.png", "alt": "This month's spending by category" },
2606
+ { "src": "store/phone.png" }
2607
+ ]
2608
+ }
2609
+ }
2610
+ ```
2611
+
2612
+ - `subtitle` — the line under your app's name, up to 40 characters, one line.
2613
+ - `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`.
2614
+ - `releaseNotes` — this release's "What's New", up to 4,000 characters of plain text.
2615
+ - `languages` — the languages your app works in, as language tags (`"en"`, `"pt-BR"`), up to 20.
2616
+ - `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.
2617
+
2618
+ 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.
2619
+
2513
2620
  ### Managing the asset library (for `useArcheAssets`)
2514
2621
 
2515
2622
  `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).
@@ -2558,11 +2665,22 @@ Manage them on your arche's settings page ("Store listings"), or from a `store-l
2558
2665
  "isGame": true,
2559
2666
  "genres": ["word"],
2560
2667
  "icon": "store-art/wordtrace.png"
2668
+ },
2669
+ {
2670
+ "path": "/tools/budget",
2671
+ "title": "Budget Planner",
2672
+ "description": "Plan a month of spending in five minutes.",
2673
+ "isGame": false,
2674
+ "genres": [],
2675
+ "categories": ["finance"],
2676
+ "icon": "store-art/budget.png"
2561
2677
  }
2562
2678
  ]
2563
2679
  }
2564
2680
  ```
2565
2681
 
2682
+ 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.
2683
+
2566
2684
  ```bash
2567
2685
  npx fias-dev listings list --env staging # Slots, live and retired listings
2568
2686
  npx fias-dev listings push --env staging # Show the plan and the charge, then ask
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.31.0 -->
1
+ <!-- fias-sdk-guide-version: 2.33.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -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
 
@@ -173,9 +174,13 @@ pairings instead of the flat catalog, see `FONT_PAIRINGS`.
173
174
  import { useFiasUser } from '@fias/arche-sdk';
174
175
 
175
176
  const user = useFiasUser();
176
- // { userId: string, displayName: string, avatar: string | null }
177
+ // { ref: string, displayName: string, avatar: string | null }
177
178
  ```
178
179
 
180
+ `ref` is your app's identifier for this person (`aur_…`): the same everywhere in your app, different
181
+ in every other app. Store it wherever your data names a person. Apps that existed before refs also
182
+ get a deprecated `userId`; new apps never do. In the dev harness, Mock mode gives the mock user a stable ref, and Live mode the ref the platform allocated for you; set `"mockUser": { "legacyUserId": true }` in `fias-dev.config.json` to also get `userId`, as an app from before refs does.
183
+
179
184
  ### `useFiasHolder()` — The Fias workspace the app was opened in
180
185
 
181
186
  **Permission:** `user:profile:read` or `data:workspace`
@@ -336,11 +341,36 @@ A collection can carry rules the platform checks on **every** write, so a buggy
336
341
  - **`nonNegative`**: these numeric fields must be ≥ 0 when present.
337
342
  - **`fieldBounds`**: `field <op> bound`, where `op` is `eq` / `ne` / `lt` / `lte` / `gt` / `gte` and the bound is a constant `value` or a sibling field `valueField` (exactly one).
338
343
  - **`immutableFields`**: set once; an overwrite must carry the same value.
344
+ - **`personFields`**: these fields name people. Each holds one app user ref (`useFiasUser().ref`) or a list of up to 20, never a name, username or id. Declare every field your data uses for a person: it is what lets Fias relate records to people and carry refs safely between apps.
339
345
  - **`references`**: `field` holds the key of a document that must exist in `collection`, in the same scope (same workspace, same user, or shared). An absent or null field is allowed. The writer must be able to read the target collection. Deleting a document that something still references is refused; there is no cascade.
340
346
  - **`ledgerSums`**: a counter `field` may change only by exactly the sum of the rows appended to `ledger` in the **same write**. A ledger row counts toward the counter whose key its term's `key` field holds, adding its `amount` (subtracted with `negate: true`, filtered with `when: { field, in: [...] }`). A counter that moves without rows, and rows whose counter did not move by their sum, are both refused. The ledger must be `appendOnly` and have the counter's scope; an absent counter counts as 0. At most 4 terms per rule.
341
347
 
342
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.
343
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
+
344
374
  ### Letting agents use your data (`agentAccess`)
345
375
 
346
376
  **Permission:** `data:agents` (requires `data:store`)
@@ -599,7 +629,8 @@ function Team() {
599
629
 
600
630
  async function setup() {
601
631
  const ws = await workspaces.create('Acme Corp'); // caller becomes owner
602
- await workspaces.addMember(ws.workspaceId, 'alice', 'member'); // members are addressed by username
632
+ // Fias shows the person a sheet; they choose who to add. You get refs back.
633
+ const { added } = await workspaces.addPeople(ws.workspaceId, { role: 'member' });
603
634
 
604
635
  // Workspace-scoped collection + document (role-gated server-side).
605
636
  await dataStore.createCollection('ledger', { userScope: 'workspace' });
@@ -613,7 +644,8 @@ function Team() {
613
644
  );
614
645
 
615
646
  const mine = await workspaces.list(); // workspaces I belong to
616
- const members = await workspaces.listMembers(ws.workspaceId);
647
+ const members = await workspaces.listMembers(ws.workspaceId); // [{ ref, displayName, role, … }]
648
+ await workspaces.updateMember(ws.workspaceId, { ref: members[1].ref }, 'admin');
617
649
  }
618
650
  }
619
651
  ```
@@ -628,7 +660,14 @@ if (holder?.type === 'org') {
628
660
  }
629
661
  ```
630
662
 
631
- **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`, `addMember`, `updateMember`, `removeMember`. `addMember(workspaceId, username, role, expiresAt?)` accepts an optional ISO-8601 `expiresAt` to grant **time-bounded** access (e.g. a contractor seat) — owners cannot expire; an expired member is treated as no longer a member. **Rate limits (per minute):** `create`/`archive` 20, member ops 30, `list`/`get`/`listMembers` 120. **Error codes:** `NOT_A_MEMBER`, `FORBIDDEN`, `ROLE_CEILING`, `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).
663
+ **Naming people.** Store `useFiasUser().ref` wherever your data names a person (an assignee, an "uploaded by"), and turn refs back into names with `resolvePeople(refs, workspaceId)`: up to 100 at a time, in order, each `{ ref, displayName, avatar }` or `null`. A ref is named only while it belongs to a current member of that workspace — someone who left, an unknown ref or another app's ref is `null`, and you can't tell which. Without `workspaceId` only the user's own ref resolves. In a Fias org workspace, names also need the `data:workspace-people:read` permission (human-reviewed) **and** the workspace's approval of your app covering it; without both every ref is `null`, so show a neutral label. Names never come with an id or an email.
664
+
665
+ ```tsx
666
+ const [assignee] = await workspaces.resolvePeople([task.assigneeRef], shared.workspaceId);
667
+ const label = assignee?.displayName ?? 'Former member';
668
+ ```
669
+
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).
632
671
 
633
672
  ### `useFiasAccess()` — The current user's own access in a workspace
634
673
 
@@ -1785,6 +1824,46 @@ await connections.release(connectionId); // give up your own access (owners and
1785
1824
 
1786
1825
  A person connects an outside account (X: `'x'`, LinkedIn: `'linkedin'`) to a Fias workspace — their personal one, or an organization's, where only owners and admins can — and lets your app use it. Your app **never sees a credential**: `connect(provider)` opens a screen the platform draws (it offers the workspace's existing accounts for that provider, or connects a new one in a popup) and resolves with the connection the person chose, or `{ canceled: true }`. `list()` returns only the accounts the workspace the app is open in lets it use; one with `status: 'needs_reconnect'` needs the person to connect it again. People manage connections and every app's access in Workspace Controls. `release` gives up the app's access for the whole workspace, so only the workspace's owners and admins may call it (`ROLE_NOT_ALLOWED` otherwise). Not available in the builder preview or the test channel. **Error codes:** `CONNECTION_NOT_FOUND`, `PERMISSION_DENIED`, `CONNECT_UNAVAILABLE`, `ROLE_NOT_ALLOWED`.
1787
1826
 
1827
+ ### `useFiasWebSources()` — Read pages on Fias-approved websites, as data
1828
+
1829
+ **Permission:** `web:sources:read` (human-reviewed at publish)
1830
+ **Returns:** `FiasWebSourcesApi`
1831
+
1832
+ ```tsx
1833
+ import { useFiasWebSources } from '@fias/arche-sdk';
1834
+
1835
+ const web = useFiasWebSources();
1836
+ const { sites } = await web.listSites({ category: 'credit_union', search: 'becu' });
1837
+ // [{ host: 'www.becu.org', name: 'BECU', category: 'credit_union' }]
1838
+ const result = await web.extract({
1839
+ url: 'https://www.becu.org/everyday-banking/certificates',
1840
+ form: {
1841
+ description: 'Share certificate rates',
1842
+ fields: {
1843
+ asOf: { kind: 'date', description: 'Date the rates are effective, if stated' },
1844
+ rates: {
1845
+ kind: 'list',
1846
+ maxItems: 50,
1847
+ item: {
1848
+ product: { kind: 'enum', values: ['certificate', 'ira_certificate', 'other'] },
1849
+ months: { kind: 'integer', min: 1, max: 120, description: 'Term in months' },
1850
+ apy: { kind: 'number', min: 0, max: 25, description: 'APY in percent' },
1851
+ },
1852
+ },
1853
+ },
1854
+ },
1855
+ maxAgeSeconds: 24 * 3600,
1856
+ reads: 2, // money: read twice, keep only what both readings agree on
1857
+ });
1858
+ // result.data.rates, result.site.name, result.fetchedAt, result.fromCache, result.discarded, result.disagreed
1859
+ ```
1860
+
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).
1862
+
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 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`. 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. 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
+ 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`.
1866
+
1788
1867
  ### `useFiasX()` / `useFiasLinkedIn()` — Act as a connected account
1789
1868
 
1790
1869
  **Permission:** `connections:use` (human-reviewed at publish)
@@ -1844,7 +1923,7 @@ import { useCompanyEmail } from '@fias/arche-sdk';
1844
1923
  const email = useCompanyEmail();
1845
1924
  const { results, sent } = await email.send({
1846
1925
  requestKey: `digest-${weekId}`, // reuse on retry — never double-sends
1847
- to: members.map((m) => ({ userId: m.userId })),
1926
+ to: assigneeRefs.map((ref) => ({ ref })), // refs your app stored (useFiasUser().ref)
1848
1927
  subject: 'Your weekly digest',
1849
1928
  text: digestText, // required
1850
1929
  html: digestHtml, // optional, sanitized
@@ -1853,7 +1932,7 @@ const { results, sent } = await email.send({
1853
1932
  ```
1854
1933
 
1855
1934
  - **You never choose the From address** — it is the address the Company assigned your app. You choose recipients and content.
1856
- - **Name members by `userId`**; you never see their addresses. Raw `{ email }` recipients work only if the Company let your app email anyone. One recipient outside what the Company allowed refuses the whole call (without saying which).
1935
+ - **Name members by their ref** (`{ ref }` — see Naming people under `useFiasWorkspaces()`); you never see their addresses. A ref that names no one is refused exactly like a member outside what the Company allowed. `{ userId }` is deprecated and accepted only from apps that existed before refs. Raw `{ email }` recipients work only if the Company let your app email anyone. One recipient outside what the Company allowed refuses the whole call (without saying which).
1857
1936
  - **The user must be working in one of the Company's workspaces, with edit access.** Each recipient is charged to that user, like any paid call; up to 50 recipients per call, and a daily limit per app per Company.
1858
1937
  - **Retry with the same `requestKey`.** A recipient already sent under that key is not sent again. `unknown` means it may have gone out — do not resend it under a new key.
1859
1938
  - **HTML is sanitized** to the email allow-list: tables, inline presentational styles, `https` images and `https`/`mailto` links are kept; `<script>`, `<form>`, `<iframe>` and similar refuse the send (`EMAIL_CONTENT_REJECTED`).
@@ -2289,7 +2368,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2289
2368
  | `expandedDescription` | No | Long-form marketplace copy |
2290
2369
  | `main` | Yes | Entry point source file |
2291
2370
  | `archeType` | Yes | `"tool"` or `"site"` |
2292
- | `categorySlug` | No | Marketplace category for discovery |
2371
+ | `categorySlug` | No | Deprecated and ignored — Arche Store categories are `store.categories` |
2293
2372
  | `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. |
2294
2373
  | `tags` | No | Discovery tags |
2295
2374
  | `pricing` | Yes | Marketplace **listing** pricing (see "Pricing detail" below). NOT for in-arche charges — use `products` |
@@ -2302,6 +2381,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2302
2381
  | `isListed` | No | Default `true`. Visible in marketplace store |
2303
2382
  | `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 |
2304
2383
  | `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` |
2384
+ | `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 |
2305
2385
  | `sdk` | Yes | SDK version range |
2306
2386
  | `dependencies` | No | npm packages with **exact** versions (max 20) |
2307
2387
  | `archeIds` | Auto | Per-environment IDs written by the CLI after first publish — don't hand-edit |
@@ -2319,7 +2399,7 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
2319
2399
 
2320
2400
  Other optional sub-fields: `currency: "usd"` (only USD supported today), `platformFeePercent` (override the default platform cut — non-standard).
2321
2401
 
2322
- **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`
2402
+ **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`
2323
2403
 
2324
2404
  **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`.
2325
2405
 
@@ -2510,6 +2590,33 @@ If the AI review rejects your submission, the listing-fee portion is
2510
2590
  refunded automatically (the per-review portion is not, since review work
2511
2591
  happened either way).
2512
2592
 
2593
+ ### Your Arche Store page
2594
+
2595
+ 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.
2596
+
2597
+ ```json
2598
+ {
2599
+ "store": {
2600
+ "subtitle": "Budgets that balance",
2601
+ "categories": ["finance", "productivity"],
2602
+ "releaseNotes": "New: monthly reports.\nFixed: rounding in CSV exports.",
2603
+ "languages": ["en", "pt-BR"],
2604
+ "screenshots": [
2605
+ { "src": "store/dashboard.png", "alt": "This month's spending by category" },
2606
+ { "src": "store/phone.png" }
2607
+ ]
2608
+ }
2609
+ }
2610
+ ```
2611
+
2612
+ - `subtitle` — the line under your app's name, up to 40 characters, one line.
2613
+ - `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`.
2614
+ - `releaseNotes` — this release's "What's New", up to 4,000 characters of plain text.
2615
+ - `languages` — the languages your app works in, as language tags (`"en"`, `"pt-BR"`), up to 20.
2616
+ - `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.
2617
+
2618
+ 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.
2619
+
2513
2620
  ### Managing the asset library (for `useArcheAssets`)
2514
2621
 
2515
2622
  `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).
@@ -2558,11 +2665,22 @@ Manage them on your arche's settings page ("Store listings"), or from a `store-l
2558
2665
  "isGame": true,
2559
2666
  "genres": ["word"],
2560
2667
  "icon": "store-art/wordtrace.png"
2668
+ },
2669
+ {
2670
+ "path": "/tools/budget",
2671
+ "title": "Budget Planner",
2672
+ "description": "Plan a month of spending in five minutes.",
2673
+ "isGame": false,
2674
+ "genres": [],
2675
+ "categories": ["finance"],
2676
+ "icon": "store-art/budget.png"
2561
2677
  }
2562
2678
  ]
2563
2679
  }
2564
2680
  ```
2565
2681
 
2682
+ 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.
2683
+
2566
2684
  ```bash
2567
2685
  npx fias-dev listings list --env staging # Slots, live and retired listings
2568
2686
  npx fias-dev listings push --env staging # Show the plan and the charge, then ask