@colixsystems/widget-sdk 0.18.0 → 0.21.1

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/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export type WidgetCategory =
12
12
  | "data"
13
13
  | "media"
14
14
  | "communication"
15
+ | "administration"
15
16
  | "custom";
16
17
 
17
18
  export type WidgetScope = string; // e.g. "datastore.read:orders"
@@ -126,6 +127,36 @@ export interface WidgetDatastoreTemplate {
126
127
  tables: WidgetDatastoreTemplateTable[];
127
128
  }
128
129
 
130
+ /**
131
+ * REQ-WIDGET-ACTION: a server-side action the widget declares. Each runs in
132
+ * the shared isolated-vm action runner (cron- or record-triggered) — never in
133
+ * the rendered app, so it has no effect on Player ↔ export parity. Operators
134
+ * enable it per tenant from the Properties Panel; it materialises DISABLED
135
+ * until they bind an integration API key (and, for `record_*` triggers, a
136
+ * target table) in the Actions admin page. `triggerTableId` / `apiKeyId` are
137
+ * deliberately absent — those are tenant-local and bound after install.
138
+ */
139
+ export interface WidgetManifestAction {
140
+ /** Stable, unique within the manifest — the idempotency key for materialise. */
141
+ key: string;
142
+ name: string;
143
+ description?: string;
144
+ triggerType:
145
+ | "schedule"
146
+ | "record_created"
147
+ | "record_updated"
148
+ | "record_deleted";
149
+ /** Required iff `triggerType === "schedule"`. node-cron syntax. */
150
+ scheduleCron?: string;
151
+ /** 100–300000. Defaults to 30000 on materialise. */
152
+ timeoutMs?: number;
153
+ /**
154
+ * Runs against `datastore`, `fetch`, `console`, `record`, `tenantId`,
155
+ * `triggerType`, `triggerTableId` — NOT the React/SDK surface. ≤ 200 KiB.
156
+ */
157
+ scriptSource: string;
158
+ }
159
+
129
160
  export interface WidgetManifest {
130
161
  id: string;
131
162
  name: string;
@@ -147,6 +178,23 @@ export interface WidgetManifest {
147
178
  * structural constraints enforced at submission time.
148
179
  */
149
180
  datastoreTemplate?: WidgetDatastoreTemplate;
181
+ /**
182
+ * Optional server-side actions (REQ-WIDGET-ACTION). Operators enable them
183
+ * per tenant from the Properties Panel; each runs in the existing
184
+ * isolated-vm action runner. See `WidgetManifestAction`.
185
+ */
186
+ actions?: WidgetManifestAction[];
187
+ /**
188
+ * Optional translation strings the widget ships (REQ-L10N-WIDGET). Maps a
189
+ * relative key to its per-locale strings; `en` is required per key. At
190
+ * install the host merges these into the tenant's localization dictionary
191
+ * under a per-widget namespace (`widget.<id>.<key>`), so `useI18n().t(key)`
192
+ * resolves the namespaced key automatically — the author never types the
193
+ * prefix. Non-destructive (admin edits win; absent languages are not
194
+ * created) and persists across uninstalls. Caps: ≤100 keys, key matches
195
+ * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
196
+ */
197
+ translations?: Record<string, { en: string } & Record<string, string>>;
150
198
  }
151
199
 
152
200
  export interface ThemeTokens {
@@ -168,47 +216,169 @@ export interface ThemeTokens {
168
216
  };
169
217
  }
170
218
 
219
+ // ----------------------------------------------------- injected data clients
220
+ //
221
+ // REQ-WSDK-DOMAIN-CLIENTS. @colixsystems/widget-sdk is CORE ONLY — manifest
222
+ // contract, primitives, rendering, hooks, events, theme/i18n. It owns NO HTTP
223
+ // and depends on NONE of the data SDK packages. The data layer is FOUR domain
224
+ // client packages, each instantiated by the host and injected into
225
+ // WidgetContext. **Widgets never import these packages** — they reach the data
226
+ // surface only through the SDK hooks, which read the injected instances. The
227
+ // shapes below are declared STRUCTURALLY here (widget-sdk must not import the
228
+ // data SDKs); the authoritative typings ship with each client package.
229
+ //
230
+ // Wire/casing: snake_case end to end. The clients send/return snake_case
231
+ // VERBATIM (e.g. `created_at`, `group_ids`, `can_read`, `amount_cents`,
232
+ // `data_type`, `is_active`). There is NO client-side case transform anywhere.
233
+ // Author-defined record column values are passed through verbatim. List
234
+ // methods return the `{ data, meta }` envelope.
235
+
236
+ /** A `{ data, meta }` list envelope, returned verbatim by every `list(...)`. */
237
+ export interface ListEnvelope<T = unknown> {
238
+ data: T[];
239
+ meta?: Record<string, unknown>;
240
+ }
241
+
242
+ /**
243
+ * Structural shape of the injected `@colixsystems/datastore-client`
244
+ * (`ctx.datastore`). Backs `useDatastoreQuery` / `useDatastoreRecord` /
245
+ * `useDatastoreSchema` / `useDatastoreMutation` / `useRecordPermissions`.
246
+ * Rows and bodies are snake_case verbatim; `list` returns `{ data, meta }`.
247
+ */
248
+ export interface DatastoreClient {
249
+ tables: {
250
+ list(): Promise<ListEnvelope>;
251
+ get(tableIdOrName: string): Promise<unknown>;
252
+ };
253
+ schema(tableId: string): Promise<unknown>;
254
+ records(tableId: string): {
255
+ list(query?: Query): Promise<ListEnvelope>;
256
+ get(recordId: string): Promise<unknown>;
257
+ create(values: Record<string, unknown>): Promise<unknown>;
258
+ /** PATCH semantics — only the supplied columns are mutated. */
259
+ update(recordId: string, values: Record<string, unknown>): Promise<unknown>;
260
+ delete(recordId: string): Promise<void>;
261
+ aggregate(spec: unknown): Promise<unknown>;
262
+ permissions(recordId: string): {
263
+ list(): Promise<ListEnvelope>;
264
+ grant(body: Record<string, unknown>): Promise<unknown>;
265
+ update(
266
+ permissionId: string,
267
+ patch: Record<string, unknown>,
268
+ ): Promise<unknown>;
269
+ revoke(permissionId: string): Promise<void>;
270
+ };
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Structural shape of the injected `@colixsystems/directory-client`
276
+ * (`ctx.directory`). Backs `useDirectory` / `useUsers` (via `.users`) and
277
+ * `useGroups` (via `.groups`). Rows and bodies are snake_case verbatim;
278
+ * `list` returns `{ data, meta }`.
279
+ */
280
+ export interface DirectoryClient {
281
+ me(): Promise<unknown>;
282
+ users: {
283
+ list(query?: DirectoryQuery): Promise<ListEnvelope>;
284
+ get(userId: string): Promise<unknown>;
285
+ invite(body: Record<string, unknown>): Promise<unknown>;
286
+ deactivate(userId: string): Promise<unknown>;
287
+ reactivate(userId: string): Promise<unknown>;
288
+ };
289
+ groups: {
290
+ list(query?: GroupsQuery): Promise<ListEnvelope>;
291
+ create(body: Record<string, unknown>): Promise<unknown>;
292
+ remove(groupId: string): Promise<void>;
293
+ addMember(groupId: string, userId: string): Promise<void>;
294
+ removeMember(groupId: string, userId: string): Promise<void>;
295
+ listMine(): Promise<ListEnvelope>;
296
+ };
297
+ invites: {
298
+ list(): Promise<ListEnvelope>;
299
+ revoke(inviteId: string): Promise<void>;
300
+ resend(inviteId: string): Promise<unknown>;
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Structural shape of the injected Asset-Manager files client (`ctx.files`).
306
+ * Slimmed to the three top-level ops the host injects — `get` / `list` /
307
+ * `upload`. Backs `useFile`, which calls `ctx.files.get(id)`. The returned
308
+ * file carries an absolute `url` safe to drop into `<Image source>`.
309
+ */
310
+ export interface FilesClient {
311
+ get(fileId: string): Promise<unknown>;
312
+ list(query?: Record<string, unknown>): Promise<ListEnvelope>;
313
+ upload(formData: unknown): Promise<unknown>;
314
+ }
315
+
316
+ /**
317
+ * Structural shape of the injected `@colixsystems/payments-client`
318
+ * (`ctx.payments`, REQ-BILL-07-WIDGETPAY). Backs `usePayments`.
319
+ */
320
+ export interface PaymentsClient {
321
+ requestPayment(body: PaymentRequest): Promise<PaymentResult>;
322
+ getPayment(paymentId: string): Promise<PaymentResult>;
323
+ }
324
+
171
325
  export interface WidgetContext<TProps = unknown> {
172
326
  props: TProps;
173
327
  widget: { id: string; instanceId: string; version: string };
328
+ /**
329
+ * REQ-LAY-08 — optional host layout hint backing `useFill()`. `true` when
330
+ * the host sized this widget to fill its layout slot's available height (a
331
+ * page-grid tile set to "Fill tile height", or a default-fill widget type).
332
+ * Absent / `false` everywhere the host has not opted the widget into filling.
333
+ */
334
+ fill?: boolean;
335
+ /** Active end-user identity, snake_case verbatim. `id` is null when anonymous. */
174
336
  user: {
175
337
  id: string | null;
176
338
  email: string | null;
177
- displayName: string | null;
339
+ display_name: string | null;
178
340
  roles: string[];
179
- groupIds: string[];
341
+ group_ids: string[];
180
342
  };
181
343
  workspace: {
182
344
  id: string;
183
- name: string;
345
+ slug: string;
184
346
  locale: string;
185
347
  theme: ThemeTokens;
186
348
  };
187
349
  navigation: {
188
350
  goTo(pageId: string, params?: Record<string, string>): void;
189
351
  goBack(): void;
352
+ push(pageId: string, params?: Record<string, unknown>): void;
353
+ replace(pageId: string, params?: Record<string, unknown>): void;
354
+ back(): void;
190
355
  currentRoute: { pageId: string; params: Record<string, string> };
191
356
  };
192
- datastore: unknown; // typed by @colixsystems/datastore-client
193
- directory: {
194
- listUsers(query?: DirectoryQuery): Promise<DirectoryUser[]>;
195
- };
357
+ /** Injected @colixsystems/datastore-client. */
358
+ datastore: DatastoreClient;
359
+ /** Injected @colixsystems/directory-client (users + groups + invites). */
360
+ directory: DirectoryClient;
361
+ /** Injected Asset-Manager files client: { get, list, upload }. */
362
+ files: FilesClient;
363
+ /** Injected @colixsystems/payments-client. */
364
+ payments: PaymentsClient;
365
+ /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
366
+ renderer: { renderNode(node: unknown): unknown };
196
367
  events: { emit(eventName: string, payload?: unknown): void };
197
- payments: {
198
- requestPayment(args: PaymentRequest): Promise<PaymentResult>;
199
- getPayment(paymentId: string): Promise<PaymentResult>;
200
- };
201
368
  i18n: {
202
369
  locale: string;
203
- t(key: string, vars?: Record<string, unknown>): string;
370
+ t(key: string, fallback?: string): string;
204
371
  };
205
- platform: "web" | "native";
206
372
  logger: {
207
373
  debug: (...args: unknown[]) => void;
208
374
  info: (...args: unknown[]) => void;
209
375
  warn: (...args: unknown[]) => void;
210
376
  error: (...args: unknown[]) => void;
211
377
  };
378
+ /** Optional host toast slot; backs useToast. */
379
+ toast?: {
380
+ showToast(args: { kind?: string; message: string }): void;
381
+ };
212
382
  }
213
383
 
214
384
  /**
@@ -303,8 +473,8 @@ export interface DirectoryQuery {
303
473
  q?: string;
304
474
  /** `"USER"` (default), `"INTEGRATION"`, or `"ALL"`. */
305
475
  role?: "USER" | "INTEGRATION" | "ALL";
306
- /** Filter by active state. */
307
- isActive?: boolean;
476
+ /** Filter by active state (snake_case on the wire). */
477
+ is_active?: boolean;
308
478
  limit?: number;
309
479
  offset?: number;
310
480
  }
@@ -317,7 +487,9 @@ export interface DirectoryResult {
317
487
  }
318
488
 
319
489
  /**
320
- * Read-only user directory hook. Resolves the tenant's app users to
490
+ * Read-only user directory hook. Reads the injected directory-client at
491
+ * `ctx.directory.users.list(query)` (returns the `{ data, meta }` envelope;
492
+ * the hook unwraps `res.data`). Resolves the tenant's app users to snake_case
321
493
  * `{ id, name, role }` rows for chat people-lists, @-mention pickers, or
322
494
  * author-id → display-name resolution. Requires the
323
495
  * `directory.read:users` scope in the widget manifest.
@@ -387,10 +559,11 @@ export function useDatastoreRecord(
387
559
  * One column in a table's schema, as returned by `useDatastoreSchema`.
388
560
  * Structural metadata only — never row data.
389
561
  */
562
+ // Returned by `ctx.datastore.schema(tableId)` — wire data, snake_case verbatim.
390
563
  export interface DatastoreSchemaColumn {
391
564
  id: string;
392
565
  name: string;
393
- dataType:
566
+ data_type:
394
567
  | "STRING"
395
568
  | "TEXT"
396
569
  | "NUMBER"
@@ -405,11 +578,11 @@ export interface DatastoreSchemaColumn {
405
578
  | "USER_GROUP";
406
579
  required: boolean;
407
580
  /** For RELATION columns only. */
408
- relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null;
581
+ relation_type?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null;
409
582
  /** For RELATION columns only — the id of the table this column points at. */
410
- targetTableId?: string | null;
583
+ target_table_id?: string | null;
411
584
  /** True when this column is the table's display/identification column. */
412
- isIdentification?: boolean;
585
+ is_identification?: boolean;
413
586
  }
414
587
 
415
588
  export interface DatastoreSchema {
@@ -462,9 +635,9 @@ export function useFile(fileId: string | null | undefined): {
462
635
  file: {
463
636
  id: string;
464
637
  url: string;
465
- storedFilename?: string;
466
- mimeType?: string;
467
- sizeBytes?: number;
638
+ stored_filename?: string;
639
+ mime_type?: string;
640
+ size_bytes?: number;
468
641
  [k: string]: unknown;
469
642
  } | null;
470
643
  loading: boolean;
@@ -485,11 +658,22 @@ export function useI18n(): {
485
658
  export function useUser(): {
486
659
  id: string | null;
487
660
  email: string | null;
488
- displayName: string | null;
661
+ display_name: string | null;
489
662
  roles: string[];
490
- groupIds: string[];
663
+ group_ids: string[];
491
664
  };
492
665
 
666
+ /**
667
+ * REQ-LAY-08 — returns `true` when the host has sized this widget to fill its
668
+ * layout slot's available height (a page-grid tile set to "Fill tile height",
669
+ * or a default-fill widget type — containers + media). Widgets that can
670
+ * stretch (Image, Chart, Map, Video, …) should switch to a fill style
671
+ * (`flex: 1` / `height: "100%"`) when this is `true`; others may ignore it.
672
+ * Defaults to `false`, so calling it is always safe, and the same value is
673
+ * injected on web and native so fill behaviour is identical on both platforms.
674
+ */
675
+ export function useFill(): boolean;
676
+
493
677
  /**
494
678
  * The host-provided navigation surface. `goTo(pageId, params?)` navigates
495
679
  * to an internal app page; `goBack()` pops the stack. Missing methods
@@ -567,13 +751,13 @@ export interface AppUserRow {
567
751
  name: string;
568
752
  email?: string;
569
753
  role: "USER" | "INTEGRATION";
570
- isActive: boolean;
754
+ is_active: boolean;
571
755
  }
572
756
 
573
757
  export interface UsersQuery {
574
758
  q?: string;
575
759
  role?: "USER" | "INTEGRATION" | "ALL";
576
- isActive?: boolean;
760
+ is_active?: boolean;
577
761
  limit?: number;
578
762
  offset?: number;
579
763
  }
@@ -581,15 +765,15 @@ export interface UsersQuery {
581
765
  export interface InviteArgs {
582
766
  email: string;
583
767
  name?: string;
584
- groupIds?: string[];
768
+ group_ids?: string[];
585
769
  }
586
770
 
587
771
  export interface AppUserInviteRow {
588
772
  id: string;
589
773
  email: string;
590
774
  status: string;
591
- invitedAt?: string;
592
- expiresAt?: string;
775
+ invited_at?: string;
776
+ expires_at?: string;
593
777
  }
594
778
 
595
779
  export interface UsersApi {
@@ -604,10 +788,12 @@ export interface UsersApi {
604
788
  }
605
789
 
606
790
  /**
607
- * AppUser administration. Reads require the `users.read:*` scope in the
608
- * manifest; mutations additionally require `users.write:*`. Widgets that
609
- * declare the scopes but whose calling APP_USER lacks the corresponding
610
- * SystemAcl `users.*` capability grant get a `FORBIDDEN` DirectoryError.
791
+ * AppUser administration. Reads through the injected directory-client at
792
+ * `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads
793
+ * require the `users.read:*` scope in the manifest; mutations additionally
794
+ * require `users.write:*`. Widgets that declare the scopes but whose calling
795
+ * APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a
796
+ * `FORBIDDEN` DirectoryError.
611
797
  */
612
798
  export function useUsers(query?: UsersQuery): UsersApi;
613
799
 
@@ -616,7 +802,7 @@ export function useUsers(query?: UsersQuery): UsersApi;
616
802
  export interface AppUserGroupRow {
617
803
  id: string;
618
804
  name: string;
619
- memberCount?: number;
805
+ member_count?: number;
620
806
  }
621
807
 
622
808
  export interface GroupsQuery {
@@ -637,47 +823,50 @@ export interface GroupsApi {
637
823
  }
638
824
 
639
825
  /**
640
- * AppUserGroup administration. Reads require `groups.read:*`; mutations
641
- * require `groups.write:*`. Same SystemAcl gating as `useUsers`.
826
+ * AppUserGroup administration. Reads through the injected directory-client at
827
+ * `ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}`.
828
+ * Reads require `groups.read:*`; mutations require `groups.write:*`. Same
829
+ * SystemAcl gating as `useUsers`.
642
830
  */
643
831
  export function useGroups(query?: GroupsQuery): GroupsApi;
644
832
 
645
833
  // ----------------------------------------------------- useRecordPermissions
646
834
  //
647
835
  // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
648
- // management for a single record. Reuses the existing
649
- // `/api/v1/tables/:tableId/records/:recordId/permissions` REST surface;
650
- // the host normalises the wire shape (`userId` / `groupId` /
651
- // both-null = public) into the `{ principalType, principalId }` pair
652
- // returned to widgets so the call site is portable.
836
+ // management for a single record. Reads the injected datastore-client at
837
+ // `ctx.datastore.records(tableId).permissions(recordId)`. Rows and bodies are
838
+ // snake_case VERBATIM the SDK does NOT transform them. A row carries
839
+ // `user_id` OR `group_id` (both null = a public grant) plus the `can_*` flags.
653
840
 
654
841
  export interface RecordPermission {
655
842
  id: string;
656
- principalType: "USER" | "GROUP" | "PUBLIC";
657
- /** `null` when `principalType === "PUBLIC"`. */
658
- principalId: string | null;
659
- canRead: boolean;
660
- canWrite: boolean;
661
- canDelete: boolean;
662
- canGrant: boolean;
843
+ /** Set when the grant targets a user; null otherwise. */
844
+ user_id: string | null;
845
+ /** Set when the grant targets a group; null otherwise. Both null = public. */
846
+ group_id: string | null;
847
+ can_read: boolean;
848
+ can_write: boolean;
849
+ can_delete: boolean;
850
+ can_grant: boolean;
851
+ [k: string]: unknown;
663
852
  }
664
853
 
665
854
  export interface RecordPermissionGrantInput {
666
- /** Either a concrete principal (`USER` / `GROUP`) or `"PUBLIC"`. */
667
- principalType: "USER" | "GROUP" | "PUBLIC";
668
- /** Required when `principalType !== "PUBLIC"`. */
669
- principalId?: string | null;
670
- canRead?: boolean;
671
- canWrite?: boolean;
672
- canDelete?: boolean;
673
- canGrant?: boolean;
855
+ /** Target a user (omit / null `group_id`). */
856
+ user_id?: string | null;
857
+ /** Target a group (omit / null `user_id`). Both omitted = a public grant. */
858
+ group_id?: string | null;
859
+ can_read?: boolean;
860
+ can_write?: boolean;
861
+ can_delete?: boolean;
862
+ can_grant?: boolean;
674
863
  }
675
864
 
676
865
  export interface RecordPermissionUpdateInput {
677
- canRead?: boolean;
678
- canWrite?: boolean;
679
- canDelete?: boolean;
680
- canGrant?: boolean;
866
+ can_read?: boolean;
867
+ can_write?: boolean;
868
+ can_delete?: boolean;
869
+ can_grant?: boolean;
681
870
  }
682
871
 
683
872
  export interface RecordPermissionsResult {
@@ -720,10 +909,13 @@ export class PermissionError extends Error {
720
909
 
721
910
  /**
722
911
  * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
723
- * management. Requires `acl.write:records` in the manifest's
724
- * `requestedScopes`. The backend gates the call on `canGrant` for the
725
- * target record; a widget that declares the scope but whose caller
726
- * lacks the grant receives `PermissionError { code: "FORBIDDEN" }`.
912
+ * management. Reads the injected datastore-client at
913
+ * `ctx.datastore.records(tableId).permissions(recordId).{list,grant,update,revoke}`.
914
+ * Requires `acl.write:records` in the manifest's `requestedScopes`. The
915
+ * backend gates the call on `can_grant` for the target record; a widget that
916
+ * declares the scope but whose caller lacks the grant receives
917
+ * `PermissionError { code: "FORBIDDEN" }`. Rows and bodies are snake_case
918
+ * verbatim.
727
919
  *
728
920
  * When `tableId` OR `recordId` is null / empty, the hook collapses to a
729
921
  * stable empty result without a network round-trip; mutation methods
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  useTheme,
26
26
  useI18n,
27
27
  useUser,
28
+ useFill,
28
29
  useNavigation,
29
30
  useChildRenderer,
30
31
  WidgetTree,
@@ -25,6 +25,7 @@ export {
25
25
  useTheme,
26
26
  useI18n,
27
27
  useUser,
28
+ useFill,
28
29
  useNavigation,
29
30
  useChildRenderer,
30
31
  WidgetTree,
package/dist/linter.cjs CHANGED
@@ -287,6 +287,61 @@ function _scopeRules(source, manifest) {
287
287
  return findings;
288
288
  }
289
289
 
290
+ // REQ-WIDGET-ACTION — validate manifest-declared server actions. These run in
291
+ // the isolated-vm action runner, NOT in the rendered component, so they are
292
+ // validated structurally here (shape + caps) rather than scanned as component
293
+ // source. Mirrors the validator in manifest.cjs; emits `error` findings so a
294
+ // malformed / oversized / mis-triggered action blocks publish.
295
+ function _manifestActionRules(manifest) {
296
+ const findings = [];
297
+ if (!manifest || manifest.actions === undefined) return findings;
298
+ const validTriggers = new Set(CONTRACT.actionTriggerTypes);
299
+ const maxBytes = CONTRACT.actionScriptMaxBytes;
300
+ const push = (label) =>
301
+ findings.push({ rule: "manifest-action", severity: "error", label, line: 0, snippet: "" });
302
+ if (!Array.isArray(manifest.actions)) {
303
+ push("manifest.actions must be an array (omit it or use [] for none)");
304
+ return findings;
305
+ }
306
+ const seenKeys = new Set();
307
+ for (const a of manifest.actions) {
308
+ if (a === null || typeof a !== "object") {
309
+ push("manifest.actions entries must be objects");
310
+ break;
311
+ }
312
+ if (typeof a.key !== "string" || a.key.length === 0) {
313
+ push("manifest.actions[].key must be a non-empty string");
314
+ } else if (seenKeys.has(a.key)) {
315
+ push(`manifest.actions[].key "${a.key}" is duplicated`);
316
+ } else {
317
+ seenKeys.add(a.key);
318
+ }
319
+ if (typeof a.name !== "string" || a.name.length === 0) {
320
+ push("manifest.actions[].name must be a non-empty string");
321
+ }
322
+ if (!validTriggers.has(a.triggerType)) {
323
+ push(`manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`);
324
+ } else if (a.triggerType === "schedule" && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
325
+ push("manifest.actions[].scheduleCron is required when triggerType is 'schedule'");
326
+ }
327
+ if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
328
+ push("manifest.actions[].scriptSource must be a non-empty string");
329
+ } else if (new TextEncoder().encode(a.scriptSource).length > maxBytes) {
330
+ push("manifest.actions[].scriptSource exceeds 200 KiB");
331
+ }
332
+ if (a.timeoutMs !== undefined) {
333
+ const t = Number(a.timeoutMs);
334
+ if (!Number.isFinite(t) || t < 100 || t > 300000) {
335
+ push("manifest.actions[].timeoutMs must be between 100 and 300000");
336
+ }
337
+ }
338
+ if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
339
+ push("manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install");
340
+ }
341
+ }
342
+ return findings;
343
+ }
344
+
290
345
  function lintSource(source, options) {
291
346
  if (typeof source !== "string") {
292
347
  return {
@@ -331,6 +386,7 @@ function lintSource(source, options) {
331
386
  severity: f.severity || "error",
332
387
  })),
333
388
  );
389
+ findings.push(..._manifestActionRules(options && options.manifest));
334
390
  const hasErrors = findings.some((f) => f.severity !== "warning");
335
391
  return { ok: !hasErrors, findings };
336
392
  }
package/dist/linter.js CHANGED
@@ -361,6 +361,61 @@ function _scopeRules(source, manifest) {
361
361
  * @param {{ manifest?: { requestedScopes?: string[], supportedPlatforms?: string[] } }} [options]
362
362
  * @returns {{ ok: boolean, findings: Array<{ rule: string, severity?: "error" | "warning", label: string, line: number, snippet: string }> }}
363
363
  */
364
+ // REQ-WIDGET-ACTION — validate manifest-declared server actions. These run in
365
+ // the isolated-vm action runner, NOT in the rendered component, so they are
366
+ // validated structurally here (shape + caps) rather than scanned as component
367
+ // source. Mirrors the validator in manifest.js; emits `error` findings so a
368
+ // malformed / oversized / mis-triggered action blocks publish.
369
+ function _manifestActionRules(manifest) {
370
+ const findings = [];
371
+ if (!manifest || manifest.actions === undefined) return findings;
372
+ const validTriggers = new Set(CONTRACT.actionTriggerTypes);
373
+ const maxBytes = CONTRACT.actionScriptMaxBytes;
374
+ const push = (label) =>
375
+ findings.push({ rule: "manifest-action", severity: "error", label, line: 0, snippet: "" });
376
+ if (!Array.isArray(manifest.actions)) {
377
+ push("manifest.actions must be an array (omit it or use [] for none)");
378
+ return findings;
379
+ }
380
+ const seenKeys = new Set();
381
+ for (const a of manifest.actions) {
382
+ if (a === null || typeof a !== "object") {
383
+ push("manifest.actions entries must be objects");
384
+ break;
385
+ }
386
+ if (typeof a.key !== "string" || a.key.length === 0) {
387
+ push("manifest.actions[].key must be a non-empty string");
388
+ } else if (seenKeys.has(a.key)) {
389
+ push(`manifest.actions[].key "${a.key}" is duplicated`);
390
+ } else {
391
+ seenKeys.add(a.key);
392
+ }
393
+ if (typeof a.name !== "string" || a.name.length === 0) {
394
+ push("manifest.actions[].name must be a non-empty string");
395
+ }
396
+ if (!validTriggers.has(a.triggerType)) {
397
+ push(`manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`);
398
+ } else if (a.triggerType === "schedule" && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
399
+ push("manifest.actions[].scheduleCron is required when triggerType is 'schedule'");
400
+ }
401
+ if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
402
+ push("manifest.actions[].scriptSource must be a non-empty string");
403
+ } else if (new TextEncoder().encode(a.scriptSource).length > maxBytes) {
404
+ push("manifest.actions[].scriptSource exceeds 200 KiB");
405
+ }
406
+ if (a.timeoutMs !== undefined) {
407
+ const t = Number(a.timeoutMs);
408
+ if (!Number.isFinite(t) || t < 100 || t > 300000) {
409
+ push("manifest.actions[].timeoutMs must be between 100 and 300000");
410
+ }
411
+ }
412
+ if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
413
+ push("manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install");
414
+ }
415
+ }
416
+ return findings;
417
+ }
418
+
364
419
  export function lintSource(source, options) {
365
420
  if (typeof source !== "string") {
366
421
  return {
@@ -410,6 +465,8 @@ export function lintSource(source, options) {
410
465
  severity: f.severity || "error",
411
466
  })),
412
467
  );
468
+ // REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
469
+ findings.push(..._manifestActionRules(options && options.manifest));
413
470
  const hasErrors = findings.some((f) => f.severity !== "warning");
414
471
  return { ok: !hasErrors, findings };
415
472
  }