@human-synthesis/norns 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Norns — SvelteKit with Civet, Pug, and the .n / .civet / .c file extensions",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -25,6 +25,10 @@ export const KIND_KEYS = {
25
25
  Trigger: 'triggers',
26
26
  Function: 'functions',
27
27
  Component: 'components',
28
+ Snippet: 'snippets',
29
+ Service: 'services',
30
+ Job: 'jobs',
31
+ Endpoint: 'endpoints',
28
32
  Route: 'routes',
29
33
  Worker: 'workers',
30
34
  Adapter: 'adapters',
@@ -68,10 +68,16 @@ function parseUnitExpr(exprSrc, states) {
68
68
  return withStateLiterals(parseExpr(exprSrc), states ?? new Set());
69
69
  }
70
70
 
71
+ /** `dealBoard` → `Deal Board`, `leads` → `Leads`. */
72
+ export function humanizeName(name) {
73
+ const spaced = String(name).replaceAll('_', ' ').replaceAll('-', ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
74
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
75
+ }
76
+
71
77
  function whereCall(exprSrc, { entity, ownerField, states }) {
72
78
  const ast = JSON.stringify(parseUnitExpr(exprSrc, states));
73
79
  const owner = ownerField ? `, ownerField: ${JSON.stringify(ownerField)}` : '';
74
- return `compileWhere(${ast}, { table: ${entity}, ops, user${owner} })`;
80
+ return `compileWhere(${ast}, { table: ${entity}, ops, user: ctx.user${owner} })`;
75
81
  }
76
82
 
77
83
  function guardExpr(exprSrc, ownerField, states) {
@@ -256,6 +262,42 @@ function inputSchema(moduleName, action, specs) {
256
262
  return `v.strictObject({ ${fields.join(', ')} })`;
257
263
  }
258
264
 
265
+ const SERVICE_CALL_RE = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/;
266
+ const SCOPE_PATH_RE = /^(row|input|user)(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
267
+
268
+ /** `with:` values are scope paths (`input.id`) or JSON literals; no `with` passes the action input through. */
269
+ function withArg(withMap) {
270
+ if (!withMap || typeof withMap !== 'object') return 'input';
271
+ const entries = Object.keys(withMap)
272
+ .sort()
273
+ .map((k) => {
274
+ const val = withMap[k];
275
+ const isPath = typeof val === 'string' && SCOPE_PATH_RE.test(val);
276
+ return `${k}: ${isPath ? val : JSON.stringify(val)}`;
277
+ });
278
+ return `{ ${entries.join(', ')} }`;
279
+ }
280
+
281
+ /** A `call` step: service operations go through the generated typed client; anything else resolves a container token. */
282
+ function callStep(step, moduleName, serviceImports, ctx) {
283
+ const m = SERVICE_CALL_RE.exec(step.call);
284
+ if (m) {
285
+ const [, module, service, op] = m;
286
+ const local = module === moduleName ? './' : `../${module}/`;
287
+ serviceImports.set(service, `${local}services.c`);
288
+ return `\t\tawait ${service}.${op}(${withArg(step.with)}, container)`;
289
+ }
290
+ return `\t\tawait container.resolve(${JSON.stringify(step.call)})(${ctx})`;
291
+ }
292
+
293
+ const JOB_ADDR_RE = /^[a-z][a-z0-9_]*\.Job\.[A-Za-z_][A-Za-z0-9_]*$/;
294
+
295
+ /** An `enqueue` step hands the input to the `jobs` facade (R-14): Queues in prod, inline in dev. */
296
+ function enqueueStep(step, moduleName) {
297
+ const addr = JOB_ADDR_RE.test(step.enqueue) ? step.enqueue : `${moduleName}.Job.${step.enqueue}`;
298
+ return `\t\tawait container.resolve('jobs').enqueue(${JSON.stringify(addr)}, ${withArg(step.with)}, user)`;
299
+ }
300
+
259
301
  export function emitModuleActions(moduleName, moduleSpec, specs) {
260
302
  const actions = moduleSpec.actions ?? {};
261
303
  const names = Object.keys(actions).sort();
@@ -263,6 +305,7 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
263
305
 
264
306
  const entityImports = new Map();
265
307
  const policyImports = new Map();
308
+ const serviceImports = new Map();
266
309
  const customImports = [];
267
310
  const fns = [];
268
311
 
@@ -328,7 +371,9 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
328
371
  `\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { row, input, user })`
329
372
  );
330
373
  } else if (step.call) {
331
- body.push(`\t\tawait container.resolve(${JSON.stringify(step.call)})({ row, input, user })`);
374
+ body.push(callStep(step, moduleName, serviceImports, '{ row, input, user }'));
375
+ } else if (step.enqueue) {
376
+ body.push(enqueueStep(step, moduleName));
332
377
  }
333
378
  }
334
379
  for (const evt of custom ? [] : (action.emits ?? [])) {
@@ -338,6 +383,25 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
338
383
  }
339
384
  } else if (custom) {
340
385
  body.push(`\t\treturn ${name}Body({ input, container, user })`);
386
+ } else {
387
+ // No entity target: pure flow actions (integrations, notifications)
388
+ // still run their emit/call steps.
389
+ for (const step of action.steps ?? []) {
390
+ if (step.emit) {
391
+ body.push(
392
+ `\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
393
+ );
394
+ } else if (step.call) {
395
+ body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
396
+ } else if (step.enqueue) {
397
+ body.push(enqueueStep(step, moduleName));
398
+ }
399
+ }
400
+ for (const evt of action.emits ?? []) {
401
+ body.push(
402
+ `\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
403
+ );
404
+ }
341
405
  }
342
406
  if (custom) {
343
407
  customImports.push(`import ${name}Body from '$custom/${moduleName}/actions/${name}.c'`);
@@ -360,11 +424,118 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
360
424
  for (const [file, names_] of groupImports(policyImports)) {
361
425
  lines.push(`import { ${names_.join(', ')} } from '${file}'`);
362
426
  }
427
+ for (const [file, names_] of groupImports(serviceImports)) {
428
+ lines.push(`import { ${names_.join(', ')} } from '${file}'`);
429
+ }
363
430
  lines.push(...customImports);
364
431
  lines.push('', fns.join('\n\n'), '');
365
432
  return { path: `lib/${moduleName}/actions.c`, text: lines.join('\n') };
366
433
  }
367
434
 
435
+ /* ------------------------------------------------------------------ */
436
+ /* Services → lib/<module>/services.c */
437
+ /* ------------------------------------------------------------------ */
438
+
439
+ /**
440
+ * Service manifests (D15) become typed clients. Credentials resolve at call
441
+ * time from the env the container carries — never from the spec. `services`
442
+ * maps unit addresses to clients so boot can container-register them (R-14).
443
+ */
444
+ export function emitModuleServices(moduleName, moduleSpec) {
445
+ const services = moduleSpec.services ?? {};
446
+ const names = Object.keys(services).sort();
447
+ if (names.length === 0) return null;
448
+
449
+ const lines = [header(moduleName), '', `import { serviceClient } from '@human-synthesis/norns/server'`, ''];
450
+ const entries = [];
451
+ for (const name of names) {
452
+ const svc = services[name];
453
+ const operations = {};
454
+ for (const op of Object.keys(svc.operations ?? {}).sort()) {
455
+ const o = svc.operations[op];
456
+ operations[op] = {
457
+ method: o.method ?? 'POST',
458
+ path: o.path ?? `/${op}`,
459
+ ...(o.input !== undefined ? { input: o.input } : {}),
460
+ ...(o.output !== undefined ? { output: o.output } : {})
461
+ };
462
+ }
463
+ const def = {
464
+ name: `${moduleName}.Service.${name}`,
465
+ base: svc.base,
466
+ auth: svc.auth,
467
+ operations
468
+ };
469
+ lines.push(`export ${name} := serviceClient(${JSON.stringify(def, null, '\t')})`, '');
470
+ entries.push(`\t${JSON.stringify(`${moduleName}.Service.${name}`)}: ${name}`);
471
+ }
472
+ lines.push(`export services := {`, entries.join(',\n'), `}`, '');
473
+ return { path: `lib/${moduleName}/services.c`, text: lines.join('\n') };
474
+ }
475
+
476
+ /* ------------------------------------------------------------------ */
477
+ /* Jobs → lib/<module>/jobs.c */
478
+ /* ------------------------------------------------------------------ */
479
+
480
+ /**
481
+ * Jobs (K-22) become `job({...})` units carrying their retry/dlq contract;
482
+ * `registerJobs` (R-14) wires them to `job:<address>` bus messages with
483
+ * retry/backoff/DLQ semantics — Cloudflare Queues in prod, inline in dev.
484
+ */
485
+ export function emitModuleJobs(moduleName, moduleSpec) {
486
+ const jobs = moduleSpec.jobs ?? {};
487
+ const names = Object.keys(jobs).sort();
488
+ if (names.length === 0) return null;
489
+
490
+ const serviceImports = new Map();
491
+ const customImports = [];
492
+ const fns = [];
493
+ const entries = [];
494
+
495
+ for (const name of names) {
496
+ const j = jobs[name];
497
+ const custom = j.impl === 'custom';
498
+ const address = `${moduleName}.Job.${name}`;
499
+ const body = [];
500
+ if (custom) {
501
+ customImports.push(`import ${name}Body from '$custom/${moduleName}/jobs/${name}.c'`);
502
+ body.push(`\t\treturn ${name}Body({ input, container, user })`);
503
+ } else {
504
+ for (const step of j.steps ?? []) {
505
+ if (step.emit) {
506
+ body.push(
507
+ `\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
508
+ );
509
+ } else if (step.call) {
510
+ body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
511
+ } else if (step.enqueue) {
512
+ body.push(enqueueStep(step, moduleName));
513
+ }
514
+ }
515
+ for (const evt of j.emits ?? []) {
516
+ body.push(
517
+ `\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
518
+ );
519
+ }
520
+ }
521
+ const props = [`\taddress: ${JSON.stringify(address)}`, `\tretry: ${JSON.stringify(j.retry)}`];
522
+ if (j.dlq) props.push(`\tdlq: ${JSON.stringify(j.dlq)}`);
523
+ if (j.concurrency) props.push(`\tconcurrency: ${j.concurrency}`);
524
+ props.push([`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n'));
525
+ fns.push([`export ${name} := job({`, props.join(',\n'), `})`].join('\n'));
526
+ entries.push(`\t${JSON.stringify(address)}: ${name}`);
527
+ }
528
+
529
+ const lines = [header(moduleName), '', `import { job } from '@human-synthesis/norns/server'`, ''];
530
+ for (const [file, names_] of groupImports(serviceImports)) {
531
+ lines.push(`import { ${names_.join(', ')} } from '${file}'`);
532
+ }
533
+ lines.push(...customImports);
534
+ lines.push('', fns.join('\n\n'), '');
535
+ lines.push(`export jobs := {`, entries.join(',\n'), `}`, '');
536
+ return { path: `lib/${moduleName}/jobs.c`, text: lines.join('\n') };
537
+ }
538
+
368
539
  /* ------------------------------------------------------------------ */
369
540
  /* Triggers → lib/<module>/triggers.c */
370
541
  /* ------------------------------------------------------------------ */
@@ -439,20 +610,69 @@ function routeSegments(route) {
439
610
  .map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg));
440
611
  }
441
612
 
613
+ /** '/api/chat' from a `stream:` Endpoint address (falls back to the address). */
614
+ function endpointRoute(specs, address) {
615
+ const parsed = isAddress(address) ? parseAddress(address) : null;
616
+ return specs?.modules?.[parsed?.module]?.endpoints?.[parsed?.name]?.route ?? address;
617
+ }
618
+
619
+ /**
620
+ * The tag key of a page component entry. Authors write the tag first, but
621
+ * TRON canonicalization sorts record keys on write, so the primary must be
622
+ * recovered semantically: a realtime object binding ({stream}/{room}) wins,
623
+ * else a Query address, else an Action address (the form target), else the
624
+ * first key in record order.
625
+ */
626
+ export function componentKey(entry) {
627
+ const keys = Object.keys(entry ?? {});
628
+ let query = null;
629
+ let action = null;
630
+ for (const key of keys) {
631
+ const value = entry[key];
632
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
633
+ if (typeof value.stream === 'string' || typeof value.room === 'string') return key;
634
+ continue;
635
+ }
636
+ if (typeof value !== 'string' || !isAddress(value)) continue;
637
+ const { kind } = parseAddress(value);
638
+ if (kind === 'Query') query ??= key;
639
+ else if (kind === 'Action') action ??= key;
640
+ }
641
+ return query ?? action ?? keys[0] ?? null;
642
+ }
643
+
442
644
  /** Collect { queries, actions, components } bound by a page spec. */
443
- function pageBindings(pageSpec) {
645
+ function pageBindings(pageSpec, specs) {
444
646
  const queries = new Map();
445
647
  const actions = new Map();
648
+ const snippets = new Map();
446
649
  const components = [];
447
650
  for (const entry of pageSpec.components ?? []) {
448
651
  const keys = Object.keys(entry);
449
652
  if (keys.length === 0) continue;
450
- const [first, ...rest] = keys;
653
+ const first = componentKey(entry);
451
654
  const component = { tag: pascal(first), props: [] };
452
655
  for (const key of keys) {
453
656
  const value = entry[key];
657
+ if (key === first && value && typeof value === 'object' && !Array.isArray(value)) {
658
+ // realtime bindings (K-27): the component connects itself via
659
+ // streamSource(url) / roomChannel(name) from norns/live-client
660
+ if (typeof value.stream === 'string') {
661
+ component.props.push(`streamSource=${JSON.stringify(endpointRoute(specs, value.stream))}`);
662
+ }
663
+ if (typeof value.room === 'string') {
664
+ component.props.push(`roomChannel=${JSON.stringify(value.room)}`);
665
+ }
666
+ continue;
667
+ }
454
668
  const parsed = typeof value === 'string' && isAddress(value) ? parseAddress(value) : null;
455
- if (parsed?.kind === 'Query') {
669
+ if (parsed?.kind === 'Snippet') {
670
+ // U-07: the page wraps the custom body in a `+snippet` forwarding
671
+ // the declared args as props, and binds that snippet to the slot
672
+ const unit = specs?.modules?.[parsed.module]?.snippets?.[parsed.name];
673
+ snippets.set(parsed.name, { module: parsed.module, args: unit?.args ?? [] });
674
+ component.props.push(`${key}!="{${parsed.name}}"`);
675
+ } else if (parsed?.kind === 'Query') {
456
676
  queries.set(parsed.name, parsed.module);
457
677
  component.props.push(
458
678
  key === first
@@ -472,7 +692,7 @@ function pageBindings(pageSpec) {
472
692
  }
473
693
  components.push(component);
474
694
  }
475
- return { queries, actions, components };
695
+ return { queries, actions, snippets, components };
476
696
  }
477
697
 
478
698
  export function emitModulePages(moduleName, moduleSpec, specs) {
@@ -485,7 +705,7 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
485
705
  const segments = routeSegments(spec.route);
486
706
  const dir = ['routes', ...segments].join('/');
487
707
  const lib = up(segments.length + 1) + 'lib';
488
- const { queries, actions, components } = pageBindings(spec);
708
+ const { queries, actions, snippets, components } = pageBindings(spec, specs);
489
709
  // Queries marked `live: true` get a depends key in the load and an
490
710
  // EventSource subscription in the page, so refresh signals from
491
711
  // /_norns/live re-run the load (R-11).
@@ -550,12 +770,23 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
550
770
  );
551
771
  } else {
552
772
  pug.push(`section.norns-page`);
773
+ const titleFrom = ['index', 'home', 'main', 'page'].includes(name) ? moduleName : name;
774
+ pug.push(`\th1.norns-page-title ${humanizeName(titleFrom)}`);
553
775
  for (const c of components) {
554
776
  pug.push(`\t${c.tag}(${c.props.join(' ')})`);
555
777
  }
778
+ for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
779
+ pug.push('', `+snippet('${sname}'${s.args.length ? `, ${s.args.join(', ')}` : ''})`);
780
+ pug.push(
781
+ `\t${pascal(sname)}${s.args.length ? `(${s.args.map((a) => `${a}!="{${a}}"`).join(' ')})` : ''}`
782
+ );
783
+ }
556
784
  const stateKeys = Object.keys(spec.state ?? {}).sort();
557
785
  pug.push('', '<script>');
558
786
  if (liveScript) pug.push(...liveScript.imports);
787
+ for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
788
+ pug.push(`\timport ${pascal(sname)} from '$custom/${s.module}/snippets/${sname}.n'`);
789
+ }
559
790
  pug.push(`\t{ data, form } := $props()`);
560
791
  for (const key of stateKeys) pug.push(`\t${key} := $state(null)`);
561
792
  if (liveScript) pug.push(liveScript.effect);
@@ -566,6 +797,45 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
566
797
  return files;
567
798
  }
568
799
 
800
+ /* ------------------------------------------------------------------ */
801
+ /* Endpoints → routes<route>/+server.c (D14/K-23) */
802
+ /* ------------------------------------------------------------------ */
803
+
804
+ /**
805
+ * Each Endpoint becomes a `+server.c` shell at its declared route: the
806
+ * `endpoint()` runtime verifies auth, validates input, runs the custom
807
+ * body from `src/<m>/endpoints/<name>.c`, then validates output — or, in
808
+ * `stream` mode, serves the body's yielded frames as typed SSE.
809
+ */
810
+ export function emitModuleEndpoints(moduleName, moduleSpec) {
811
+ const endpoints = moduleSpec.endpoints ?? {};
812
+ const names = Object.keys(endpoints).sort();
813
+ if (names.length === 0) return null;
814
+
815
+ return names.map((name) => {
816
+ const spec = endpoints[name];
817
+ const def = { name: `${moduleName}.Endpoint.${name}`, auth: spec.auth };
818
+ if (spec.input !== undefined) def.input = spec.input;
819
+ if (spec.output !== undefined) def.output = spec.output;
820
+ if (spec.stream !== undefined) def.stream = spec.stream;
821
+ const defJson = JSON.stringify(def, null, '\t');
822
+ const withBody = defJson.slice(0, defJson.lastIndexOf('}')).trimEnd() + `,\n\tbody: ${name}Body\n}`;
823
+ return {
824
+ path: ['routes', ...routeSegments(spec.route), '+server.c'].join('/'),
825
+ text: [
826
+ header(moduleName),
827
+ '',
828
+ `import { endpoint } from '@human-synthesis/norns/server'`,
829
+ '',
830
+ `import ${name}Body from '$custom/${moduleName}/endpoints/${name}.c'`,
831
+ '',
832
+ `export ${spec.method ?? 'POST'} := endpoint(${withBody})`,
833
+ ''
834
+ ].join('\n')
835
+ };
836
+ });
837
+ }
838
+
569
839
  /* ------------------------------------------------------------------ */
570
840
 
571
841
  const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
@@ -576,6 +846,8 @@ const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
576
846
  export const policiesEmitter = { name: 'policies', emit: single(emitModulePolicies) };
577
847
  export const queriesEmitter = { name: 'queries', emit: single(emitModuleQueries) };
578
848
  export const actionsEmitter = { name: 'actions', emit: single(emitModuleActions) };
849
+ export const servicesEmitter = { name: 'services', emit: single(emitModuleServices) };
850
+ export const jobsEmitter = { name: 'jobs', emit: single(emitModuleJobs) };
579
851
  export const triggersEmitter = { name: 'triggers', emit: single(emitModuleTriggers) };
580
852
  export const pagesEmitter = {
581
853
  name: 'pages',
@@ -585,3 +857,7 @@ export const remotesEmitter = {
585
857
  name: 'remotes',
586
858
  emit: ({ moduleName, moduleSpec }) => emitModuleRemotes(moduleName, moduleSpec) ?? []
587
859
  };
860
+ export const endpointsEmitter = {
861
+ name: 'endpoints',
862
+ emit: ({ moduleName, moduleSpec }) => emitModuleEndpoints(moduleName, moduleSpec) ?? []
863
+ };
@@ -41,6 +41,17 @@ function needsRoom(specs) {
41
41
  return false;
42
42
  }
43
43
 
44
+ /** Jobs share the app events queue in v3-A; the strictest job wins the consumer settings. */
45
+ function jobUnits(specs) {
46
+ const out = [];
47
+ for (const mod of Object.values(specs.modules)) {
48
+ for (const j of Object.values(mod.jobs ?? {})) {
49
+ if (j && typeof j === 'object') out.push(j);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
44
55
  function cronSchedules(specs) {
45
56
  const crons = new Set();
46
57
  for (const mod of Object.values(specs.modules)) {
@@ -85,11 +96,20 @@ export function wranglerConfig(specs) {
85
96
  config.r2_buckets = [{ binding: 'STORAGE', bucket_name: `${name}-storage` }];
86
97
  }
87
98
 
88
- if (cf.queue === true) {
99
+ const jobs = jobUnits(specs);
100
+ if (cf.queue === true || jobs.length > 0) {
89
101
  const queue = `${name}-events`;
102
+ const consumer = { queue };
103
+ if (jobs.length > 0) {
104
+ consumer.max_retries = Math.max(...jobs.map((j) => j.retry?.attempts ?? 1));
105
+ const dlqs = [...new Set(jobs.map((j) => j.dlq).filter(Boolean))].sort();
106
+ if (dlqs.length > 0) consumer.dead_letter_queue = dlqs[0];
107
+ const conc = jobs.map((j) => j.concurrency).filter(Boolean);
108
+ if (conc.length > 0) consumer.max_concurrency = Math.min(...conc);
109
+ }
90
110
  config.queues = {
91
111
  producers: [{ binding: 'EVENTS', queue }],
92
- consumers: [{ queue }]
112
+ consumers: [consumer]
93
113
  };
94
114
  }
95
115
 
@@ -147,7 +147,10 @@ export function compileWhere(ast, ctx) {
147
147
  if ('path' in node) return ops.eq(column(node.path), true);
148
148
  if (node.owner === true) {
149
149
  if (!ownerField) throw new Error('cannot compile `owner` without ownerField');
150
- return ops.eq(column([ownerField]), user?.id);
150
+ // Anonymous user → deny, matching evalExpr. Binding undefined into
151
+ // `owner = ?` is a driver error on D1 rather than an empty result.
152
+ if (user?.id == null) return ops.bool(false);
153
+ return ops.eq(column([ownerField]), user.id);
151
154
  }
152
155
  if ('role' in node) return ops.bool(!!user?.roles?.includes(node.role));
153
156
  switch (node.op) {