@colixsystems/widget-sdk 0.92.0 → 0.94.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
@@ -49,6 +49,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
49
49
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
50
50
  | **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (edits also `users.write:*`; `remove()` also `users.delete:*`) |
51
51
  | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
52
+ | **DIRECTORY** | `useInvites(query?)` | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
52
53
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
53
54
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
54
55
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
@@ -65,6 +66,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
65
66
 
66
67
  `v0.91.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**.
67
68
 
69
+ ### What's new in 0.93.0 (contract 1.66.0)
70
+
71
+ **BREAKING: a widget no longer declares server-side actions.** `manifest.actions` is removed from the contract and **refused** by `validateManifest` — an author who declares it now fails the publish with a message naming the replacement, rather than shipping a widget that quietly carries no automation.
72
+
73
+ - **Why.** `actions` made a widget two products in one package: a React component that renders, and a script that never renders at all — different runtime, different lifecycle, different review concerns, one manifest. Automation is now its own marketplace deliverable, an **Action** (`@colixsystems/action-sdk`), with its own manifest, starter kit, developer guide, submit button and platform-admin review. A workspace installs and configures it separately from any widget.
74
+ - **What to do instead.** Move the script into an Action manifest (`appstudio-action lint` / `pack`), publish it, and let the workspace install it. Its `propertySchema` is filled in by the **installing operator** rather than a page author, and the values reach the script as the `properties` global.
75
+ - **Removed from the contract**: the `actions` manifest field. `CONTRACT.actionTriggerTypes` / `actionScriptGlobals` / `actionScriptMaxBytes` remain exported for now but describe a surface no widget field uses — the action-sdk owns that vocabulary.
76
+ - **Existing installs are unaffected.** A tenant Action row materialised from a widget manifest keeps running, keeps its bindings and keeps its run history; the platform migrated those rows onto the Action that ships the automation. What is gone is the ability to declare a NEW one on a widget.
77
+ - **No parity impact.** Actions never ran in the rendered app, so nothing about the Player or the export changes.
78
+
68
79
  ### What's new in 0.92.0 (contract 1.65.0)
69
80
 
70
81
  **New `useInterpretDraft(tableId)` hook — turn one sentence into DRAFT record values.** A new DATASTORE hook reading a new `interpret` method on the existing `ctx.datastore` slice (`@colixsystems/datastore-client` 0.13.0). Returns `{ interpret, interpreting, error, result, available }`. Call `interpret(text, { fields, timeZone })` **imperatively** from an event handler — never on mount or in a render loop — and it resolves to `{ values, unresolved }`, where `values` is keyed by column NAME (the same shape `useDatastoreMutation().create` takes) and `unresolved` names the fields the sentence did not state.
@@ -866,6 +877,42 @@ The matching manifest declares the scopes:
866
877
 
867
878
  The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
868
879
 
880
+ ## Listing pending invitations from a widget
881
+
882
+ `useInvites(query?)` lists the workspace's invites and resends / revokes them:
883
+
884
+ ```jsx
885
+ import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
886
+
887
+ // Manifest: requestedScopes: ['users.read:*', 'users.write:*']
888
+ function PendingInvites() {
889
+ const { invites, loading, error, resend, revoke } = useInvites({ status: 'pending' });
890
+ if (loading) return null;
891
+ if (error) return <Text>{error.message}</Text>; // code === 'FORBIDDEN' when the capability is missing
892
+ return (
893
+ <View>
894
+ {invites.map((i) => (
895
+ <View key={i.id}>
896
+ <Text>{i.email} · {i.status}</Text>
897
+ <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
898
+ <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
899
+ </View>
900
+ ))}
901
+ </View>
902
+ );
903
+ }
904
+ ```
905
+
906
+ Rows are snake_case: `{ id, email, name, group_ids, status, expires_at, accepted_at,
907
+ revoked_at, created_at }`. Trust the server-computed `status` rather than comparing
908
+ `expires_at` against the device clock.
909
+
910
+ **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
911
+ someone who is not a member yet, so the backend gates the entire invite surface —
912
+ listing included — on the `users.write` capability plus a signed `users.write:*`
913
+ scope. The `invites.read:*` / `invites.write:*` scope names mint but no route
914
+ enforces them, so declaring only those yields `FORBIDDEN`. Both mutations refetch
915
+ the list on success.
869
916
  ## Managing per-record permissions from a widget
870
917
 
871
918
  `useRecordPermissions(tableId, recordId)` is the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (messages inherit those grants). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access record-by-record.
package/dist/contract.cjs CHANGED
@@ -1023,6 +1023,44 @@ const HOOKS = [
1023
1023
  requiredContextSlice: ["directory.groups"],
1024
1024
  scopes: ["groups.read:*"],
1025
1025
  },
1026
+ // sc-5097 — pending AppUser invite administration. Returns
1027
+ // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1028
+ // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1029
+ // caller's `users.write` SystemAcl capability.
1030
+ {
1031
+ name: "useInvites",
1032
+ signature: "useInvites(query?)",
1033
+ description:
1034
+ "Pending AppUser invite administration via the injected " +
1035
+ "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1036
+ "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1037
+ "returns the { data, meta } envelope verbatim — the hook unwraps " +
1038
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1039
+ "status is the server-computed pending/accepted/revoked/expired " +
1040
+ "value. query is { status?, limit?, offset? } passed verbatim; the " +
1041
+ "endpoint defaults to status 'all', so pass { status: 'pending' } " +
1042
+ "for the usual outstanding-invite list. The WHOLE invite surface — " +
1043
+ "list included — is gated on users.write:* plus the users.write " +
1044
+ "SystemAcl capability, because a pending invite exposes the email of " +
1045
+ "someone who is not a member yet; a users.read-only caller cannot see " +
1046
+ "it. (invites.read:* / invites.write:* mint but gate nothing today.) " +
1047
+ "Without the capability every call rejects with DirectoryError code " +
1048
+ "FORBIDDEN — surface that, do not hide the tab. Both mutations " +
1049
+ "refetch on success.",
1050
+ returnShape: {
1051
+ invites:
1052
+ "Array<{ id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1053
+ loading: "boolean",
1054
+ error: "DirectoryError | null // { code, message, retryable }",
1055
+ refetch: "() => Promise<void>",
1056
+ resend:
1057
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1058
+ revoke:
1059
+ "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1060
+ },
1061
+ requiredContextSlice: ["directory.invites"],
1062
+ scopes: ["users.write:*"],
1063
+ },
1026
1064
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in
1027
1065
  // app-user. Self-service + JWT-gated (no widget scope). Mirror of contract.js.
1028
1066
  {
@@ -1590,17 +1628,6 @@ const MANIFEST_SCHEMA = {
1590
1628
  description:
1591
1629
  "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` (must be declared earlier in the array). Tables persist across uninstalls.",
1592
1630
  },
1593
- actions: {
1594
- type: "object[]",
1595
- required: false,
1596
- description:
1597
- "Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?, triggerTypes (a non-empty array of unique values from " +
1598
- ACTION_TRIGGER_TYPES.join(", ") +
1599
- "; combine them so one script serves several events — the script's `triggerType` global names the event that actually fired), scheduleCron? (required iff triggerTypes contains 'schedule'; node-cron syntax), timeoutMs? (100–300000), scriptSource (≤200 KiB; runs against " +
1600
- ACTION_SCRIPT_GLOBALS.join(", ") +
1601
- " — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
1602
- default: [],
1603
- },
1604
1631
  translations: {
1605
1632
  type: "object",
1606
1633
  required: false,
@@ -1688,9 +1715,9 @@ const WIDGET_CONTEXT_SHAPE = {
1688
1715
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
1689
1716
  "invites: { list(), revoke(id), resend(id) }, " +
1690
1717
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
1691
- "users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
1718
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
1692
1719
  required: true,
1693
- fields: { users: "object", groups: "object", bankid: "object" },
1720
+ fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
1694
1721
  },
1695
1722
  assets: {
1696
1723
  description:
@@ -2891,7 +2918,11 @@ const CONTRACT = deepFreeze({
2891
2918
  // published widget keeps validating. The script's `triggerType` global
2892
2919
  // now names the trigger that actually FIRED the run ('manual' and 'app'
2893
2920
  // included), which is what makes a multi-trigger script able to branch.
2894
- version: "1.65.0",
2921
+ // 1.66.0: BREAKING — `actions` is removed from the manifest contract and
2922
+ // REFUSED by the validator (sc-4879). Server-side automation ships as its own
2923
+ // marketplace deliverable (`@colixsystems/action-sdk`), which a workspace
2924
+ // installs and configures separately. A widget renders; it does not automate.
2925
+ version: "1.66.0",
2895
2926
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2896
2927
  hooks: HOOKS,
2897
2928
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1023,6 +1023,44 @@ const HOOKS = [
1023
1023
  requiredContextSlice: ["directory.groups"],
1024
1024
  scopes: ["groups.read:*"],
1025
1025
  },
1026
+ // sc-5097 — pending AppUser invite administration. Returns
1027
+ // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1028
+ // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1029
+ // caller's `users.write` SystemAcl capability.
1030
+ {
1031
+ name: "useInvites",
1032
+ signature: "useInvites(query?)",
1033
+ description:
1034
+ "Pending AppUser invite administration via the injected " +
1035
+ "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1036
+ "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1037
+ "returns the { data, meta } envelope verbatim — the hook unwraps " +
1038
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1039
+ "status is the server-computed pending/accepted/revoked/expired " +
1040
+ "value. query is { status?, limit?, offset? } passed verbatim; the " +
1041
+ "endpoint defaults to status 'all', so pass { status: 'pending' } " +
1042
+ "for the usual outstanding-invite list. The WHOLE invite surface — " +
1043
+ "list included — is gated on users.write:* plus the users.write " +
1044
+ "SystemAcl capability, because a pending invite exposes the email of " +
1045
+ "someone who is not a member yet; a users.read-only caller cannot see " +
1046
+ "it. (invites.read:* / invites.write:* mint but gate nothing today.) " +
1047
+ "Without the capability every call rejects with DirectoryError code " +
1048
+ "FORBIDDEN — surface that, do not hide the tab. Both mutations " +
1049
+ "refetch on success.",
1050
+ returnShape: {
1051
+ invites:
1052
+ "Array<{ id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1053
+ loading: "boolean",
1054
+ error: "DirectoryError | null // { code, message, retryable }",
1055
+ refetch: "() => Promise<void>",
1056
+ resend:
1057
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1058
+ revoke:
1059
+ "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1060
+ },
1061
+ requiredContextSlice: ["directory.invites"],
1062
+ scopes: ["users.write:*"],
1063
+ },
1026
1064
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in
1027
1065
  // app-user. Self-service + JWT-gated (no widget scope). Mirror of contract.cjs.
1028
1066
  {
@@ -1590,17 +1628,6 @@ const MANIFEST_SCHEMA = {
1590
1628
  description:
1591
1629
  "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` (must be declared earlier in the array). Tables persist across uninstalls.",
1592
1630
  },
1593
- actions: {
1594
- type: "object[]",
1595
- required: false,
1596
- description:
1597
- "Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?, triggerTypes (a non-empty array of unique values from " +
1598
- ACTION_TRIGGER_TYPES.join(", ") +
1599
- "; combine them so one script serves several events — the script's `triggerType` global names the event that actually fired), scheduleCron? (required iff triggerTypes contains 'schedule'; node-cron syntax), timeoutMs? (100–300000), scriptSource (≤200 KiB; runs against " +
1600
- ACTION_SCRIPT_GLOBALS.join(", ") +
1601
- " — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
1602
- default: [],
1603
- },
1604
1631
  translations: {
1605
1632
  type: "object",
1606
1633
  required: false,
@@ -1688,9 +1715,9 @@ const WIDGET_CONTEXT_SHAPE = {
1688
1715
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
1689
1716
  "invites: { list(), revoke(id), resend(id) }, " +
1690
1717
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
1691
- "users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
1718
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
1692
1719
  required: true,
1693
- fields: { users: "object", groups: "object", bankid: "object" },
1720
+ fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
1694
1721
  },
1695
1722
  assets: {
1696
1723
  description:
@@ -2891,7 +2918,11 @@ const CONTRACT = deepFreeze({
2891
2918
  // published widget keeps validating. The script's `triggerType` global
2892
2919
  // now names the trigger that actually FIRED the run ('manual' and 'app'
2893
2920
  // included), which is what makes a multi-trigger script able to branch.
2894
- version: "1.65.0",
2921
+ // 1.66.0: BREAKING — `actions` is removed from the manifest contract and
2922
+ // REFUSED by the validator (sc-4879). Server-side automation ships as its own
2923
+ // marketplace deliverable (`@colixsystems/action-sdk`), which a workspace
2924
+ // installs and configures separately. A widget renders; it does not automate.
2925
+ version: "1.66.0",
2895
2926
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2896
2927
  hooks: HOOKS,
2897
2928
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3467,6 +3467,118 @@ export function useGroups(query) {
3467
3467
  return { groups, loading, error, refetch, create, remove, addMember, removeMember };
3468
3468
  }
3469
3469
 
3470
+ /**
3471
+ * sc-5097 — pending AppUser invite administration.
3472
+ *
3473
+ * Returns `{ invites, loading, error, refetch, resend, revoke }`. Reads the
3474
+ * injected `@colixsystems/directory-client` at
3475
+ * `ctx.directory.invites.{list, resend, revoke}` — `list` resolves to the
3476
+ * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
3477
+ *
3478
+ * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
3479
+ * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
3480
+ * created_at, invited_by_studio_user_id }`. `status` is the server-computed
3481
+ * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
3482
+ * it over re-deriving expiry on the client, whose clock may disagree.
3483
+ *
3484
+ * `query` is an optional `{ status?, limit?, offset? }` passed through
3485
+ * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
3486
+ * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
3487
+ * for the usual "who hasn't accepted yet" list.
3488
+ *
3489
+ * The WHOLE invite surface — list included — is gated on the `users.write`
3490
+ * capability plus a signed `users.write:*` scope: a pending invite exposes
3491
+ * the email of someone who is not a member yet, so a `users.read`-only
3492
+ * caller cannot see it. The `invites.read:*` / `invites.write:*` scope names
3493
+ * mint but gate nothing today, so declaring them alone yields FORBIDDEN.
3494
+ * Every call rejects with a `DirectoryError` carrying `code: "FORBIDDEN"`
3495
+ * when the capability is missing — surface it rather than hiding the tab.
3496
+ */
3497
+ export function useInvites(query) {
3498
+ const ctx = useWidgetContextOrThrow("useInvites");
3499
+ if (
3500
+ !ctx.directory ||
3501
+ !ctx.directory.invites ||
3502
+ typeof ctx.directory.invites.list !== "function"
3503
+ ) {
3504
+ throw new Error(
3505
+ "useInvites: host did not inject a directory client (ctx.directory.invites)",
3506
+ );
3507
+ }
3508
+ const [invites, setInvites] = useState([]);
3509
+ const [loading, setLoading] = useState(true);
3510
+ const [error, setError] = useState(null);
3511
+
3512
+ const queryRef = useRef(query);
3513
+ const invitesRef = useRef(ctx.directory.invites);
3514
+ queryRef.current = query;
3515
+ invitesRef.current = ctx.directory.invites;
3516
+
3517
+ const runRef = useRef(0);
3518
+
3519
+ const doFetch = useCallback(async () => {
3520
+ const myRun = ++runRef.current;
3521
+ setLoading(true);
3522
+ setError(null);
3523
+ try {
3524
+ const res = await invitesRef.current.list(queryRef.current);
3525
+ // Directory invites.list returns the { data, meta } envelope verbatim.
3526
+ const rows = res && Array.isArray(res.data) ? res.data : [];
3527
+ if (runRef.current !== myRun) return;
3528
+ setInvites(rows);
3529
+ setLoading(false);
3530
+ } catch (err) {
3531
+ if (runRef.current !== myRun) return;
3532
+ setError(toDirectoryError(err));
3533
+ setLoading(false);
3534
+ }
3535
+ }, []);
3536
+
3537
+ const queryKey = (() => {
3538
+ try {
3539
+ return JSON.stringify(query);
3540
+ } catch (_e) {
3541
+ return null;
3542
+ }
3543
+ })();
3544
+ useEffect(() => {
3545
+ doFetch();
3546
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3547
+ }, [queryKey]);
3548
+
3549
+ const refetch = useCallback(async () => {
3550
+ await doFetch();
3551
+ }, [doFetch]);
3552
+
3553
+ // Both mutations re-list on success: revoking drops the row and resending
3554
+ // moves `expires_at`, so a stale list would show the operator the old state.
3555
+ const resend = useCallback(
3556
+ async (inviteId) => {
3557
+ try {
3558
+ const row = await invitesRef.current.resend(inviteId);
3559
+ await doFetch();
3560
+ return row;
3561
+ } catch (err) {
3562
+ throw toDirectoryError(err);
3563
+ }
3564
+ },
3565
+ [doFetch],
3566
+ );
3567
+ const revoke = useCallback(
3568
+ async (inviteId) => {
3569
+ try {
3570
+ await invitesRef.current.revoke(inviteId);
3571
+ await doFetch();
3572
+ } catch (err) {
3573
+ throw toDirectoryError(err);
3574
+ }
3575
+ },
3576
+ [doFetch],
3577
+ );
3578
+
3579
+ return { invites, loading, error, refetch, resend, revoke };
3580
+ }
3581
+
3470
3582
  /**
3471
3583
  * REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in app-user,
3472
3584
  * and read whether BankID is available + already linked. Returns
package/dist/index.d.ts CHANGED
@@ -1523,12 +1523,26 @@ export interface InviteArgs {
1523
1523
  group_ids?: string[];
1524
1524
  }
1525
1525
 
1526
+ export type AppUserInviteStatus =
1527
+ | "pending"
1528
+ | "accepted"
1529
+ | "revoked"
1530
+ | "expired";
1531
+
1526
1532
  export interface AppUserInviteRow {
1527
1533
  id: string;
1528
1534
  email: string;
1529
- status: string;
1535
+ /** Server-computed lifecycle value — prefer it over re-deriving expiry. */
1536
+ status: AppUserInviteStatus;
1537
+ name?: string;
1538
+ group_ids?: string[];
1530
1539
  invited_at?: string;
1531
1540
  expires_at?: string;
1541
+ accepted_at?: string | null;
1542
+ revoked_at?: string | null;
1543
+ created_at?: string;
1544
+ tenant_id?: string;
1545
+ invited_by_studio_user_id?: string | null;
1532
1546
  }
1533
1547
 
1534
1548
  export interface UsersApi {
@@ -1585,6 +1599,40 @@ export interface GroupsApi {
1585
1599
  */
1586
1600
  export function useGroups(query?: GroupsQuery): GroupsApi;
1587
1601
 
1602
+ // --------------------------------------------------------------- useInvites
1603
+ //
1604
+ // sc-5097 — pending AppUser invite administration.
1605
+
1606
+ export interface InvitesQuery {
1607
+ /** Defaults to "all" server-side; pass "pending" for outstanding invites. */
1608
+ status?: AppUserInviteStatus | "all";
1609
+ limit?: number;
1610
+ offset?: number;
1611
+ }
1612
+
1613
+ export interface InvitesApi {
1614
+ invites: AppUserInviteRow[];
1615
+ loading: boolean;
1616
+ error: DirectoryError | null;
1617
+ refetch(): Promise<void>;
1618
+ /** Re-send the invitation email. Refetches on success. */
1619
+ resend(inviteId: string): Promise<AppUserInviteRow>;
1620
+ /** Cancel a pending invitation. Refetches on success. */
1621
+ revoke(inviteId: string): Promise<void>;
1622
+ }
1623
+
1624
+ /**
1625
+ * Pending AppUser invite administration through the injected directory-client
1626
+ * at `ctx.directory.invites.{list,resend,revoke}`.
1627
+ *
1628
+ * The WHOLE surface — listing included — requires the `users.write:*` scope
1629
+ * AND the SystemAcl `users.write` capability, because a pending invite exposes
1630
+ * the email of someone who is not a member yet. The `invites.read:*` /
1631
+ * `invites.write:*` scope names mint but no route enforces them, so declaring
1632
+ * only those yields a `FORBIDDEN` DirectoryError.
1633
+ */
1634
+ export function useInvites(query?: InvitesQuery): InvitesApi;
1635
+
1588
1636
  // ----------------------------------------------------- useBankIdLink
1589
1637
  //
1590
1638
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in app-user.
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  useDirectory,
32
32
  useUsers,
33
33
  useGroups,
34
+ useInvites,
34
35
  useBankIdLink,
35
36
  useIdentification,
36
37
  IdentificationError,
@@ -31,6 +31,7 @@ export {
31
31
  useDirectory,
32
32
  useUsers,
33
33
  useGroups,
34
+ useInvites,
34
35
  useBankIdLink,
35
36
  useIdentification,
36
37
  IdentificationError,
package/dist/linter.cjs CHANGED
@@ -447,6 +447,7 @@ function _scopeRules(source, manifest) {
447
447
  );
448
448
  const usesUsersHook = /\buseUsers\s*\(/.test(source);
449
449
  const usesGroupsHook = /\buseGroups\s*\(/.test(source);
450
+ const usesInvitesHook = /\buseInvites\s*\(/.test(source);
450
451
 
451
452
  if (usesUsersHook) {
452
453
  const reads = declared.has("users.read:*") || declared.has("users.read");
@@ -473,6 +474,22 @@ function _scopeRules(source, manifest) {
473
474
  }
474
475
  }
475
476
 
477
+ // sc-5097 — the whole invite surface (list, resend, revoke) is gated on
478
+ // the "users.write" capability, and the scope gate accepts only a signed
479
+ // scope matching that key. `invites.*` scopes mint but gate nothing.
480
+ if (usesInvitesHook) {
481
+ const writes =
482
+ declared.has("users.write:*") || declared.has("users.write");
483
+ if (!writes) {
484
+ findings.push({
485
+ rule: "scope-required-for-useInvites",
486
+ label:
487
+ "useInvites() requires `users.write:*` in manifest.requestedScopes",
488
+ line: 0,
489
+ snippet: "",
490
+ });
491
+ }
492
+ }
476
493
  const lines = source.split(/\r?\n/);
477
494
  let firedUsersWrite = false;
478
495
  let firedUsersDelete = false;
package/dist/linter.js CHANGED
@@ -522,6 +522,7 @@ function _scopeRules(source, manifest) {
522
522
  );
523
523
  const usesUsersHook = /\buseUsers\s*\(/.test(source);
524
524
  const usesGroupsHook = /\buseGroups\s*\(/.test(source);
525
+ const usesInvitesHook = /\buseInvites\s*\(/.test(source);
525
526
 
526
527
  if (usesUsersHook) {
527
528
  const reads = declared.has("users.read:*") || declared.has("users.read");
@@ -547,6 +548,22 @@ function _scopeRules(source, manifest) {
547
548
  }
548
549
  }
549
550
 
551
+ // sc-5097 — the whole invite surface (list, resend, revoke) is gated on
552
+ // the "users.write" capability, and the scope gate accepts only a signed
553
+ // scope matching that key. `invites.*` scopes mint but gate nothing.
554
+ if (usesInvitesHook) {
555
+ const writes =
556
+ declared.has("users.write:*") || declared.has("users.write");
557
+ if (!writes) {
558
+ findings.push({
559
+ rule: "scope-required-for-useInvites",
560
+ label:
561
+ "useInvites() requires `users.write:*` in manifest.requestedScopes",
562
+ line: 0,
563
+ snippet: "",
564
+ });
565
+ }
566
+ }
550
567
  const lines = source.split(/\r?\n/);
551
568
  let firedUsersWrite = false;
552
569
  let firedUsersDelete = false;
package/dist/manifest.cjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // re-exports the same functions defined here so there is exactly one
5
5
  // implementation.
6
6
 
7
- const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
7
+ const { CONTRACT } = require("./contract.cjs");
8
8
 
9
9
  const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
10
10
 
@@ -39,20 +39,6 @@ const VALID_CATEGORIES = new Set([
39
39
  ]);
40
40
  const VALID_PLATFORMS = new Set(["web", "native"]);
41
41
 
42
- // REQ-WIDGET-ACTION — structural validation for manifest-declared server
43
- // actions. Mirrors the backend action.service caps; the runner re-validates
44
- // the cron expression (node-cron) and binds the tenant-local API key + table
45
- // at enable time, so we only check shape + size here (cross-env: no Buffer).
46
- const VALID_ACTION_TRIGGERS = new Set([
47
- "schedule",
48
- "record_created",
49
- "record_updated",
50
- "record_deleted",
51
- ]);
52
- const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
53
- const ACTION_TIMEOUT_MIN_MS = 100;
54
- const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
55
-
56
42
  // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
57
43
  // The relative key pattern is deliberately tighter than the dictionary's
58
44
  // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
@@ -133,54 +119,22 @@ function validateManifestTranslations(translations, manifestId, errors) {
133
119
  }
134
120
  }
135
121
 
136
- function validateManifestActions(actions, errors) {
137
- if (!Array.isArray(actions)) {
138
- errors.push("manifest.actions must be an array (omit it or use [] for none)");
139
- return;
140
- }
141
- const seenKeys = new Set();
142
- for (const a of actions) {
143
- if (a === null || typeof a !== "object") {
144
- errors.push("manifest.actions entries must be objects");
145
- break;
146
- }
147
- if (!isNonEmptyString(a.key)) {
148
- errors.push("manifest.actions[].key must be a non-empty string");
149
- } else if (seenKeys.has(a.key)) {
150
- errors.push(`manifest.actions[].key "${a.key}" is duplicated`);
151
- } else {
152
- seenKeys.add(a.key);
153
- }
154
- pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
155
- const triggerTypes = normaliseActionTriggerTypes(a);
156
- if (triggerTypes === null) {
157
- errors.push(
158
- `manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
159
- );
160
- } else if (triggerTypes.includes("schedule")) {
161
- pushIf(
162
- errors,
163
- isNonEmptyString(a.scheduleCron),
164
- "manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
165
- );
166
- }
167
- if (!isNonEmptyString(a.scriptSource)) {
168
- errors.push("manifest.actions[].scriptSource must be a non-empty string");
169
- } else if (utf8ByteLength(a.scriptSource) > ACTION_SCRIPT_MAX_BYTES) {
170
- errors.push("manifest.actions[].scriptSource exceeds 200 KiB");
171
- }
172
- if (a.timeoutMs !== undefined) {
173
- const t = Number(a.timeoutMs);
174
- if (!Number.isFinite(t) || t < ACTION_TIMEOUT_MIN_MS || t > ACTION_TIMEOUT_MAX_MS) {
175
- errors.push("manifest.actions[].timeoutMs must be between 100 and 300000");
176
- }
177
- }
178
- if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
179
- errors.push(
180
- "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
181
- );
182
- }
183
- }
122
+ // REQ-MKT-ACTION (sc-4879): a widget no longer declares server-side actions.
123
+ //
124
+ // `manifest.actions` was how an automation shipped before Actions existed as
125
+ // their own marketplace deliverable — a widget was two products in one package,
126
+ // a component that renders and a script that never renders at all. It is now
127
+ // REFUSED rather than ignored: an author who declares it believes the
128
+ // automation will ship, and silence would publish that misunderstanding.
129
+ //
130
+ // The replacement is a marketplace Action (`@colixsystems/action-sdk`), which
131
+ // the workspace installs and configures on its own.
132
+ function validateManifestActions(_actions, errors) {
133
+ errors.push(
134
+ "manifest.actions is no longer supported — ship the automation as a " +
135
+ "marketplace Action (@colixsystems/action-sdk) instead of declaring it " +
136
+ "on a widget",
137
+ );
184
138
  }
185
139
 
186
140
  function canonicalCategory(c) {
@@ -355,7 +309,7 @@ function validateManifest(m) {
355
309
  }
356
310
  }
357
311
 
358
- // `actions` is optional (additive in SDK 1.8.0) — only validate when present.
312
+ // Present at all is now an error (sc-4879) — see validateManifestActions.
359
313
  if (manifest.actions !== undefined) {
360
314
  validateManifestActions(manifest.actions, errors);
361
315
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.92.0",
3
+ "version": "0.94.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"