@tinkerlandai/indiana-iam 0.2.0 → 0.4.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.
Files changed (55) hide show
  1. package/README.md +127 -5
  2. package/assets/document-controller.js +18 -1
  3. package/assets/document-runtime.js +189 -1
  4. package/assets/viewer.js +74 -7
  5. package/package.json +6 -13
  6. package/src/analyze.mjs +897 -123
  7. package/src/anchors.mjs +5 -0
  8. package/src/build-options.d.ts +6 -1
  9. package/src/build-runtime.mjs +4 -1
  10. package/src/build-styles.mjs +49 -6
  11. package/src/build.mjs +13 -7
  12. package/src/cli.mjs +10 -4
  13. package/src/component-registry.mjs +229 -0
  14. package/src/dependencies.mjs +2 -2
  15. package/src/dom-payload-browser.mjs +28 -0
  16. package/src/dom-payload-json.mjs +12 -0
  17. package/src/dom-payload.mjs +50 -0
  18. package/src/framework-routes.mjs +44 -89
  19. package/src/generate.mjs +20 -7
  20. package/src/graph-sources.mjs +54 -0
  21. package/src/index.d.ts +13 -1
  22. package/src/index.mjs +1 -1
  23. package/src/integrity.mjs +4 -2
  24. package/src/library-entry.mjs +5 -0
  25. package/src/lucide-source.mjs +18 -0
  26. package/src/next-loader.cjs +7 -1
  27. package/src/next.cjs +5 -0
  28. package/src/next.mjs +4 -3
  29. package/src/node-hashes.mjs +13 -9
  30. package/src/render-index.mjs +6 -4
  31. package/src/route-lazy.mjs +80 -0
  32. package/src/route-tree.mjs +15 -3
  33. package/src/routing.mjs +1 -1
  34. package/src/schema-definition.mjs +8 -1
  35. package/src/source-attributes.mjs +24 -0
  36. package/src/source-charts.mjs +38 -0
  37. package/src/source-collections.mjs +241 -0
  38. package/src/source-entry.mjs +50 -0
  39. package/src/source-environment.mjs +26 -0
  40. package/src/source-media.mjs +21 -0
  41. package/src/source-query-evidence.mjs +64 -0
  42. package/src/source-redirects.mjs +388 -0
  43. package/src/source-resolution.mjs +268 -0
  44. package/src/static-primitives.mjs +48 -0
  45. package/src/static-query.mjs +39 -11
  46. package/src/tab-scenes.mjs +214 -0
  47. package/src/tanstack-routes.mjs +143 -0
  48. package/src/transform.mjs +2 -2
  49. package/src/ui-preview.mjs +225 -0
  50. package/src/ui-state-machine.mjs +76 -0
  51. package/src/ui-structure.mjs +645 -0
  52. package/src/upload.mjs +28 -6
  53. package/src/validate.mjs +19 -4
  54. package/src/vite.d.ts +4 -1
  55. package/src/vite.mjs +25 -10
@@ -0,0 +1,645 @@
1
+ import { identity, canonical } from "./integrity.mjs";
2
+ import { documentDom } from "./dom-payload.mjs";
3
+ import { JSDOM, VirtualConsole } from "jsdom";
4
+ import { checkedCollectionFragment } from "./source-collections.mjs";
5
+ import {graphSource} from './graph-sources.mjs';
6
+
7
+ const text = { type: "string", minLength: 1, maxLength: 2048 };
8
+ const nullable = (shape) => ({ anyOf: [shape, { type: "null" }] });
9
+ const array = (items, maxItems = 20000) => ({ type: "array", items, maxItems });
10
+ const object = (properties) => ({
11
+ type: "object",
12
+ properties,
13
+ required: Object.keys(properties),
14
+ additionalProperties: false,
15
+ });
16
+ const source = object({
17
+ file: text,
18
+ symbol: text,
19
+ line: { type: "integer", minimum: 1 },
20
+ column: { type: "integer", minimum: 1 },
21
+ fileHash: { type: "string", pattern: "^[a-f0-9]{64}$" },
22
+ });
23
+
24
+ /** Inert, source-derived presentation relationships. They never authorize an action. */
25
+ export const uiStructureSchema = object({
26
+ schemaVersion: { const: 1 },
27
+ conditions: array(object({ conditionKey: text, expression: text, source })),
28
+ tabs: array(
29
+ object({
30
+ tabKey: text,
31
+ routeKey: text,
32
+ renderKey: text,
33
+ nodeId: text,
34
+ variableKey: nullable(text),
35
+ initialValue: nullable(text),
36
+ items: array(object({ value: text, nodeId: text }), 100),
37
+ source,
38
+ scopeSource: source,
39
+ }),
40
+ ),
41
+ navigation: array(
42
+ object({
43
+ relationKey: text,
44
+ routeKey: text,
45
+ targetRouteKey: text,
46
+ kind: { enum: ["login", "return", "redirect"] },
47
+ returnParameter: nullable(text),
48
+ condition: nullable(text),
49
+ activation: { enum: ["route-condition", "source-continuation"] },
50
+ sourceRefs: array(source, 16),
51
+ }),
52
+ ),
53
+ });
54
+
55
+ uiStructureSchema.properties.regions = array(
56
+ object({
57
+ regionKey: text,
58
+ routeKey: text,
59
+ renderKey: text,
60
+ nodeId: text,
61
+ expression: text,
62
+ variableKey: nullable(text),
63
+ initialValue: nullable(text),
64
+ options: array(
65
+ object({
66
+ value: text,
67
+ label: text,
68
+ nodeId: text,
69
+ componentKey: text,
70
+ source,
71
+ scope: nullable(text),
72
+ }),
73
+ 32,
74
+ ),
75
+ source,
76
+ }),
77
+ );
78
+
79
+ uiStructureSchema.properties.tabs.items.properties.scopeNodeId = text;
80
+ uiStructureSchema.properties.tabs.items.properties.contentNodeIds = array(
81
+ text,
82
+ 2000,
83
+ );
84
+ uiStructureSchema.properties.conditions.items.properties.observation = {
85
+ enum: ["remote", "unobserved-data"],
86
+ };
87
+ uiStructureSchema.properties.fragments = array(
88
+ object({
89
+ fragmentKey: text,
90
+ html: { type: "string", maxLength: 262144 },
91
+ elementKeys: array({ type: "integer", minimum: 0, maximum: 1999 }, 2000),
92
+ sourceElements: array(text, 2000),
93
+ classNames: array({ type: "string", maxLength: 4000 }, 128),
94
+ source,
95
+ itemCount: { type: "integer", minimum: 0, maximum: 128 },
96
+ }),
97
+ 200,
98
+ );
99
+ uiStructureSchema.properties.collections = array(
100
+ object({
101
+ collectionKey: text,
102
+ routeKey: text,
103
+ renderKey: text,
104
+ nodeId: text,
105
+ variableKey: text,
106
+ requires: array(
107
+ object({
108
+ variableKey: text,
109
+ value: { type: ["string", "number", "boolean"] },
110
+ }),
111
+ 16,
112
+ ),
113
+ cases: array(object({ value: text, fragmentKey: text }), 8),
114
+ source,
115
+ }),
116
+ 1000,
117
+ );
118
+
119
+ uiStructureSchema.properties.attributeSets = array(
120
+ object({
121
+ setKey: text,
122
+ property: { enum: ["class", "placeholder", "aria-label"] },
123
+ cases: array(
124
+ object({ value: text, text: { type: "string", maxLength: 4000 } }),
125
+ 8,
126
+ ),
127
+ source,
128
+ }),
129
+ 500,
130
+ );
131
+ uiStructureSchema.properties.attributes = array(
132
+ object({
133
+ attributeKey: text,
134
+ routeKey: text,
135
+ renderKey: text,
136
+ nodeId: text,
137
+ variableKey: text,
138
+ setKey: text,
139
+ }),
140
+ 2000,
141
+ );
142
+
143
+ export function attachUiStructure(graph, structure, documents) {
144
+ const nodeRenders = new Map(
145
+ documents.flatMap((d) =>
146
+ d.nodes.map((n) => [
147
+ n.nodeId,
148
+ graph.renders.find((r) => r.documentKey === d.documentKey)?.renderKey,
149
+ ]),
150
+ ),
151
+ );
152
+ // Conditions and adapted primitives can be synthetic and therefore are not SDK targets.
153
+ for (const row of [
154
+ ...structure.tabs,
155
+ ...(structure.regions ?? []),
156
+ ...(structure.collections ?? []),
157
+ ...(structure.attributes ?? []),
158
+ ])
159
+ row.renderKey = nodeRenders.get(row.truthyNodeId ?? row.nodeId);
160
+ const allNodes = new Map(
161
+ documents.flatMap((d) => d.nodes.map((n) => [n.nodeId, n])),
162
+ );
163
+ const within = (id, root) => {
164
+ let n = allNodes.get(id),
165
+ count = 0;
166
+ while (n && count++ < 128) {
167
+ if (n.nodeId === root) return true;
168
+ n = allNodes.get(n.parentId);
169
+ }
170
+ return false;
171
+ };
172
+ const reads = (expr, key) =>
173
+ (expr?.op === "var" && expr.key === key) ||
174
+ [
175
+ expr?.arg,
176
+ expr?.left,
177
+ expr?.right,
178
+ ...(expr?.args ?? []),
179
+ ...(expr?.parts ?? []).map((p) => p.when),
180
+ ].some((e) => e && reads(e, key));
181
+ for (const tab of structure.tabs) {
182
+ const chain = [];
183
+ let n = allNodes.get(tab.nodeId);
184
+ while (n && chain.length < 128) {
185
+ chain.push(n);
186
+ n = allNodes.get(n.parentId);
187
+ }
188
+ const local = chain.filter(
189
+ (n) =>
190
+ n.source.file === tab.scopeSource.file &&
191
+ n.source.symbol === tab.scopeSource.symbol,
192
+ );
193
+ tab.scopeNodeId =
194
+ chain.find((n) => n.attributes.role === "dialog")?.nodeId ??
195
+ local.at(-1)?.nodeId ??
196
+ tab.nodeId;
197
+ tab.contentNodeIds = [
198
+ ...new Set(
199
+ graph.bindings
200
+ .filter(
201
+ (b) =>
202
+ b.renderKey === tab.renderKey &&
203
+ tab.variableKey &&
204
+ reads(b.value, tab.variableKey) &&
205
+ within(b.nodeId, tab.scopeNodeId) &&
206
+ !within(b.nodeId, tab.nodeId),
207
+ )
208
+ .map((b) => b.nodeId),
209
+ ),
210
+ ];
211
+ }
212
+ const {
213
+ conditions,
214
+ tabs,
215
+ navigation,
216
+ regions = [],
217
+ collections = [],
218
+ fragments = [],
219
+ attributes = [],
220
+ attributeSets = [],
221
+ } = structure;
222
+ // Analysis can visit JSX that a parent adapter does not project. Only the
223
+ // conditions attached to final concrete containers belong to this graph.
224
+ const projectedConditions = new Set();
225
+ for (const document of documents) {
226
+ const dom = new JSDOM(document.html, {
227
+ virtualConsole: new VirtualConsole(),
228
+ });
229
+ try {
230
+ for (const node of dom.window.document.querySelectorAll(
231
+ "[data-iam-condition]",
232
+ ))
233
+ projectedConditions.add(node.getAttribute("data-iam-condition"));
234
+ } finally {
235
+ dom.window.close();
236
+ }
237
+ }
238
+ return {
239
+ ...graph,
240
+ ui: {
241
+ schemaVersion: 1,
242
+ conditions: conditions.filter((c) =>
243
+ projectedConditions.has(c.conditionKey),
244
+ ),
245
+ tabs,
246
+ navigation,
247
+ regions,
248
+ collections,
249
+ fragments,
250
+ attributes,
251
+ attributeSets,
252
+ },
253
+ };
254
+ }
255
+
256
+ export function validateUiStructure(graph, manifest, documents) {
257
+ if (!graph.ui) return;
258
+ const nodes = new Map(
259
+ documents.flatMap((d) =>
260
+ d.nodes.map((n) => [
261
+ n.nodeId,
262
+ { ...n, pageKey: d.pageKey, documentKey: d.documentKey },
263
+ ]),
264
+ ),
265
+ );
266
+ const renders = new Map(graph.renders.map((r) => [r.renderKey, r]));
267
+ const sourceCheck = (s) => {
268
+ if (
269
+ !manifest.sourceFiles.some(
270
+ (f) => f.file === s.file && f.sha256 === s.fileHash,
271
+ )
272
+ )
273
+ throw Error("UI structure source mismatch");
274
+ };
275
+ const seen = new Set();
276
+ function own(row, nodeId) {
277
+ const node = nodes.get(nodeId),
278
+ render = renders.get(row.renderKey);
279
+ if (
280
+ !node ||
281
+ !render ||
282
+ node.pageKey !== row.routeKey ||
283
+ render.routeKey !== row.routeKey ||
284
+ node.documentKey !== render.documentKey
285
+ )
286
+ throw Error("UI structure DOM scope mismatch");
287
+ return node;
288
+ }
289
+ const within = (id, root) => {
290
+ let node = nodes.get(id),
291
+ count = 0;
292
+ while (node && count++ < 128) {
293
+ if (node.nodeId === root) return true;
294
+ node = nodes.get(node.parentId);
295
+ }
296
+ return false;
297
+ };
298
+ for (const row of graph.ui.conditions) {
299
+ if (seen.has(row.conditionKey)) throw Error("Duplicate UI condition");
300
+ seen.add(row.conditionKey);
301
+ sourceCheck(row.source);
302
+ }
303
+ const conditions = new Map(
304
+ graph.ui.conditions.map((c) => [c.conditionKey, c]),
305
+ ),
306
+ usedConditions = new Set(),
307
+ regionMarkers = new Map();
308
+ for (const document of documents) {
309
+ const dom = new JSDOM(
310
+ documentDom(
311
+ manifest.documents.find((d) => d.documentKey === document.documentKey),
312
+ ).html,
313
+ { virtualConsole: new VirtualConsole() },
314
+ );
315
+ try {
316
+ for (const node of dom.window.document.querySelectorAll(
317
+ "[data-iam-region],[data-iam-region-value],[data-iam-collection]",
318
+ ))
319
+ regionMarkers.set(node.getAttribute("data-iam-node"), {
320
+ key: node.getAttribute("data-iam-region"),
321
+ value: node.getAttribute("data-iam-region-value"),
322
+ collection: node.getAttribute("data-iam-collection"),
323
+ });
324
+ for (const node of dom.window.document.querySelectorAll(
325
+ "[data-iam-condition]",
326
+ )) {
327
+ const key = node.getAttribute("data-iam-condition"),
328
+ condition = conditions.get(key),
329
+ id = node.getAttribute("data-iam-node"),
330
+ mapped = nodes.get(id);
331
+ if (
332
+ !condition ||
333
+ !mapped?.synthetic ||
334
+ !node.hasAttribute("hidden") ||
335
+ !["true", "false"].includes(node.getAttribute("data-iam-alternative"))
336
+ )
337
+ throw Error(
338
+ "Invalid source condition container: " +
339
+ JSON.stringify({
340
+ key,
341
+ id,
342
+ known: !!condition,
343
+ synthetic: mapped?.synthetic,
344
+ hidden: node.hasAttribute("hidden"),
345
+ source: mapped?.source,
346
+ }),
347
+ );
348
+ usedConditions.add(key);
349
+ }
350
+ } finally {
351
+ dom.window.close();
352
+ }
353
+ }
354
+ if (conditions.size !== usedConditions.size)
355
+ throw Error("Source condition has no concrete UI");
356
+ const fragments = new Map(),
357
+ fragmentElements = new Map(manifest.elements.map((e) => [e.elementKey, e]));
358
+ let fragmentBytes = 0;
359
+ for (const fragment of graph.ui.fragments ?? []) {
360
+ const { fragmentKey, ...body } = fragment;
361
+ sourceCheck(fragment.source);
362
+ fragmentBytes += Buffer.byteLength(fragment.html);
363
+ if (
364
+ fragmentBytes > 1_048_576 ||
365
+ fragments.has(fragmentKey) ||
366
+ identity("ui-fragment", body) !== fragmentKey
367
+ )
368
+ throw Error("Invalid shared source fragment");
369
+ const dom = new JSDOM("<body>" + fragment.html + "</body>", {
370
+ virtualConsole: new VirtualConsole(),
371
+ });
372
+ try {
373
+ const nodes = checkedCollectionFragment(
374
+ dom.window.document,
375
+ fragment,
376
+ ).nodes;
377
+ if (nodes.length !== fragment.elementKeys.length)
378
+ throw Error("Shared source fragment mapping mismatch");
379
+ const allowed = new Set(
380
+ "div span p button input svg path circle rect line polyline polygon ellipse g title".split(
381
+ " ",
382
+ ),
383
+ );
384
+ for (const [index, node] of nodes.entries()) {
385
+ const element = fragmentElements.get(
386
+ fragment.sourceElements[fragment.elementKeys[index]],
387
+ );
388
+ if (
389
+ !element ||
390
+ (/^[a-z]/.test(element.tag) &&
391
+ element.tag !== node.tagName.toLowerCase()) ||
392
+ !allowed.has(node.tagName.toLowerCase())
393
+ )
394
+ throw Error(
395
+ "Invalid shared source fragment element: " +
396
+ String(element?.tag) +
397
+ " / " +
398
+ node.tagName.toLowerCase() +
399
+ " / " +
400
+ fragment.source.file +
401
+ ":" +
402
+ fragment.source.line,
403
+ );
404
+ sourceCheck(element.source);
405
+ for (const attr of node.attributes)
406
+ if (
407
+ /^on/i.test(attr.name) ||
408
+ (attr.name.startsWith("data-") && !/^data-(?:slot|state|active|disabled|pressed)$/.test(attr.name)) ||
409
+ [
410
+ "id",
411
+ "href",
412
+ "src",
413
+ "srcset",
414
+ "srcdoc",
415
+ "action",
416
+ "formaction",
417
+ "name",
418
+ "autofocus",
419
+ ].includes(attr.name) ||
420
+ (attr.name === "style" &&
421
+ /(?:url\(|@import|expression\()/i.test(attr.value))
422
+ )
423
+ throw Error("Active shared source fragment attribute");
424
+ }
425
+ } finally {
426
+ dom.window.close();
427
+ }
428
+ fragments.set(fragmentKey, fragment);
429
+ }
430
+ const usedFragments = new Set();
431
+ const sets = new Map();
432
+ for (const set of graph.ui.attributeSets ?? []) {
433
+ sourceCheck(set.source);
434
+ const { setKey, ...body } = set;
435
+ if (
436
+ sets.has(setKey) ||
437
+ setKey !== identity("ui-attribute-set", body) ||
438
+ set.cases.length < 2 ||
439
+ new Set(set.cases.map((c) => c.value)).size !== set.cases.length
440
+ )
441
+ throw Error("Invalid source attribute set");
442
+ sets.set(setKey, set);
443
+ }
444
+ const usedSets = new Set();
445
+ for (const row of graph.ui.attributes ?? []) {
446
+ const node = own(row, row.nodeId),
447
+ set = sets.get(row.setKey),
448
+ variable = graph.variables.find(
449
+ (v) =>
450
+ v.variableKey === row.variableKey && v.renderKey === row.renderKey,
451
+ );
452
+ if (
453
+ !set ||
454
+ !variable ||
455
+ !graph.ui.tabs.some(
456
+ (t) =>
457
+ t.variableKey === row.variableKey &&
458
+ set.cases.every((c) => t.items.some((i) => i.value === c.value)),
459
+ )
460
+ )
461
+ throw Error("Source attribute scope mismatch");
462
+ const source = node.source ?? fragmentElements.get(node.elementKey)?.source;
463
+ const forwardedWithinTab=graph.ui.tabs.some(t=>t.variableKey===row.variableKey&&t.scopeSource.file===set.source.file&&t.scopeSource.symbol===set.source.symbol&&within(row.nodeId,t.scopeNodeId??t.nodeId));
464
+ if ((source.file !== set.source.file || source.symbol !== set.source.symbol)&&!forwardedWithinTab)
465
+ throw Error("Source attribute owner mismatch");
466
+ if (seen.has(row.attributeKey)) throw Error("Duplicate source attribute");
467
+ seen.add(row.attributeKey);
468
+ usedSets.add(row.setKey);
469
+ }
470
+ if (usedSets.size !== sets.size) throw Error("Unused source attribute set");
471
+ for (const row of graph.ui.collections ?? []) {
472
+ if (seen.has(row.collectionKey)) throw Error("Duplicate source collection");
473
+ seen.add(row.collectionKey);
474
+ sourceCheck(row.source);
475
+ const root = own(row, row.nodeId);
476
+ if (
477
+ !root.synthetic ||
478
+ !Object.hasOwn(root.attributes, "hidden") ||
479
+ regionMarkers.get(row.nodeId)?.collection !== row.collectionKey
480
+ )
481
+ throw Error("Invalid source collection mount");
482
+ const variable = graph.variables.find(
483
+ (v) =>
484
+ v.variableKey === row.variableKey &&
485
+ v.routeKey === row.routeKey &&
486
+ v.renderKey === row.renderKey,
487
+ );
488
+ if (
489
+ !variable ||
490
+ variable.valueType !== "string" ||
491
+ !graph.ui.tabs.some(
492
+ (t) =>
493
+ t.variableKey === row.variableKey && t.renderKey === row.renderKey,
494
+ )
495
+ )
496
+ throw Error("Source collection has no controlled Tab");
497
+ for (const required of row.requires)
498
+ if (
499
+ !graph.variables.some(
500
+ (v) =>
501
+ v.variableKey === required.variableKey &&
502
+ v.routeKey === row.routeKey &&
503
+ v.renderKey === row.renderKey &&
504
+ v.valueType === typeof required.value,
505
+ )
506
+ )
507
+ throw Error("Source collection constraint mismatch");
508
+ const values = new Set();
509
+ for (const item of row.cases) {
510
+ if (
511
+ values.has(item.value) ||
512
+ !fragments.has(item.fragmentKey) ||
513
+ !graph.ui.tabs.some(
514
+ (t) =>
515
+ t.variableKey === row.variableKey &&
516
+ t.items.some((i) => i.value === item.value),
517
+ )
518
+ )
519
+ throw Error("Invalid source collection case");
520
+ values.add(item.value);
521
+ usedFragments.add(item.fragmentKey);
522
+ }
523
+ if (values.size < 2) throw Error("Source collection needs distinct cases");
524
+ }
525
+ if (usedFragments.size !== fragments.size)
526
+ throw Error("Unreferenced shared source fragment");
527
+ for (const row of graph.ui.tabs) {
528
+ if (seen.has(row.tabKey)) throw Error("Duplicate UI Tab");
529
+ seen.add(row.tabKey);
530
+ sourceCheck(row.source);
531
+ sourceCheck(row.scopeSource);
532
+ const root = own(row, row.nodeId);
533
+ if (
534
+ canonical(
535
+ root.source ??
536
+ manifest.elements.find((e) => e.elementKey === root.elementKey)
537
+ ?.source,
538
+ ) !== canonical(row.source)
539
+ )
540
+ throw Error("UI Tab source mismatch");
541
+ if (
542
+ row.variableKey &&
543
+ !graph.variables.some(
544
+ (v) =>
545
+ v.variableKey === row.variableKey &&
546
+ v.routeKey === row.routeKey &&
547
+ v.renderKey === row.renderKey &&
548
+ canonical(graphSource(graph,v)) === canonical(row.scopeSource),
549
+ )
550
+ )
551
+ throw Error("UI Tab variable scope mismatch");
552
+ if (row.scopeNodeId) {
553
+ own(row, row.scopeNodeId);
554
+ if (!within(row.nodeId, row.scopeNodeId))
555
+ throw Error("UI Tab scope is not an ancestor");
556
+ }
557
+ for (const id of row.contentNodeIds ?? []) {
558
+ own(row, id);
559
+ if (!within(id, row.scopeNodeId ?? row.nodeId))
560
+ throw Error("UI Tab content lies outside its scope");
561
+ }
562
+ const values = new Set();
563
+ for (const item of row.items) {
564
+ const node = own(row, item.nodeId);
565
+ if (
566
+ node.tag !== "button" ||
567
+ node.attributes.role !== "tab" ||
568
+ !within(item.nodeId, row.nodeId) ||
569
+ values.has(item.value)
570
+ )
571
+ throw Error("Invalid UI Tab item");
572
+ values.add(item.value);
573
+ }
574
+ if (row.initialValue !== null && !values.has(row.initialValue))
575
+ throw Error("UI Tab initial value is absent");
576
+ }
577
+ for (const region of graph.ui.regions ?? []) {
578
+ if (seen.has(region.regionKey)) throw Error("Duplicate component region");
579
+ seen.add(region.regionKey);
580
+ sourceCheck(region.source);
581
+ const root = own(region, region.nodeId);
582
+ if (regionMarkers.get(root.nodeId)?.key !== region.regionKey)
583
+ throw Error("Source region marker mismatch");
584
+ if (
585
+ region.variableKey &&
586
+ !graph.variables.some(
587
+ (v) =>
588
+ v.variableKey === region.variableKey &&
589
+ v.routeKey === region.routeKey &&
590
+ v.renderKey === region.renderKey,
591
+ )
592
+ )
593
+ throw Error("Region variable scope mismatch");
594
+ const values = new Set();
595
+ for (const option of region.options) {
596
+ sourceCheck(option.source);
597
+ const node = own(region, option.nodeId);
598
+ if (
599
+ !node.synthetic ||
600
+ !Object.hasOwn(node.attributes, "hidden") ||
601
+ node.parentId !== region.nodeId ||
602
+ regionMarkers.get(node.nodeId)?.value !== option.value ||
603
+ values.has(option.value) ||
604
+ !manifest.components.some((c) => c.componentKey === option.componentKey)
605
+ )
606
+ throw Error("Invalid source component alternative");
607
+ values.add(option.value);
608
+ }
609
+ if (
610
+ region.options.length < 2 ||
611
+ (region.initialValue !== null && !values.has(region.initialValue))
612
+ )
613
+ throw Error("Invalid source registry selection");
614
+ }
615
+ for (const row of graph.ui.navigation) {
616
+ if (seen.has(row.relationKey)) throw Error("Duplicate UI navigation");
617
+ seen.add(row.relationKey);
618
+ if (
619
+ !graph.routes.some((r) => r.routeKey === row.routeKey) ||
620
+ !graph.routes.some((r) => r.routeKey === row.targetRouteKey)
621
+ )
622
+ throw Error("UI navigation route mismatch");
623
+ row.sourceRefs.forEach(sourceCheck);
624
+ const { relationKey, ...data } = row;
625
+ if (uiRelation(data).relationKey !== relationKey || !row.sourceRefs.length)
626
+ throw Error("UI relation identity mismatch");
627
+ if (
628
+ row.kind === "login" &&
629
+ !graph.ui.navigation.some(
630
+ (r) =>
631
+ r.kind === "return" &&
632
+ r.routeKey === row.targetRouteKey &&
633
+ r.targetRouteKey === row.routeKey &&
634
+ r.returnParameter === row.returnParameter,
635
+ )
636
+ )
637
+ throw Error("Login return consumer is absent");
638
+ if (["login", "return"].includes(row.kind) && !row.returnParameter)
639
+ throw Error("UI return parameter is missing");
640
+ }
641
+ }
642
+
643
+ export function uiRelation(row) {
644
+ return { relationKey: identity("ui-relation", row), ...row };
645
+ }
package/src/upload.mjs CHANGED
@@ -2,10 +2,13 @@ import { repositoryCommit } from "./git-metadata.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
4
  export const DEFAULT_BASE_URL = "https://tinker.cash";
5
+ /** One App environment can hold several clients (named builds); each has its own current version. */
6
+ export const DEFAULT_CLIENT = "web";
7
+ const CLIENT_KEY = /^[a-z][a-z0-9-]{0,39}$/;
5
8
  const SIGNED_UPLOAD_ORIGIN = "https://storage.googleapis.com";
6
9
 
7
- /** The build's upload settings: the key and API origin come from options or the build environment. */
8
- export function uploadSettings({ apiKey, baseUrl, upload = "production" } = {}, env = process.env) {
10
+ /** The build's upload settings: the key, client and API origin come from options or the build environment. */
11
+ export function uploadSettings({ apiKey, baseUrl, upload = "production", client } = {}, env = process.env) {
9
12
  const key = apiKey ?? env.INDIANA_IAM_KEY ?? "";
10
13
  const mode = upload === true ? "always" : upload;
11
14
  const enabled =
@@ -13,7 +16,13 @@ export function uploadSettings({ apiKey, baseUrl, upload = "production" } = {},
13
16
  mode !== false &&
14
17
  // On Vercel only production deployments upload; preview builds would keep switching the current version.
15
18
  (mode === "always" || !env.VERCEL_ENV || env.VERCEL_ENV === "production");
16
- return { enabled, apiKey: key, baseUrl: baseUrl ?? env.INDIANA_IAM_BASE_URL ?? DEFAULT_BASE_URL, missingKey: !key };
19
+ return {
20
+ enabled,
21
+ apiKey: key,
22
+ baseUrl: baseUrl ?? env.INDIANA_IAM_BASE_URL ?? DEFAULT_BASE_URL,
23
+ client: client ?? env.INDIANA_IAM_CLIENT ?? DEFAULT_CLIENT,
24
+ missingKey: !key,
25
+ };
17
26
  }
18
27
 
19
28
  /** A short version label: the commit being built when the CI or git exposes it. */
@@ -35,19 +44,32 @@ async function failure(response, step) {
35
44
  /**
36
45
  * Uploads one generated IAM to Indiana: register, PUT the bytes to the server-signed URL,
37
46
  * then let the server verify them. The key needs only the app-manifests:write permission;
38
- * the uploaded (or redeployed) version becomes the App environment's current version.
47
+ * the uploaded (or redeployed) version becomes its client's current version.
39
48
  */
40
- export async function uploadAppManifest({ serialized, digest, label, apiKey, baseUrl = DEFAULT_BASE_URL, fetch = globalThis.fetch }) {
49
+ export async function uploadAppManifest({
50
+ serialized,
51
+ digest,
52
+ label,
53
+ client = DEFAULT_CLIENT,
54
+ apiKey,
55
+ baseUrl = DEFAULT_BASE_URL,
56
+ fetch = globalThis.fetch,
57
+ }) {
41
58
  if (!apiKey) throw new Error("IAM upload needs a key with the app-manifests:write permission");
42
59
  const base = new URL(baseUrl);
43
60
  if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
44
61
  throw new Error("IAM upload needs an https Indiana origin");
62
+ if (!CLIENT_KEY.test(client))
63
+ throw new Error("IAM client must be 1-40 lowercase letters, digits or hyphens, starting with a letter");
64
+ // The server checks the stored file declares this same platform before the version becomes current.
65
+ const platforms = JSON.parse(serialized)?.app?.platforms;
66
+ if (!Array.isArray(platforms) || platforms.length !== 1) throw new Error("IAM upload needs a manifest for exactly one platform");
45
67
  const bytes = Buffer.from(serialized);
46
68
  const authorization = { Authorization: "Bearer " + apiKey };
47
69
  const registered = await fetch(new URL("/api/iam/versions", base), {
48
70
  method: "POST",
49
71
  headers: { ...authorization, "Content-Type": "application/json" },
50
- body: JSON.stringify({ requestId: randomUUID(), label, sha256: digest, bytes: bytes.byteLength }),
72
+ body: JSON.stringify({ requestId: randomUUID(), client, platform: platforms[0], label, sha256: digest, bytes: bytes.byteLength }),
51
73
  signal: AbortSignal.timeout(60_000),
52
74
  });
53
75
  if (!registered.ok) throw await failure(registered, "registration");