@colixsystems/widget-sdk 0.18.0 → 0.19.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/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,12 @@ 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[];
150
187
  }
151
188
 
152
189
  export interface ThemeTokens {
@@ -168,47 +205,162 @@ export interface ThemeTokens {
168
205
  };
169
206
  }
170
207
 
208
+ // ----------------------------------------------------- injected data clients
209
+ //
210
+ // REQ-WSDK-DOMAIN-CLIENTS. @colixsystems/widget-sdk is CORE ONLY — manifest
211
+ // contract, primitives, rendering, hooks, events, theme/i18n. It owns NO HTTP
212
+ // and depends on NONE of the data SDK packages. The data layer is FOUR domain
213
+ // client packages, each instantiated by the host and injected into
214
+ // WidgetContext. **Widgets never import these packages** — they reach the data
215
+ // surface only through the SDK hooks, which read the injected instances. The
216
+ // shapes below are declared STRUCTURALLY here (widget-sdk must not import the
217
+ // data SDKs); the authoritative typings ship with each client package.
218
+ //
219
+ // Wire/casing: snake_case end to end. The clients send/return snake_case
220
+ // VERBATIM (e.g. `created_at`, `group_ids`, `can_read`, `amount_cents`,
221
+ // `data_type`, `is_active`). There is NO client-side case transform anywhere.
222
+ // Author-defined record column values are passed through verbatim. List
223
+ // methods return the `{ data, meta }` envelope.
224
+
225
+ /** A `{ data, meta }` list envelope, returned verbatim by every `list(...)`. */
226
+ export interface ListEnvelope<T = unknown> {
227
+ data: T[];
228
+ meta?: Record<string, unknown>;
229
+ }
230
+
231
+ /**
232
+ * Structural shape of the injected `@colixsystems/datastore-client`
233
+ * (`ctx.datastore`). Backs `useDatastoreQuery` / `useDatastoreRecord` /
234
+ * `useDatastoreSchema` / `useDatastoreMutation` / `useRecordPermissions`.
235
+ * Rows and bodies are snake_case verbatim; `list` returns `{ data, meta }`.
236
+ */
237
+ export interface DatastoreClient {
238
+ tables: {
239
+ list(): Promise<ListEnvelope>;
240
+ get(tableIdOrName: string): Promise<unknown>;
241
+ };
242
+ schema(tableId: string): Promise<unknown>;
243
+ records(tableId: string): {
244
+ list(query?: Query): Promise<ListEnvelope>;
245
+ get(recordId: string): Promise<unknown>;
246
+ create(values: Record<string, unknown>): Promise<unknown>;
247
+ /** PATCH semantics — only the supplied columns are mutated. */
248
+ update(recordId: string, values: Record<string, unknown>): Promise<unknown>;
249
+ delete(recordId: string): Promise<void>;
250
+ aggregate(spec: unknown): Promise<unknown>;
251
+ permissions(recordId: string): {
252
+ list(): Promise<ListEnvelope>;
253
+ grant(body: Record<string, unknown>): Promise<unknown>;
254
+ update(
255
+ permissionId: string,
256
+ patch: Record<string, unknown>,
257
+ ): Promise<unknown>;
258
+ revoke(permissionId: string): Promise<void>;
259
+ };
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Structural shape of the injected `@colixsystems/directory-client`
265
+ * (`ctx.directory`). Backs `useDirectory` / `useUsers` (via `.users`) and
266
+ * `useGroups` (via `.groups`). Rows and bodies are snake_case verbatim;
267
+ * `list` returns `{ data, meta }`.
268
+ */
269
+ export interface DirectoryClient {
270
+ me(): Promise<unknown>;
271
+ users: {
272
+ list(query?: DirectoryQuery): Promise<ListEnvelope>;
273
+ get(userId: string): Promise<unknown>;
274
+ invite(body: Record<string, unknown>): Promise<unknown>;
275
+ deactivate(userId: string): Promise<unknown>;
276
+ reactivate(userId: string): Promise<unknown>;
277
+ };
278
+ groups: {
279
+ list(query?: GroupsQuery): Promise<ListEnvelope>;
280
+ create(body: Record<string, unknown>): Promise<unknown>;
281
+ remove(groupId: string): Promise<void>;
282
+ addMember(groupId: string, userId: string): Promise<void>;
283
+ removeMember(groupId: string, userId: string): Promise<void>;
284
+ listMine(): Promise<ListEnvelope>;
285
+ };
286
+ invites: {
287
+ list(): Promise<ListEnvelope>;
288
+ revoke(inviteId: string): Promise<void>;
289
+ resend(inviteId: string): Promise<unknown>;
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Structural shape of the injected Asset-Manager files client (`ctx.files`).
295
+ * Slimmed to the three top-level ops the host injects — `get` / `list` /
296
+ * `upload`. Backs `useFile`, which calls `ctx.files.get(id)`. The returned
297
+ * file carries an absolute `url` safe to drop into `<Image source>`.
298
+ */
299
+ export interface FilesClient {
300
+ get(fileId: string): Promise<unknown>;
301
+ list(query?: Record<string, unknown>): Promise<ListEnvelope>;
302
+ upload(formData: unknown): Promise<unknown>;
303
+ }
304
+
305
+ /**
306
+ * Structural shape of the injected `@colixsystems/payments-client`
307
+ * (`ctx.payments`, REQ-BILL-07-WIDGETPAY). Backs `usePayments`.
308
+ */
309
+ export interface PaymentsClient {
310
+ requestPayment(body: PaymentRequest): Promise<PaymentResult>;
311
+ getPayment(paymentId: string): Promise<PaymentResult>;
312
+ }
313
+
171
314
  export interface WidgetContext<TProps = unknown> {
172
315
  props: TProps;
173
316
  widget: { id: string; instanceId: string; version: string };
317
+ /** Active end-user identity, snake_case verbatim. `id` is null when anonymous. */
174
318
  user: {
175
319
  id: string | null;
176
320
  email: string | null;
177
- displayName: string | null;
321
+ display_name: string | null;
178
322
  roles: string[];
179
- groupIds: string[];
323
+ group_ids: string[];
180
324
  };
181
325
  workspace: {
182
326
  id: string;
183
- name: string;
327
+ slug: string;
184
328
  locale: string;
185
329
  theme: ThemeTokens;
186
330
  };
187
331
  navigation: {
188
332
  goTo(pageId: string, params?: Record<string, string>): void;
189
333
  goBack(): void;
334
+ push(pageId: string, params?: Record<string, unknown>): void;
335
+ replace(pageId: string, params?: Record<string, unknown>): void;
336
+ back(): void;
190
337
  currentRoute: { pageId: string; params: Record<string, string> };
191
338
  };
192
- datastore: unknown; // typed by @colixsystems/datastore-client
193
- directory: {
194
- listUsers(query?: DirectoryQuery): Promise<DirectoryUser[]>;
195
- };
339
+ /** Injected @colixsystems/datastore-client. */
340
+ datastore: DatastoreClient;
341
+ /** Injected @colixsystems/directory-client (users + groups + invites). */
342
+ directory: DirectoryClient;
343
+ /** Injected Asset-Manager files client: { get, list, upload }. */
344
+ files: FilesClient;
345
+ /** Injected @colixsystems/payments-client. */
346
+ payments: PaymentsClient;
347
+ /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
348
+ renderer: { renderNode(node: unknown): unknown };
196
349
  events: { emit(eventName: string, payload?: unknown): void };
197
- payments: {
198
- requestPayment(args: PaymentRequest): Promise<PaymentResult>;
199
- getPayment(paymentId: string): Promise<PaymentResult>;
200
- };
201
350
  i18n: {
202
351
  locale: string;
203
- t(key: string, vars?: Record<string, unknown>): string;
352
+ t(key: string, fallback?: string): string;
204
353
  };
205
- platform: "web" | "native";
206
354
  logger: {
207
355
  debug: (...args: unknown[]) => void;
208
356
  info: (...args: unknown[]) => void;
209
357
  warn: (...args: unknown[]) => void;
210
358
  error: (...args: unknown[]) => void;
211
359
  };
360
+ /** Optional host toast slot; backs useToast. */
361
+ toast?: {
362
+ showToast(args: { kind?: string; message: string }): void;
363
+ };
212
364
  }
213
365
 
214
366
  /**
@@ -303,8 +455,8 @@ export interface DirectoryQuery {
303
455
  q?: string;
304
456
  /** `"USER"` (default), `"INTEGRATION"`, or `"ALL"`. */
305
457
  role?: "USER" | "INTEGRATION" | "ALL";
306
- /** Filter by active state. */
307
- isActive?: boolean;
458
+ /** Filter by active state (snake_case on the wire). */
459
+ is_active?: boolean;
308
460
  limit?: number;
309
461
  offset?: number;
310
462
  }
@@ -317,7 +469,9 @@ export interface DirectoryResult {
317
469
  }
318
470
 
319
471
  /**
320
- * Read-only user directory hook. Resolves the tenant's app users to
472
+ * Read-only user directory hook. Reads the injected directory-client at
473
+ * `ctx.directory.users.list(query)` (returns the `{ data, meta }` envelope;
474
+ * the hook unwraps `res.data`). Resolves the tenant's app users to snake_case
321
475
  * `{ id, name, role }` rows for chat people-lists, @-mention pickers, or
322
476
  * author-id → display-name resolution. Requires the
323
477
  * `directory.read:users` scope in the widget manifest.
@@ -387,10 +541,11 @@ export function useDatastoreRecord(
387
541
  * One column in a table's schema, as returned by `useDatastoreSchema`.
388
542
  * Structural metadata only — never row data.
389
543
  */
544
+ // Returned by `ctx.datastore.schema(tableId)` — wire data, snake_case verbatim.
390
545
  export interface DatastoreSchemaColumn {
391
546
  id: string;
392
547
  name: string;
393
- dataType:
548
+ data_type:
394
549
  | "STRING"
395
550
  | "TEXT"
396
551
  | "NUMBER"
@@ -405,11 +560,11 @@ export interface DatastoreSchemaColumn {
405
560
  | "USER_GROUP";
406
561
  required: boolean;
407
562
  /** For RELATION columns only. */
408
- relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null;
563
+ relation_type?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null;
409
564
  /** For RELATION columns only — the id of the table this column points at. */
410
- targetTableId?: string | null;
565
+ target_table_id?: string | null;
411
566
  /** True when this column is the table's display/identification column. */
412
- isIdentification?: boolean;
567
+ is_identification?: boolean;
413
568
  }
414
569
 
415
570
  export interface DatastoreSchema {
@@ -462,9 +617,9 @@ export function useFile(fileId: string | null | undefined): {
462
617
  file: {
463
618
  id: string;
464
619
  url: string;
465
- storedFilename?: string;
466
- mimeType?: string;
467
- sizeBytes?: number;
620
+ stored_filename?: string;
621
+ mime_type?: string;
622
+ size_bytes?: number;
468
623
  [k: string]: unknown;
469
624
  } | null;
470
625
  loading: boolean;
@@ -485,9 +640,9 @@ export function useI18n(): {
485
640
  export function useUser(): {
486
641
  id: string | null;
487
642
  email: string | null;
488
- displayName: string | null;
643
+ display_name: string | null;
489
644
  roles: string[];
490
- groupIds: string[];
645
+ group_ids: string[];
491
646
  };
492
647
 
493
648
  /**
@@ -567,13 +722,13 @@ export interface AppUserRow {
567
722
  name: string;
568
723
  email?: string;
569
724
  role: "USER" | "INTEGRATION";
570
- isActive: boolean;
725
+ is_active: boolean;
571
726
  }
572
727
 
573
728
  export interface UsersQuery {
574
729
  q?: string;
575
730
  role?: "USER" | "INTEGRATION" | "ALL";
576
- isActive?: boolean;
731
+ is_active?: boolean;
577
732
  limit?: number;
578
733
  offset?: number;
579
734
  }
@@ -581,15 +736,15 @@ export interface UsersQuery {
581
736
  export interface InviteArgs {
582
737
  email: string;
583
738
  name?: string;
584
- groupIds?: string[];
739
+ group_ids?: string[];
585
740
  }
586
741
 
587
742
  export interface AppUserInviteRow {
588
743
  id: string;
589
744
  email: string;
590
745
  status: string;
591
- invitedAt?: string;
592
- expiresAt?: string;
746
+ invited_at?: string;
747
+ expires_at?: string;
593
748
  }
594
749
 
595
750
  export interface UsersApi {
@@ -604,10 +759,12 @@ export interface UsersApi {
604
759
  }
605
760
 
606
761
  /**
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.
762
+ * AppUser administration. Reads through the injected directory-client at
763
+ * `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads
764
+ * require the `users.read:*` scope in the manifest; mutations additionally
765
+ * require `users.write:*`. Widgets that declare the scopes but whose calling
766
+ * APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a
767
+ * `FORBIDDEN` DirectoryError.
611
768
  */
612
769
  export function useUsers(query?: UsersQuery): UsersApi;
613
770
 
@@ -616,7 +773,7 @@ export function useUsers(query?: UsersQuery): UsersApi;
616
773
  export interface AppUserGroupRow {
617
774
  id: string;
618
775
  name: string;
619
- memberCount?: number;
776
+ member_count?: number;
620
777
  }
621
778
 
622
779
  export interface GroupsQuery {
@@ -637,47 +794,50 @@ export interface GroupsApi {
637
794
  }
638
795
 
639
796
  /**
640
- * AppUserGroup administration. Reads require `groups.read:*`; mutations
641
- * require `groups.write:*`. Same SystemAcl gating as `useUsers`.
797
+ * AppUserGroup administration. Reads through the injected directory-client at
798
+ * `ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}`.
799
+ * Reads require `groups.read:*`; mutations require `groups.write:*`. Same
800
+ * SystemAcl gating as `useUsers`.
642
801
  */
643
802
  export function useGroups(query?: GroupsQuery): GroupsApi;
644
803
 
645
804
  // ----------------------------------------------------- useRecordPermissions
646
805
  //
647
806
  // 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.
807
+ // management for a single record. Reads the injected datastore-client at
808
+ // `ctx.datastore.records(tableId).permissions(recordId)`. Rows and bodies are
809
+ // snake_case VERBATIM the SDK does NOT transform them. A row carries
810
+ // `user_id` OR `group_id` (both null = a public grant) plus the `can_*` flags.
653
811
 
654
812
  export interface RecordPermission {
655
813
  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;
814
+ /** Set when the grant targets a user; null otherwise. */
815
+ user_id: string | null;
816
+ /** Set when the grant targets a group; null otherwise. Both null = public. */
817
+ group_id: string | null;
818
+ can_read: boolean;
819
+ can_write: boolean;
820
+ can_delete: boolean;
821
+ can_grant: boolean;
822
+ [k: string]: unknown;
663
823
  }
664
824
 
665
825
  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;
826
+ /** Target a user (omit / null `group_id`). */
827
+ user_id?: string | null;
828
+ /** Target a group (omit / null `user_id`). Both omitted = a public grant. */
829
+ group_id?: string | null;
830
+ can_read?: boolean;
831
+ can_write?: boolean;
832
+ can_delete?: boolean;
833
+ can_grant?: boolean;
674
834
  }
675
835
 
676
836
  export interface RecordPermissionUpdateInput {
677
- canRead?: boolean;
678
- canWrite?: boolean;
679
- canDelete?: boolean;
680
- canGrant?: boolean;
837
+ can_read?: boolean;
838
+ can_write?: boolean;
839
+ can_delete?: boolean;
840
+ can_grant?: boolean;
681
841
  }
682
842
 
683
843
  export interface RecordPermissionsResult {
@@ -720,10 +880,13 @@ export class PermissionError extends Error {
720
880
 
721
881
  /**
722
882
  * 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" }`.
883
+ * management. Reads the injected datastore-client at
884
+ * `ctx.datastore.records(tableId).permissions(recordId).{list,grant,update,revoke}`.
885
+ * Requires `acl.write:records` in the manifest's `requestedScopes`. The
886
+ * backend gates the call on `can_grant` for the target record; a widget that
887
+ * declares the scope but whose caller lacks the grant receives
888
+ * `PermissionError { code: "FORBIDDEN" }`. Rows and bodies are snake_case
889
+ * verbatim.
727
890
  *
728
891
  * When `tableId` OR `recordId` is null / empty, the hook collapses to a
729
892
  * stable empty result without a network round-trip; mutation methods
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
  }