@zveltio/sdk 3.0.0-beta.5 → 3.0.0-beta.70

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.
Files changed (105) hide show
  1. package/dist/client/Auth.d.ts.map +1 -1
  2. package/dist/client/Auth.js +3 -0
  3. package/dist/client/QueryBuilder.d.ts.map +1 -1
  4. package/dist/client/QueryBuilder.js +4 -0
  5. package/dist/client/RealtimeClient.d.ts +19 -1
  6. package/dist/client/RealtimeClient.d.ts.map +1 -1
  7. package/dist/client/RealtimeClient.js +65 -12
  8. package/dist/client/ZveltioClient.d.ts.map +1 -1
  9. package/dist/client/ZveltioClient.js +12 -1
  10. package/dist/client.d.ts +28 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +41 -14
  13. package/dist/core.d.ts.map +1 -1
  14. package/dist/core.js +21 -3
  15. package/dist/crdt.d.ts.map +1 -1
  16. package/dist/crdt.js +11 -2
  17. package/dist/ddl/index.d.ts.map +1 -1
  18. package/dist/errors.d.ts +58 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +124 -0
  21. package/dist/extension/index.d.ts +459 -29
  22. package/dist/extension/index.d.ts.map +1 -1
  23. package/dist/extension/index.js +3 -0
  24. package/dist/extension/jsonb.d.ts +62 -0
  25. package/dist/extension/jsonb.d.ts.map +1 -0
  26. package/dist/extension/jsonb.js +64 -0
  27. package/dist/extension/multipart.d.ts +58 -0
  28. package/dist/extension/multipart.d.ts.map +1 -0
  29. package/dist/extension/multipart.js +61 -0
  30. package/dist/extension/numeric.d.ts +94 -0
  31. package/dist/extension/numeric.d.ts.map +1 -0
  32. package/dist/extension/numeric.js +162 -0
  33. package/dist/extension/permission-gate.d.ts +15 -0
  34. package/dist/extension/permission-gate.d.ts.map +1 -1
  35. package/dist/extension/permission-gate.js +33 -1
  36. package/dist/index.d.ts +3 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/local-store.d.ts +5 -0
  40. package/dist/local-store.d.ts.map +1 -1
  41. package/dist/local-store.js +62 -10
  42. package/dist/offline/index.d.ts +3 -3
  43. package/dist/offline/index.js +51 -15
  44. package/dist/publish/index.d.ts.map +1 -1
  45. package/dist/publish/index.js +6 -1
  46. package/dist/realtime.d.ts +18 -1
  47. package/dist/realtime.d.ts.map +1 -1
  48. package/dist/realtime.js +34 -3
  49. package/dist/rpc/index.d.ts.map +1 -1
  50. package/dist/rpc/index.js +2 -0
  51. package/dist/schema-codegen.d.ts +3 -1
  52. package/dist/schema-codegen.d.ts.map +1 -1
  53. package/dist/schema-codegen.js +41 -7
  54. package/dist/schema-watcher.d.ts +10 -2
  55. package/dist/schema-watcher.d.ts.map +1 -1
  56. package/dist/schema-watcher.js +30 -10
  57. package/dist/studio/index.d.ts +1 -0
  58. package/dist/studio/index.d.ts.map +1 -1
  59. package/dist/studio/index.js +3 -0
  60. package/dist/studio/slot-owner-registry.d.ts +13 -0
  61. package/dist/studio/slot-owner-registry.d.ts.map +1 -0
  62. package/dist/studio/slot-owner-registry.js +21 -0
  63. package/dist/svelte.d.ts +1 -1
  64. package/dist/svelte.d.ts.map +1 -1
  65. package/dist/svelte.js +4 -2
  66. package/dist/sync-manager.d.ts +30 -4
  67. package/dist/sync-manager.d.ts.map +1 -1
  68. package/dist/sync-manager.js +101 -15
  69. package/dist/testing/index.d.ts.map +1 -1
  70. package/dist/testing/index.js +23 -1
  71. package/dist/testing/with-test-db.d.ts.map +1 -1
  72. package/dist/testing/with-test-db.js +21 -14
  73. package/dist/tests/client.test.d.ts +2 -0
  74. package/dist/tests/client.test.d.ts.map +1 -0
  75. package/dist/tests/client.test.js +61 -0
  76. package/dist/tests/crdt-orset.test.d.ts +2 -0
  77. package/dist/tests/crdt-orset.test.d.ts.map +1 -0
  78. package/dist/tests/crdt-orset.test.js +23 -0
  79. package/dist/tests/local-store.test.js +44 -0
  80. package/dist/tests/multipart.test.d.ts +2 -0
  81. package/dist/tests/multipart.test.d.ts.map +1 -0
  82. package/dist/tests/multipart.test.js +37 -0
  83. package/dist/tests/offline-provider-crdt.test.d.ts +2 -0
  84. package/dist/tests/offline-provider-crdt.test.d.ts.map +1 -0
  85. package/dist/tests/offline-provider-crdt.test.js +119 -0
  86. package/dist/tests/realtime.test.d.ts +2 -0
  87. package/dist/tests/realtime.test.d.ts.map +1 -0
  88. package/dist/tests/realtime.test.js +284 -0
  89. package/dist/tests/sync-manager.test.d.ts +2 -0
  90. package/dist/tests/sync-manager.test.d.ts.map +1 -0
  91. package/dist/tests/sync-manager.test.js +65 -0
  92. package/dist/tests/with-test-db.test.d.ts +2 -0
  93. package/dist/tests/with-test-db.test.d.ts.map +1 -0
  94. package/dist/tests/with-test-db.test.js +57 -0
  95. package/dist/types/index.d.ts.map +1 -1
  96. package/dist/validate/index.d.ts +49 -0
  97. package/dist/validate/index.d.ts.map +1 -1
  98. package/dist/validate/index.js +503 -7
  99. package/dist/validate/shared-message-keys.d.ts +16 -0
  100. package/dist/validate/shared-message-keys.d.ts.map +1 -0
  101. package/dist/validate/shared-message-keys.js +227 -0
  102. package/package.json +7 -7
  103. package/dist/generate-types.d.ts +0 -37
  104. package/dist/generate-types.d.ts.map +0 -1
  105. package/dist/generate-types.js +0 -157
@@ -24,6 +24,7 @@
24
24
  * which can call validate as a sub-step.
25
25
  */
26
26
  import { parseMigrationSql } from './migration-parse.js';
27
+ export { SHARED_MESSAGE_KEYS } from './shared-message-keys.js';
27
28
  // ─── Helpers ───────────────────────────────────────────────────────────────
28
29
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/;
29
30
  const KNOWN_CATEGORIES = new Set([
@@ -62,6 +63,9 @@ const SAFE_VERSION_RANGE = /^[\d.*^~>=<| -]+$/;
62
63
  function err(code, message, file) {
63
64
  return file ? { code, message, file } : { code, message };
64
65
  }
66
+ function warn(code, message, file) {
67
+ return { ...err(code, message, file), severity: 'warning' };
68
+ }
65
69
  export function validateManifest(input) {
66
70
  const out = [];
67
71
  const m = input.manifest;
@@ -89,10 +93,20 @@ export function validateManifest(input) {
89
93
  if (typeof obj.zveltioMaxVersion === 'string' && !SEMVER_RE.test(obj.zveltioMaxVersion)) {
90
94
  out.push(err('MANIFEST_BAD_MAX_VERSION', `zveltioMaxVersion "${obj.zveltioMaxVersion}" is not valid semver`));
91
95
  }
96
+ // The engine's manifest schema refuses the whole extension (422) on a bad entry.
97
+ if (obj.apiKeyRoutes !== undefined) {
98
+ const routes = Array.isArray(obj.apiKeyRoutes) ? obj.apiKeyRoutes : [obj.apiKeyRoutes];
99
+ for (const r of routes) {
100
+ if (typeof r !== 'string' || !/^(GET|POST|PUT|PATCH|DELETE) \S+$/.test(r)) {
101
+ out.push(err('MANIFEST_BAD_API_KEY_ROUTE', `apiKeyRoutes entry ${JSON.stringify(r)} must be "<GET|POST|PUT|PATCH|DELETE> <pattern>"`));
102
+ }
103
+ }
104
+ }
92
105
  if (typeof obj.category === 'string' && !KNOWN_CATEGORIES.has(obj.category)) {
93
- // Warning rather than error — categories are open-ended in practice.
94
- // Surface so authors notice typos like "finanace".
95
- out.push(err('MANIFEST_UNKNOWN_CATEGORY', `category "${obj.category}" isn't in the known set — typo? Known: ${[...KNOWN_CATEGORIES].sort().join(', ')}`));
106
+ // A true warning — categories are open-ended in practice (e.g. the RO
107
+ // compliance set uses "compliance/ro"). Surfaced so authors notice typos
108
+ // like "finanace", but it must NOT fail validation.
109
+ out.push(warn('MANIFEST_UNKNOWN_CATEGORY', `category "${obj.category}" isn't in the known set — typo? Known: ${[...KNOWN_CATEGORIES].sort().join(', ')}`));
96
110
  }
97
111
  return out;
98
112
  }
@@ -126,18 +140,178 @@ export function validatePeerDependencies(input) {
126
140
  */
127
141
  function hasDestructiveDdl(upSql) {
128
142
  // Strip strings and comments-light: this is a heuristic; full SQL parsing
129
- // is overkill for a pre-publish lint.
143
+ // is overkill for a pre-publish lint. Only DATA-destructive ops count —
144
+ // DROP TABLE / DROP COLUMN lose rows. `ALTER COLUMN ... DROP NOT NULL` and
145
+ // `DROP DEFAULT` only relax a constraint / remove a default and lose no data,
146
+ // so they do NOT require a -- DOWN (they were false positives on legit
147
+ // forward migrations that make a column nullable).
130
148
  const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
131
149
  if (/\bDROP\s+TABLE\b/i.test(s))
132
150
  return true;
133
151
  if (/\bDROP\s+COLUMN\b/i.test(s))
134
152
  return true;
135
- if (/\bALTER\s+COLUMN\b[\s\S]*?\bDROP\s+(NOT\s+NULL|DEFAULT)\b/i.test(s))
136
- return true;
137
153
  return false;
138
154
  }
155
+ /**
156
+ * Columns that record WHO did something, declared as uuid.
157
+ *
158
+ * `"user".id` is a 32-character nanoid, not a uuid. A column named `created_by`
159
+ * or `checked_by` reads like a foreign key to a table whose primary key happens
160
+ * to be one, so eleven of them were declared UUID across this codebase and
161
+ * Postgres answered 22P02 to every write. Creating a document, creating a
162
+ * purchase order, ticking an item off a checklist — none of them ever worked on
163
+ * any installation.
164
+ *
165
+ * Caught here rather than explained in a guide because an extension author has
166
+ * no way to know this. It is a property of the host's user table, it fails only
167
+ * at runtime, and the error names the type rather than the cause.
168
+ */
169
+ function userRefUuidColumns(upSql) {
170
+ const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
171
+ const found = new Set();
172
+ // `foo_by UUID` in a CREATE TABLE body, or an ADD COLUMN of the same shape.
173
+ for (const m of s.matchAll(/(\b[a-z_][a-z0-9_]*_by)\s+UUID\b/gi)) {
174
+ found.add(m[1].toLowerCase());
175
+ }
176
+ // `ALTER COLUMN foo_by TYPE UUID` — the same mistake, made later.
177
+ for (const m of s.matchAll(/ALTER\s+COLUMN\s+(\b[a-z_][a-z0-9_]*_by)\s+TYPE\s+UUID\b/gi)) {
178
+ found.add(m[1].toLowerCase());
179
+ }
180
+ return [...found];
181
+ }
182
+ /** `ALTER COLUMN foo_by TYPE TEXT` — a later migration putting one right. */
183
+ function userRefFixedColumns(upSql) {
184
+ const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
185
+ return [
186
+ ...new Set([...s.matchAll(/ALTER\s+COLUMN\s+(\b[a-z_][a-z0-9_]*_by)\s+TYPE\s+TEXT\b/gi)].map((m) => m[1].toLowerCase())),
187
+ ];
188
+ }
189
+ /**
190
+ * Unique keys over a natural column that leave out `tenant_id`.
191
+ *
192
+ * Sixty of these predated multi-tenancy here. The effect is that the SECOND
193
+ * company on a shared instance is refused a value the first one took — its own
194
+ * invoice number, its own product code, its own fiscal year — and because
195
+ * row-level security hides the conflicting row, it is shown a database error
196
+ * about something it cannot see and cannot resolve.
197
+ *
198
+ * Only flags keys made of plain names. Deliberately silent about:
199
+ * - `id` alone, since a uuid is unique everywhere already;
200
+ * - anything with `token`, `hash` or `secret` in it, which MUST collide across
201
+ * the whole instance — that is the point of them;
202
+ * - keys made of `*_id` columns, because a uuid parent already belongs to one
203
+ * company, so the child cannot straddle two.
204
+ */
205
+ /** Top-level items of every `CREATE TABLE ... ( … )` body in `sql`. */
206
+ function createTableColumnDefs(sql) {
207
+ const out = [];
208
+ const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[^(]*\(/gi;
209
+ let m;
210
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
211
+ while ((m = re.exec(sql)) !== null) {
212
+ let depth = 1;
213
+ let item = '';
214
+ let i = m.index + m[0].length;
215
+ for (; i < sql.length && depth > 0; i++) {
216
+ const ch = sql[i];
217
+ if (ch === '(')
218
+ depth++;
219
+ else if (ch === ')') {
220
+ depth--;
221
+ if (depth === 0)
222
+ break;
223
+ }
224
+ if (depth === 1 && ch === ',') {
225
+ out.push(item);
226
+ item = '';
227
+ continue;
228
+ }
229
+ item += ch;
230
+ }
231
+ out.push(item);
232
+ re.lastIndex = i;
233
+ }
234
+ return out;
235
+ }
236
+ /** Column names carrying an inline `UNIQUE` constraint. */
237
+ function columnLevelUniqueColumns(sql) {
238
+ const out = [];
239
+ for (const def of createTableColumnDefs(sql)) {
240
+ const t = def.trim();
241
+ // Strip string literals first: `CHECK (rule_type IN ('unique', …))` is a
242
+ // column whose ALLOWED VALUES include the word, not a unique constraint.
243
+ const bare = t.replace(/'(?:[^']|'')*'/g, "''");
244
+ if (!/\bUNIQUE\b(?!\s*\()/i.test(bare))
245
+ continue;
246
+ const name = /^([a-z_][a-z0-9_]*)\s+\S/i.exec(t)?.[1];
247
+ if (!name)
248
+ continue;
249
+ if (/^(?:primary|unique|constraint|foreign|check|exclude|like)$/i.test(name))
250
+ continue;
251
+ out.push(name);
252
+ }
253
+ return out;
254
+ }
255
+ function keyColumnSets(upSql) {
256
+ const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
257
+ const sets = [];
258
+ const add = (cols) => sets.push(cols
259
+ .split(',')
260
+ .map((c) => c.trim().replace(/["`]/g, '').toLowerCase())
261
+ .filter(Boolean));
262
+ for (const m of s.matchAll(/\bUNIQUE\s*\(([^)]+)\)/gi))
263
+ add(m[1]);
264
+ for (const m of s.matchAll(/\bPRIMARY\s+KEY\s*\(([^)]+)\)/gi))
265
+ add(m[1]);
266
+ for (const m of s.matchAll(/CREATE\s+UNIQUE\s+INDEX[^(]*\(([^)]+)\)/gi))
267
+ add(m[1]);
268
+ // Column-level `sku TEXT NOT NULL UNIQUE` — the same constraint, written
269
+ // inline. Fifty-eight of these are in the shipped extensions and every one was
270
+ // invisible here, because the three patterns above all require a parenthesised
271
+ // column list. A `UNIQUE` with no `(` after it is a column constraint, and the
272
+ // column is the name the definition opens with.
273
+ for (const col of columnLevelUniqueColumns(s))
274
+ add(col);
275
+ for (const m of s.matchAll(/\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)[^,;\n]*?\bUNIQUE\b(?!\s*\()/gi))
276
+ add(m[1]);
277
+ return sets;
278
+ }
279
+ /** Narrow: a key over plain names with no `tenant_id`. Returns the joined columns. */
280
+ function narrowKey(parts) {
281
+ if (parts.length === 0)
282
+ return null;
283
+ if (parts.includes('tenant_id'))
284
+ return null;
285
+ if (parts.length === 1 && parts[0] === 'id')
286
+ return null;
287
+ if (parts.some((p) => /token|hash|secret/.test(p)))
288
+ return null;
289
+ // ANY `*_id` in the key is enough. It references a row that already belongs to
290
+ // one company, so the key cannot straddle two — `(fiscal_year_id, account_id,
291
+ // month)` and `(product_id, warehouse_id, batch_number)` are correctly scoped
292
+ // without naming the tenant. Requiring EVERY part to be an id was too strict
293
+ // and flagged sixteen keys that a hand review had already accepted.
294
+ if (parts.some((p) => /_id$/.test(p)))
295
+ return null;
296
+ // Sorted, so a later migration that re-adds the key with its columns in a
297
+ // different order still counts as the fix for this one.
298
+ return [...parts].sort().join(', ');
299
+ }
300
+ /** Wide: the same key WITH `tenant_id`, which is a later migration fixing it. */
301
+ function widenedKey(parts) {
302
+ if (!parts.includes('tenant_id'))
303
+ return null;
304
+ const rest = parts.filter((p) => p !== 'tenant_id').sort();
305
+ return rest.length > 0 ? rest.join(', ') : null;
306
+ }
139
307
  export function validateMigrations(input) {
140
308
  const out = [];
309
+ // Accumulated across every file, because a migration set is a sequence and the
310
+ // only thing that matters is where it ends up.
311
+ const narrowKeys = [];
312
+ const widened = new Set();
313
+ const uuidRefs = [];
314
+ const fixedRefs = new Set();
141
315
  for (const file of input.files) {
142
316
  if (file.sql.trim() === '') {
143
317
  out.push(err('MIGRATION_EMPTY', 'migration file is empty', file.filename));
@@ -158,6 +332,41 @@ export function validateMigrations(input) {
158
332
  if (input.requireDownForDestructive && hasDestructiveDdl(parsed.up) && !parsed.down) {
159
333
  out.push(err('MIGRATION_DESTRUCTIVE_NO_DOWN', 'migration contains destructive DDL (DROP TABLE/COLUMN or ALTER ... DROP) but has no "-- DOWN" section. Add one so uninstall --purgeData can roll back.', file.filename));
160
334
  }
335
+ // Schema shape is judged on the END STATE, below, not file by file: an
336
+ // extension that declared a column wrong in 001 and put it right in 006 is
337
+ // correct, and complaining about 001 would only teach people to edit
338
+ // history.
339
+ narrowKeys.push(...keyColumnSets(parsed.up)
340
+ .map(narrowKey)
341
+ .filter((k) => k !== null)
342
+ .map((cols) => ({ cols, filename: file.filename })));
343
+ for (const parts of keyColumnSets(parsed.up)) {
344
+ const wide = widenedKey(parts);
345
+ if (wide)
346
+ widened.add(wide);
347
+ }
348
+ uuidRefs.push(...userRefUuidColumns(parsed.up).map((column) => ({ column, filename: file.filename })));
349
+ for (const column of userRefFixedColumns(parsed.up))
350
+ fixedRefs.add(column);
351
+ }
352
+ for (const { column, filename } of uuidRefs) {
353
+ if (fixedRefs.has(column))
354
+ continue;
355
+ out.push(err('SCHEMA_USER_REF_UUID', `"${column}" is declared UUID and its name says it records a user. ` +
356
+ '`"user".id` is a 32-character nanoid, so Postgres rejects every write with ' +
357
+ '22P02 "invalid input syntax for type uuid" — the route then fails for ' +
358
+ 'everybody, always, with an error naming the type rather than the cause. ' +
359
+ 'Declare it TEXT.', filename));
360
+ }
361
+ for (const { cols, filename } of narrowKeys) {
362
+ if (widened.has(cols))
363
+ continue;
364
+ out.push(err('SCHEMA_UNIQUE_WITHOUT_TENANT', `UNIQUE (${cols}) leaves out tenant_id. Several companies share one ` +
365
+ 'instance, so the second one to use a value is refused it — its own invoice ' +
366
+ 'number, its own product code, its own fiscal year — and row-level security ' +
367
+ 'hides the conflicting row, so it is shown a database error about something ' +
368
+ `it cannot see and cannot resolve. Use UNIQUE (tenant_id, ${cols}) unless ` +
369
+ 'the value must be unique across the whole instance, as a token or a hash is.', filename));
161
370
  }
162
371
  return out;
163
372
  }
@@ -181,6 +390,292 @@ export function validateBundleSize(input) {
181
390
  }
182
391
  return [];
183
392
  }
393
+ // ─── SDUI declarative pages: endpoint contract ─────────────────────────────
394
+ //
395
+ // Catches the class of bug that shipped silently in beta.11: a declarative
396
+ // schema whose `dataSource`/`endpoint` points at a route the extension does NOT
397
+ // serve (wrong path → empty table / 404), or at a foreign endpoint outside the
398
+ // extension's own `/ext/<name>/` namespace (security). The response-key check
399
+ // (`dataPath`) needs the live response, so it lives in the runtime
400
+ // `zveltio extension test`, not here.
401
+ /** Read-only core endpoints a schema is legitimately allowed to call (relation
402
+ * pickers etc.). Keep this list tiny and read-only. */
403
+ export const SDUI_SHARED_READ_ALLOWLIST = new Set([
404
+ '/api/collections',
405
+ '/api/views',
406
+ ]);
407
+ /** Collapse `{token}` and `:param` path segments so a schema endpoint
408
+ * (`/periods/{id}/generate`) matches an engine route (`/periods/:id/generate`). */
409
+ function normalizeRoute(p) {
410
+ return (p
411
+ .split('?')[0]
412
+ .replace(/\{[^}]+\}/g, '*')
413
+ .replace(/:[^/]+/g, '*')
414
+ .replace(/\/+$/, '') || '/');
415
+ }
416
+ /**
417
+ * Schema keys whose value is user-visible text.
418
+ *
419
+ * The host renders these through `t()`, which looks the string up in the
420
+ * message bundle and falls back to the literal. That fallback is why a typo is
421
+ * invisible at runtime: the user simply sees `auth.saml.ui.begin_certificate`
422
+ * rendered as a placeholder. Catching it here is the only place it is cheap.
423
+ */
424
+ export const SDUI_I18N_SLOTS = [
425
+ 'title',
426
+ 'subtitle',
427
+ 'label',
428
+ 'placeholder',
429
+ 'note',
430
+ 'confirm',
431
+ 'description',
432
+ 'emptyText',
433
+ 'emptyTitle',
434
+ 'hint',
435
+ ];
436
+ /** Tokens that read the same in every locale we ship. */
437
+ const I18N_UNIVERSAL = new Set([
438
+ 'API',
439
+ 'JSON',
440
+ 'NDJSON',
441
+ 'CSV',
442
+ 'SQL',
443
+ 'URL',
444
+ 'UUID',
445
+ 'ID',
446
+ 'HTTP',
447
+ 'HTTPS',
448
+ 'GET',
449
+ 'POST',
450
+ 'PUT',
451
+ 'PATCH',
452
+ 'DELETE',
453
+ 'Zveltio',
454
+ 'OK',
455
+ 'UTC',
456
+ 'AI',
457
+ 'SDK',
458
+ 'CLI',
459
+ 'RLS',
460
+ 'DB',
461
+ 'PDF',
462
+ 'XML',
463
+ 'YAML',
464
+ ]);
465
+ /**
466
+ * Third-party product names an integration has to name literally.
467
+ *
468
+ * A validator cannot tell `Notion` from `Furniture` — both are one capitalised
469
+ * word — so the judgement has to be written down. Translating a vendor's name
470
+ * would be wrong in every locale, and warning about it teaches authors to
471
+ * ignore the warning. Extend deliberately, one product at a time.
472
+ */
473
+ const I18N_PRODUCT_NAMES = new Set([
474
+ 'HubSpot',
475
+ 'Notion',
476
+ 'Airtable',
477
+ 'Okta',
478
+ 'Entra ID',
479
+ 'Slack',
480
+ 'Stripe',
481
+ 'Salesforce',
482
+ 'ANAF',
483
+ ]);
484
+ /** A string built only from product names and separators, e.g. "Entra ID / Okta". */
485
+ function isProductNameList(s) {
486
+ const parts = s
487
+ .split(/[/,·|]/)
488
+ .map((p) => p.trim())
489
+ .filter(Boolean);
490
+ return parts.length > 0 && parts.every((p) => I18N_PRODUCT_NAMES.has(p));
491
+ }
492
+ /** Dotted lowercase identifier — the shape every message key in this project has. */
493
+ function looksLikeMessageKey(s) {
494
+ return /^[a-z][a-z0-9]*(\.[a-z0-9_]+)+$/i.test(s);
495
+ }
496
+ /**
497
+ * Prose a user would read, as opposed to a token, an identifier or a code
498
+ * sample. Deliberately the same judgement the Studio's own i18n gate makes —
499
+ * flagging `CSV` or `status=active` would train authors to ignore the warning.
500
+ */
501
+ function looksTranslatable(s) {
502
+ const t = s.trim();
503
+ if (t.length < 3)
504
+ return false;
505
+ if (I18N_UNIVERSAL.has(t))
506
+ return false;
507
+ if (isProductNameList(t))
508
+ return false;
509
+ if (!/[a-z]{2}/.test(t))
510
+ return false;
511
+ if (/^[a-z0-9_.\-/]+$/.test(t))
512
+ return false; // identifier or path
513
+ if (/^[a-z0-9_]+=/.test(t))
514
+ return false; // filter/code sample, e.g. status=active
515
+ if (t.startsWith('http'))
516
+ return false;
517
+ return /^[A-Z]/.test(t) || t.split(/\s+/).length > 1;
518
+ }
519
+ /** Recursively collect every user-visible string with the slot it came from. */
520
+ function collectI18nStrings(node, acc) {
521
+ if (Array.isArray(node)) {
522
+ for (const x of node)
523
+ collectI18nStrings(x, acc);
524
+ return;
525
+ }
526
+ if (node && typeof node === 'object') {
527
+ for (const [k, v] of Object.entries(node)) {
528
+ if (SDUI_I18N_SLOTS.includes(k) && typeof v === 'string' && v)
529
+ acc.push({ slot: k, value: v });
530
+ else
531
+ collectI18nStrings(v, acc);
532
+ }
533
+ }
534
+ }
535
+ /** Recursively collect every `dataSource` / `endpoint` / `saveEndpoint` string. */
536
+ function collectEndpoints(node, acc) {
537
+ if (Array.isArray(node)) {
538
+ for (const x of node)
539
+ collectEndpoints(x, acc);
540
+ return;
541
+ }
542
+ if (node && typeof node === 'object') {
543
+ for (const [k, v] of Object.entries(node)) {
544
+ if ((k === 'dataSource' ||
545
+ k === 'endpoint' ||
546
+ k === 'saveEndpoint' ||
547
+ k === 'catalogDataSource' ||
548
+ k === 'loadEndpoint') &&
549
+ typeof v === 'string')
550
+ acc.push(v);
551
+ else
552
+ collectEndpoints(v, acc);
553
+ }
554
+ }
555
+ }
556
+ export function validateSduiSchema(input) {
557
+ const out = [];
558
+ const { schema, extName, file } = input;
559
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
560
+ out.push(err('SDUI_NOT_OBJECT', 'schema must be a JSON object', file));
561
+ return out;
562
+ }
563
+ const s = schema;
564
+ // Light structural guard (the host's validateSchema is the full one).
565
+ if (typeof s.title !== 'string' || !s.title)
566
+ out.push(err('SDUI_NO_TITLE', 'schema missing "title"', file));
567
+ if (s.kind === 'settings') {
568
+ if (typeof s.dataSource !== 'string' || typeof s.saveEndpoint !== 'string')
569
+ out.push(err('SDUI_SETTINGS_SHAPE', 'settings schema needs dataSource + saveEndpoint', file));
570
+ }
571
+ else if (!Array.isArray(s.resources) || s.resources.length === 0) {
572
+ out.push(err('SDUI_NO_RESOURCES', 'page schema needs a non-empty "resources" array', file));
573
+ }
574
+ const mount = `/ext/${extName}`;
575
+ const depMounts = (input.dependencies ?? []).map((d) => `/ext/${d}`);
576
+ const provided = input.providedRoutes
577
+ ? new Set(input.providedRoutes.map(normalizeRoute))
578
+ : undefined;
579
+ const inMount = (bare, m) => bare === m || bare.startsWith(`${m}/`);
580
+ const endpoints = [];
581
+ collectEndpoints(schema, endpoints);
582
+ for (const raw of endpoints) {
583
+ const bare = raw.split('?')[0];
584
+ if (SDUI_SHARED_READ_ALLOWLIST.has(bare))
585
+ continue;
586
+ // A dependency's namespace is allowed (relation pickers etc.).
587
+ if (depMounts.some((m) => inMount(bare, m)))
588
+ continue;
589
+ if (!inMount(bare, mount)) {
590
+ out.push(err('SDUI_ENDPOINT_FOREIGN', `endpoint "${raw}" is outside this extension's namespace "${mount}/" (or a declared dependency, or the shared-read allowlist). Schemas may only call their own routes.`, file));
591
+ continue;
592
+ }
593
+ if (provided) {
594
+ const rel = normalizeRoute(bare.slice(mount.length) || '/');
595
+ if (!isProvidedRoute(provided, rel)) {
596
+ out.push(err('SDUI_ENDPOINT_UNKNOWN', `endpoint "${raw}" resolves to "${rel}", which the engine does not serve. Provided routes: ${[...provided].sort().join(', ') || '(none found)'}.`, file));
597
+ }
598
+ }
599
+ }
600
+ // ── i18n ─────────────────────────────────────────────────────────────────
601
+ // A schema is data, so every user-visible string sits in a named slot and can
602
+ // be checked exactly — no source parsing, no heuristics about what is prose.
603
+ // That is the property hand-written pages do not have.
604
+ if (input.messageKeys) {
605
+ const known = new Set(input.messageKeys);
606
+ const strings = [];
607
+ collectI18nStrings(schema, strings);
608
+ for (const { slot, value } of strings) {
609
+ if (known.has(value))
610
+ continue;
611
+ if (looksLikeMessageKey(value)) {
612
+ out.push(err('SDUI_I18N_KEY_MISSING', `${slot} "${value}" is not a known message key. The host renders an unknown key as literal text, so this reaches the user as-is. Add it to studio/messages/{locale}.json, or use a shared common.* key.`, file));
613
+ }
614
+ else if (looksTranslatable(value)) {
615
+ out.push(warn('SDUI_I18N_HARDCODED', `${slot} "${value}" is hardcoded text, not a message key — it stays English in all nine locales. Move it to studio/messages/{locale}.json.`, file));
616
+ }
617
+ }
618
+ }
619
+ return out;
620
+ }
621
+ /** Join a Hono mount prefix with a child path (`/query` + `/history` → `/query/history`). */
622
+ function joinRoutePath(prefix, child) {
623
+ if (!child || child === '/')
624
+ return prefix || '/';
625
+ const p = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
626
+ return child.startsWith('/') ? `${p}${child}` : `${p}/${child}`;
627
+ }
628
+ /** True when `rel` is listed, or falls under a `.route('/prefix')` mount recorded as `/prefix/*`. */
629
+ function isProvidedRoute(provided, rel) {
630
+ if (provided.has(rel))
631
+ return true;
632
+ for (const p of provided) {
633
+ if (!p.endsWith('/*'))
634
+ continue;
635
+ const base = p.slice(0, -2);
636
+ // Never treat a root `/*` as "matches everything" — only explicit mounts.
637
+ if (!base || base === '/')
638
+ continue;
639
+ if (rel === base || rel.startsWith(`${base}/`))
640
+ return true;
641
+ }
642
+ return false;
643
+ }
644
+ /** Extract route paths an extension serves from its engine source text.
645
+ *
646
+ * Heuristic over `.get/post/put/patch/delete('…')` and `.route('/prefix', …)`
647
+ * mounts. Nested Hono sub-apps (e.g. `app.route('/query', aiQueryRoutes)`) used
648
+ * to be invisible here, so schemas pointing at the real `/query/history` path
649
+ * failed `SDUI_ENDPOINT_UNKNOWN` while a wrong bare `/history` falsely passed.
650
+ *
651
+ * Mounts register `/prefix` and `/prefix/*`; matching allows any path under that
652
+ * prefix (see `isProvidedRoute`). Parsing each factory body is intentionally
653
+ * avoided — route handlers often contain regexes/`${…}` that defeat brace
654
+ * matching. */
655
+ export function extractEngineRoutes(engineSourceTexts) {
656
+ const routes = new Set();
657
+ const methodRe = /\.(?:get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/g;
658
+ const mountRe = /\.route\(\s*['"`]([^'"`]+)['"`]/g;
659
+ for (const text of engineSourceTexts) {
660
+ let m;
661
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
662
+ while ((m = methodRe.exec(text)) !== null) {
663
+ if (m[1].startsWith('/'))
664
+ routes.add(m[1]);
665
+ }
666
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
667
+ while ((m = mountRe.exec(text)) !== null) {
668
+ const prefix = m[1];
669
+ if (!prefix.startsWith('/'))
670
+ continue;
671
+ routes.add(prefix);
672
+ // Root mounts (`app.route('/', sub)`) do not get a blanket `/*`.
673
+ if (prefix !== '/')
674
+ routes.add(joinRoutePath(prefix, '/*'));
675
+ }
676
+ }
677
+ return [...routes];
678
+ }
184
679
  export function validateExtension(input) {
185
680
  const errors = [
186
681
  ...validateManifest(input.manifest),
@@ -190,7 +685,8 @@ export function validateExtension(input) {
190
685
  ...(input.bundleSize ? validateBundleSize(input.bundleSize) : []),
191
686
  ];
192
687
  return {
193
- ok: errors.length === 0,
688
+ // Warnings are surfaced in `errors` but do not fail validation.
689
+ ok: errors.every((e) => e.severity === 'warning'),
194
690
  errors,
195
691
  stats: {
196
692
  tables: input.stats.tables,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * GENERATED — do not edit. Run `bun run scripts/sync-shared-message-keys.ts`.
3
+ *
4
+ * The subset of the Studio's core message catalogue that extensions may use
5
+ * in SDUI schemas without shipping the key themselves: the generic vocabulary
6
+ * (`common.*`) plus the shared extension helpers (`ext.*`).
7
+ *
8
+ * An extension that needs anything else must ship it in its own
9
+ * `studio/messages/{locale}.json` — otherwise it renders correctly only on a
10
+ * host whose core catalogue happens to know about it, which is not a property
11
+ * an installable extension should depend on.
12
+ *
13
+ * Source: packages/studio/messages/core/en.json (211 keys)
14
+ */
15
+ export declare const SHARED_MESSAGE_KEYS: ReadonlySet<string>;
16
+ //# sourceMappingURL=shared-message-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared-message-keys.d.ts","sourceRoot":"","sources":["../../src/validate/shared-message-keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAoNlD,CAAC"}