@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/manifest.cjs CHANGED
@@ -5,11 +5,154 @@
5
5
  // implementation.
6
6
 
7
7
  const VALID_CATEGORIES = new Set([
8
- "input", "display", "layout", "data", "media", "communication", "custom",
9
- "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
8
+ "input", "display", "layout", "data", "media", "communication", "administration", "custom",
9
+ "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "ADMINISTRATION", "CUSTOM",
10
10
  ]);
11
11
  const VALID_PLATFORMS = new Set(["web", "native"]);
12
12
 
13
+ // REQ-WIDGET-ACTION — structural validation for manifest-declared server
14
+ // actions. Mirrors the backend action.service caps; the runner re-validates
15
+ // the cron expression (node-cron) and binds the tenant-local API key + table
16
+ // at enable time, so we only check shape + size here (cross-env: no Buffer).
17
+ const VALID_ACTION_TRIGGERS = new Set([
18
+ "schedule",
19
+ "record_created",
20
+ "record_updated",
21
+ "record_deleted",
22
+ ]);
23
+ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
24
+ const ACTION_TIMEOUT_MIN_MS = 100;
25
+ const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
26
+
27
+ // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
28
+ // The relative key pattern is deliberately tighter than the dictionary's
29
+ // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
30
+ // must still satisfy the dictionary cap (128 chars / KEY_RE in
31
+ // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
32
+ // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
33
+ // would reject.
34
+ const TRANSLATIONS_MAX_KEYS = 100;
35
+ const TRANSLATION_VALUE_MAX_BYTES = 1024;
36
+ const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
37
+ const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
38
+ const TRANSLATION_FULL_KEY_MAX = 128;
39
+
40
+ function utf8ByteLength(s) {
41
+ return new TextEncoder().encode(s).length;
42
+ }
43
+
44
+ function validateManifestTranslations(translations, manifestId, errors) {
45
+ if (
46
+ translations === null ||
47
+ typeof translations !== "object" ||
48
+ Array.isArray(translations)
49
+ ) {
50
+ errors.push(
51
+ "manifest.translations must be an object mapping key -> { en, <locale>? }",
52
+ );
53
+ return;
54
+ }
55
+ const keys = Object.keys(translations);
56
+ if (keys.length > TRANSLATIONS_MAX_KEYS) {
57
+ errors.push(
58
+ `manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
59
+ );
60
+ }
61
+ for (const key of keys) {
62
+ if (!TRANSLATION_KEY_RE.test(key)) {
63
+ errors.push(
64
+ `manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
65
+ );
66
+ continue;
67
+ }
68
+ if (isNonEmptyString(manifestId)) {
69
+ const fullKey = `widget.${manifestId}.${key}`;
70
+ if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
71
+ errors.push(
72
+ `manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
73
+ );
74
+ }
75
+ }
76
+ const entry = translations[key];
77
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
78
+ errors.push(
79
+ `manifest.translations["${key}"] must be an object of locale -> string`,
80
+ );
81
+ continue;
82
+ }
83
+ if (!isNonEmptyString(entry.en)) {
84
+ errors.push(
85
+ `manifest.translations["${key}"].en is required and must be a non-empty string`,
86
+ );
87
+ }
88
+ for (const [locale, value] of Object.entries(entry)) {
89
+ if (!TRANSLATION_LOCALE_RE.test(locale)) {
90
+ errors.push(
91
+ `manifest.translations["${key}"] has invalid locale "${locale}"`,
92
+ );
93
+ }
94
+ if (typeof value !== "string") {
95
+ errors.push(
96
+ `manifest.translations["${key}"]["${locale}"] must be a string`,
97
+ );
98
+ } else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
99
+ errors.push(
100
+ `manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function validateManifestActions(actions, errors) {
108
+ if (!Array.isArray(actions)) {
109
+ errors.push("manifest.actions must be an array (omit it or use [] for none)");
110
+ return;
111
+ }
112
+ const seenKeys = new Set();
113
+ for (const a of actions) {
114
+ if (a === null || typeof a !== "object") {
115
+ errors.push("manifest.actions entries must be objects");
116
+ break;
117
+ }
118
+ if (!isNonEmptyString(a.key)) {
119
+ errors.push("manifest.actions[].key must be a non-empty string");
120
+ } else if (seenKeys.has(a.key)) {
121
+ errors.push(`manifest.actions[].key "${a.key}" is duplicated`);
122
+ } else {
123
+ seenKeys.add(a.key);
124
+ }
125
+ pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
126
+ if (!VALID_ACTION_TRIGGERS.has(a.triggerType)) {
127
+ errors.push(
128
+ `manifest.actions[].triggerType must be one of ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
129
+ );
130
+ } else if (a.triggerType === "schedule") {
131
+ pushIf(
132
+ errors,
133
+ isNonEmptyString(a.scheduleCron),
134
+ "manifest.actions[].scheduleCron is required when triggerType is 'schedule'",
135
+ );
136
+ }
137
+ if (!isNonEmptyString(a.scriptSource)) {
138
+ errors.push("manifest.actions[].scriptSource must be a non-empty string");
139
+ } else if (utf8ByteLength(a.scriptSource) > ACTION_SCRIPT_MAX_BYTES) {
140
+ errors.push("manifest.actions[].scriptSource exceeds 200 KiB");
141
+ }
142
+ if (a.timeoutMs !== undefined) {
143
+ const t = Number(a.timeoutMs);
144
+ if (!Number.isFinite(t) || t < ACTION_TIMEOUT_MIN_MS || t > ACTION_TIMEOUT_MAX_MS) {
145
+ errors.push("manifest.actions[].timeoutMs must be between 100 and 300000");
146
+ }
147
+ }
148
+ if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
149
+ errors.push(
150
+ "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
151
+ );
152
+ }
153
+ }
154
+ }
155
+
13
156
  function canonicalCategory(c) {
14
157
  if (typeof c !== "string") return null;
15
158
  const upper = c.toUpperCase();
@@ -20,6 +163,7 @@ function canonicalCategory(c) {
20
163
  "DATA",
21
164
  "MEDIA",
22
165
  "COMMUNICATION",
166
+ "ADMINISTRATION",
23
167
  "CUSTOM",
24
168
  ].includes(upper)
25
169
  ? upper
@@ -135,10 +279,21 @@ function validateManifest(m) {
135
279
  }
136
280
  }
137
281
 
282
+ // `actions` is optional (additive in SDK 1.8.0) — only validate when present.
283
+ if (manifest.actions !== undefined) {
284
+ validateManifestActions(manifest.actions, errors);
285
+ }
286
+
287
+ // `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
288
+ if (manifest.translations !== undefined) {
289
+ validateManifestTranslations(manifest.translations, manifest.id, errors);
290
+ }
291
+
138
292
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
139
293
  }
140
294
 
141
295
  module.exports = {
142
296
  validateManifest,
297
+ validateManifestTranslations,
143
298
  canonicalCategory,
144
299
  };
package/dist/manifest.js CHANGED
@@ -5,11 +5,154 @@
5
5
  // implementation.
6
6
 
7
7
  const VALID_CATEGORIES = new Set([
8
- "input", "display", "layout", "data", "media", "communication", "custom",
9
- "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
8
+ "input", "display", "layout", "data", "media", "communication", "administration", "custom",
9
+ "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "ADMINISTRATION", "CUSTOM",
10
10
  ]);
11
11
  const VALID_PLATFORMS = new Set(["web", "native"]);
12
12
 
13
+ // REQ-WIDGET-ACTION — structural validation for manifest-declared server
14
+ // actions. Mirrors the backend action.service caps; the runner re-validates
15
+ // the cron expression (node-cron) and binds the tenant-local API key + table
16
+ // at enable time, so we only check shape + size here (cross-env: no Buffer).
17
+ const VALID_ACTION_TRIGGERS = new Set([
18
+ "schedule",
19
+ "record_created",
20
+ "record_updated",
21
+ "record_deleted",
22
+ ]);
23
+ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
24
+ const ACTION_TIMEOUT_MIN_MS = 100;
25
+ const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
26
+
27
+ // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
28
+ // The relative key pattern is deliberately tighter than the dictionary's
29
+ // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
30
+ // must still satisfy the dictionary cap (128 chars / KEY_RE in
31
+ // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
32
+ // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
33
+ // would reject.
34
+ const TRANSLATIONS_MAX_KEYS = 100;
35
+ const TRANSLATION_VALUE_MAX_BYTES = 1024;
36
+ const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
37
+ const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
38
+ const TRANSLATION_FULL_KEY_MAX = 128;
39
+
40
+ function utf8ByteLength(s) {
41
+ return new TextEncoder().encode(s).length;
42
+ }
43
+
44
+ function validateManifestTranslations(translations, manifestId, errors) {
45
+ if (
46
+ translations === null ||
47
+ typeof translations !== "object" ||
48
+ Array.isArray(translations)
49
+ ) {
50
+ errors.push(
51
+ "manifest.translations must be an object mapping key -> { en, <locale>? }",
52
+ );
53
+ return;
54
+ }
55
+ const keys = Object.keys(translations);
56
+ if (keys.length > TRANSLATIONS_MAX_KEYS) {
57
+ errors.push(
58
+ `manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
59
+ );
60
+ }
61
+ for (const key of keys) {
62
+ if (!TRANSLATION_KEY_RE.test(key)) {
63
+ errors.push(
64
+ `manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
65
+ );
66
+ continue;
67
+ }
68
+ if (isNonEmptyString(manifestId)) {
69
+ const fullKey = `widget.${manifestId}.${key}`;
70
+ if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
71
+ errors.push(
72
+ `manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
73
+ );
74
+ }
75
+ }
76
+ const entry = translations[key];
77
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
78
+ errors.push(
79
+ `manifest.translations["${key}"] must be an object of locale -> string`,
80
+ );
81
+ continue;
82
+ }
83
+ if (!isNonEmptyString(entry.en)) {
84
+ errors.push(
85
+ `manifest.translations["${key}"].en is required and must be a non-empty string`,
86
+ );
87
+ }
88
+ for (const [locale, value] of Object.entries(entry)) {
89
+ if (!TRANSLATION_LOCALE_RE.test(locale)) {
90
+ errors.push(
91
+ `manifest.translations["${key}"] has invalid locale "${locale}"`,
92
+ );
93
+ }
94
+ if (typeof value !== "string") {
95
+ errors.push(
96
+ `manifest.translations["${key}"]["${locale}"] must be a string`,
97
+ );
98
+ } else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
99
+ errors.push(
100
+ `manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function validateManifestActions(actions, errors) {
108
+ if (!Array.isArray(actions)) {
109
+ errors.push("manifest.actions must be an array (omit it or use [] for none)");
110
+ return;
111
+ }
112
+ const seenKeys = new Set();
113
+ for (const a of actions) {
114
+ if (a === null || typeof a !== "object") {
115
+ errors.push("manifest.actions entries must be objects");
116
+ break;
117
+ }
118
+ if (!isNonEmptyString(a.key)) {
119
+ errors.push("manifest.actions[].key must be a non-empty string");
120
+ } else if (seenKeys.has(a.key)) {
121
+ errors.push(`manifest.actions[].key "${a.key}" is duplicated`);
122
+ } else {
123
+ seenKeys.add(a.key);
124
+ }
125
+ pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
126
+ if (!VALID_ACTION_TRIGGERS.has(a.triggerType)) {
127
+ errors.push(
128
+ `manifest.actions[].triggerType must be one of ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
129
+ );
130
+ } else if (a.triggerType === "schedule") {
131
+ pushIf(
132
+ errors,
133
+ isNonEmptyString(a.scheduleCron),
134
+ "manifest.actions[].scheduleCron is required when triggerType is 'schedule'",
135
+ );
136
+ }
137
+ if (!isNonEmptyString(a.scriptSource)) {
138
+ errors.push("manifest.actions[].scriptSource must be a non-empty string");
139
+ } else if (utf8ByteLength(a.scriptSource) > ACTION_SCRIPT_MAX_BYTES) {
140
+ errors.push("manifest.actions[].scriptSource exceeds 200 KiB");
141
+ }
142
+ if (a.timeoutMs !== undefined) {
143
+ const t = Number(a.timeoutMs);
144
+ if (!Number.isFinite(t) || t < ACTION_TIMEOUT_MIN_MS || t > ACTION_TIMEOUT_MAX_MS) {
145
+ errors.push("manifest.actions[].timeoutMs must be between 100 and 300000");
146
+ }
147
+ }
148
+ if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
149
+ errors.push(
150
+ "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
151
+ );
152
+ }
153
+ }
154
+ }
155
+
13
156
  function canonicalCategory(c) {
14
157
  if (typeof c !== "string") return null;
15
158
  const upper = c.toUpperCase();
@@ -20,6 +163,7 @@ function canonicalCategory(c) {
20
163
  "DATA",
21
164
  "MEDIA",
22
165
  "COMMUNICATION",
166
+ "ADMINISTRATION",
23
167
  "CUSTOM",
24
168
  ].includes(upper)
25
169
  ? upper
@@ -135,10 +279,21 @@ function validateManifest(m) {
135
279
  }
136
280
  }
137
281
 
282
+ // `actions` is optional (additive in SDK 1.8.0) — only validate when present.
283
+ if (manifest.actions !== undefined) {
284
+ validateManifestActions(manifest.actions, errors);
285
+ }
286
+
287
+ // `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
288
+ if (manifest.translations !== undefined) {
289
+ validateManifestTranslations(manifest.translations, manifest.id, errors);
290
+ }
291
+
138
292
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
139
293
  }
140
294
 
141
295
  export {
142
296
  validateManifest,
297
+ validateManifestTranslations,
143
298
  canonicalCategory,
144
299
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.21.1",
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",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/build.js",
38
- "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-record-permissions.test.js src/__tests__/linter-users-scope.test.js"
38
+ "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-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18"