@colixsystems/widget-sdk 0.239.0 → 0.241.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/README.md CHANGED
@@ -84,7 +84,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.239.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+ `v0.241.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
88
+
89
+ ### What's new in 0.241.0 (contract 1.195.0)
90
+
91
+ **A signing round can include people without an account in the app.** `useSigningRound(fileId).prepare(signers)` takes, beside account ids, `{ name, email }` entries, mixed in the order of the places on the signature page. When the round is confirmed, each of those people is emailed a personal BankID signing link that is valid for 24 hours. Anyone who holds the link can sign, so tell the person sending the document before they send it. In the round a manager sees each place's `id`, `kind` (`"app_user"` or `"email"`), `email`, `signed_as` (the name BankID verified, which can differ from the name you entered) and, for an email place, `link`: `{ status, sent_at, expires_at, used_at, links_sent, can_resend }`, where `status` is `"sending"`, `"sent"`, `"delivery_failed"`, `"expired"`, `"used"` or `"cancelled"`. `resendLink(signerId, { email })` replaces the place's link with a new one (the old link stops working), sent to a corrected address when you pass one. Offer it only where `link.can_resend` is true. A refused signer's `errors` entry now carries `index`, its position in the list you sent, so you can mark the right entry. A new link sent too soon is refused with `SIGNING_LINK_RESEND_TOO_SOON` and a `retryAfter` in seconds. A list of account ids alone works exactly as before. **One type changes:** `SigningRoundSigner.app_user_id` widens from `string` to `string | null`, because an email place has no account; TypeScript code that read it as a string must check for null. `resendLink` needs `@colixsystems/filestore-client@0.14.0`; with an older client it is refused with code `NOT_SUPPORTED`.
92
+
93
+ ```jsx
94
+ const { round, prepare, resendLink } = useSigningRound(fileId);
95
+ await prepare(["user_1", { name: "Bo Ek", email: "bo@example.se" }]);
96
+ // later, for an email place whose link.can_resend is true:
97
+ await resendLink(place.id, { email: "bo.ek@example.se" });
98
+ ```
99
+
100
+ ### What's new in 0.240.0 (contract 1.194.0)
101
+
102
+ **A `datastoreTemplate` can describe a whole system.** A template may now declare up to 100 tables of up to 200 columns, up to 100 sample `rows` per table and up to 32 `roles`. Sample rows have one more bound: the whole template seeds at most 20000 cells, where each row counts the keys it sets and at least one, summed over every row of every table. A template past any limit is refused at publish with a message naming the limit and the count. Every template valid under 0.239.0 is still valid.
88
103
 
89
104
  ### What's new in 0.239.0 (contract 1.193.0)
90
105
 
@@ -1601,7 +1616,7 @@ datastoreTemplate: {
1601
1616
 
1602
1617
  `roles` is declared ONCE per template and referenced by every table, so three widgets over two tables ask the installing workspace **one** question per audience rather than one per grant. At install they pick one of their own groups for each role, or have one created for them under the role's label.
1603
1618
 
1604
- - **`key`** is lowercase-kebab (`/^[a-z][a-z0-9-]*$/`), at most 8 roles per template. A `roleGrants` entry naming a role you did not declare is a publish error, as is granting the same role twice on one table or granting it no verb at all.
1619
+ - **`key`** is lowercase-kebab (`/^[a-z][a-z0-9-]*$/`); the roles cap is in `WidgetDatastoreTemplate`. A `roleGrants` entry naming a role you did not declare is a publish error, as is granting the same role twice on one table or granting it no verb at all.
1605
1620
  - **`canDelete` is row-level only.** A table-scope grant answers "may I create a record"; only a record permission answers a question about a row. Both halves are written for you from the one declaration.
1606
1621
  - **An unbound role is an ordinary outcome, never a failed install.** If the installer skips a role, no grant is written and they are told which role is still unbound — they can bind it later from the table's permissions.
1607
1622
  - **Purely additive.** A template declaring neither `roles` nor `roleGrants` behaves exactly as it does today.
@@ -2574,7 +2589,7 @@ useEffect(() => {
2574
2589
 
2575
2590
  ### What's new in 0.51.0
2576
2591
 
2577
- **`datastoreTemplate` tables can ship sample `rows` (sc-2070).** Each `WidgetDatastoreTemplateTable` now takes an optional `rows` array — sample data seeded into the table at install time so the widget renders with real content instead of an empty state. Each entry is an object keyed by column `name`; only the scalar/array column types are seedable (`STRING`, `TEXT`, `NUMBER`, `FLOAT`, `BOOL`, `DATE`, `STRING_ARRAY`, `INT_ARRAY`). RELATION, FILE, USER, and USER_GROUP columns are rejected at validation time (a sample row has no way to express their ids). A `null` value skips that cell; at most 25 rows per table. The "Seed data" action and every install path seed these rows in the same transaction that creates the tables. Additive — `rows` is optional and existing templates that omit it behave exactly as before.
2592
+ **`datastoreTemplate` tables can ship sample `rows` (sc-2070).** Each `WidgetDatastoreTemplateTable` now takes an optional `rows` array — sample data seeded into the table at install time so the widget renders with real content instead of an empty state. Each entry is an object keyed by column `name`; only the scalar/array column types are seedable (`STRING`, `TEXT`, `NUMBER`, `FLOAT`, `BOOL`, `DATE`, `STRING_ARRAY`, `INT_ARRAY`). RELATION, FILE, USER, and USER_GROUP columns are rejected at validation time (a sample row has no way to express their ids). A `null` value skips that cell; the per-table row cap is on `WidgetDatastoreTemplateTable.rows`. The "Seed data" action and every install path seed these rows in the same transaction that creates the tables. Additive — `rows` is optional and existing templates that omit it behave exactly as before.
2578
2593
 
2579
2594
  ### What's new in 0.49.0
2580
2595
 
@@ -2939,7 +2954,7 @@ The "split-implementation + vetted package list" pivot.
2939
2954
 
2940
2955
  ### What was in 0.3.0
2941
2956
 
2942
- Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the built-in template semantics (auto-suffixed naming, creator-grants, public grants, cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
2957
+ Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the built-in template semantics (auto-suffixed naming, creator-grants, public grants, cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the current limits; RELATION columns address siblings by `suffix`.
2943
2958
 
2944
2959
  ## Public API
2945
2960
 
package/dist/contract.cjs CHANGED
@@ -1747,15 +1747,27 @@ const HOOKS = [
1747
1747
  signature: "useSigningRound(fileId)",
1748
1748
  description:
1749
1749
  "One file's current signing round, and the actions of someone who " +
1750
- "manages the file's folder. prepare(signerIds) drafts a round for those " +
1751
- "users, their places on the signature page in the order given (take the " +
1752
- "ids from useFileRoster()); it can take " +
1750
+ "manages the file's folder. prepare(signers) drafts a round for those " +
1751
+ "people, their places on the signature page in the order given: each " +
1752
+ "entry is an account id from useFileRoster() (or { app_user_id }), or " +
1753
+ "{ name, email } for someone without an account, who is emailed a " +
1754
+ "personal BankID signing link valid for 24 hours once the round is " +
1755
+ "confirmed (anyone holding that link can sign, so say so before " +
1756
+ "sending); it can take " +
1753
1757
  "up to a minute. preparedFile() returns a five-minute link to the " +
1754
1758
  "prepared document; show it to the user before confirming. confirm() " +
1755
1759
  "opens the round for exactly the document preparedFile() last returned, " +
1756
1760
  "and rejects with code PREVIEW_REQUIRED when it was not called first. " +
1757
1761
  "cancel() ends a draft or an open round and resolves to the cancelled " +
1758
- "round (status 'CANCELLED'); round is then null. ONE ACTION RUNS AT A " +
1762
+ "round (status 'CANCELLED'); round is then null. " +
1763
+ "resendLink(signerId, { email }) replaces an email place's link with " +
1764
+ "a new one (the old one stops working), sent to the corrected email " +
1765
+ "when given, and resolves to the round; offer it only where the " +
1766
+ "place's link.can_resend is true. A manager sees each place's id, kind " +
1767
+ "('app_user' | 'email'), email, signed_as (the name BankID verified) " +
1768
+ "and link ({ status: 'sending' | 'sent' | 'delivery_failed' | " +
1769
+ "'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, " +
1770
+ "links_sent, can_resend }, null for an account place). ONE ACTION RUNS AT A " +
1759
1771
  "TIME: while busy is true any other action is refused at once with code " +
1760
1772
  "ACTION_IN_PROGRESS (it is not queued), so disable your buttons on busy. " +
1761
1773
  "round is the full round as the " +
@@ -1763,8 +1775,15 @@ const HOOKS = [
1763
1775
  "every signer's name and position and their own progress only, anyone " +
1764
1776
  "else sees null. A refused action sets error and rejects with it: code " +
1765
1777
  "and message come from the server (show message as it is), errors has " +
1766
- "one entry per refused signer ({ app_user_id, message, reason, " +
1767
- "characters }), retryAfter is the seconds to wait. The hook's own " +
1778
+ "one entry per refused signer ({ index, app_user_id, message, reason, " +
1779
+ "characters }; index is the entry's place in the list sent), " +
1780
+ "retryAfter is the seconds to wait. resendLink is refused with " +
1781
+ "SIGNING_LINK_RESEND_TOO_SOON (with retryAfter), " +
1782
+ "SIGNING_LINK_RESEND_IN_PROGRESS, SIGNING_LINK_LIMIT_REACHED, " +
1783
+ "SIGNING_LINK_BUDGET_EXCEEDED, SIGNING_LINK_PLATFORM_LIMIT, " +
1784
+ "INVALID_EMAIL, DUPLICATE_SIGNER, SIGNING_ROUND_NOT_OPEN, " +
1785
+ "SIGNING_ROUND_CLOSED or ALREADY_SIGNED, and by the hook itself with " +
1786
+ "NOT_SUPPORTED when the host's filestore client cannot send links. The hook's own " +
1768
1787
  "refusals (PREVIEW_REQUIRED, ACTION_IN_PROGRESS, NO_SIGNING_ROUND) carry " +
1769
1788
  "a message that can be shown the same way. When a refusal means the " +
1770
1789
  "round is no longer what was shown (it expired, was closed, or the " +
@@ -1776,14 +1795,16 @@ const HOOKS = [
1776
1795
  returnShape: {
1777
1796
  round:
1778
1797
  "{ id, file_id, status: 'DRAFT' | 'OPEN' | 'COMPLETED', created_at, confirmed_at, completed_at, expires_at, " +
1779
- "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as? }[] } | null",
1798
+ "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as?, id?, kind?: 'app_user' | 'email', email?, " +
1799
+ "link?: { status: 'sending' | 'sent' | 'delivery_failed' | 'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, links_sent, can_resend } | null }[] } | null",
1780
1800
  loading: "boolean",
1781
1801
  error: "PermissionError | null // { code, message, retryable, errors, retryAfter }",
1782
1802
  refresh: "() => Promise<void>",
1783
- prepare: "(signerIds: string[]) => Promise<round> // status 'DRAFT'",
1803
+ prepare: "(signers: (string | { app_user_id } | { name, email })[]) => Promise<round> // status 'DRAFT'",
1784
1804
  preparedFile: "() => Promise<{ url, sha256, expires_at, size_bytes }>",
1785
1805
  confirm: "() => Promise<round> // status 'OPEN'",
1786
1806
  cancel: "() => Promise<round> // the round it ended, status 'CANCELLED'",
1807
+ resendLink: "(signerId: string, options?: { email?: string }) => Promise<round> // a new link for an email place",
1787
1808
  busy: "boolean // an action is running; another one is refused with ACTION_IN_PROGRESS",
1788
1809
  },
1789
1810
  requiredContextSlice: ["filestore.signingRounds"],
@@ -3690,7 +3711,7 @@ const MANIFEST_SCHEMA = {
3690
3711
  type: "object",
3691
3712
  required: false,
3692
3713
  description:
3693
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3714
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3694
3715
  },
3695
3716
  translations: {
3696
3717
  type: "object",
@@ -3892,7 +3913,7 @@ const WIDGET_CONTEXT_SHAPE = {
3892
3913
  "{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), copy(id, body), remove(id), preview(id) }, " +
3893
3914
  "folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
3894
3915
  "signatures: { list(fileIds), roster(fileId, query), initiate(fileId), status(id), cancel(id), verify(id) }, " +
3895
- "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signerIds), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId) }, " +
3916
+ "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signers), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId), resendLink(roundId, signerId, { email }) }, " +
3896
3917
  "objectUrl(token), fetchObject(token) }. " +
3897
3918
  "Backs useFilestoreFiles() + useFilestoreFolders() + useFilestoreCreateFolder() + useFilestoreCopy() + useFileSignature() + useFileSignatures() + useFileRoster() + useSigningRounds() + useSigningRound(). Optional — a host without an end-user filestore omits it and those hooks throw a clear error.",
3898
3919
  required: false,
@@ -6090,7 +6111,13 @@ const CONTRACT = deepFreeze({
6090
6111
  // 1.193.0: additive (sc-11821) — `useNavigation().scrollToSection(id)`
6091
6112
  // scrolls the page to one of its sections on both hosts, and the
6092
6113
  // `sectionRef` propertySchema type gives the author a section picker.
6093
- version: "1.193.0",
6114
+ // 1.194.0: more permissive (sc-11945) — a datastoreTemplate takes 100
6115
+ // tables, 200 columns, 100 rows per table, 20000 cells and 32 roles.
6116
+ // 1.195.0: additive (sc-11879) — `useSigningRound().prepare` takes
6117
+ // `{ name, email }` entries for signers without an account, and
6118
+ // `resendLink(signerId, { email })` sends such a signer a new link.
6119
+ // The manager's places carry id, kind, email and link.
6120
+ version: "1.195.0",
6094
6121
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6095
6122
  hooks: HOOKS,
6096
6123
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1747,15 +1747,27 @@ const HOOKS = [
1747
1747
  signature: "useSigningRound(fileId)",
1748
1748
  description:
1749
1749
  "One file's current signing round, and the actions of someone who " +
1750
- "manages the file's folder. prepare(signerIds) drafts a round for those " +
1751
- "users, their places on the signature page in the order given (take the " +
1752
- "ids from useFileRoster()); it can take " +
1750
+ "manages the file's folder. prepare(signers) drafts a round for those " +
1751
+ "people, their places on the signature page in the order given: each " +
1752
+ "entry is an account id from useFileRoster() (or { app_user_id }), or " +
1753
+ "{ name, email } for someone without an account, who is emailed a " +
1754
+ "personal BankID signing link valid for 24 hours once the round is " +
1755
+ "confirmed (anyone holding that link can sign, so say so before " +
1756
+ "sending); it can take " +
1753
1757
  "up to a minute. preparedFile() returns a five-minute link to the " +
1754
1758
  "prepared document; show it to the user before confirming. confirm() " +
1755
1759
  "opens the round for exactly the document preparedFile() last returned, " +
1756
1760
  "and rejects with code PREVIEW_REQUIRED when it was not called first. " +
1757
1761
  "cancel() ends a draft or an open round and resolves to the cancelled " +
1758
- "round (status 'CANCELLED'); round is then null. ONE ACTION RUNS AT A " +
1762
+ "round (status 'CANCELLED'); round is then null. " +
1763
+ "resendLink(signerId, { email }) replaces an email place's link with " +
1764
+ "a new one (the old one stops working), sent to the corrected email " +
1765
+ "when given, and resolves to the round; offer it only where the " +
1766
+ "place's link.can_resend is true. A manager sees each place's id, kind " +
1767
+ "('app_user' | 'email'), email, signed_as (the name BankID verified) " +
1768
+ "and link ({ status: 'sending' | 'sent' | 'delivery_failed' | " +
1769
+ "'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, " +
1770
+ "links_sent, can_resend }, null for an account place). ONE ACTION RUNS AT A " +
1759
1771
  "TIME: while busy is true any other action is refused at once with code " +
1760
1772
  "ACTION_IN_PROGRESS (it is not queued), so disable your buttons on busy. " +
1761
1773
  "round is the full round as the " +
@@ -1763,8 +1775,15 @@ const HOOKS = [
1763
1775
  "every signer's name and position and their own progress only, anyone " +
1764
1776
  "else sees null. A refused action sets error and rejects with it: code " +
1765
1777
  "and message come from the server (show message as it is), errors has " +
1766
- "one entry per refused signer ({ app_user_id, message, reason, " +
1767
- "characters }), retryAfter is the seconds to wait. The hook's own " +
1778
+ "one entry per refused signer ({ index, app_user_id, message, reason, " +
1779
+ "characters }; index is the entry's place in the list sent), " +
1780
+ "retryAfter is the seconds to wait. resendLink is refused with " +
1781
+ "SIGNING_LINK_RESEND_TOO_SOON (with retryAfter), " +
1782
+ "SIGNING_LINK_RESEND_IN_PROGRESS, SIGNING_LINK_LIMIT_REACHED, " +
1783
+ "SIGNING_LINK_BUDGET_EXCEEDED, SIGNING_LINK_PLATFORM_LIMIT, " +
1784
+ "INVALID_EMAIL, DUPLICATE_SIGNER, SIGNING_ROUND_NOT_OPEN, " +
1785
+ "SIGNING_ROUND_CLOSED or ALREADY_SIGNED, and by the hook itself with " +
1786
+ "NOT_SUPPORTED when the host's filestore client cannot send links. The hook's own " +
1768
1787
  "refusals (PREVIEW_REQUIRED, ACTION_IN_PROGRESS, NO_SIGNING_ROUND) carry " +
1769
1788
  "a message that can be shown the same way. When a refusal means the " +
1770
1789
  "round is no longer what was shown (it expired, was closed, or the " +
@@ -1776,14 +1795,16 @@ const HOOKS = [
1776
1795
  returnShape: {
1777
1796
  round:
1778
1797
  "{ id, file_id, status: 'DRAFT' | 'OPEN' | 'COMPLETED', created_at, confirmed_at, completed_at, expires_at, " +
1779
- "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as? }[] } | null",
1798
+ "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as?, id?, kind?: 'app_user' | 'email', email?, " +
1799
+ "link?: { status: 'sending' | 'sent' | 'delivery_failed' | 'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, links_sent, can_resend } | null }[] } | null",
1780
1800
  loading: "boolean",
1781
1801
  error: "PermissionError | null // { code, message, retryable, errors, retryAfter }",
1782
1802
  refresh: "() => Promise<void>",
1783
- prepare: "(signerIds: string[]) => Promise<round> // status 'DRAFT'",
1803
+ prepare: "(signers: (string | { app_user_id } | { name, email })[]) => Promise<round> // status 'DRAFT'",
1784
1804
  preparedFile: "() => Promise<{ url, sha256, expires_at, size_bytes }>",
1785
1805
  confirm: "() => Promise<round> // status 'OPEN'",
1786
1806
  cancel: "() => Promise<round> // the round it ended, status 'CANCELLED'",
1807
+ resendLink: "(signerId: string, options?: { email?: string }) => Promise<round> // a new link for an email place",
1787
1808
  busy: "boolean // an action is running; another one is refused with ACTION_IN_PROGRESS",
1788
1809
  },
1789
1810
  requiredContextSlice: ["filestore.signingRounds"],
@@ -3686,7 +3707,7 @@ const MANIFEST_SCHEMA = {
3686
3707
  type: "object",
3687
3708
  required: false,
3688
3709
  description:
3689
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3710
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3690
3711
  },
3691
3712
  translations: {
3692
3713
  type: "object",
@@ -3888,7 +3909,7 @@ const WIDGET_CONTEXT_SHAPE = {
3888
3909
  "{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), copy(id, body), remove(id), preview(id) }, " +
3889
3910
  "folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
3890
3911
  "signatures: { list(fileIds), roster(fileId, query), initiate(fileId), status(id), cancel(id), verify(id) }, " +
3891
- "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signerIds), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId) }, " +
3912
+ "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signers), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId), resendLink(roundId, signerId, { email }) }, " +
3892
3913
  "objectUrl(token), fetchObject(token) }. " +
3893
3914
  "Backs useFilestoreFiles() + useFilestoreFolders() + useFilestoreCreateFolder() + useFilestoreCopy() + useFileSignature() + useFileSignatures() + useFileRoster() + useSigningRounds() + useSigningRound(). Optional — a host without an end-user filestore omits it and those hooks throw a clear error.",
3894
3915
  required: false,
@@ -6086,7 +6107,13 @@ const CONTRACT = deepFreeze({
6086
6107
  // 1.193.0: additive (sc-11821) — `useNavigation().scrollToSection(id)`
6087
6108
  // scrolls the page to one of its sections on both hosts, and the
6088
6109
  // `sectionRef` propertySchema type gives the author a section picker.
6089
- version: "1.193.0",
6110
+ // 1.194.0: more permissive (sc-11945) — a datastoreTemplate takes 100
6111
+ // tables, 200 columns, 100 rows per table, 20000 cells and 32 roles.
6112
+ // 1.195.0: additive (sc-11879) — `useSigningRound().prepare` takes
6113
+ // `{ name, email }` entries for signers without an account, and
6114
+ // `resendLink(signerId, { email })` sends such a signer a new link.
6115
+ // The manager's places carry id, kind, email and link.
6116
+ version: "1.195.0",
6090
6117
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6091
6118
  hooks: HOOKS,
6092
6119
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -155,12 +155,15 @@ export interface WidgetInputDescriptor {
155
155
  * tenant may have authored records into them.
156
156
  *
157
157
  * The shape mirrors the built-in template registry on the backend, with
158
- * two limits a third-party widget must satisfy (enforced at submission
159
- * time by the static analyzer):
158
+ * limits a third-party widget must satisfy (enforced at submission time by
159
+ * the static analyzer):
160
160
  *
161
- * - At most 8 tables per widget. Templates exist to seed schema, not
162
- * to be a full database design tool.
163
- * - At most 24 columns per table. Same reason.
161
+ * - At most 100 tables per widget.
162
+ * - At most 200 columns per table.
163
+ * - At most 100 sample rows per table, and 20000 sample cells across the
164
+ * whole template (each row counts the keys it sets, and at least one,
165
+ * summed over every table).
166
+ * - At most 32 roles.
164
167
  *
165
168
  * RELATION columns reference siblings within the same template via
166
169
  * `targetSuffix` — that suffix MUST belong to a table in the same array,
@@ -316,7 +319,9 @@ export interface WidgetDatastoreTemplateTable {
316
319
  * Optional sample rows seeded into the table at install time so the widget
317
320
  * renders with real data instead of an empty state. Each key is a column
318
321
  * `name`; only non-relation/file/user columns are seedable (RELATION, FILE,
319
- * USER, USER_GROUP are rejected). `null` skips that cell. Max 25 rows.
322
+ * USER, USER_GROUP are rejected). `null` skips that cell. Max 100 rows, and
323
+ * each row counts the keys it sets, and at least one, toward the
324
+ * template's 20000-cell budget.
320
325
  */
321
326
  rows?: Array<
322
327
  Record<string, string | number | boolean | string[] | number[] | null>
@@ -5106,6 +5111,9 @@ export interface SigningRefusalEntry {
5106
5111
  field?: string;
5107
5112
  message: string;
5108
5113
  code?: string;
5114
+ /** The entry's place in the list of signers that was sent. */
5115
+ index?: number;
5116
+ /** Set when the entry names an account. */
5109
5117
  app_user_id?: string;
5110
5118
  reason?: SignatureNameRefusal["reason"];
5111
5119
  characters?: string[];
@@ -5166,14 +5174,43 @@ export function useSigningRounds(fileIds: string[] | null | undefined): {
5166
5174
  export interface SigningRoundSigner {
5167
5175
  name: string;
5168
5176
  position: number;
5169
- /** Present for every place when the user manages the folder; otherwise for their own place only. */
5170
- app_user_id?: string;
5177
+ /**
5178
+ * Present for every place when the user manages the folder; otherwise for
5179
+ * their own place only. Null for a place held by someone without an account.
5180
+ */
5181
+ app_user_id?: string | null;
5171
5182
  signed?: boolean;
5172
5183
  signed_at?: string | null;
5173
5184
  /** The name BankID verified on this place. Managers only. */
5174
5185
  signed_as?: string | null;
5186
+ /** The place, as `resendLink` names it. Managers only. */
5187
+ id?: string;
5188
+ /** Who holds the place: an account, or someone signing through an emailed link. Managers only. */
5189
+ kind?: "app_user" | "email";
5190
+ /** An email place's address; null once the round has closed. Managers only. */
5191
+ email?: string | null;
5192
+ /** An email place's current link; null for an account place. Managers only. */
5193
+ link?: SigningRoundSignerLink | null;
5194
+ }
5195
+
5196
+ export interface SigningRoundSignerLink {
5197
+ status: "sending" | "sent" | "delivery_failed" | "expired" | "used" | "cancelled";
5198
+ sent_at: string | null;
5199
+ expires_at: string | null;
5200
+ used_at: string | null;
5201
+ links_sent: number;
5202
+ /** A new link may be sent now. */
5203
+ can_resend: boolean;
5175
5204
  }
5176
5205
 
5206
+ /**
5207
+ * One signer to prepare, in the order of the places: an account id (or
5208
+ * `{ app_user_id }`), or someone without an account, who is emailed a
5209
+ * personal signing link valid for 24 hours when the round is confirmed.
5210
+ * Anyone holding that link can sign.
5211
+ */
5212
+ export type SigningRoundSignerInput = string | { app_user_id: string } | { name: string; email: string };
5213
+
5177
5214
  export interface SigningRound {
5178
5215
  id: string;
5179
5216
  file_id: string;
@@ -5216,13 +5253,25 @@ export function useSigningRound(fileId: string | null | undefined): {
5216
5253
  loading: boolean;
5217
5254
  error: SigningRoundError | null;
5218
5255
  refresh(): Promise<void>;
5219
- /** `signerIds` in the order their places appear on the signature page. Resolves to the DRAFT. */
5220
- prepare(signerIds: string[]): Promise<SigningRound>;
5256
+ /** `signers` in the order their places appear on the signature page. Resolves to the DRAFT. */
5257
+ prepare(signers: SigningRoundSignerInput[]): Promise<SigningRound>;
5221
5258
  preparedFile(): Promise<SigningRoundPreparedFile>;
5222
5259
  /** Resolves to the OPEN round. */
5223
5260
  confirm(): Promise<SigningRound>;
5224
5261
  /** Resolves to the round it ended, with status "CANCELLED". */
5225
5262
  cancel(): Promise<SigningRound>;
5263
+ /**
5264
+ * Replace an email place's link with a new one, sent to `email` when given
5265
+ * (a corrected address). Offer it only where `link.can_resend` is true.
5266
+ * Resolves to the round. Refusals: `SIGNING_LINK_RESEND_TOO_SOON` (with
5267
+ * `retryAfter`), `SIGNING_LINK_RESEND_IN_PROGRESS`,
5268
+ * `SIGNING_LINK_LIMIT_REACHED`, `SIGNING_LINK_BUDGET_EXCEEDED`,
5269
+ * `SIGNING_LINK_PLATFORM_LIMIT`, `INVALID_EMAIL`, `DUPLICATE_SIGNER`,
5270
+ * `SIGNING_ROUND_NOT_OPEN`, `SIGNING_ROUND_CLOSED`, `ALREADY_SIGNED`, and
5271
+ * the hook's own `NOT_SUPPORTED` when the host's filestore client predates
5272
+ * sending links.
5273
+ */
5274
+ resendLink(signerId: string, options?: { email?: string }): Promise<SigningRound>;
5226
5275
  /** An action is running; another one is refused with `ACTION_IN_PROGRESS`. */
5227
5276
  busy: boolean;
5228
5277
  };
@@ -15,11 +15,13 @@ import {
15
15
  indexOverview,
16
16
  readRefusalDetail,
17
17
  reportedRoundId,
18
+ resendLinkOptions,
18
19
  roundChangedUnderneath,
19
20
  signOrderStateAfterAnswer,
20
21
  signOrderStateAfterCancel,
21
22
  signOrderStateAfterInitiate,
22
23
  signedOrderState,
24
+ signerListForPrepare,
23
25
  uniqueFileIds,
24
26
  } from "./signing-state.js";
25
27
 
@@ -209,6 +211,10 @@ const previewRequired = () =>
209
211
  const actionInProgress = () =>
210
212
  localRefusal("ACTION_IN_PROGRESS", "Another change to this signing round is still in progress. Try again in a moment.");
211
213
  const noFile = () => localRefusal("VALIDATION", "Choose a document first.");
214
+ const noResend = () =>
215
+ localRefusal("NOT_SUPPORTED", "Sending a new link is not available in this version of the app.");
216
+ const badSignerList = () =>
217
+ localRefusal("VALIDATION", "Each signer needs to be a person in the app, or a name and an email address.");
212
218
 
213
219
  function signingRoundsClientOrThrow(ctx, hookName) {
214
220
  const rounds = ctx.filestore && ctx.filestore.signingRounds;
@@ -289,19 +295,24 @@ export function useSigningRounds(fileIds) {
289
295
  /**
290
296
  * One file's current signing round and the actions of whoever manages the
291
297
  * file's folder. Returns `{ round, loading, error, refresh, prepare,
292
- * preparedFile, confirm, cancel, busy }`.
298
+ * preparedFile, confirm, cancel, resendLink, busy }`.
293
299
  *
294
- * `prepare(signerIds)` drafts a round for those people, in the order their
295
- * places appear on the signature page; `preparedFile()` answers `{ url,
296
- * sha256, expires_at, size_bytes }` for the draft's document; `confirm()`
297
- * opens the round for the document that `preparedFile()` last returned, so it
298
- * needs no argument; `cancel()` ends a draft or an open round and resolves to
299
- * the cancelled round. One action runs at a time: while `busy` is true another
300
- * one is refused with code `ACTION_IN_PROGRESS`, never queued. A refused
301
- * action sets `error` and rejects with it: `code` and `message` come from the
302
- * server, `errors` has one entry per refused signer, `retryAfter` is the
303
- * seconds to wait. When the refusal means the round is no longer what was
304
- * shown, the round is read again.
300
+ * `prepare(signers)` drafts a round for those people, in the order their
301
+ * places appear on the signature page: each is an account id (or
302
+ * `{ app_user_id }`), or `{ name, email }` for someone without an account,
303
+ * who is emailed a 24-hour signing link once the round is confirmed;
304
+ * `preparedFile()` answers `{ url, sha256, expires_at, size_bytes }` for the
305
+ * draft's document; `confirm()` opens the round for the document that
306
+ * `preparedFile()` last returned, so it needs no argument; `cancel()` ends a
307
+ * draft or an open round and resolves to the cancelled round;
308
+ * `resendLink(signerId, { email })` replaces an email place's link with a new
309
+ * one, sent to the corrected address when given. One action runs at a time:
310
+ * while `busy` is true another one is refused with code `ACTION_IN_PROGRESS`,
311
+ * never queued. A refused action sets `error` and rejects with it: `code` and
312
+ * `message` come from the server, `errors` has one entry per refused signer
313
+ * (with `index`, its place in the list sent), `retryAfter` is the seconds to
314
+ * wait. When the refusal means the round is no longer what was shown, the
315
+ * round is read again.
305
316
  */
306
317
  export function useSigningRound(fileId) {
307
318
  const ctx = useWidgetContextOrThrow("useSigningRound");
@@ -395,10 +406,12 @@ export function useSigningRound(fileId) {
395
406
  );
396
407
 
397
408
  const prepare = useCallback(
398
- (signerIds) =>
409
+ (signers) =>
399
410
  run(async (rounds, _round, forFile) => {
400
411
  if (!forFile) throw noFile();
401
- const draft = await rounds.prepare(forFile, signerIds);
412
+ const list = signerListForPrepare(signers);
413
+ if (!list) throw badSignerList();
414
+ const draft = await rounds.prepare(forFile, list);
402
415
  return { value: draft, round: draft };
403
416
  }),
404
417
  [run],
@@ -438,6 +451,18 @@ export function useSigningRound(fileId) {
438
451
  [run],
439
452
  );
440
453
 
454
+ const resendLink = useCallback(
455
+ (signerId, options) =>
456
+ run(async (rounds, round) => {
457
+ if (!round) throw noRound();
458
+ // A filestore client older than 0.14.0 has no resendLink.
459
+ if (typeof rounds.resendLink !== "function") throw noResend();
460
+ const resent = await rounds.resendLink(round.id, signerId, resendLinkOptions(options));
461
+ return { value: resent, round: resent };
462
+ }),
463
+ [run],
464
+ );
465
+
441
466
  return {
442
467
  round: current.round,
443
468
  loading: Boolean(fileId) && (!current.settled || reading),
@@ -447,6 +472,7 @@ export function useSigningRound(fileId) {
447
472
  preparedFile,
448
473
  confirm,
449
474
  cancel,
475
+ resendLink,
450
476
  busy,
451
477
  };
452
478
  }
@@ -71,8 +71,9 @@ export function signOrderStateAfterCancel(previous, answer) {
71
71
 
72
72
  /**
73
73
  * The parts of a refusal a widget shows beside its message, as the filestore
74
- * client put them on its error: one entry per refused signer (`app_user_id`,
75
- * `message`, and for a name `reason` and `characters`), and how many seconds
74
+ * client put them on its error: one entry per refused signer (`index`, its
75
+ * place in the list that was sent, `app_user_id` for an account, `message`,
76
+ * and for a name `reason` and `characters`), and how many seconds
76
77
  * to wait before trying again. An error from anywhere else has neither.
77
78
  */
78
79
  export function readRefusalDetail(err) {
@@ -81,6 +82,39 @@ export function readRefusalDetail(err) {
81
82
  return { errors, retryAfter: typeof wait === "number" && Number.isFinite(wait) && wait >= 0 ? wait : null };
82
83
  }
83
84
 
85
+ const nonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
86
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
87
+
88
+ /** One prepare entry as the filestore client takes it, or null when it is neither form. */
89
+ function signerForPrepare(signer) {
90
+ if (nonEmptyString(signer)) return signer;
91
+ if (!isObject(signer)) return null;
92
+ const keys = Object.keys(signer).sort().join(",");
93
+ if (keys === "app_user_id" && nonEmptyString(signer.app_user_id)) return signer.app_user_id;
94
+ if (keys === "email,name" && nonEmptyString(signer.name) && nonEmptyString(signer.email)) {
95
+ return { name: signer.name.trim(), email: signer.email.trim() };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * The signers of a prepare, in their order, as the filestore client takes
102
+ * them: an account as its id (a list of ids alone keeps the request older
103
+ * servers read), someone without an account as `{ name, email }` trimmed.
104
+ * Null when an entry is neither an id, `{ app_user_id }` nor `{ name, email }`.
105
+ */
106
+ export function signerListForPrepare(signers) {
107
+ if (!Array.isArray(signers)) return null;
108
+ const list = signers.map(signerForPrepare);
109
+ return list.includes(null) ? null : list;
110
+ }
111
+
112
+ /** The body of a new link: a corrected `email` when one is given, otherwise nothing. */
113
+ export function resendLinkOptions(options) {
114
+ const email = isObject(options) && typeof options.email === "string" ? options.email.trim() : "";
115
+ return email ? { email } : {};
116
+ }
117
+
84
118
  // The round is no longer what the widget is showing: read it again.
85
119
  const ROUND_CHANGED_CODES = new Set([
86
120
  "SIGNING_ROUND_EXPIRED",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.239.0",
3
+ "version": "0.241.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1747,15 +1747,27 @@ const HOOKS = [
1747
1747
  signature: "useSigningRound(fileId)",
1748
1748
  description:
1749
1749
  "One file's current signing round, and the actions of someone who " +
1750
- "manages the file's folder. prepare(signerIds) drafts a round for those " +
1751
- "users, their places on the signature page in the order given (take the " +
1752
- "ids from useFileRoster()); it can take " +
1750
+ "manages the file's folder. prepare(signers) drafts a round for those " +
1751
+ "people, their places on the signature page in the order given: each " +
1752
+ "entry is an account id from useFileRoster() (or { app_user_id }), or " +
1753
+ "{ name, email } for someone without an account, who is emailed a " +
1754
+ "personal BankID signing link valid for 24 hours once the round is " +
1755
+ "confirmed (anyone holding that link can sign, so say so before " +
1756
+ "sending); it can take " +
1753
1757
  "up to a minute. preparedFile() returns a five-minute link to the " +
1754
1758
  "prepared document; show it to the user before confirming. confirm() " +
1755
1759
  "opens the round for exactly the document preparedFile() last returned, " +
1756
1760
  "and rejects with code PREVIEW_REQUIRED when it was not called first. " +
1757
1761
  "cancel() ends a draft or an open round and resolves to the cancelled " +
1758
- "round (status 'CANCELLED'); round is then null. ONE ACTION RUNS AT A " +
1762
+ "round (status 'CANCELLED'); round is then null. " +
1763
+ "resendLink(signerId, { email }) replaces an email place's link with " +
1764
+ "a new one (the old one stops working), sent to the corrected email " +
1765
+ "when given, and resolves to the round; offer it only where the " +
1766
+ "place's link.can_resend is true. A manager sees each place's id, kind " +
1767
+ "('app_user' | 'email'), email, signed_as (the name BankID verified) " +
1768
+ "and link ({ status: 'sending' | 'sent' | 'delivery_failed' | " +
1769
+ "'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, " +
1770
+ "links_sent, can_resend }, null for an account place). ONE ACTION RUNS AT A " +
1759
1771
  "TIME: while busy is true any other action is refused at once with code " +
1760
1772
  "ACTION_IN_PROGRESS (it is not queued), so disable your buttons on busy. " +
1761
1773
  "round is the full round as the " +
@@ -1763,8 +1775,15 @@ const HOOKS = [
1763
1775
  "every signer's name and position and their own progress only, anyone " +
1764
1776
  "else sees null. A refused action sets error and rejects with it: code " +
1765
1777
  "and message come from the server (show message as it is), errors has " +
1766
- "one entry per refused signer ({ app_user_id, message, reason, " +
1767
- "characters }), retryAfter is the seconds to wait. The hook's own " +
1778
+ "one entry per refused signer ({ index, app_user_id, message, reason, " +
1779
+ "characters }; index is the entry's place in the list sent), " +
1780
+ "retryAfter is the seconds to wait. resendLink is refused with " +
1781
+ "SIGNING_LINK_RESEND_TOO_SOON (with retryAfter), " +
1782
+ "SIGNING_LINK_RESEND_IN_PROGRESS, SIGNING_LINK_LIMIT_REACHED, " +
1783
+ "SIGNING_LINK_BUDGET_EXCEEDED, SIGNING_LINK_PLATFORM_LIMIT, " +
1784
+ "INVALID_EMAIL, DUPLICATE_SIGNER, SIGNING_ROUND_NOT_OPEN, " +
1785
+ "SIGNING_ROUND_CLOSED or ALREADY_SIGNED, and by the hook itself with " +
1786
+ "NOT_SUPPORTED when the host's filestore client cannot send links. The hook's own " +
1768
1787
  "refusals (PREVIEW_REQUIRED, ACTION_IN_PROGRESS, NO_SIGNING_ROUND) carry " +
1769
1788
  "a message that can be shown the same way. When a refusal means the " +
1770
1789
  "round is no longer what was shown (it expired, was closed, or the " +
@@ -1776,14 +1795,16 @@ const HOOKS = [
1776
1795
  returnShape: {
1777
1796
  round:
1778
1797
  "{ id, file_id, status: 'DRAFT' | 'OPEN' | 'COMPLETED', created_at, confirmed_at, completed_at, expires_at, " +
1779
- "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as? }[] } | null",
1798
+ "signers: { name, position, app_user_id?, signed?, signed_at?, signed_as?, id?, kind?: 'app_user' | 'email', email?, " +
1799
+ "link?: { status: 'sending' | 'sent' | 'delivery_failed' | 'expired' | 'used' | 'cancelled', sent_at, expires_at, used_at, links_sent, can_resend } | null }[] } | null",
1780
1800
  loading: "boolean",
1781
1801
  error: "PermissionError | null // { code, message, retryable, errors, retryAfter }",
1782
1802
  refresh: "() => Promise<void>",
1783
- prepare: "(signerIds: string[]) => Promise<round> // status 'DRAFT'",
1803
+ prepare: "(signers: (string | { app_user_id } | { name, email })[]) => Promise<round> // status 'DRAFT'",
1784
1804
  preparedFile: "() => Promise<{ url, sha256, expires_at, size_bytes }>",
1785
1805
  confirm: "() => Promise<round> // status 'OPEN'",
1786
1806
  cancel: "() => Promise<round> // the round it ended, status 'CANCELLED'",
1807
+ resendLink: "(signerId: string, options?: { email?: string }) => Promise<round> // a new link for an email place",
1787
1808
  busy: "boolean // an action is running; another one is refused with ACTION_IN_PROGRESS",
1788
1809
  },
1789
1810
  requiredContextSlice: ["filestore.signingRounds"],
@@ -3686,7 +3707,7 @@ const MANIFEST_SCHEMA = {
3686
3707
  type: "object",
3687
3708
  required: false,
3688
3709
  description:
3689
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3710
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
3690
3711
  },
3691
3712
  translations: {
3692
3713
  type: "object",
@@ -3888,7 +3909,7 @@ const WIDGET_CONTEXT_SHAPE = {
3888
3909
  "{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), copy(id, body), remove(id), preview(id) }, " +
3889
3910
  "folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
3890
3911
  "signatures: { list(fileIds), roster(fileId, query), initiate(fileId), status(id), cancel(id), verify(id) }, " +
3891
- "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signerIds), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId) }, " +
3912
+ "signingRounds: { overview(fileIds), list(fileId, query), get(roundId), prepare(fileId, signers), preparedFile(roundId), confirm(roundId, preparedSha256), cancel(roundId), resendLink(roundId, signerId, { email }) }, " +
3892
3913
  "objectUrl(token), fetchObject(token) }. " +
3893
3914
  "Backs useFilestoreFiles() + useFilestoreFolders() + useFilestoreCreateFolder() + useFilestoreCopy() + useFileSignature() + useFileSignatures() + useFileRoster() + useSigningRounds() + useSigningRound(). Optional — a host without an end-user filestore omits it and those hooks throw a clear error.",
3894
3915
  required: false,
@@ -6086,7 +6107,13 @@ const CONTRACT = deepFreeze({
6086
6107
  // 1.193.0: additive (sc-11821) — `useNavigation().scrollToSection(id)`
6087
6108
  // scrolls the page to one of its sections on both hosts, and the
6088
6109
  // `sectionRef` propertySchema type gives the author a section picker.
6089
- version: "1.193.0",
6110
+ // 1.194.0: more permissive (sc-11945) — a datastoreTemplate takes 100
6111
+ // tables, 200 columns, 100 rows per table, 20000 cells and 32 roles.
6112
+ // 1.195.0: additive (sc-11879) — `useSigningRound().prepare` takes
6113
+ // `{ name, email }` entries for signers without an account, and
6114
+ // `resendLink(signerId, { email })` sends such a signer a new link.
6115
+ // The manager's places carry id, kind, email and link.
6116
+ version: "1.195.0",
6090
6117
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6091
6118
  hooks: HOOKS,
6092
6119
  primitives: PRIMITIVES,