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

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