@human-synthesis/norns 0.1.0 → 0.2.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.
@@ -22,14 +22,19 @@ import { isAddress, listUnits, parseAddress } from './address.js';
22
22
  import { schemaEmitter } from './emit-schema.js';
23
23
  import {
24
24
  actionsEmitter,
25
+ componentKey,
26
+ endpointsEmitter,
27
+ jobsEmitter,
25
28
  pagesEmitter,
26
29
  policiesEmitter,
27
30
  queriesEmitter,
28
31
  remotesEmitter,
32
+ servicesEmitter,
29
33
  triggersEmitter
30
34
  } from './emit-units.js';
31
35
  import { machinesEmitter } from './emit-machines.js';
32
36
  import { wranglerFile } from './emit-wrangler.js';
37
+ import { emitFlow } from './flow.js';
33
38
  import { buildGraph } from './graph.js';
34
39
  import { loadSpecs, validateSpecs } from './validate.js';
35
40
 
@@ -49,6 +54,10 @@ export function checkGenerate(specs, opts = {}) {
49
54
  const refusals = [];
50
55
  const graph = buildGraph(specs.modules);
51
56
  if (opts.contracts) refusals.push(...checkBindings(specs, opts.contracts));
57
+ refusals.push(...checkLiveBindings(specs));
58
+ refusals.push(...checkSnippetBindings(specs, opts.snippetSlots ?? null));
59
+ refusals.push(...checkTokenOverrides(specs, opts.tokens ?? null));
60
+ refusals.push(...checkNetworkInBodies(specs));
52
61
 
53
62
  for (const [moduleName, spec] of Object.entries(specs.modules)) {
54
63
  for (const unit of listUnits(moduleName, spec)) {
@@ -81,6 +90,9 @@ export function checkGenerate(specs, opts = {}) {
81
90
  });
82
91
  }
83
92
  }
93
+ if (unit.kind === 'Service' || unit.kind === 'Endpoint') {
94
+ refusals.push(...checkServiceSecrets(unit));
95
+ }
84
96
  if (unit.kind === 'Query') {
85
97
  const q = unit.value;
86
98
  if (q && typeof q === 'object' && !q.live && !q.groupBy && q.limit === undefined) {
@@ -98,6 +110,128 @@ export function checkGenerate(specs, opts = {}) {
98
110
  return refusals;
99
111
  }
100
112
 
113
+ const SECRET_SHAPE_RE =
114
+ /(?:^|[^A-Za-z0-9])(?:sk|pk|rk)_(?:live|test|prod)_[A-Za-z0-9]{8,}|^(?:ghp|gho|github_pat)_|^xox[a-z]-|^AKIA[0-9A-Z]{12}|^eyJ[A-Za-z0-9_-]{10,}/;
115
+
116
+ /** Token-shaped: known prefixes, or long spaceless mixed-class strings that are not URLs. */
117
+ function looksLikeSecret(s) {
118
+ if (typeof s !== 'string') return false;
119
+ if (SECRET_SHAPE_RE.test(s)) return true;
120
+ return (
121
+ s.length >= 32 &&
122
+ !/\s/.test(s) &&
123
+ /[A-Z]/.test(s) &&
124
+ /[a-z]/.test(s) &&
125
+ /[0-9]/.test(s) &&
126
+ !/^https?:\/\//.test(s)
127
+ );
128
+ }
129
+
130
+ function* stringLeaves(value, path = '') {
131
+ if (typeof value === 'string') yield [path, value];
132
+ else if (value && typeof value === 'object') {
133
+ for (const [k, child] of Object.entries(value)) {
134
+ yield* stringLeaves(child, path ? `${path}.${k}` : k);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * D15: credentials never in spec. Refuses userinfo/query params in a
141
+ * service base URL and any token-shaped literal anywhere in the unit —
142
+ * `auth.binding` is a binding *name*; the value lives in the environment
143
+ * (`wrangler secret put`).
144
+ *
145
+ * @param {{ address: string, value: * }} unit a Service unit
146
+ * @returns {Refusal[]}
147
+ */
148
+ export function checkServiceSecrets(unit) {
149
+ /** @type {Refusal[]} */
150
+ const refusals = [];
151
+ const svc = unit.value ?? {};
152
+ const push = (path, message) =>
153
+ refusals.push({
154
+ address: unit.address,
155
+ path: `${unit.address}.${path}`,
156
+ code: 'SECRET_IN_SPEC',
157
+ message,
158
+ fix: 'keep only an UPPER_SNAKE binding name in spec and set the value with `wrangler secret put`'
159
+ });
160
+ if (typeof svc.base === 'string') {
161
+ try {
162
+ const u = new URL(svc.base);
163
+ if (u.username || u.password) push('base', 'base URL embeds userinfo credentials');
164
+ if (u.search) push('base', 'base URL embeds query parameters — move keys/tokens to an env binding');
165
+ } catch {
166
+ // meta-schema already rejects non-URL bases
167
+ }
168
+ }
169
+ for (const [path, s] of stringLeaves(svc)) {
170
+ if (path !== 'base' && looksLikeSecret(s)) {
171
+ push(path, `"${s.slice(0, 8)}…" looks like a literal secret — credentials never go in spec`);
172
+ }
173
+ }
174
+ return refusals;
175
+ }
176
+
177
+ /** Global `fetch(` — not `.fetch(` (DO stubs, service bindings) or `myfetch(`. */
178
+ const GLOBAL_FETCH_RE = /(?<![.\w])fetch\s*\(/;
179
+
180
+ function* customBodyFiles(moduleName, spec) {
181
+ for (const [name, a] of Object.entries(spec.actions ?? {})) {
182
+ if (a?.impl === 'custom') yield [`${moduleName}.Action.${name}`, `src/${moduleName}/actions/${name}.c`];
183
+ }
184
+ for (const [name, j] of Object.entries(spec.jobs ?? {})) {
185
+ if (j?.impl === 'custom') yield [`${moduleName}.Job.${name}`, `src/${moduleName}/jobs/${name}.c`];
186
+ }
187
+ for (const name of Object.keys(spec.functions ?? {})) {
188
+ yield [`${moduleName}.Function.${name}`, `src/${moduleName}/functions/${name}.c`];
189
+ }
190
+ for (const name of Object.keys(spec.endpoints ?? {})) {
191
+ yield [`${moduleName}.Endpoint.${name}`, `src/${moduleName}/endpoints/${name}.c`];
192
+ }
193
+ for (const [name, w] of Object.entries(spec.workers ?? {})) {
194
+ if (typeof w?.source === 'string') yield [`${moduleName}.Worker.${name}`, w.source];
195
+ }
196
+ for (const [name, r] of Object.entries(spec.routes ?? {})) {
197
+ if (typeof r?.source === 'string') yield [`${moduleName}.Route.${name}`, r.source];
198
+ }
199
+ }
200
+
201
+ /**
202
+ * X-07: the generated service client is the only network path a custom
203
+ * body may take. Scans declared L3 bodies (custom actions/jobs, functions,
204
+ * endpoints, worker/route sources) for direct global `fetch(` calls.
205
+ * No-op for in-memory specs (no `dir`) — the lint needs files on disk.
206
+ *
207
+ * @param {{ dir?: string, modules: Record<string, *> }} specs
208
+ * @returns {Refusal[]}
209
+ */
210
+ export function checkNetworkInBodies(specs) {
211
+ if (typeof specs.dir !== 'string') return [];
212
+ const appRoot = dirname(specs.dir);
213
+ /** @type {Refusal[]} */
214
+ const refusals = [];
215
+ for (const [moduleName, spec] of Object.entries(specs.modules)) {
216
+ for (const [address, rel] of customBodyFiles(moduleName, spec)) {
217
+ const file = join(appRoot, rel);
218
+ if (!existsSync(file)) continue;
219
+ const hit = readFileSync(file, 'utf-8')
220
+ .split('\n')
221
+ .findIndex((line) => GLOBAL_FETCH_RE.test(line));
222
+ if (hit === -1) continue;
223
+ refusals.push({
224
+ address,
225
+ path: `${rel}:${hit + 1}`,
226
+ code: 'UNDECLARED_NETWORK',
227
+ message: `custom body calls global fetch() at ${rel}:${hit + 1} — the generated service client is the only network path`,
228
+ fix: 'declare the host as a Service (with auth binding + operations) and call it through the generated client'
229
+ });
230
+ }
231
+ }
232
+ return refusals;
233
+ }
234
+
101
235
  /**
102
236
  * Validate page `components:` entries against palette props contracts
103
237
  * (U-02). Each entry is normalized the way the pages emitter binds it: the
@@ -117,7 +251,8 @@ export function checkBindings(specs, contracts) {
117
251
  (page.components ?? []).forEach((entry, i) => {
118
252
  const keys = Object.keys(entry);
119
253
  if (keys.length === 0) return;
120
- const [first, ...rest] = keys;
254
+ const first = componentKey(entry);
255
+ const rest = keys.filter((k) => k !== first);
121
256
  const tag = first[0].toUpperCase() + first.slice(1);
122
257
  const contract = contracts[tag];
123
258
  if (!contract) return;
@@ -127,6 +262,12 @@ export function checkBindings(specs, contracts) {
127
262
  typeof primary === 'string' && isAddress(primary) ? parseAddress(primary) : null;
128
263
  const props = {};
129
264
  if (parsed) props[parsed.kind === 'Action' ? 'action' : 'data'] = primary;
265
+ else if (primary && typeof primary === 'object' && !Array.isArray(primary)) {
266
+ // realtime bindings (K-27) — the emitter turns these into
267
+ // streamSource/roomChannel props, so validate that shape
268
+ if (typeof primary.stream === 'string') props.streamSource = primary.stream;
269
+ if (typeof primary.room === 'string') props.roomChannel = primary.room;
270
+ }
130
271
  for (const key of rest) props[key] = entry[key];
131
272
 
132
273
  const result = v.safeParse(contract, props);
@@ -146,6 +287,254 @@ export function checkBindings(specs, contracts) {
146
287
  return refusals;
147
288
  }
148
289
 
290
+ /**
291
+ * Validate realtime page bindings (K-27) — object-primary component
292
+ * entries like `{ streamText: { stream: "m.Endpoint.x" } }` and
293
+ * `{ chatThread: { room: "m.Worker.x", sends: [...], receives: [...] } }`.
294
+ * `stream` must target an Endpoint with a `stream` output mode; `room` a
295
+ * Worker declared `room: true`. Optional `sends`/`receives` message-name
296
+ * lists are cross-checked against the Room's declared message schemas
297
+ * (`in` for sends, `out` for receives). Needs no palette contracts — the
298
+ * target shape lives in the spec itself.
299
+ *
300
+ * @param {{ modules: Record<string, *> }} specs
301
+ * @returns {Refusal[]}
302
+ */
303
+ export function checkLiveBindings(specs) {
304
+ /** @type {Refusal[]} */
305
+ const refusals = [];
306
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
307
+ for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
308
+ (page.components ?? []).forEach((entry, i) => {
309
+ const first = componentKey(entry);
310
+ if (!first) return;
311
+ const binding = entry[first];
312
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return;
313
+ const tag = first[0].toUpperCase() + first.slice(1);
314
+ const refuse = (sub, message, fix) =>
315
+ refusals.push({
316
+ address: `${moduleName}.Page.${pageName}`,
317
+ path: `${moduleName}.Page.${pageName}.components[${i}].${first}.${sub}`,
318
+ code: 'INVALID_BINDING',
319
+ message,
320
+ fix
321
+ });
322
+
323
+ if ('stream' in binding) {
324
+ const addr = binding.stream;
325
+ const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
326
+ const target =
327
+ parsed?.kind === 'Endpoint'
328
+ ? specs.modules[parsed.module]?.endpoints?.[parsed.name]
329
+ : undefined;
330
+ if (!parsed || parsed.kind !== 'Endpoint' || !target) {
331
+ refuse(
332
+ 'stream',
333
+ `<${tag}> \`stream\` binding needs an existing Endpoint address, got ${JSON.stringify(addr)}`,
334
+ 'point `stream` at a declared module.Endpoint.name'
335
+ );
336
+ } else if (!target.stream) {
337
+ refuse(
338
+ 'stream',
339
+ `${addr} has no \`stream\` output mode — <${tag}> consumes typed SSE frames`,
340
+ 'declare `stream: { frame: { … } }` on the Endpoint (output and stream are exclusive)'
341
+ );
342
+ }
343
+ }
344
+
345
+ if ('room' in binding) {
346
+ const addr = binding.room;
347
+ const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
348
+ const target =
349
+ parsed?.kind === 'Worker'
350
+ ? specs.modules[parsed.module]?.workers?.[parsed.name]
351
+ : undefined;
352
+ if (!parsed || parsed.kind !== 'Worker' || !target) {
353
+ refuse(
354
+ 'room',
355
+ `<${tag}> \`room\` binding needs an existing Worker address, got ${JSON.stringify(addr)}`,
356
+ 'point `room` at a declared module.Worker.name'
357
+ );
358
+ return;
359
+ }
360
+ if (target.room !== true) {
361
+ refuse(
362
+ 'room',
363
+ `${addr} is not a Room — <${tag}> needs a Worker declared \`room: true\``,
364
+ 'set `room: true` (plus `messages`/`state`) on the Worker'
365
+ );
366
+ return;
367
+ }
368
+ const messages = target.messages ?? {};
369
+ for (const [listKey, dir] of [
370
+ ['sends', 'in'],
371
+ ['receives', 'out']
372
+ ]) {
373
+ for (const name of Array.isArray(binding[listKey]) ? binding[listKey] : []) {
374
+ if (messages[name]?.[dir]) continue;
375
+ refuse(
376
+ listKey,
377
+ `${addr} declares no \`${dir}\` schema for message ${JSON.stringify(name)}`,
378
+ `declare \`messages.${name}.${dir}\` on the Worker or drop it from \`${listKey}\``
379
+ );
380
+ }
381
+ }
382
+ }
383
+ });
384
+ }
385
+ }
386
+ return refusals;
387
+ }
388
+
389
+ /**
390
+ * Validate Snippet bindings (U-07): a page component prop bound to a
391
+ * `m.Snippet.n` address must reference a declared Snippet unit, and — when
392
+ * palette slot metadata is available — its declared `args` must match the
393
+ * slot's calling convention (the emitter forwards those args as props to
394
+ * the custom body, so a mismatch is a broken render, not a style issue).
395
+ *
396
+ * @param {{ modules: Record<string, *> }} specs
397
+ * @param {Record<string, Record<string, string[]>> | null} [slots]
398
+ * `snippetSlots` from @human-synthesis/norns-ui/contracts: tag → prop → args
399
+ * @returns {Refusal[]}
400
+ */
401
+ export function checkSnippetBindings(specs, slots = null) {
402
+ /** @type {Refusal[]} */
403
+ const refusals = [];
404
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
405
+ for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
406
+ (page.components ?? []).forEach((entry, i) => {
407
+ const keys = Object.keys(entry ?? {});
408
+ if (keys.length === 0) return;
409
+ const first = componentKey(entry);
410
+ const tag = first[0].toUpperCase() + first.slice(1);
411
+ for (const key of keys) {
412
+ const value = entry[key];
413
+ if (typeof value !== 'string' || !isAddress(value)) continue;
414
+ const parsed = parseAddress(value);
415
+ if (parsed.kind !== 'Snippet') continue;
416
+ const refuse = (message, fix) =>
417
+ refusals.push({
418
+ address: `${moduleName}.Page.${pageName}`,
419
+ path: `${moduleName}.Page.${pageName}.components[${i}].${key}`,
420
+ code: 'INVALID_BINDING',
421
+ message,
422
+ fix
423
+ });
424
+ const target = specs.modules[parsed.module]?.snippets?.[parsed.name];
425
+ if (!target) {
426
+ refuse(
427
+ `no Snippet declared at ${value}`,
428
+ `declare \`snippets.${parsed.name}\` (with \`args\`) in module "${parsed.module}" — the body lives in src/${parsed.module}/snippets/${parsed.name}.n`
429
+ );
430
+ continue;
431
+ }
432
+ const want = slots?.[tag]?.[key];
433
+ if (!want) continue;
434
+ const got = target.args ?? [];
435
+ if (want.length !== got.length || want.some((a, j) => a !== got[j])) {
436
+ refuse(
437
+ `<${tag}> \`${key}\` slot passes (${want.join(', ')}) but ${value} declares args (${got.join(', ')})`,
438
+ `set \`args: [${want.map((a) => `"${a}"`).join(', ')}]\` on the Snippet — the emitter forwards them as same-named props`
439
+ );
440
+ }
441
+ }
442
+ });
443
+ }
444
+ }
445
+ return refusals;
446
+ }
447
+
448
+ const TOKEN_NAME_RE = /^--[a-z][a-z0-9-]*$/;
449
+
450
+ const asVarName = (name) => (name.startsWith('--') ? name : `--${name}`);
451
+
452
+ /**
453
+ * Validate app-level design-token overrides (U-10): `app.settings.tokens`
454
+ * is a record of token name → CSS value, emitted verbatim into a
455
+ * generated stylesheet — so names must be kebab-case custom properties
456
+ * (checked against the palette token manifest when available) and values
457
+ * must not be able to escape their declaration.
458
+ *
459
+ * @param {{ app?: * }} specs
460
+ * @param {{ vars?: Record<string, string> } | null} [tokens]
461
+ * `tokens` section of @human-synthesis/norns-ui/manifest
462
+ * @returns {Refusal[]}
463
+ */
464
+ export function checkTokenOverrides(specs, tokens = null) {
465
+ const overrides = specs.app?.settings?.tokens;
466
+ if (overrides === undefined) return [];
467
+ const at = (name) => ({
468
+ address: 'app.settings.tokens',
469
+ path: name ? `app.settings.tokens.${name}` : 'app.settings.tokens'
470
+ });
471
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
472
+ return [
473
+ {
474
+ ...at(''),
475
+ code: 'INVALID_TOKEN',
476
+ message: 'settings.tokens must be a record of design-token name → CSS value',
477
+ fix: 'e.g. `"settings": { "tokens": { "color-primary-500": "oklch(55% 0.2 260)" } }`'
478
+ }
479
+ ];
480
+ }
481
+ /** @type {Refusal[]} */
482
+ const refusals = [];
483
+ const known = tokens?.vars ? new Set(Object.keys(tokens.vars)) : null;
484
+ for (const [name, value] of Object.entries(overrides)) {
485
+ const varName = asVarName(name);
486
+ if (!TOKEN_NAME_RE.test(varName)) {
487
+ refusals.push({
488
+ ...at(name),
489
+ code: 'INVALID_TOKEN',
490
+ message: `"${name}" is not a token name (lowercase kebab-case, optional leading --)`
491
+ });
492
+ continue;
493
+ }
494
+ if (known && !known.has(varName)) {
495
+ refusals.push({
496
+ ...at(name),
497
+ code: 'UNKNOWN_TOKEN',
498
+ message: `"${varName}" is not a palette design token`,
499
+ fix: 'browse `tokens.vars` in @human-synthesis/norns-ui/manifest for the addressable set'
500
+ });
501
+ continue;
502
+ }
503
+ if (typeof value !== 'string' || value.trim() === '') {
504
+ refusals.push({
505
+ ...at(name),
506
+ code: 'INVALID_TOKEN',
507
+ message: `override for "${varName}" must be a non-empty CSS value string`
508
+ });
509
+ continue;
510
+ }
511
+ // eslint-disable-next-line no-control-regex
512
+ if (/[;{}]|url\s*\(|[-]/i.test(value)) {
513
+ refusals.push({
514
+ ...at(name),
515
+ code: 'INVALID_TOKEN',
516
+ message: `override for "${varName}" is not a plain CSS value — \`;\`, braces, \`url()\` and control characters are refused`
517
+ });
518
+ }
519
+ }
520
+ return refusals;
521
+ }
522
+
523
+ /**
524
+ * Normalized `--name → value` record from `app.settings.tokens`, or null
525
+ * when the app declares no overrides.
526
+ *
527
+ * @param {{ app?: * }} specs
528
+ * @returns {Record<string, string> | null}
529
+ */
530
+ export function tokenOverrides(specs) {
531
+ const raw = specs.app?.settings?.tokens;
532
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
533
+ const entries = Object.entries(raw);
534
+ if (entries.length === 0) return null;
535
+ return Object.fromEntries(entries.map(([name, value]) => [asVarName(name), value]));
536
+ }
537
+
149
538
  /**
150
539
  * Emitters (K-10..K-12). Each: { name, emit(ctx) } where ctx =
151
540
  * { moduleName, moduleSpec, specs, graph } and the return value is a list
@@ -158,9 +547,12 @@ export const EMITTERS = [
158
547
  machinesEmitter,
159
548
  queriesEmitter,
160
549
  actionsEmitter,
550
+ servicesEmitter,
551
+ jobsEmitter,
161
552
  triggersEmitter,
162
553
  pagesEmitter,
163
- remotesEmitter
554
+ remotesEmitter,
555
+ endpointsEmitter
164
556
  ];
165
557
 
166
558
  const require = createRequire(import.meta.url);
@@ -171,6 +563,8 @@ const FILE_KINDS = {
171
563
  'actions.c': 'Action',
172
564
  'machines.c': 'Action',
173
565
  'policies.c': 'Policy',
566
+ 'services.c': 'Service',
567
+ 'jobs.c': 'Job',
174
568
  'triggers.c': 'Trigger'
175
569
  };
176
570
 
@@ -233,18 +627,26 @@ export class GenerateError extends Error {
233
627
  }
234
628
 
235
629
  /**
236
- * Load palette props contracts from the app's own dependency tree. Apps
237
- * that don't use norns-ui simply skip binding validation.
630
+ * Load palette props contracts + snippet slot metadata from the app's own
631
+ * dependency tree. Apps that don't use norns-ui simply skip binding
632
+ * validation.
238
633
  *
239
634
  * @param {string} appRoot
240
- * @returns {Record<string, *> | null}
635
+ * @returns {{ contracts: Record<string, *> | null, snippetSlots: Record<string, *> | null, tokens: { vars?: Record<string, string> } | null }}
241
636
  */
242
- function loadContracts(appRoot) {
637
+ function loadPalette(appRoot) {
243
638
  try {
244
639
  const appRequire = createRequire(join(appRoot, 'package.json'));
245
- return appRequire('@human-synthesis/norns-ui/contracts').contracts ?? null;
640
+ const mod = appRequire('@human-synthesis/norns-ui/contracts');
641
+ let tokens = null;
642
+ try {
643
+ tokens = appRequire('@human-synthesis/norns-ui/manifest')?.tokens ?? null;
644
+ } catch {
645
+ tokens = null;
646
+ }
647
+ return { contracts: mod.contracts ?? null, snippetSlots: mod.snippetSlots ?? null, tokens };
246
648
  } catch {
247
- return null;
649
+ return { contracts: null, snippetSlots: null, tokens: null };
248
650
  }
249
651
  }
250
652
 
@@ -280,18 +682,22 @@ export function liveRouteFile() {
280
682
  /**
281
683
  * Root layout for the generated route tree (app-level, like wrangler.json).
282
684
  * Plain `.svelte` — no Pug/Civet — so it stays outside the vetted-subset
283
- * surface. Imports the app's global stylesheet when `src/app.css` exists.
685
+ * surface. Imports the app's global stylesheet when `src/app.css` exists,
686
+ * then the generated token-override sheet (after, so `app.settings.tokens`
687
+ * wins over library defaults pulled in via app.css).
284
688
  *
285
689
  * @param {boolean} hasAppCss
690
+ * @param {boolean} [hasTokens]
286
691
  * @returns {{ path: string, text: string }}
287
692
  */
288
- export function layoutFile(hasAppCss) {
693
+ export function layoutFile(hasAppCss, hasTokens = false) {
289
694
  return {
290
695
  path: 'routes/+layout.svelte',
291
696
  text: [
292
697
  '<!-- GENERATED by `norns generate` — do not edit. -->',
293
698
  '<script>',
294
699
  ...(hasAppCss ? ["\timport '$custom/app.css';"] : []),
700
+ ...(hasTokens ? ["\timport './tokens.css';"] : []),
295
701
  '\tlet { children } = $props();',
296
702
  '</script>',
297
703
  '',
@@ -301,6 +707,25 @@ export function layoutFile(hasAppCss) {
301
707
  };
302
708
  }
303
709
 
710
+ /**
711
+ * App-level stylesheet applying `app.settings.tokens` overrides (U-10).
712
+ *
713
+ * @param {Record<string, string>} overrides normalized `--name → value`
714
+ * @returns {{ path: string, text: string }}
715
+ */
716
+ export function tokensFile(overrides) {
717
+ return {
718
+ path: 'routes/tokens.css',
719
+ text: [
720
+ '/* GENERATED by `norns generate` — do not edit. */',
721
+ ':root {',
722
+ ...Object.entries(overrides).map(([name, value]) => `\t${name}: ${value};`),
723
+ '}',
724
+ ''
725
+ ].join('\n')
726
+ };
727
+ }
728
+
304
729
  function readCache(file) {
305
730
  try {
306
731
  return JSON.parse(readFileSync(file, 'utf-8'));
@@ -330,8 +755,15 @@ export function generateApp(dir, opts = {}) {
330
755
  .map((i) => ({ address: i.address, code: 'INVALID_SPEC', message: i.message }))
331
756
  );
332
757
  }
333
- const contracts = opts.contracts ?? loadContracts(appRoot);
334
- const refusals = checkGenerate(specs, contracts ? { contracts } : {});
758
+ const palette = loadPalette(appRoot);
759
+ const contracts = opts.contracts ?? palette.contracts;
760
+ const snippetSlots = opts.snippetSlots ?? palette.snippetSlots;
761
+ const tokens = opts.tokens ?? palette.tokens;
762
+ const refusals = checkGenerate(specs, {
763
+ ...(contracts ? { contracts } : {}),
764
+ ...(snippetSlots ? { snippetSlots } : {}),
765
+ ...(tokens ? { tokens } : {})
766
+ });
335
767
  if (refusals.length > 0) throw new GenerateError(refusals);
336
768
 
337
769
  const cache = opts.force ? { moduleHashes: {} } : readCache(cacheFile);
@@ -351,11 +783,14 @@ export function generateApp(dir, opts = {}) {
351
783
  cache.moduleHashes[moduleName] = specs.hashes[moduleName];
352
784
  }
353
785
 
354
- // App-level: the wrangler config derives from all modules, so refresh it
355
- // whenever anything re-emitted (or it's missing entirely).
356
- if (pending.length > 0 || !existsSync(join(outRoot, 'wrangler.json'))) {
786
+ // App-level: the wrangler config derives from all modules AND app.tron
787
+ // (name, dialect, cloudflare settings), so refresh it whenever anything
788
+ // re-emitted, the app spec itself changed, or it's missing entirely.
789
+ const appChanged = cache.appHash !== specs.hashes.app;
790
+ if (pending.length > 0 || appChanged || !existsSync(join(outRoot, 'wrangler.json'))) {
357
791
  pending.push(wranglerFile(specs));
358
792
  }
793
+ cache.appHash = specs.hashes.app;
359
794
 
360
795
  // App-level: the live SSE route exists iff any query is live.
361
796
  if (
@@ -365,9 +800,15 @@ export function generateApp(dir, opts = {}) {
365
800
  pending.push(liveRouteFile());
366
801
  }
367
802
 
803
+ // App-level: token overrides from `app.settings.tokens` (U-10).
804
+ const overrides = tokenOverrides(specs);
805
+ if (overrides && (pending.length > 0 || !existsSync(join(outRoot, 'routes', 'tokens.css')))) {
806
+ pending.push(tokensFile(overrides));
807
+ }
808
+
368
809
  // App-level: a root layout so generated routes render inside a shell.
369
810
  if (pending.length > 0 || !existsSync(join(outRoot, 'routes', '+layout.svelte'))) {
370
- pending.push(layoutFile(existsSync(join(appRoot, 'src', 'app.css'))));
811
+ pending.push(layoutFile(existsSync(join(appRoot, 'src', 'app.css')), Boolean(overrides)));
371
812
  }
372
813
 
373
814
  const failures = selfCheck(pending);
@@ -393,5 +834,7 @@ export function generateApp(dir, opts = {}) {
393
834
  mkdirSync(dirname(cacheFile), { recursive: true });
394
835
  writeFileSync(cacheFile, JSON.stringify(cache, null, '\t') + '\n');
395
836
 
837
+ emitFlow(specs, { force: opts.force });
838
+
396
839
  return { version: specs.version, written, skipped, refusals: [] };
397
840
  }
@@ -49,7 +49,8 @@ export {
49
49
  } from './absorb.js';
50
50
  export { inferKind, inferCapabilities, inferAuth, adoptUnit, adoptFiles } from './adopt.js';
51
51
  export { loadSpecs, validateSpecs } from './validate.js';
52
- export { generateApp, checkGenerate, checkBindings, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
52
+ export { generateApp, checkGenerate, checkBindings, checkServiceSecrets, checkNetworkInBodies, checkTokenOverrides, tokenOverrides, tokensFile, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
53
+ export { indexBody, unitFlow, buildModuleFlow, emitFlow, flowApp, flowDelta } from './flow.js';
53
54
  export { wranglerConfig, wranglerFile } from './emit-wrangler.js';
54
55
  export { emitModuleMachines, machinesEmitter } from './emit-machines.js';
55
56
  export { migrateApp } from './migrate.js';
@@ -59,13 +60,19 @@ export {
59
60
  emitModulePolicies,
60
61
  emitModuleQueries,
61
62
  emitModuleActions,
63
+ emitModuleServices,
64
+ emitModuleJobs,
62
65
  emitModuleTriggers,
63
66
  emitModulePages,
64
67
  emitModuleRemotes,
68
+ emitModuleEndpoints,
65
69
  policiesEmitter,
66
70
  queriesEmitter,
67
71
  actionsEmitter,
72
+ servicesEmitter,
73
+ jobsEmitter,
68
74
  triggersEmitter,
69
75
  pagesEmitter,
70
- remotesEmitter
76
+ remotesEmitter,
77
+ endpointsEmitter
71
78
  } from './emit-units.js';