unoverse 0.1.208 → 0.1.210
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/operator/.env.example +1 -1
- package/operator/infra/aws/main.tf +2 -2
- package/operator/infra/aws/pretoken/index.mjs +2 -2
- package/operator/infra/aws/terraform.tfvars.example +1 -1
- package/operator/infra/digitalocean/outputs.tf +1 -1
- package/operator/infra/digitalocean/terraform.tfvars.example +3 -3
- package/operator/infra/digitalocean/variables.tf +3 -3
- package/operator/lib/deploy.sh +1 -1
- package/operator/lib/ground.sh +1 -1
- package/operator/lib/setup.sh +2 -2
- package/package.json +1 -1
- package/vendor/base/lint/design/file.mjs +29 -5
- package/vendor/base/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/lint/design/walk.mjs +31 -156
- package/vendor/base/lint/nodes/_schema/audio.schema.json +7 -0
- package/vendor/base/lint/nodes/_schema/credential.schema.json +1 -1
- package/vendor/base/lint/nodes/_schema/node.schema.json +10 -2
package/operator/.env.example
CHANGED
|
@@ -61,7 +61,7 @@ UNOVERSE_MARKETPLACE_URL=
|
|
|
61
61
|
CREDENTIAL_ENCRYPTION_KEY=
|
|
62
62
|
|
|
63
63
|
# Auth is ON by default (missing flag = enabled), same as production.
|
|
64
|
-
AUTH_ISSUER=https://your-
|
|
64
|
+
AUTH_ISSUER=https://your-environment.authkit.app
|
|
65
65
|
AUTH_CLIENT_ID=your-client-id
|
|
66
66
|
AUTH_AUDIENCE=gravity-api
|
|
67
67
|
|
|
@@ -430,7 +430,7 @@ resource "aws_elasticache_replication_group" "redis" {
|
|
|
430
430
|
|
|
431
431
|
# ── Cognito: user pool (Essentials) + pre-token Lambda ────────────────────────
|
|
432
432
|
# The Lambda is LOAD-BEARING: it puts email/roles/permissions on the ACCESS token
|
|
433
|
-
# (the platform's token contract,
|
|
433
|
+
# (the platform's token contract, docs/auth-security/IDENTITY.md). Roles come from Cognito
|
|
434
434
|
# groups. It lives in Terraform precisely so a pool rebuild cannot drop it — the
|
|
435
435
|
# documented Auth0 footgun, not repeated here.
|
|
436
436
|
|
|
@@ -561,7 +561,7 @@ resource "aws_cognito_user_pool" "pool" {
|
|
|
561
561
|
locals {
|
|
562
562
|
# TWO LEVELS, BECAUSE THE PLATFORM READS TWO CLAIMS.
|
|
563
563
|
#
|
|
564
|
-
#
|
|
564
|
+
# The standing contract, whichever IdP issues the token:
|
|
565
565
|
#
|
|
566
566
|
# ROLE admin → permission admin:access
|
|
567
567
|
# ROLE developer → permissions marketplace:publish, workflow:author, workflow:promote
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Cognito Pre Token Generation (V2_0) — the platform's token contract.
|
|
3
3
|
*
|
|
4
4
|
* LOAD-BEARING. The platform reads sub/email/roles (and permissions) off the
|
|
5
|
-
* ACCESS token and never calls back to the IdP (docs/
|
|
5
|
+
* ACCESS token and never calls back to the IdP (docs/auth-security/IDENTITY.md). Without
|
|
6
6
|
* this Lambda, email-keyed features silently no-op and no role-gated surface
|
|
7
7
|
* (workflow:author builder, marketplace:publish publish, requires.role nodes)
|
|
8
|
-
* can ever pass. The Cognito equivalent of
|
|
8
|
+
* can ever pass. The Cognito equivalent of another IdP's claims configuration.
|
|
9
9
|
*
|
|
10
10
|
* Roles = the user's Cognito groups, verbatim. Groups are named in the platform's
|
|
11
11
|
* noun:verb grammar (workflow:author, marketplace:publish, finance:approve, ...),
|
|
@@ -40,7 +40,7 @@ oauth_callback_urls = [
|
|
|
40
40
|
|
|
41
41
|
# RBAC roles (noun:verb — the grammar node manifests' requires.role matches).
|
|
42
42
|
# Each becomes a Cognito group; put a user in the group and the role rides their
|
|
43
|
-
# token. THE FULL SET, mirroring the
|
|
43
|
+
# token. THE FULL SET, mirroring the platform's standing permission set
|
|
44
44
|
# (2026-07-28). The first two are enforced by code gates and are always created
|
|
45
45
|
# even if removed from this list; the last two are declared intent (no code gate
|
|
46
46
|
# yet — enforcement arrives with the surfaces they name).
|
|
@@ -109,7 +109,7 @@ output "env_production" {
|
|
|
109
109
|
REDIS_TLS=${local.redis_tls}
|
|
110
110
|
REDIS_NAMESPACE=universe
|
|
111
111
|
|
|
112
|
-
# OIDC — byo (
|
|
112
|
+
# OIDC — byo (WorkOS today): the tenant is authoritative for roles/permissions.
|
|
113
113
|
AUTH_ISSUER=${var.auth_issuer}
|
|
114
114
|
AUTH_CLIENT_ID=${var.auth_client_id}
|
|
115
115
|
AUTH_AUDIENCE=${var.auth_audience}
|
|
@@ -24,9 +24,9 @@ manage_dns = false # true only if the domain's DNS is on DO
|
|
|
24
24
|
# Add that URL to the IdP's allowed origins. Default false = admin-only.
|
|
25
25
|
canvas_public = true
|
|
26
26
|
|
|
27
|
-
# Auth — byo-oidc (the standing choice: DO universes use the existing
|
|
28
|
-
#
|
|
29
|
-
auth_issuer = "https://your-
|
|
27
|
+
# Auth — byo-oidc (the standing choice: DO universes use the existing WorkOS
|
|
28
|
+
# environment; roles/permissions live THERE, Terraform doesn't touch them).
|
|
29
|
+
auth_issuer = "https://your-environment.authkit.app"
|
|
30
30
|
auth_client_id = "your-spa-client-id"
|
|
31
31
|
auth_audience = "gravity-api"
|
|
32
32
|
# In that client's Allowed Callback URLs, include http://127.0.0.1:4109/callback —
|
|
@@ -51,11 +51,11 @@ variable "manage_dns" {
|
|
|
51
51
|
default = false
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
# ── Auth: DigitalOcean universes run byo-oidc (
|
|
54
|
+
# ── Auth: DigitalOcean universes run byo-oidc (WorkOS today) — the tenant is
|
|
55
55
|
# authoritative for roles/permissions; Terraform only passes the pointers through
|
|
56
56
|
# (INFRASTRUCTURE.md: roles are PROVISIONED only under Cognito on AWS).
|
|
57
57
|
variable "auth_issuer" {
|
|
58
|
-
description = "OIDC issuer, e.g. https://your-
|
|
58
|
+
description = "OIDC issuer, e.g. https://your-environment.authkit.app"
|
|
59
59
|
type = string
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -65,7 +65,7 @@ variable "auth_client_id" {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
variable "auth_audience" {
|
|
68
|
-
description = "OIDC audience (the
|
|
68
|
+
description = "OIDC audience (the API identifier, e.g. gravity-api)."
|
|
69
69
|
type = string
|
|
70
70
|
default = "gravity-api"
|
|
71
71
|
}
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -485,7 +485,7 @@ _ensure_ground_config() {
|
|
|
485
485
|
if [ -z "$env_auth" ]; then
|
|
486
486
|
echo ""
|
|
487
487
|
info "A deployed universe requires a login. Local auth-off does not deploy"
|
|
488
|
-
info "${DIM}(
|
|
488
|
+
info "${DIM}(WorkOS or any OIDC provider; the issuer looks like https://your-environment.authkit.app)${NC}"
|
|
489
489
|
fi
|
|
490
490
|
fi
|
|
491
491
|
# AWS ONLY. Cognito needs a first administrator, and nothing else in the CLI collects an
|
package/operator/lib/ground.sh
CHANGED
|
@@ -187,7 +187,7 @@ manage_dns = false # true only if the domain's DNS is on D
|
|
|
187
187
|
canvas_public = true
|
|
188
188
|
|
|
189
189
|
# Auth — byo-oidc: roles/permissions live in YOUR IdP tenant.
|
|
190
|
-
auth_issuer = "FILL_ME" # e.g. https://your-
|
|
190
|
+
auth_issuer = "FILL_ME" # e.g. https://your-environment.authkit.app
|
|
191
191
|
auth_client_id = "FILL_ME"
|
|
192
192
|
auth_audience = "gravity-api"
|
|
193
193
|
|
package/operator/lib/setup.sh
CHANGED
|
@@ -219,7 +219,7 @@ cmd_setup() {
|
|
|
219
219
|
[ "$cur_auth" = "true" ] && idp_prompt="[Y/n]"
|
|
220
220
|
echo ""
|
|
221
221
|
info "Sign-in: deploying provides one. ${DIM}AWS creates a Cognito user pool; DigitalOcean asks for an issuer then${NC}"
|
|
222
|
-
read -r -p " Are you bringing your own (
|
|
222
|
+
read -r -p " Are you bringing your own (WorkOS/OIDC)? $idp_prompt " HAS_IDP
|
|
223
223
|
echo ""
|
|
224
224
|
if [ -z "$HAS_IDP" ] && [ "$cur_auth" = "true" ]; then HAS_IDP=y; fi
|
|
225
225
|
|
|
@@ -235,7 +235,7 @@ cmd_setup() {
|
|
|
235
235
|
read -p " AUTH_ISSUER [$cur_iss]: " AUTH_ISSUER || { fail "no input (end of stream). Run unoverse create interactively"; exit 1; }
|
|
236
236
|
AUTH_ISSUER="${AUTH_ISSUER:-$cur_iss}"
|
|
237
237
|
else
|
|
238
|
-
read -p " AUTH_ISSUER (e.g. https://your-
|
|
238
|
+
read -p " AUTH_ISSUER (e.g. https://your-environment.authkit.app): " AUTH_ISSUER || { fail "no input (end of stream). Run unoverse create interactively"; exit 1; }
|
|
239
239
|
fi
|
|
240
240
|
if [ -n "$AUTH_ISSUER" ] && [[ "$AUTH_ISSUER" == https://* ]]; then
|
|
241
241
|
break
|
package/package.json
CHANGED
|
@@ -413,6 +413,28 @@ function lintFile(file) {
|
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
// AN ORG'S OWN IMAGES (definitions/load/assets.ts): `src: assets/<file>` must name a file in
|
|
417
|
+
// an assets/ folder from the file's own folder up to the org's (a template keeps its own), or
|
|
418
|
+
// the shared one, of an image type, under 256 KB.
|
|
419
|
+
{
|
|
420
|
+
const home = (orgDirs ?? []).find((d) => file.startsWith(d + sep)) ?? null;
|
|
421
|
+
const shared = DS ?? null;
|
|
422
|
+
const walk = (n) => {
|
|
423
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
424
|
+
if (!n || typeof n !== "object") return;
|
|
425
|
+
if (n.type === "Image" && typeof n.src === "string" && n.src.startsWith("assets/")) {
|
|
426
|
+
const dirs = [];
|
|
427
|
+
for (let d = dirname(file); home && d.startsWith(home); d = dirname(d)) dirs.push(d);
|
|
428
|
+
const hit = [...dirs, shared].map((d) => d && join(d, n.src)).find((f) => f && existsSync(f));
|
|
429
|
+
if (!hit) report("error", file, `image "${n.src}" is not in this org's assets/ folder (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
430
|
+
else if (!/\.(svg|png|jpe?g|webp|gif)$/i.test(n.src)) report("error", file, `image "${n.src}" is not an image type the platform serves (svg, png, jpg, webp, gif)`);
|
|
431
|
+
else if (statSync(hit).size > 256 * 1024) report("error", file, `image "${n.src}" is over 256 KB; a photo belongs on a CDN, not in the design folder`);
|
|
432
|
+
}
|
|
433
|
+
Object.values(n).forEach(walk);
|
|
434
|
+
};
|
|
435
|
+
walk(json);
|
|
436
|
+
}
|
|
437
|
+
|
|
416
438
|
// A STATE'S PLACE (UNOVERSE_SCREEN.md §2.3) is `chat` or a Place some app declares. A name no
|
|
417
439
|
// app has sends the interface to the conversation without a word, so it is refused here.
|
|
418
440
|
if (placeNames && !isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object") {
|
|
@@ -552,8 +574,10 @@ function lintFile(file) {
|
|
|
552
574
|
report("error", file, `preview."${state}" must be an array of component names, or an object of template-state mock data`);
|
|
553
575
|
continue;
|
|
554
576
|
}
|
|
577
|
+
// `name#state` names the state the seed opens in (the address grammar, owner
|
|
578
|
+
// 2026-09-11); the lookup is the name before the hash.
|
|
555
579
|
for (const c of list)
|
|
556
|
-
if (comps && !comps.has(String(c).toLowerCase()))
|
|
580
|
+
if (comps && !comps.has(String(c).split("#")[0].toLowerCase()))
|
|
557
581
|
report("error", file, `preview."${state}" names unknown component "${c}". No match in design/marketplace/components/ or this org's components/ (org-privacy: another org's components are out of reach; lookup is case-insensitive)`);
|
|
558
582
|
}
|
|
559
583
|
}
|
|
@@ -676,13 +700,13 @@ function lintFile(file) {
|
|
|
676
700
|
// EVERY PROP CARRIES A DESCRIPTION (UNOVERSE_AUTHORING §3b, RULED 2026-08-30).
|
|
677
701
|
//
|
|
678
702
|
// It reads BOTH ways, which is why it is compulsory rather than encouraged. A
|
|
679
|
-
// description is not only what
|
|
703
|
+
// description is not only what an Agent fills against: it is what lets an agent
|
|
680
704
|
// UNDERSTAND the screen it is looking at. `read` reports where the person is; the
|
|
681
705
|
// descriptions say what is actually in front of them. A screen of unnamed fields is
|
|
682
706
|
// one no agent can reason about, in either direction.
|
|
683
707
|
//
|
|
684
|
-
// AND IT SAYS NOTHING ABOUT PERMISSION. That is the
|
|
685
|
-
//
|
|
708
|
+
// AND IT SAYS NOTHING ABOUT PERMISSION. That is the prop's `input` (UNOVERSE_AUTHORING
|
|
709
|
+
// "input decides"), never its description. The rule this
|
|
686
710
|
// replaces was "a described prop is a writable prop", which made silence the safety
|
|
687
711
|
// mechanism: a field was protected because nobody had explained it, and explaining it
|
|
688
712
|
// was what exposed it. Describing a field is now free of consequence, so there is no
|
|
@@ -691,7 +715,7 @@ function lintFile(file) {
|
|
|
691
715
|
.filter(([, v]) => !(v && typeof v === "object" && typeof v.description === "string" && v.description.trim()))
|
|
692
716
|
.map(([k]) => k);
|
|
693
717
|
if (undescribed.length)
|
|
694
|
-
report("error", file, `props [${undescribed.join(", ")}] carry no "description". Every prop says what it IS, so an agent can read the screen as well as fill it. A description grants nothing: permission is the
|
|
718
|
+
report("error", file, `props [${undescribed.join(", ")}] carry no "description". Every prop says what it IS, so an agent can read the screen as well as fill it. A description grants nothing: permission is the prop's input: true or false (UNOVERSE_AUTHORING §3b)`);
|
|
695
719
|
|
|
696
720
|
// only components that ADOPTED the structure are held to the full discipline
|
|
697
721
|
if (hasLayouts || stateFiles.length || hasStateBlock) {
|
|
@@ -114,7 +114,7 @@ export const STYLE_KEYS = new Set([
|
|
|
114
114
|
"width", "height", "maxWidth", "minWidth", "minHeight", "maxHeight", "flex",
|
|
115
115
|
"padding", "margin", "gap", "overflow",
|
|
116
116
|
"position", "inset", "top", "right", "bottom", "left", "zIndex",
|
|
117
|
-
"direction", "wrap", "align", "justify", "display", "columns", "span", "stackBelow", "container", "hideBelow", "hideAbove",
|
|
117
|
+
"direction", "wrap", "align", "justify", "display", "columns", "span", "stackBelow", "stage", "container", "hideBelow", "hideAbove",
|
|
118
118
|
"background", "radial", "border", "borderTop", "borderRight", "borderBottom", "borderLeft",
|
|
119
119
|
"outline", "shadow", "radius", "radiusTopLeft", "radiusTopRight", "radiusBottomLeft", "radiusBottomRight",
|
|
120
120
|
"font", "weight", "lineHeight", "color", "textAlign", "fit",
|
|
@@ -13,26 +13,19 @@
|
|
|
13
13
|
* The context is created per run by index.mjs, so nothing survives between runs.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
-
import { join, dirname,
|
|
16
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
17
17
|
import { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS, UNIVERSAL_PROPS, PRIMITIVE_PROPS, PRIMITIVE_BINDS } from "./vocabulary.mjs";
|
|
18
|
-
import { isDefFile,
|
|
18
|
+
import { isDefFile, defPath } from "./defs.mjs";
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// page's own searches are SILENT (no app unlocked, no tool minted, no card lane, no
|
|
27
|
-
// director woken), or every place would rain cards onto a page still assembling itself.
|
|
28
|
-
// `products` since migration 035 (2026-09-05; `services` retired 2026-09-07, `actions` with it:
|
|
29
|
-
// no row is written as one). Each word maps to a declared spatial type (guarded by a test).
|
|
30
|
-
const QUERY_TYPES = ["products", "images", "needs"];
|
|
20
|
+
// RETIRED LAYOUT WORDS. A template layout places its own parts with the ordinary element,
|
|
21
|
+
// `type: Ref` + `ref: components/<name>`, and what an Agent writes into a part is each
|
|
22
|
+
// prop's `input`, never the element that places it (UNOVERSE_TEMPLATE_MODEL §9b).
|
|
23
|
+
const RETIRED_WORDS = ["static", "director", "copywriter", "query"];
|
|
24
|
+
/** The one way a layout places a part: a `Ref` into the template's own `components/`. */
|
|
25
|
+
const PART_PREFIX = "components/";
|
|
31
26
|
|
|
32
27
|
export function makeWalkNode(ctx) {
|
|
33
|
-
const { report, checkCondition, checkDimension, checkToken, appSizesForFile,
|
|
34
|
-
// One director per LAYOUT FILE, tracked for this run only (each file is walked once).
|
|
35
|
-
const directorsSeen = new Map();
|
|
28
|
+
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, isTemplatePart, defRoot } = ctx;
|
|
36
29
|
|
|
37
30
|
function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
38
31
|
if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
|
|
@@ -54,147 +47,29 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
54
47
|
return; // the included file is linted on its own
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* writing, the page's pool, the component's contract, or the guest's ask.
|
|
76
|
-
*/
|
|
77
|
-
const d = node.director;
|
|
78
|
-
if (!d || typeof d !== "object" || Array.isArray(d)) {
|
|
79
|
-
report("error", file, `"director:" is a block, not a path (§9b rule 1)`);
|
|
80
|
-
} else if (d.limit !== undefined) {
|
|
81
|
-
report("error", file, `director.limit is retired — the one cap word is "pick": how many the director keeps (§9e rule 9, renamed 2026-09-02)`);
|
|
82
|
-
} else if (d.ask !== undefined || d.query !== undefined) {
|
|
83
|
-
report("error", file, `a director place never states its question — it is DERIVED (§9e rule 10d): the writer's headings (items), the page's pool (a gallery), the component's own contract, or the guest's ask`);
|
|
84
|
-
} else if (typeof d.component === "string" && d.component) {
|
|
85
|
-
// A SEARCH PLACE. The checks here are the ones that fail SILENTLY at runtime: a
|
|
86
|
-
// place missing include or pick is skipped, and a kind nobody stores draws
|
|
87
|
-
// nothing — both read as "the design is broken" with no log saying why.
|
|
88
|
-
//
|
|
89
|
-
// THE SEARCH WORDS SIT ON THE THING THAT SEARCHES (owner ruling 2026-09-02): on
|
|
90
|
-
// an items place each ITEM searches, so type/include/pick nest INSIDE items:
|
|
91
|
-
// beside min/max, and pick reads unambiguously as per-item. Spelled flat, the
|
|
92
|
-
// same word meant two scopes depending on whether items: was present.
|
|
93
|
-
const itemed = d.items && typeof d.items === "object" && !Array.isArray(d.items);
|
|
94
|
-
if (d.items !== undefined && !itemed)
|
|
95
|
-
report("error", file, `director.items is a block — items: { min, max, type, include, pick } (§9e rule 10c)`);
|
|
96
|
-
if (itemed && (d.type !== undefined || d.include !== undefined || d.pick !== undefined))
|
|
97
|
-
report("error", file, `an items place nests its search words INSIDE items: { min, max, type, include, pick } — each ITEM searches, so pick reads per item (§9e rule 10c, 2026-09-02)`);
|
|
98
|
-
const TOP_KEYS = itemed ? ["component", "items"] : SEARCH_KEYS;
|
|
99
|
-
for (const k of Object.keys(d))
|
|
100
|
-
if (!TOP_KEYS.includes(k))
|
|
101
|
-
report("error", file, `a search place has no "${k}". It reads ${TOP_KEYS.map((x) => `"${x}"`).join(", ")} (§9e; "state"/"rules" belong to a delivery band — a place with a component is governed by the page's brief)`);
|
|
102
|
-
if (!defPath(root, d.component) && !existsSync(join(root, d.component)))
|
|
103
|
-
report("error", file, `director.component "${d.component}" does not resolve under ${relative(process.cwd(), root)}/ (§9e)`);
|
|
104
|
-
const ITEM_KEYS = ["min", "max", "type", "include", "pick"];
|
|
105
|
-
if (itemed) {
|
|
106
|
-
for (const k of Object.keys(d.items))
|
|
107
|
-
if (!ITEM_KEYS.includes(k))
|
|
108
|
-
report("error", file, `director.items has no "${k}". It reads ${ITEM_KEYS.map((x) => `"${x}"`).join(", ")} (§9e rule 10c)`);
|
|
109
|
-
const { min, max } = d.items;
|
|
110
|
-
if (typeof min !== "number" || typeof max !== "number" || min < 1 || max < min)
|
|
111
|
-
report("error", file, `director.items carries { min, max } — how many items the writer may shape, 1 <= min <= max (§9e rule 10c)`);
|
|
112
|
-
}
|
|
113
|
-
// The search words, read where they live: the place itself, or each item.
|
|
114
|
-
const s = itemed ? d.items : d;
|
|
115
|
-
const at = itemed ? "director.items" : "director";
|
|
116
|
-
// REQUIRED, both (2026-09-01, carried over). The engine supplies NO defaults: a
|
|
117
|
-
// default include or pick would be one template's answer written into the platform.
|
|
118
|
-
if (s.include === undefined)
|
|
119
|
-
report("error", file, `${at}.include is REQUIRED on a search place — what KIND may answer; the engine has no default kind (§9e rule 6)`);
|
|
120
|
-
else if (!Array.isArray(s.include) || s.include.some((t) => typeof t !== "string"))
|
|
121
|
-
report("error", file, `${at}.include is an array of result types — ${QUERY_TYPES.map((t) => `"${t}"`).join(", ")} (§9e rule 6)`);
|
|
122
|
-
else
|
|
123
|
-
for (const t of s.include)
|
|
124
|
-
if (!QUERY_TYPES.includes(String(t)))
|
|
125
|
-
report("error", file, `${at}.include has no "${t}". A place may admit ${QUERY_TYPES.map((x) => `"${x}"`).join(", ")}; apps and skills are never included, because the page's own searches are SILENT (§9e rule 6)`);
|
|
126
|
-
if (s.pick === undefined)
|
|
127
|
-
report("error", file, `${at}.pick is REQUIRED — how many the director keeps${itemed ? " PER ITEM" : ""}; the engine has no default count (§9e rule 9)`);
|
|
128
|
-
else if (typeof s.pick !== "number" || s.pick < 1)
|
|
129
|
-
report("error", file, `${at}.pick is a NUMBER — how many the director keeps${itemed ? " per item" : ""}, a cap the machine enforces (§9e rule 9)`);
|
|
130
|
-
// `type` is OPTIONAL since the question derives (rule 10d): authored it stands;
|
|
131
|
-
// absent, a contract subject searches as intent and the guest's ask as discovery.
|
|
132
|
-
if (s.type !== undefined && s.type !== "discovery" && s.type !== "intent")
|
|
133
|
-
report("error", file, `${at}.type is "discovery" (what lives around a topic) or "intent" (a named need) — or omitted, and the derivation decides (§9e rule 10d)`);
|
|
134
|
-
} else {
|
|
135
|
-
// A DELIVERY BAND.
|
|
136
|
-
for (const k of Object.keys(d))
|
|
137
|
-
if (!DELIVERY_KEYS.includes(k))
|
|
138
|
-
report("error", file, `a delivery band has no "${k}". It reads ${DELIVERY_KEYS.map((x) => `"${x}"`).join(", ")} (§9b; a place that names a component is a search place)`);
|
|
139
|
-
if (d.pick !== undefined && (typeof d.pick !== "number" || d.pick < 1))
|
|
140
|
-
report("error", file, `director.pick is a NUMBER — how many the director keeps, a cap the machine enforces (§9e rule 9)`);
|
|
141
|
-
// NOTHING READS `rules`: the director's model was removed (UNOVERSE_SCREEN.md §2.7).
|
|
142
|
-
if (d.rules !== undefined)
|
|
143
|
-
report("warn", file, `director.rules is ignored: no model reads it any more. A band holds what arrives in search rank, up to pick. Remove it`);
|
|
144
|
-
if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
|
|
145
|
-
report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
|
|
146
|
-
else if (Array.isArray(d.preview)) {
|
|
147
|
-
const comps = componentNamesForFile(file);
|
|
148
|
-
for (const c of d.preview)
|
|
149
|
-
if (comps && !comps.has(String(c).toLowerCase()))
|
|
150
|
-
report("error", file, `director.preview names unknown component "${c}". No match in the design system or this org's components (lookup is case-insensitive)`);
|
|
151
|
-
}
|
|
152
|
-
// A PLACE NEEDS NO NAME (§9d): the compiler addresses delivery bands by
|
|
153
|
-
// position; an AUTHORED state: still stands (the flat-email pin), so two bands
|
|
154
|
-
// pinned to the SAME face remains the one genuine ambiguity.
|
|
155
|
-
const seen = directorsSeen.get(file) ?? [];
|
|
156
|
-
const face = typeof d?.state === "string" ? d.state : null;
|
|
157
|
-
if (face && seen.includes(face))
|
|
158
|
-
report("error", file, `two directed bands claim the face "${face}". A face has exactly one surface, or the active one is ambiguous (§9b rule 3, the reaction contract)`);
|
|
159
|
-
directorsSeen.set(file, [...seen, face]);
|
|
160
|
-
}
|
|
161
|
-
} else if (typeof node[word] !== "string") {
|
|
162
|
-
report("error", file, `"${word}:" links a component by PATH (${word}: components/<part>) — that is what makes it a known part (§9b rule 1)`);
|
|
163
|
-
} else {
|
|
164
|
-
const path = node[word];
|
|
165
|
-
if (!defPath(root, path) && !existsSync(join(root, path)))
|
|
166
|
-
report("error", file, `${word} "${path}" does not resolve under ${relative(process.cwd(), root)}/ (§9b rule 1)`);
|
|
167
|
-
}
|
|
168
|
-
// Everything else on the line rides through to the slot it compiles into. The select
|
|
169
|
-
// is SYNTHESIZED here exactly as the compiler builds it (a linked part claims its own
|
|
170
|
-
// face, limit 1; the director's claim is its `state:` pin) so the rest of the walk
|
|
171
|
-
// sees what will actually be served — and an authored one is the ceremony §9b rule 9
|
|
172
|
-
// exists to abolish.
|
|
50
|
+
// RETIRED: the section words. A stale layout fails here with the fix in the message.
|
|
51
|
+
const retired = RETIRED_WORDS.find((w) => node[w] !== undefined);
|
|
52
|
+
if (retired) {
|
|
53
|
+
report("error", file, `"${retired}:" is retired. Place the part with type: Ref and ref: components/<name>, and mark each of its props input: true (an Agent fills it) or input: false (drawn from its default) (UNOVERSE_AUTHORING "input decides")`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A PLACED PART (UNOVERSE_TEMPLATE_MODEL §9b): in a template's layout, a `Ref` into the
|
|
58
|
+
// template's own `components/` folder. It compiles to a ComponentSlot at serve time,
|
|
59
|
+
// claiming the part's own face (its basename) at limit 1, so the linter checks the part
|
|
60
|
+
// resolves and hands the rest of the node to the ordinary walk as the slot it becomes.
|
|
61
|
+
if (node.type === "Ref" && typeof node.ref === "string" && node.ref.startsWith(PART_PREFIX) && isTemplatePath(file) && !isTemplatePart(file)) {
|
|
62
|
+
const path = node.ref;
|
|
63
|
+
if (!defPath(root, path) && !existsSync(join(root, path)))
|
|
64
|
+
report("error", file, `part "${path}" does not resolve under ${relative(process.cwd(), root)}/. A layout places the template's own parts: components/<name> (§9b)`);
|
|
65
|
+
if (node.props !== undefined || node.with !== undefined)
|
|
66
|
+
report("error", file, `a placed part takes no "props" or "with": its props are its own, filled by an Agent where marked input: true and drawn from their defaults where input: false (§9b)`);
|
|
67
|
+
// An authored select is the ceremony §9b rule 9 abolishes: the claim is synthesized.
|
|
173
68
|
if (node.select !== undefined)
|
|
174
|
-
report("error", file, `a
|
|
175
|
-
const {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// comes from whichever state in the envelope declares this layout.
|
|
179
|
-
const owningState = () => {
|
|
180
|
-
const env = defPath(root, basename(root));
|
|
181
|
-
const states = env ? readDef(env)?.states : undefined;
|
|
182
|
-
if (!states || typeof states !== "object") return undefined;
|
|
183
|
-
const here = defName(basename(file));
|
|
184
|
-
return Object.keys(states).find((s) => {
|
|
185
|
-
const l = states[s]?.layout;
|
|
186
|
-
return typeof l === "string" && basename(l).toLowerCase() === here.toLowerCase();
|
|
187
|
-
});
|
|
188
|
-
};
|
|
189
|
-
// A QUERIED place links its component under `component:`, and claims that part's own
|
|
190
|
-
// face at limit 1 exactly as a linked part does — its `limit:` counts ROWS inside the
|
|
191
|
-
// part, not instances of it, so it never reaches the slot (§9e).
|
|
192
|
-
const linkedPath = word === "query" ? node.query?.component : node[word];
|
|
193
|
-
const face =
|
|
194
|
-
word === "director" ? (node.director?.state ?? owningState()) : typeof linkedPath === "string" ? linkedPath.split("/").pop() : undefined;
|
|
195
|
-
const limit = word === "director" ? node.director?.limit : 1;
|
|
196
|
-
const select = { from: "all", ...(face ? { where: { field: "state", eq: face } } : {}), ...(typeof limit === "number" ? { limit } : {}) };
|
|
197
|
-
return walkNode({ type: "ComponentSlot", select, ...rest }, file, root, widthCap);
|
|
69
|
+
report("error", file, `a placed part never authors "select". It claims its own face at serve time (§9b rule 9)`);
|
|
70
|
+
const { type: _t, ref: _r, props: _p, with: _w, select: _sel, ...rest } = node;
|
|
71
|
+
const face = path.split("/").pop();
|
|
72
|
+
return walkNode({ type: "ComponentSlot", select: { from: "all", where: { field: "state", eq: face }, limit: 1 }, ...rest }, file, root, widthCap);
|
|
198
73
|
}
|
|
199
74
|
|
|
200
75
|
const t = node.type;
|
|
@@ -108,6 +108,13 @@
|
|
|
108
108
|
"endWhen": {
|
|
109
109
|
"$ref": "_defs.schema.json#/definitions/expression",
|
|
110
110
|
"description": "THE MODEL ENDING THE CALL ITSELF, tested against every vendor event with it in scope as `response`. True ends the session exactly as a hang-up does.\n\n`endOn` above is the LANE's list and every entry on it means the PERSON left: a carrier's stop frame, a browser tab closing. Nothing there can say the ASSISTANT is finished, and a live voice model may send no end-of-call event of its own, so a call whose business was done stayed open: it said goodbye, stopped talking, and held the line until the idle timer fired minutes later. The caller hears silence and the session bills for it.\n\nWhat counts is the node's to decide. For a vendor with function calling the natural answer is a tool the model may call, so the expression tests for that name. Absent, nothing changes: only the lane ends a call."
|
|
111
|
+
},
|
|
112
|
+
"speech": {
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"description": "The vendor events that mean someone actually SPOKE, the person or the assistant. A call with none of them for 60 seconds ends (docs/VOICE_STREAMING_GUIDE.md, \"Every Call Ends\"), so a call nobody hangs up is not billed for ever.\n\nName them when the defaults are wrong for the vendor. Absent, speech is the person's speech event (`userSpeaking`) and the assistant's audio (`out.match`), which is wrong for a vendor that streams silent audio: GPT-Live names its transcript deltas instead."
|
|
111
118
|
}
|
|
112
119
|
},
|
|
113
120
|
"additionalProperties": false
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"secret": {
|
|
75
75
|
"type": "boolean",
|
|
76
76
|
"default": true,
|
|
77
|
-
"description": "REQUIRED. Encrypted at rest, masked in the UI, never returned to a client once saved. Optional was how a Google API key and a Cloudinary API key ended up in the database in plaintext (docs/auth-security/
|
|
77
|
+
"description": "REQUIRED. Encrypted at rest, masked in the UI, never returned to a client once saved. Optional was how a Google API key and a Cloudinary API key ended up in the database in plaintext (docs/auth-security/legacy/SECURITY_FINDINGS_LOG.md, 2026-08-30): an omission read as false and nothing warned. Set false ONLY for a genuinely non-sensitive field such as a region or an account id, and say why."
|
|
78
78
|
},
|
|
79
79
|
"default": {
|
|
80
80
|
"type": "string"
|
|
@@ -185,8 +185,11 @@
|
|
|
185
185
|
"items": {
|
|
186
186
|
"type": "object",
|
|
187
187
|
"required": [
|
|
188
|
-
"label"
|
|
189
|
-
|
|
188
|
+
"label"
|
|
189
|
+
],
|
|
190
|
+
"oneOf": [
|
|
191
|
+
{ "required": ["component"], "not": { "required": ["service"] } },
|
|
192
|
+
{ "required": ["service"], "not": { "required": ["component"] } }
|
|
190
193
|
],
|
|
191
194
|
"additionalProperties": false,
|
|
192
195
|
"properties": {
|
|
@@ -195,6 +198,11 @@
|
|
|
195
198
|
"minLength": 1,
|
|
196
199
|
"description": "The tab, as a person reads it."
|
|
197
200
|
},
|
|
201
|
+
"service": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"enum": ["voice"],
|
|
204
|
+
"description": "A native service the tab opens instead of drawing a component. `voice` is a live call: the tab starts this workflow from the trigger upstream of this node, with that trigger's Testing inputs, and carries the person's microphone and speakers on the browser lane for as long as the tab is open. For a voice node on the browser lane."
|
|
205
|
+
},
|
|
198
206
|
"component": {
|
|
199
207
|
"type": "string",
|
|
200
208
|
"minLength": 1,
|