@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc
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/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- package/src/yaml.ts +15 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
// createApp lifecycle: validate the schema, build a default config shape
|
|
2
|
+
// from inputs' prefills + tab/section structure, deep-merge user config
|
|
3
|
+
// over defaults, hand the merged tree to getDesiredState, then
|
|
4
|
+
// resolveDesiredApp the result.
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import {
|
|
8
|
+
resolveDesiredApp,
|
|
9
|
+
type TrustedHeaderSource,
|
|
10
|
+
type TrustedHeadersManifest,
|
|
11
|
+
type HomeApp,
|
|
12
|
+
} from "./resolve.ts";
|
|
13
|
+
import { deepMergeConfig } from "./deep-merge.ts";
|
|
14
|
+
import { HEADER_NAME_RE, HEADER_VALUE_RE } from "./http-header.ts";
|
|
15
|
+
import { checkPermissionAppKinds, checkSystemAppRoutes } from "./security.ts";
|
|
16
|
+
import type { Service } from "./service.ts";
|
|
17
|
+
import type { ServiceDefinition, SystemServiceDefinition } from "./service-definition.ts";
|
|
18
|
+
import type { ConfigSchema, InferConfigSchema, Node } from "./types.ts";
|
|
19
|
+
import { isInput, isSection, isTab } from "./types.ts";
|
|
20
|
+
import type { ConfigFieldLeaf, ConfigFieldTree, SerializablePermission } from "./sandbox.ts";
|
|
21
|
+
import type { Action, Loader, Page, UiConfig, Widget } from "./ui-surface.ts";
|
|
22
|
+
import type {
|
|
23
|
+
ActionManifest,
|
|
24
|
+
LoaderManifest,
|
|
25
|
+
ManifestHeader,
|
|
26
|
+
SurfaceManifest,
|
|
27
|
+
UiManifest,
|
|
28
|
+
WidgetManifest,
|
|
29
|
+
} from "./resolve.ts";
|
|
30
|
+
import { identityRef, REF_SYMBOL, type Ref } from "./ref.ts";
|
|
31
|
+
|
|
32
|
+
interface DesiredApp {
|
|
33
|
+
/**
|
|
34
|
+
* Map of service names to their definitions. Each key becomes the
|
|
35
|
+
* container's display name and the in-app DNS alias other services use
|
|
36
|
+
* to reach it. Service classes (e.g. `new Postgres(...)`) and plain
|
|
37
|
+
* `ServiceDefinition` objects can be mixed.
|
|
38
|
+
*
|
|
39
|
+
* Optional: a config-only app (no `services`) runs no container. An app
|
|
40
|
+
* may resolve to nothing and simply deploys nothing.
|
|
41
|
+
*/
|
|
42
|
+
services?: Record<string, Service | ServiceDefinition>;
|
|
43
|
+
/**
|
|
44
|
+
* Set to `config.egress` from a `r.host.egress()` permission to allow
|
|
45
|
+
* outbound traffic from this app's containers. When absent or
|
|
46
|
+
* `undefined`, the per-app network is created with Docker's
|
|
47
|
+
* `internal: true` flag and containers can only reach peers they have
|
|
48
|
+
* explicit permissions for. Realm.ts authors thread this through
|
|
49
|
+
* themselves, the same way every other resolved permission flows
|
|
50
|
+
* from `config` into the desired state.
|
|
51
|
+
*/
|
|
52
|
+
egress?: true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The desired state a system app's `getDesiredState` returns: the same
|
|
57
|
+
* shape as {@link DesiredApp} but services are routeless
|
|
58
|
+
* ({@link SystemServiceDefinition}) — system apps have no routes, so a
|
|
59
|
+
* `router` key is a type error at authoring time. The resolved-state
|
|
60
|
+
* check (`checkSystemAppRoutes`) backs the type at runtime.
|
|
61
|
+
*/
|
|
62
|
+
interface SystemDesiredApp {
|
|
63
|
+
/** Same as {@link DesiredApp.services}, minus `router`. */
|
|
64
|
+
services?: Record<string, Service | SystemServiceDefinition>;
|
|
65
|
+
/** Same as {@link DesiredApp.egress}. */
|
|
66
|
+
egress?: true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The authenticated Home user, as seen by `getTrustedHeaders`. This is the
|
|
71
|
+
* person logged into Home reaching the app - through its authenticated
|
|
72
|
+
* subdomain, or through one of its dashboard surfaces - never an app-local
|
|
73
|
+
* account.
|
|
74
|
+
*
|
|
75
|
+
* `username` / `email` are `Ref<string>`, not plain strings: at the point
|
|
76
|
+
* `getTrustedHeaders` runs there is no real user (it is sampled at deploy
|
|
77
|
+
* time to extract the header mapping), and the value is filled in per
|
|
78
|
+
* request on whichever door the request came through. Pass them straight
|
|
79
|
+
* through as a header value or compose with `interpolate`. `role` is a real
|
|
80
|
+
* literal so authors can branch on it.
|
|
81
|
+
*/
|
|
82
|
+
export interface User {
|
|
83
|
+
username: Ref<string>;
|
|
84
|
+
email: Ref<string>;
|
|
85
|
+
role: "admin" | "member";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Per-install preview context, passed to `getDesiredState` as `preview`.
|
|
90
|
+
*
|
|
91
|
+
* `null` for a normal (persistent) install; a `{ previewKey }` object when
|
|
92
|
+
* Home stamps this app as an ephemeral `role: preview` child of a template
|
|
93
|
+
* (one per PR). The `previewKey` is opaque to the app (a PR number on GitHub,
|
|
94
|
+
* an MR id on GitLab, …) and DNS-label-safe, so it's safe to fold straight
|
|
95
|
+
* into a hostname or an id. The idiom is to branch a per-preview identity off
|
|
96
|
+
* it - a base URL, a bucket prefix, a database name:
|
|
97
|
+
*
|
|
98
|
+
* ```ts
|
|
99
|
+
* getDesiredState: ({ config, preview }) => ({
|
|
100
|
+
* services: {
|
|
101
|
+
* web: {
|
|
102
|
+
* image: "...",
|
|
103
|
+
* env: {
|
|
104
|
+
* SITE_URL: preview
|
|
105
|
+
* ? `https://pr-${preview.previewKey}.example.com`
|
|
106
|
+
* : config.siteUrl,
|
|
107
|
+
* },
|
|
108
|
+
* },
|
|
109
|
+
* },
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* Not a per-preview *public domain*: `r.home.publicDomain()` is an operator
|
|
114
|
+
* grant and a child inherits its template's grants verbatim, so every child
|
|
115
|
+
* would claim the same host. Home clears the resolved value on any preview
|
|
116
|
+
* child rather than trusting the source not to wire it, so previews ride the
|
|
117
|
+
* gated auto subdomain.
|
|
118
|
+
*/
|
|
119
|
+
export interface PreviewContext {
|
|
120
|
+
previewKey: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One operator-facing value surfaced by `info` on the app's summary page. A
|
|
125
|
+
* **bare value** - a string, a `Ref<string>`, or `null` (a service URL Home
|
|
126
|
+
* drops) - or a **rich object** that adds a label, description, and mask/copy
|
|
127
|
+
* flags.
|
|
128
|
+
*/
|
|
129
|
+
type InfoValue =
|
|
130
|
+
| string
|
|
131
|
+
| null
|
|
132
|
+
| Ref<string>
|
|
133
|
+
| {
|
|
134
|
+
value: string | null | Ref<string>;
|
|
135
|
+
/** Display label, distinct from the dict key. Falls back to the key. */
|
|
136
|
+
label?: string;
|
|
137
|
+
/** One line of context rendered as muted subtext under the value. */
|
|
138
|
+
description?: string;
|
|
139
|
+
/** Mask with a reveal control; not shown in plaintext by default. */
|
|
140
|
+
sensitive?: boolean;
|
|
141
|
+
/** Show a copy button (default: true). */
|
|
142
|
+
copyable?: boolean;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `info`'s signature: a `services` URL map in, a dict of {@link InfoValue}s
|
|
147
|
+
* out (`undefined` keys omitted). See platform/docs realm-ts/info.mdx.
|
|
148
|
+
*/
|
|
149
|
+
type InfoFn = (args: {
|
|
150
|
+
services: Record<string, string | null>;
|
|
151
|
+
}) => Record<string, InfoValue | undefined>;
|
|
152
|
+
|
|
153
|
+
// Everything common to both factories. `info`, `getTrustedHeaders` and
|
|
154
|
+
// `verifyAnonymousRequest` are layered on by `SingleAppOptions` — all three
|
|
155
|
+
// hang off an app's routes, and system apps have none. The desired state type
|
|
156
|
+
// is a parameter so `createSystemApp` can narrow services to the routeless
|
|
157
|
+
// `SystemServiceDefinition`.
|
|
158
|
+
interface BaseAppOptions<TSchema extends ConfigSchema, TState extends DesiredApp> {
|
|
159
|
+
/**
|
|
160
|
+
* Stable, human-readable identifier shown in the dashboard. Free-form
|
|
161
|
+
* text - the per-install app id (used in container/network names) is
|
|
162
|
+
* generated separately and isn't derived from this.
|
|
163
|
+
*/
|
|
164
|
+
name: string;
|
|
165
|
+
/**
|
|
166
|
+
* One-sentence description shown next to the app name in the dashboard
|
|
167
|
+
* and on the consent screen.
|
|
168
|
+
*/
|
|
169
|
+
description?: string;
|
|
170
|
+
/**
|
|
171
|
+
* Typed tree of permissions, inputs, sections, and tabs the admin
|
|
172
|
+
* configures on the consent screen. Either every top-level entry is an
|
|
173
|
+
* `r.tab(...)` or none of them are - mixing throws at `createApp` time.
|
|
174
|
+
*/
|
|
175
|
+
configSchema?: TSchema;
|
|
176
|
+
/**
|
|
177
|
+
* Pages and widgets contributed to the Home dashboard. Render fns are
|
|
178
|
+
* extracted by realm-sdk and bundled for the iframe runtime; loader
|
|
179
|
+
* schemas become a JSON manifest Home uses to validate proxy calls
|
|
180
|
+
* and route to the named service.
|
|
181
|
+
*/
|
|
182
|
+
ui?: UiConfig;
|
|
183
|
+
/**
|
|
184
|
+
* Pure function that turns the resolved config into the app's desired
|
|
185
|
+
* services. Called by Home on every reconcile. Read fields off
|
|
186
|
+
* `config` (typed from `configSchema`) and return a `services` map;
|
|
187
|
+
* the SDK handles permission checks, ref resolution, and compose
|
|
188
|
+
* rendering downstream.
|
|
189
|
+
*/
|
|
190
|
+
getDesiredState: (args: {
|
|
191
|
+
/**
|
|
192
|
+
* The resolved config tree. Inputs land as their primitive value;
|
|
193
|
+
* permissions land as their resolved shape (e.g. `host.port` ->
|
|
194
|
+
* `{ path }`, `host.port` -> `{ port }`); peer permissions land as
|
|
195
|
+
* the aggregated peer data; remote permissions land as parsed
|
|
196
|
+
* fetched data.
|
|
197
|
+
*
|
|
198
|
+
* Per-worker context (worker id + Home's `home_state` identity) lands
|
|
199
|
+
* under `host.management()` when granted - read `config.<path>.workerId`
|
|
200
|
+
* etc. rather than expecting a separate `worker` arg.
|
|
201
|
+
*/
|
|
202
|
+
config: InferConfigSchema<TSchema>;
|
|
203
|
+
/**
|
|
204
|
+
* `null` on a normal install; a `{ previewKey }` object when Home stamps
|
|
205
|
+
* this app as an ephemeral preview child of a template. Branch on it to
|
|
206
|
+
* vary per-preview state (typically a public domain). See
|
|
207
|
+
* {@link PreviewContext}.
|
|
208
|
+
*/
|
|
209
|
+
preview: PreviewContext | null;
|
|
210
|
+
}) => TState;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Options for `createApp` (a single app): runs on one selected worker. The
|
|
215
|
+
* install panel shows a single-pick; UI loaders forward to that one worker
|
|
216
|
+
* and `merge` is not allowed. Only single apps carry `info` and the two
|
|
217
|
+
* route-bound auth hooks.
|
|
218
|
+
*/
|
|
219
|
+
interface SingleAppOptions<TSchema extends ConfigSchema = ConfigSchema> extends BaseAppOptions<
|
|
220
|
+
TSchema,
|
|
221
|
+
DesiredApp
|
|
222
|
+
> {
|
|
223
|
+
/**
|
|
224
|
+
* Header brokering: turn the authenticated Home user into the request
|
|
225
|
+
* headers this app trusts for sign-in (e.g. Miniflux's
|
|
226
|
+
* `AUTH_PROXY_HEADER`). Returns a flat `header -> value` map; Home
|
|
227
|
+
* computes it per request at the ForwardAuth hop and Caddy injects the
|
|
228
|
+
* result on the app's authenticated subdomain. The same header names are
|
|
229
|
+
* stripped from every other path (the public route, the client's own
|
|
230
|
+
* copy) so identity can't be spoofed.
|
|
231
|
+
*
|
|
232
|
+
* Header *names* must be static: Home samples this function once per role
|
|
233
|
+
* at eval time and unions the keys. A header *value* is a bare `user`
|
|
234
|
+
* field, a constant, or an `interpolate` template mixing the two
|
|
235
|
+
* (`` i`Bearer ${user.username}` ``). A transformed expression
|
|
236
|
+
* (`user.username.toUpperCase()`) won't round-trip and is rejected.
|
|
237
|
+
*
|
|
238
|
+
* Declaring this is the opt-in; installing an app that declares it is the
|
|
239
|
+
* operator's consent to forward identity to it.
|
|
240
|
+
*/
|
|
241
|
+
getTrustedHeaders?: (user: User) => Record<string, string | Ref<string>>;
|
|
242
|
+
/**
|
|
243
|
+
* Anonymous gate: admit session-less machine callers (a webhook, a
|
|
244
|
+
* `docker pull`) that carry their own credential instead of a Home
|
|
245
|
+
* session. Home runs this at the ForwardAuth hop for every request to
|
|
246
|
+
* this app that arrives without a usable Home session.
|
|
247
|
+
*
|
|
248
|
+
* Return `true` to admit the request as the built-in `anonymous`
|
|
249
|
+
* principal, a `Response` to relay to the caller verbatim (e.g. a 401
|
|
250
|
+
* auth challenge), or `false` to deny. The hook can only ever govern
|
|
251
|
+
* its own app's routes.
|
|
252
|
+
*
|
|
253
|
+
* `request` carries the original call's method, URL, and headers - not
|
|
254
|
+
* its body (the verify hop never receives it). `stableSecret(name)`
|
|
255
|
+
* returns the live value of `r.stableSecret(name)` for this app, derived
|
|
256
|
+
* by Home; compare the incoming credential against it. The call is
|
|
257
|
+
* synchronous - no `await` - even though Home derives it asynchronously.
|
|
258
|
+
*/
|
|
259
|
+
verifyAnonymousRequest?: (
|
|
260
|
+
request: Request,
|
|
261
|
+
stableSecret: (name: string) => string,
|
|
262
|
+
) => boolean | Response;
|
|
263
|
+
/**
|
|
264
|
+
* Operator-facing setup values - a webhook URL, a generated secret, a
|
|
265
|
+
* derived string - surfaced on the app's summary page. See platform/docs
|
|
266
|
+
* realm-ts/info.mdx.
|
|
267
|
+
*/
|
|
268
|
+
info?: InfoFn;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Options for `createSystemApp`: the app runs on every compatible worker —
|
|
273
|
+
* the operator has no per-worker choice; the install panel shows a static
|
|
274
|
+
* caption instead of a picker. UI loaders fan out to every reachable worker
|
|
275
|
+
* and `merge` is required on every loader.
|
|
276
|
+
*
|
|
277
|
+
* System apps have no routes, so the route-bound options are unavailable:
|
|
278
|
+
* `info` (a fanned-out service has one URL *per worker*, so there's no
|
|
279
|
+
* single URL for `services.<name>` to resolve to) and
|
|
280
|
+
* `verifyAnonymousRequest` (anonymous gates admit callers to an app's
|
|
281
|
+
* routes).
|
|
282
|
+
*
|
|
283
|
+
* `getTrustedHeaders` is banned for a different reason, and not a
|
|
284
|
+
* route-shaped one - brokered identity also reaches a container through a
|
|
285
|
+
* page or widget loader, which a system app very much has. The reason is
|
|
286
|
+
* consent: a system app is seeded by the platform with no operator install
|
|
287
|
+
* click, so disclosing who is signed in would be granted by the platform
|
|
288
|
+
* rather than by the operator. Revisit if a system app ever needs a viewer;
|
|
289
|
+
* none does today.
|
|
290
|
+
*
|
|
291
|
+
* The `never` tombstones hold for spread and variable-typed options, not
|
|
292
|
+
* just literals.
|
|
293
|
+
*
|
|
294
|
+
* See platform/docs realm-ts/workers.mdx.
|
|
295
|
+
*/
|
|
296
|
+
interface SystemAppOptions<TSchema extends ConfigSchema = ConfigSchema> extends BaseAppOptions<
|
|
297
|
+
TSchema,
|
|
298
|
+
SystemDesiredApp
|
|
299
|
+
> {
|
|
300
|
+
info?: never;
|
|
301
|
+
getTrustedHeaders?: never;
|
|
302
|
+
verifyAnonymousRequest?: never;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface AppDefinition<TSchema extends ConfigSchema = ConfigSchema> {
|
|
306
|
+
name: string;
|
|
307
|
+
description?: string;
|
|
308
|
+
kind: "single" | "system";
|
|
309
|
+
/**
|
|
310
|
+
* Raw anonymous gate, retained on the definition so Home can call it
|
|
311
|
+
* inside the sandbox at the verify hop (unlike `getTrustedHeaders`,
|
|
312
|
+
* which is sampled to a static manifest, the gate is live logic over
|
|
313
|
+
* the request and must execute).
|
|
314
|
+
*/
|
|
315
|
+
verifyAnonymousRequest?: (
|
|
316
|
+
request: Request,
|
|
317
|
+
stableSecret: (name: string) => string,
|
|
318
|
+
) => boolean | Response;
|
|
319
|
+
configSchema?: TSchema;
|
|
320
|
+
/**
|
|
321
|
+
* Author-provided ui block, retained so the iframe bundle can reach
|
|
322
|
+
* each surface's `render` fn. Loader runtime values stay here too —
|
|
323
|
+
* only the JSON manifest is stripped down for `evaluate`'s output.
|
|
324
|
+
*/
|
|
325
|
+
ui?: UiConfig;
|
|
326
|
+
/** Raw `info` fn, retained so Home can run it in the sandbox on the
|
|
327
|
+
* summary-page path. */
|
|
328
|
+
info?: InfoFn;
|
|
329
|
+
/**
|
|
330
|
+
* Attach file-based surfaces (pages/*, widgets/*) after construction. Called
|
|
331
|
+
* only by the merge's generated glue — it imports the surface files and
|
|
332
|
+
* wires them in before the app is bundled, so a file-based app needs no `ui:`
|
|
333
|
+
* block and the merge runs with esbuild alone (no TypeScript compiler,
|
|
334
|
+
* which is why it can run in Home). Throws if an inline `ui:` is already set.
|
|
335
|
+
*/
|
|
336
|
+
attachSurfaces: (ui: UiConfig) => void;
|
|
337
|
+
evaluate: (args: {
|
|
338
|
+
config: InferConfigSchema<TSchema>;
|
|
339
|
+
appId?: string;
|
|
340
|
+
preview?: PreviewContext | null;
|
|
341
|
+
}) => HomeApp;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Plain JS identifier shape — keys appear in dotted paths
|
|
345
|
+
// ("general.webhooks"), filesystem cache names, and getDesiredState as
|
|
346
|
+
// `config.general.webhooks`.
|
|
347
|
+
const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
348
|
+
|
|
349
|
+
function assertValidKeys(schema: Record<string, Node>, prefix = ""): void {
|
|
350
|
+
for (const [key, node] of Object.entries(schema)) {
|
|
351
|
+
if (!IDENT_RE.test(key)) {
|
|
352
|
+
const where = prefix ? `at "${prefix}.${key}"` : `at "${key}"`;
|
|
353
|
+
throw new Error(
|
|
354
|
+
`configSchema key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ ${where} - ` +
|
|
355
|
+
`use identifier-shaped names (letters, digits, underscore)`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (isTab(node) || isSection(node)) {
|
|
359
|
+
assertValidKeys(node.children as Record<string, Node>, prefix ? `${prefix}.${key}` : key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function assertSchemaHomogeneous(schema: Record<string, Node>): void {
|
|
365
|
+
const keys = Object.keys(schema);
|
|
366
|
+
if (keys.length === 0) return;
|
|
367
|
+
const tabs = keys.filter((k) => isTab(schema[k]!));
|
|
368
|
+
if (tabs.length > 0 && tabs.length !== keys.length) {
|
|
369
|
+
const first = tabs[0]!;
|
|
370
|
+
const other = keys.find((k) => !isTab(schema[k]!))!;
|
|
371
|
+
throw new Error(
|
|
372
|
+
`configSchema must be either all r.tab(...) or none - "${first}" is a tab, "${other}" is not`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Flatten the authoring tree to dotted-path → permission node, the same
|
|
379
|
+
* shape (and the same paths) `extractSchema` produces inside QuickJS. A
|
|
380
|
+
* permission node is whatever isn't a tab, a section or an input, which
|
|
381
|
+
* is how the sandbox walk classifies them too.
|
|
382
|
+
*
|
|
383
|
+
* Exists so the factories can run `checkPermissionAppKinds` against the
|
|
384
|
+
* live schema, before any bundling or eval. Home runs the same check on
|
|
385
|
+
* the extracted `permsFlat`.
|
|
386
|
+
*/
|
|
387
|
+
function collectPermissionNodes(
|
|
388
|
+
schema: Record<string, Node>,
|
|
389
|
+
out: Record<string, SerializablePermission> = {},
|
|
390
|
+
prefix = "",
|
|
391
|
+
): Record<string, SerializablePermission> {
|
|
392
|
+
for (const [key, node] of Object.entries(schema)) {
|
|
393
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
394
|
+
if (isTab(node) || isSection(node)) {
|
|
395
|
+
collectPermissionNodes(node.children as Record<string, Node>, out, path);
|
|
396
|
+
} else if (!isInput(node)) {
|
|
397
|
+
// A permission record is already `{kind, data, meta}` plus chain methods;
|
|
398
|
+
// the sandbox serializer drops the methods and reads the same three fields.
|
|
399
|
+
out[path] = node as unknown as SerializablePermission;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const OMIT = Symbol("omit");
|
|
406
|
+
|
|
407
|
+
function buildConfigShape(schema: Record<string, Node>): Record<string, unknown> {
|
|
408
|
+
const out: Record<string, unknown> = {};
|
|
409
|
+
for (const [key, node] of Object.entries(schema)) {
|
|
410
|
+
const built = buildNodeShape(node);
|
|
411
|
+
if (built !== OMIT) out[key] = built;
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function buildNodeShape(node: Node): unknown {
|
|
417
|
+
if (isTab(node)) {
|
|
418
|
+
const sub: Record<string, unknown> = {};
|
|
419
|
+
for (const [k, child] of Object.entries(node.children)) {
|
|
420
|
+
const built = buildNodeShape(child as Node);
|
|
421
|
+
if (built !== OMIT) sub[k] = built;
|
|
422
|
+
}
|
|
423
|
+
return sub;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (isSection(node)) {
|
|
427
|
+
if (node.meta.isOptional) return OMIT;
|
|
428
|
+
const sub: Record<string, unknown> = {};
|
|
429
|
+
for (const [k, child] of Object.entries(node.children)) {
|
|
430
|
+
const built = buildNodeShape(child as Node);
|
|
431
|
+
if (built !== OMIT) sub[k] = built;
|
|
432
|
+
}
|
|
433
|
+
return sub;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (isInput(node)) {
|
|
437
|
+
const value = (node.meta as { prefill?: unknown }).prefill;
|
|
438
|
+
if (value !== undefined) return value;
|
|
439
|
+
return OMIT;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Permission leaf: admin must grant; defaults don't apply.
|
|
443
|
+
return OMIT;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ----- getTrustedHeaders sampling -------------------------------------------
|
|
447
|
+
//
|
|
448
|
+
// Header *names* must be static so Caddy can list them for copy_headers
|
|
449
|
+
// (inject) and delete (anti-spoof) without seeing a request. Sampling finds
|
|
450
|
+
// them: call getTrustedHeaders once per role with identity refs for
|
|
451
|
+
// username/email (and the real role, so role-branching works), then read each
|
|
452
|
+
// returned value as a `{field}` reference, a `{literal}` string, or a
|
|
453
|
+
// `{template}` of literal + field segments (an `interpolate` over identity,
|
|
454
|
+
// e.g. `i`Bearer ${user.username}``). The verify hop substitutes the live
|
|
455
|
+
// value; role itself rides along as a literal captured in each sample.
|
|
456
|
+
|
|
457
|
+
const AUTH_ROLES = ["admin", "member"] as const;
|
|
458
|
+
|
|
459
|
+
function sampleTrustedHeaders(
|
|
460
|
+
fn: (user: User) => Record<string, string | Ref<string>>,
|
|
461
|
+
): TrustedHeadersManifest {
|
|
462
|
+
const keys = new Set<string>();
|
|
463
|
+
const byRole = {} as TrustedHeadersManifest["byRole"];
|
|
464
|
+
for (const role of AUTH_ROLES) {
|
|
465
|
+
const out = fn({ username: identityRef("username"), email: identityRef("email"), role });
|
|
466
|
+
const map: Record<string, TrustedHeaderSource> = {};
|
|
467
|
+
for (const [key, value] of Object.entries(out)) {
|
|
468
|
+
if (!HEADER_NAME_RE.test(key)) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`getTrustedHeaders["${key}"]: not a valid HTTP header name (letters, digits, ` +
|
|
471
|
+
"and !#$%&'*+-.^_`|~). Every call into the app carries these headers, and a " +
|
|
472
|
+
"malformed name is rejected outright",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
// `X-Realm-*` is the platform's namespace: Home and the worker stamp into
|
|
476
|
+
// it and Caddy strips it off every client request, so a container is
|
|
477
|
+
// entitled to trust whatever arrives under it. An app declaring into it
|
|
478
|
+
// would break that reservation, and a name Home later stamps would
|
|
479
|
+
// silently take over the app's value.
|
|
480
|
+
if (key.toLowerCase().startsWith("x-realm-")) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`getTrustedHeaders["${key}"]: the X-Realm-* namespace is reserved for the ` +
|
|
483
|
+
"platform. Home and the worker stamp headers there and clients are stripped " +
|
|
484
|
+
"of them, so a container can trust them; pick a name outside it",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
keys.add(key);
|
|
488
|
+
map[key] = toTrustedHeaderSource(key, value);
|
|
489
|
+
}
|
|
490
|
+
byRole[role] = map;
|
|
491
|
+
}
|
|
492
|
+
return { keys: [...keys], byRole };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Literal text an author baked into a header value, checked against the same
|
|
496
|
+
// rule the worker applies at request time. Literals only: a `{field}` is filled
|
|
497
|
+
// per request from the live session, and its charset is bounded at account
|
|
498
|
+
// creation, not here.
|
|
499
|
+
function assertHeaderLiteral(key: string, text: string): void {
|
|
500
|
+
if (HEADER_VALUE_RE.test(text)) return;
|
|
501
|
+
throw new Error(
|
|
502
|
+
`getTrustedHeaders["${key}"]: value must be printable ASCII. A character outside ` +
|
|
503
|
+
"it is rejected on the way into the container, failing every call the app makes",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// A returned header value is a literal string, a single user field (a bare
|
|
508
|
+
// identity ref), or an `interpolate` template mixing literal text with user
|
|
509
|
+
// fields (e.g. `i`Bearer ${user.username}``). Other ref kinds (secret, derived,
|
|
510
|
+
// deferred) and any field other than username/email are rejected.
|
|
511
|
+
function toTrustedHeaderSource(key: string, value: string | Ref<string>): TrustedHeaderSource {
|
|
512
|
+
if (typeof value === "string") {
|
|
513
|
+
assertHeaderLiteral(key, value);
|
|
514
|
+
return { literal: value };
|
|
515
|
+
}
|
|
516
|
+
if (typeof value === "object" && value !== null && REF_SYMBOL in value) {
|
|
517
|
+
const json = (value as Ref<string>).toJSON();
|
|
518
|
+
if (typeof json === "string") {
|
|
519
|
+
assertHeaderLiteral(key, json);
|
|
520
|
+
return { literal: json };
|
|
521
|
+
}
|
|
522
|
+
const marker = (json as { __realmRef: { kind?: string; field?: string; parts?: unknown[] } })
|
|
523
|
+
.__realmRef;
|
|
524
|
+
if (marker.kind === "identity") return { field: identityField(key, marker.field) };
|
|
525
|
+
// `interpolate` flattens to a single concat of string + leaf segments;
|
|
526
|
+
// for identity headers every leaf must itself be an identity field.
|
|
527
|
+
if (marker.kind === "concat") {
|
|
528
|
+
return { template: (marker.parts ?? []).map((part) => headerSegment(key, part)) };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
throw new Error(`getTrustedHeaders["${key}"]: value must be a user field or a literal string`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// One segment of an interpolated header value: literal text passes through,
|
|
535
|
+
// an identity leaf becomes a `{field}` the verify hop fills per request.
|
|
536
|
+
function headerSegment(key: string, part: unknown): string | { field: "username" | "email" } {
|
|
537
|
+
if (typeof part === "string") {
|
|
538
|
+
assertHeaderLiteral(key, part);
|
|
539
|
+
return part;
|
|
540
|
+
}
|
|
541
|
+
const m = part as { kind?: string; field?: string };
|
|
542
|
+
if (m?.kind === "identity") return { field: identityField(key, m.field) };
|
|
543
|
+
throw new Error(
|
|
544
|
+
`getTrustedHeaders["${key}"]: only user fields (user.username / user.email) and literal text can compose a header value`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function identityField(key: string, field: string | undefined): "username" | "email" {
|
|
549
|
+
if (field === "username" || field === "email") return field;
|
|
550
|
+
throw new Error(`getTrustedHeaders["${key}"]: unknown user field "${field}"`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ----- ui validation + manifest extraction -------------------------------
|
|
554
|
+
|
|
555
|
+
function assertBaseSurface(surface: Page, where: string): void {
|
|
556
|
+
if (typeof surface.title !== "string" || surface.title.length === 0) {
|
|
557
|
+
throw new Error(`${where}.title must be a non-empty string`);
|
|
558
|
+
}
|
|
559
|
+
if (typeof surface.icon !== "string" || surface.icon.length === 0) {
|
|
560
|
+
throw new Error(`${where}.icon must be a non-empty string from the icon registry`);
|
|
561
|
+
}
|
|
562
|
+
if (typeof surface.render !== "function") {
|
|
563
|
+
throw new Error(`${where}.render must be a function`);
|
|
564
|
+
}
|
|
565
|
+
if (surface.loaders) {
|
|
566
|
+
for (const [lk, l] of Object.entries(surface.loaders)) {
|
|
567
|
+
if (!IDENT_RE.test(lk)) {
|
|
568
|
+
throw new Error(`${where}.loaders key "${lk}" must be an identifier`);
|
|
569
|
+
}
|
|
570
|
+
if (typeof l.service !== "string" || typeof l.port !== "number") {
|
|
571
|
+
throw new Error(`${where}.loaders.${lk} needs a string service and a number port`);
|
|
572
|
+
}
|
|
573
|
+
if (typeof l.request !== "function") {
|
|
574
|
+
throw new Error(`${where}.loaders.${lk}.request must be a (state, config) => request fn`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (surface.actions) {
|
|
579
|
+
for (const [ak, a] of Object.entries(surface.actions)) {
|
|
580
|
+
if (!IDENT_RE.test(ak)) {
|
|
581
|
+
throw new Error(`${where}.actions key "${ak}" must be an identifier`);
|
|
582
|
+
}
|
|
583
|
+
if (typeof a.service !== "string" || typeof a.port !== "number") {
|
|
584
|
+
throw new Error(`${where}.actions.${ak} needs a string service and a number port`);
|
|
585
|
+
}
|
|
586
|
+
if (typeof a.request !== "function") {
|
|
587
|
+
throw new Error(`${where}.actions.${ak}.request must be an (input, config) => request fn`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function assertPageMap(pages: Record<string, Page>): void {
|
|
594
|
+
for (const [key, p] of Object.entries(pages)) {
|
|
595
|
+
if (!IDENT_RE.test(key)) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`ui.pages key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ at "${key}" - ` +
|
|
598
|
+
`use identifier-shaped names (letters, digits, underscore)`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
assertBaseSurface(p, `ui.pages.${key}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function assertWidgetMap(widgets: Record<string, Widget>): void {
|
|
606
|
+
for (const [key, w] of Object.entries(widgets)) {
|
|
607
|
+
if (!IDENT_RE.test(key)) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`ui.widgets key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ at "${key}" - ` +
|
|
610
|
+
`use identifier-shaped names (letters, digits, underscore)`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
assertBaseSurface(w, `ui.widgets.${key}`);
|
|
614
|
+
for (const dim of ["cols", "rows"] as const) {
|
|
615
|
+
const v = w[dim];
|
|
616
|
+
if (v === undefined) continue;
|
|
617
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
618
|
+
throw new Error(`ui.widgets.${key}.${dim} must be an integer ≥ 1`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function assertValidUi(ui: UiConfig): void {
|
|
625
|
+
if (ui.pages) assertPageMap(ui.pages);
|
|
626
|
+
if (ui.widgets) assertWidgetMap(ui.widgets);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// A system app fans out to every worker, so the iframe runtime needs a `merge`
|
|
630
|
+
// reducer to combine per-worker loader responses. A single app only ever has one
|
|
631
|
+
// response, where a `merge` would never run — reject it rather than let authors
|
|
632
|
+
// think it is wired up. Actions take no `merge` either way: a system app's
|
|
633
|
+
// resolve with the raw `{results: [...]}` envelope, a single app's with the
|
|
634
|
+
// single upstream body.
|
|
635
|
+
function assertMergeMatchesKind(ui: UiConfig, kind: "single" | "system"): void {
|
|
636
|
+
const visit = (where: string, surface: Page) => {
|
|
637
|
+
if (!surface.loaders) return;
|
|
638
|
+
for (const [name, l] of Object.entries(surface.loaders)) {
|
|
639
|
+
const hasMerge = typeof l.merge === "function";
|
|
640
|
+
if (kind === "system" && !hasMerge) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`${where}.loaders.${name}: createSystemApp requires merge(results) on every loader`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (kind === "single" && hasMerge) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`${where}.loaders.${name}: createApp forbids merge — only one worker responds`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
if (ui.pages) for (const [k, p] of Object.entries(ui.pages)) visit(`ui.pages.${k}`, p);
|
|
653
|
+
if (ui.widgets) for (const [k, w] of Object.entries(ui.widgets)) visit(`ui.widgets.${k}`, w);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function headerToManifest(value: string | { toJSON?: () => unknown }): ManifestHeader {
|
|
657
|
+
if (typeof value === "string") return value;
|
|
658
|
+
if (value && typeof value === "object" && REF_SYMBOL in value) {
|
|
659
|
+
const json = (value as { toJSON: () => unknown }).toJSON();
|
|
660
|
+
return json as ManifestHeader;
|
|
661
|
+
}
|
|
662
|
+
throw new Error("ui proxy headers must be strings or refs (e.g. r.stableSecret(...))");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function headersToManifest(
|
|
666
|
+
headers: Record<string, string | { toJSON?: () => unknown }> | undefined,
|
|
667
|
+
): Record<string, ManifestHeader> | undefined {
|
|
668
|
+
if (!headers) return undefined;
|
|
669
|
+
const out: Record<string, ManifestHeader> = {};
|
|
670
|
+
for (const [k, v] of Object.entries(headers)) out[k] = headerToManifest(v);
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Zod v4's toJSONSchema throws on z.void() / z.undefined() because they
|
|
675
|
+
// have no JSON representation. Loaders rarely use void but actions
|
|
676
|
+
// frequently return nothing — coerce both to an empty object schema
|
|
677
|
+
// (matches anything, which is the right semantic for "no return").
|
|
678
|
+
function toJsonSchemaSafe(schema: unknown): unknown {
|
|
679
|
+
try {
|
|
680
|
+
return z.toJSONSchema(schema as Parameters<typeof z.toJSONSchema>[0]);
|
|
681
|
+
} catch (e) {
|
|
682
|
+
if (e instanceof Error && e.message.includes("cannot be represented in JSON Schema")) {
|
|
683
|
+
return {};
|
|
684
|
+
}
|
|
685
|
+
throw e;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// The loader's static shape. Which loaders prefill - and the request each uses
|
|
690
|
+
// - is decided at shell-render time: Home runs each loader's
|
|
691
|
+
// `request(seedState, config)` live in a warm VM with the placement's real
|
|
692
|
+
// config. See `stitchLoaderRequests` in sandbox.ts.
|
|
693
|
+
function loaderToManifest(loader: Loader): LoaderManifest {
|
|
694
|
+
const manifest: LoaderManifest = {
|
|
695
|
+
outputSchema: toJsonSchemaSafe(loader.outputSchema),
|
|
696
|
+
service: loader.service,
|
|
697
|
+
port: loader.port,
|
|
698
|
+
headers: headersToManifest(loader.headers),
|
|
699
|
+
};
|
|
700
|
+
if (loader.merge) manifest.merge = true;
|
|
701
|
+
if (manifest.headers === undefined) delete manifest.headers;
|
|
702
|
+
return manifest;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function actionToManifest(action: Action): ActionManifest {
|
|
706
|
+
const manifest: ActionManifest = {
|
|
707
|
+
inputSchema: toJsonSchemaSafe(action.inputSchema),
|
|
708
|
+
service: action.service,
|
|
709
|
+
port: action.port,
|
|
710
|
+
headers: headersToManifest(action.headers),
|
|
711
|
+
};
|
|
712
|
+
if (action.outputSchema) manifest.outputSchema = toJsonSchemaSafe(action.outputSchema);
|
|
713
|
+
if (manifest.headers === undefined) delete manifest.headers;
|
|
714
|
+
return manifest;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Walk a ui surface's `configSchema` — a flat record of `r.input.*` leaves —
|
|
718
|
+
// into the three wire forms Home needs: a `configFields` render descriptor for
|
|
719
|
+
// the config screen, a `configSchema` JSON Schema for validating a submitted
|
|
720
|
+
// config, and a `defaultConfig` seeded from each leaf's `.prefill()`.
|
|
721
|
+
//
|
|
722
|
+
// Rejects anything a surface config may not hold: a permission / tab / section
|
|
723
|
+
// leaf, or an `r.input.secret()`. A widget config is a per-placement display
|
|
724
|
+
// knob, not a credential store, and a secret can't ride the in-iframe
|
|
725
|
+
// `request(state, config)` without leaking to the sandbox; credentials live in
|
|
726
|
+
// the app's top-level configSchema and reach a loader via a header ref. The
|
|
727
|
+
// type-level `SurfaceConfigSchema` rejects it at authoring time, so this covers
|
|
728
|
+
// the packed/JS path.
|
|
729
|
+
function describeSurfaceConfig(schema: Record<string, Node>): {
|
|
730
|
+
fields: ConfigFieldTree;
|
|
731
|
+
defaultConfig: Record<string, unknown>;
|
|
732
|
+
validation: z.ZodType;
|
|
733
|
+
} {
|
|
734
|
+
const fields: ConfigFieldTree = {};
|
|
735
|
+
const shape: Record<string, z.ZodType> = {};
|
|
736
|
+
const defaultConfig: Record<string, unknown> = {};
|
|
737
|
+
for (const [key, node] of Object.entries(schema)) {
|
|
738
|
+
if (!isInput(node)) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
`ui surface configSchema "${key}": only r.input.* leaves are allowed ` +
|
|
741
|
+
`(no permissions, tabs, or sections).`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (node.kind === "input.secret") {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`ui surface configSchema "${key}": r.input.secret() isn't supported in a ` +
|
|
747
|
+
`surface config - a widget config holds display settings, not credentials. ` +
|
|
748
|
+
`Declare the secret in the app's top-level configSchema and pass its ref to a loader header.`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const specific = node.kind.slice("input.".length) as ConfigFieldLeaf["type"];
|
|
752
|
+
const meta = (node.meta ?? {}) as Record<string, unknown>;
|
|
753
|
+
const leaf: ConfigFieldLeaf = { type: specific };
|
|
754
|
+
if (meta.title) leaf.label = meta.title as string;
|
|
755
|
+
if (meta.placeholder !== undefined) leaf.placeholder = meta.placeholder as string | number;
|
|
756
|
+
if (meta.prefill !== undefined) {
|
|
757
|
+
leaf.default = meta.prefill;
|
|
758
|
+
defaultConfig[key] = meta.prefill;
|
|
759
|
+
}
|
|
760
|
+
if (meta.description) leaf.description = meta.description as string;
|
|
761
|
+
if (meta.isOptional) leaf.optional = true;
|
|
762
|
+
if (specific === "enum" && "values" in node) {
|
|
763
|
+
leaf.values = [...(node as { values: readonly string[] }).values];
|
|
764
|
+
}
|
|
765
|
+
if (specific === "number") {
|
|
766
|
+
if (meta.int) leaf.step = 1;
|
|
767
|
+
if (meta.min !== undefined) leaf.min = meta.min as number;
|
|
768
|
+
if (meta.max !== undefined) leaf.max = meta.max as number;
|
|
769
|
+
}
|
|
770
|
+
fields[key] = leaf;
|
|
771
|
+
// A field that is `.optional()` or carries a `.prefill()` need not be
|
|
772
|
+
// submitted: the prefill rides `defaultConfig` as a base layer Home merges in
|
|
773
|
+
// before running loaders. Only builders like `.min()`/`.max()` tighten
|
|
774
|
+
// `node.schema`, while `.optional()`/`.prefill()` live purely in `meta`, so
|
|
775
|
+
// they are marked non-required here or `z.object` rejects a blank leaf.
|
|
776
|
+
shape[key] =
|
|
777
|
+
meta.isOptional || meta.prefill !== undefined ? node.schema.optional() : node.schema;
|
|
778
|
+
}
|
|
779
|
+
return { fields, defaultConfig, validation: z.object(shape) };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Shared surface manifest bits (title / icon / state / loaders / actions).
|
|
783
|
+
// Config is widget-only — `widgetToManifest` layers it on; pages carry none.
|
|
784
|
+
function surfaceToManifest(s: Page): SurfaceManifest {
|
|
785
|
+
const manifest: SurfaceManifest = { title: s.title, icon: s.icon };
|
|
786
|
+
if (s.stateSchema) manifest.stateSchema = toJsonSchemaSafe(s.stateSchema);
|
|
787
|
+
if (s.loaders) {
|
|
788
|
+
manifest.loaders = {};
|
|
789
|
+
for (const [k, l] of Object.entries(s.loaders)) {
|
|
790
|
+
manifest.loaders[k] = loaderToManifest(l);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (s.actions) {
|
|
794
|
+
manifest.actions = {};
|
|
795
|
+
for (const [k, a] of Object.entries(s.actions)) {
|
|
796
|
+
manifest.actions[k] = actionToManifest(a);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return manifest;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function widgetToManifest(w: Widget): WidgetManifest {
|
|
803
|
+
const manifest: WidgetManifest = {
|
|
804
|
+
...surfaceToManifest(w),
|
|
805
|
+
cols: w.cols ?? 2,
|
|
806
|
+
rows: w.rows ?? 2,
|
|
807
|
+
};
|
|
808
|
+
if (w.configSchema) {
|
|
809
|
+
const { fields, defaultConfig, validation } = describeSurfaceConfig(
|
|
810
|
+
w.configSchema as Record<string, Node>,
|
|
811
|
+
);
|
|
812
|
+
manifest.configFields = fields;
|
|
813
|
+
manifest.configSchema = toJsonSchemaSafe(validation);
|
|
814
|
+
// Ship the prefill defaults to the iframe as the base layer; Home merges the
|
|
815
|
+
// placement's persisted config over it before running the surface's loaders
|
|
816
|
+
// (both in the prefill VM and, client-side, in the runtime).
|
|
817
|
+
manifest.defaultConfig = defaultConfig;
|
|
818
|
+
}
|
|
819
|
+
return manifest;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function buildUiManifest(ui: UiConfig): UiManifest {
|
|
823
|
+
const manifest: UiManifest = {};
|
|
824
|
+
if (ui.pages) {
|
|
825
|
+
manifest.pages = {};
|
|
826
|
+
for (const [k, p] of Object.entries(ui.pages)) {
|
|
827
|
+
// Only widgets are configurable — a page has no config screen, so a config
|
|
828
|
+
// it could never collect would leave its loaders/render with undefined
|
|
829
|
+
// values the author's types say are present. Fail loud (covers the
|
|
830
|
+
// packed/JS path that skips the type-level rejection on `page()`).
|
|
831
|
+
if ((p as { configSchema?: unknown }).configSchema) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
`ui page "${k}": only widgets can be configured — ` +
|
|
834
|
+
`move the configSchema to a widget or drop it.`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
manifest.pages[k] = surfaceToManifest(p);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (ui.widgets) {
|
|
841
|
+
manifest.widgets = {};
|
|
842
|
+
for (const [k, w] of Object.entries(ui.widgets)) {
|
|
843
|
+
manifest.widgets[k] = widgetToManifest(w);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return manifest;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Shared factory body. `createApp` / `createSystemApp` are thin typed
|
|
850
|
+
// wrappers that pin `kind`; everything else — schema validation, ui
|
|
851
|
+
// manifest building, the evaluate closure — is identical.
|
|
852
|
+
function buildApp<TSchema extends ConfigSchema>(
|
|
853
|
+
kind: "single" | "system",
|
|
854
|
+
options: SingleAppOptions<TSchema>,
|
|
855
|
+
): AppDefinition<TSchema> {
|
|
856
|
+
const schema = options.configSchema as Record<string, Node> | undefined;
|
|
857
|
+
if (schema) {
|
|
858
|
+
assertValidKeys(schema);
|
|
859
|
+
assertSchemaHomogeneous(schema);
|
|
860
|
+
// Not all permissions are legal on both factories. Home's graph verification
|
|
861
|
+
// runs the same check authoritatively; this one is so the author sees it from
|
|
862
|
+
// `realm-sdk validate` or the test harness rather than through Home's
|
|
863
|
+
// redacted "app evaluation failed".
|
|
864
|
+
const scopeViolations = checkPermissionAppKinds(kind, collectPermissionNodes(schema));
|
|
865
|
+
if (scopeViolations.length > 0) {
|
|
866
|
+
const factory = kind === "system" ? "createSystemApp" : "createApp";
|
|
867
|
+
throw new Error(`${factory}: ${scopeViolations.map((v) => v.description).join("; ")}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (options.info && kind === "system") {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`createSystemApp: \`info\` is not supported — a fanned-out service has no ` +
|
|
873
|
+
`single URL for services.<name> to resolve to`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (options.ui) {
|
|
877
|
+
assertValidUi(options.ui);
|
|
878
|
+
assertMergeMatchesKind(options.ui, kind);
|
|
879
|
+
}
|
|
880
|
+
// `ui` + `uiManifest` are mutable so file-based surfaces can attach after
|
|
881
|
+
// construction (see attachSurfaces); inline-ui apps never call it.
|
|
882
|
+
let uiManifest = options.ui ? buildUiManifest(options.ui) : undefined;
|
|
883
|
+
const app: AppDefinition<TSchema> = {
|
|
884
|
+
name: options.name,
|
|
885
|
+
description: options.description,
|
|
886
|
+
kind,
|
|
887
|
+
verifyAnonymousRequest: options.verifyAnonymousRequest,
|
|
888
|
+
configSchema: options.configSchema,
|
|
889
|
+
ui: options.ui,
|
|
890
|
+
info: options.info,
|
|
891
|
+
attachSurfaces(ui: UiConfig) {
|
|
892
|
+
if (app.ui) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
"createApp: `ui` is already set — remove the inline `ui:` block to use " +
|
|
895
|
+
"file-based surfaces (pages/*, widgets/*), or delete the surface files.",
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
assertValidUi(ui);
|
|
899
|
+
assertMergeMatchesKind(ui, kind);
|
|
900
|
+
app.ui = ui;
|
|
901
|
+
uiManifest = buildUiManifest(ui);
|
|
902
|
+
},
|
|
903
|
+
evaluate({ config, appId, preview }) {
|
|
904
|
+
const shaped = schema
|
|
905
|
+
? (deepMergeConfig(
|
|
906
|
+
buildConfigShape(schema),
|
|
907
|
+
(config ?? {}) as Record<string, unknown>,
|
|
908
|
+
) as InferConfigSchema<TSchema>)
|
|
909
|
+
: config;
|
|
910
|
+
const state = options.getDesiredState({ config: shaped, preview: preview ?? null });
|
|
911
|
+
const withExtras = {
|
|
912
|
+
...state,
|
|
913
|
+
...(options.getTrustedHeaders
|
|
914
|
+
? { trustedHeaders: sampleTrustedHeaders(options.getTrustedHeaders) }
|
|
915
|
+
: {}),
|
|
916
|
+
...(options.verifyAnonymousRequest ? { hasAnonymousGate: true } : {}),
|
|
917
|
+
...(uiManifest ? { ui: uiManifest } : {}),
|
|
918
|
+
};
|
|
919
|
+
const resolved = resolveDesiredApp({ ...withExtras, kind }, { appId });
|
|
920
|
+
// The types already make routes unrepresentable on a system app; this
|
|
921
|
+
// catches what they can't — a Service class instance smuggling a router
|
|
922
|
+
// (flattened at resolve time) and raw trustedHeaders / hasAnonymousGate
|
|
923
|
+
// keys returned straight from getDesiredState. Home's graph verification
|
|
924
|
+
// runs the same check authoritatively.
|
|
925
|
+
if (kind === "system") {
|
|
926
|
+
const violations = checkSystemAppRoutes(resolved);
|
|
927
|
+
if (violations.length > 0) {
|
|
928
|
+
throw new Error(violations.map((v) => v.description).join("; "));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return resolved;
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
return app;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Define a realm.ts app that runs on one operator-selected worker.
|
|
939
|
+
*
|
|
940
|
+
* Returns an `AppDefinition` Home can compile. The author surface is
|
|
941
|
+
* `name`, `description`, `configSchema`, `ui`, and `getDesiredState`;
|
|
942
|
+
* everything downstream (validation, permission checks, ref resolution,
|
|
943
|
+
* compose rendering, iframe bundling) is the SDK's job.
|
|
944
|
+
*
|
|
945
|
+
* @see https://yourrealm.eu/docs/realm-ts/
|
|
946
|
+
*/
|
|
947
|
+
export function createApp<TSchema extends ConfigSchema>(
|
|
948
|
+
options: SingleAppOptions<TSchema>,
|
|
949
|
+
): AppDefinition<TSchema> {
|
|
950
|
+
return buildApp("single", options);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Define a realm.ts system app: fleet-wide infrastructure that runs on
|
|
955
|
+
* every compatible worker (the seeded backup / firewall / logger / stats apps
|
|
956
|
+
* are system apps). The operator gets no per-worker choice, UI
|
|
957
|
+
* loaders fan out to every reachable worker (`merge` required on each),
|
|
958
|
+
* and the route-bound options — `info`, `getTrustedHeaders`,
|
|
959
|
+
* `verifyAnonymousRequest`, `router` on a service — are not available:
|
|
960
|
+
* system apps have no routes.
|
|
961
|
+
*
|
|
962
|
+
* @see https://yourrealm.eu/docs/realm-ts/workers/
|
|
963
|
+
*/
|
|
964
|
+
export function createSystemApp<TSchema extends ConfigSchema>(
|
|
965
|
+
options: SystemAppOptions<TSchema>,
|
|
966
|
+
): AppDefinition<TSchema> {
|
|
967
|
+
// Runtime backstops for the packed/JS path, which skips the type-level
|
|
968
|
+
// `never` tombstones (precedent: the `info` throw in buildApp).
|
|
969
|
+
if ((options as { getTrustedHeaders?: unknown }).getTrustedHeaders) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
"createSystemApp: `getTrustedHeaders` is not supported — header brokering " +
|
|
972
|
+
"happens on an authenticated subdomain, and a system app has no routes",
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if ((options as { verifyAnonymousRequest?: unknown }).verifyAnonymousRequest) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
"createSystemApp: `verifyAnonymousRequest` is not supported — anonymous " +
|
|
978
|
+
"gates admit callers to an app's routes, and a system app has none",
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return buildApp("system", options);
|
|
982
|
+
}
|