@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
package/src/security.ts
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// Home-side security primitives: the loose outer-shape parser for a stored
|
|
2
|
+
// grants blob (`grantsSchema`), the strict write-time filter (`sanitizeGrants`),
|
|
3
|
+
// and the gates that check a resolved app's elevated uses against its grants.
|
|
4
|
+
// `HOST_DIRECTORY_BLOCKLIST` / `isValidHostDirectoryPath` are re-exported from
|
|
5
|
+
// schema/host.ts, which owns them to avoid an import cycle.
|
|
6
|
+
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { Network } from "./identifiers.ts";
|
|
9
|
+
import type { HomeApp } from "./resolve.ts";
|
|
10
|
+
import type { Grant, Grants } from "./grants.ts";
|
|
11
|
+
import {
|
|
12
|
+
collectUses,
|
|
13
|
+
homeHooks,
|
|
14
|
+
dataMatches,
|
|
15
|
+
hostHooks,
|
|
16
|
+
isHomeGrant,
|
|
17
|
+
isHostGrant,
|
|
18
|
+
} from "./grants.ts";
|
|
19
|
+
import { PERMISSION_META, permissionAllowsAppKind } from "./permissions.ts";
|
|
20
|
+
import type { AppKind, PermissionKind, WorkerCapability } from "./permissions.ts";
|
|
21
|
+
import type { SerializablePermission } from "./sandbox.ts";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
HOST_DIRECTORY_BLOCKLIST,
|
|
25
|
+
isValidHostDirectoryPath,
|
|
26
|
+
normalizeHostPath,
|
|
27
|
+
pathsOverlap,
|
|
28
|
+
} from "./schema/host.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Loose outer-shape parser for the JSON-stored grants blob. Per-kind data
|
|
32
|
+
* validation runs in sanitizeGrants — keeping the schema parse loose lets
|
|
33
|
+
* us land readable error messages for per-kind issues there instead of
|
|
34
|
+
* burying them inside Zod's superRefine machinery.
|
|
35
|
+
*/
|
|
36
|
+
const rawGrantSchema = z.object({
|
|
37
|
+
kind: z.string(),
|
|
38
|
+
data: z.unknown().optional(),
|
|
39
|
+
}) as z.ZodType<Grant>;
|
|
40
|
+
|
|
41
|
+
export const grantsSchema: z.ZodType<Grants> = z.record(z.string(), rawGrantSchema);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Per-grant rejection/prune entry. `path` is the grants-blob key (the
|
|
45
|
+
* permission descriptor path), `kind` is the kind the admin sent (helps
|
|
46
|
+
* the UI show which control failed), `reason` is a human-readable
|
|
47
|
+
* explanation suitable for surfacing to the operator.
|
|
48
|
+
*/
|
|
49
|
+
export interface GrantIssue {
|
|
50
|
+
path: string;
|
|
51
|
+
kind: string;
|
|
52
|
+
reason: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of {@link sanitizeGrants}. Two distinct buckets:
|
|
57
|
+
*
|
|
58
|
+
* - `rejected` — admin sent grants whose data the per-kind grantSchema
|
|
59
|
+
* refused (an out-of-range port, an unknown kind, etc.) or
|
|
60
|
+
* whose kind isn't recognized. These represent user error and the API
|
|
61
|
+
* must surface them as a 400 instead of silently dropping.
|
|
62
|
+
* - `pruned` — grants whose declaration is missing (orphan path) or whose
|
|
63
|
+
* kind doesn't match the declaration at that path. These represent
|
|
64
|
+
* source drift (the realm.ts changed and removed/renamed a permission),
|
|
65
|
+
* not admin input bugs, so the API logs them and proceeds.
|
|
66
|
+
*/
|
|
67
|
+
export interface SanitizeResult {
|
|
68
|
+
sanitized: Grants;
|
|
69
|
+
rejected: GrantIssue[];
|
|
70
|
+
pruned: GrantIssue[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate one admin-supplied grant's data shape against the per-kind
|
|
75
|
+
* grantSchema. Returns the (possibly narrowed) grant on success, a
|
|
76
|
+
* reason string on rejection. The caller decides whether the rejection
|
|
77
|
+
* is a hard error (bad admin input) or a soft prune (source drift) —
|
|
78
|
+
* this function only checks the per-kind data shape.
|
|
79
|
+
*/
|
|
80
|
+
function validateGrantData(
|
|
81
|
+
grant: Grant,
|
|
82
|
+
): { ok: true; grant: Grant } | { ok: false; reason: string } {
|
|
83
|
+
if (isHostGrant(grant)) {
|
|
84
|
+
const hooks = hostHooks(grant.kind);
|
|
85
|
+
if (!hooks?.grantSchema) {
|
|
86
|
+
return { ok: false, reason: `unknown host permission kind: ${grant.kind}` };
|
|
87
|
+
}
|
|
88
|
+
const r = hooks.grantSchema.safeParse(grant.data);
|
|
89
|
+
if (!r.success) {
|
|
90
|
+
const reason = r.error.issues.map((i) => i.message).join("; ");
|
|
91
|
+
return { ok: false, reason };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, grant: { kind: grant.kind, data: r.data } };
|
|
94
|
+
}
|
|
95
|
+
// home.* carries data like host (the granted scopes). Validate against the
|
|
96
|
+
// per-kind grantSchema — an unknown scope lands in the `rejected` bucket.
|
|
97
|
+
if (isHomeGrant(grant)) {
|
|
98
|
+
const hooks = homeHooks(grant.kind);
|
|
99
|
+
if (!hooks?.grantSchema) {
|
|
100
|
+
return { ok: false, reason: `unknown home permission kind: ${grant.kind}` };
|
|
101
|
+
}
|
|
102
|
+
const r = hooks.grantSchema.safeParse(grant.data);
|
|
103
|
+
if (!r.success) {
|
|
104
|
+
const reason = r.error.issues.map((i) => i.message).join("; ");
|
|
105
|
+
return { ok: false, reason };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, grant: { kind: grant.kind, data: r.data } };
|
|
108
|
+
}
|
|
109
|
+
// peers.* and remote.* are presence-only — the grant is a flag, no
|
|
110
|
+
// data field. The descriptor carries any necessary info (fetchUrl URL)
|
|
111
|
+
// and the runtime resolver pulls real values from world state / fetch.
|
|
112
|
+
if (grant.kind.startsWith("peers.") || grant.kind.startsWith("remote.")) {
|
|
113
|
+
return { ok: true, grant: { kind: grant.kind } };
|
|
114
|
+
}
|
|
115
|
+
return { ok: false, reason: `unknown permission kind: ${grant.kind}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Walk an admin-supplied grants blob against the source's declared
|
|
120
|
+
* permissions. Splits each entry into three buckets — kept (passes both
|
|
121
|
+
* declaration and per-kind schema checks), rejected (admin-input error),
|
|
122
|
+
* or pruned (source drift). Source drives what's installed; grants are
|
|
123
|
+
* narrowing choices, not expansions.
|
|
124
|
+
*/
|
|
125
|
+
export function sanitizeGrants(
|
|
126
|
+
permsFlat: Record<string, SerializablePermission>,
|
|
127
|
+
grants: Grants | undefined,
|
|
128
|
+
): SanitizeResult {
|
|
129
|
+
if (!grants) return { sanitized: {}, rejected: [], pruned: [] };
|
|
130
|
+
const sanitized: Grants = {};
|
|
131
|
+
const rejected: GrantIssue[] = [];
|
|
132
|
+
const pruned: GrantIssue[] = [];
|
|
133
|
+
for (const [path, grant] of Object.entries(grants)) {
|
|
134
|
+
const perm = permsFlat[path];
|
|
135
|
+
if (!perm) {
|
|
136
|
+
pruned.push({ path, kind: grant.kind, reason: "no declared permission at this path" });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (grant.kind !== perm.kind) {
|
|
140
|
+
pruned.push({
|
|
141
|
+
path,
|
|
142
|
+
kind: grant.kind,
|
|
143
|
+
reason: `kind ${grant.kind} does not match declared ${perm.kind}`,
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const result = validateGrantData(grant);
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
rejected.push({ path, kind: grant.kind, reason: result.reason });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
sanitized[path] = result.grant;
|
|
153
|
+
}
|
|
154
|
+
return { sanitized, rejected, pruned };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Violations
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
export interface GrantViolation {
|
|
162
|
+
kind: "grant";
|
|
163
|
+
permissionKind: string;
|
|
164
|
+
/**
|
|
165
|
+
* Compose service the use originated from, omitted for app-level uses
|
|
166
|
+
* (e.g. `state.egress`, which is declared once for the whole app, not
|
|
167
|
+
* per service).
|
|
168
|
+
*/
|
|
169
|
+
serviceName?: string;
|
|
170
|
+
use: unknown;
|
|
171
|
+
description: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface EvalErrorViolation {
|
|
175
|
+
kind: "eval";
|
|
176
|
+
message: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Raised when a *pinned* worker (`deployed: <worker id>`, a single app)
|
|
181
|
+
* doesn't advertise one of the app's required host permission kinds.
|
|
182
|
+
* A system app, which stores no placement (`deployed: null`), doesn't
|
|
183
|
+
* produce this violation: install proceeds dormant and the per-worker
|
|
184
|
+
* recompute filter handles the gap as workers come and go.
|
|
185
|
+
*/
|
|
186
|
+
export interface WorkerCapabilityViolation {
|
|
187
|
+
kind: "worker-capability";
|
|
188
|
+
/** The worker's slug: what an operator reading the message calls the box. */
|
|
189
|
+
workerId: string;
|
|
190
|
+
missing: WorkerCapability[];
|
|
191
|
+
description: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Raised when something about the app contradicts its declared kind.
|
|
196
|
+
* Three producers, all reported the same way so `logViolations` needs no
|
|
197
|
+
* new branch:
|
|
198
|
+
*
|
|
199
|
+
* - Home's deploy-target check: a single app requires a worker id, the box
|
|
200
|
+
* it runs on. A system app has nothing to check — it stores no
|
|
201
|
+
* placement, which Home derives from the source rather than accepting
|
|
202
|
+
* from the client.
|
|
203
|
+
* - `checkSystemAppRoutes`: routes, header brokering or an anonymous
|
|
204
|
+
* gate on a system app.
|
|
205
|
+
* - `checkPermissionAppKinds`: a permission whose `appKinds` scope
|
|
206
|
+
* excludes this app's kind.
|
|
207
|
+
*/
|
|
208
|
+
export interface AppKindViolation {
|
|
209
|
+
kind: "app-kind";
|
|
210
|
+
appKind: "single" | "system";
|
|
211
|
+
description: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Raised when a `router.publicDomain` sits under the Realm `baseDomain`.
|
|
216
|
+
* That namespace is Home's: every routed service already gets an
|
|
217
|
+
* authenticated subdomain there, and the `.{baseDomain}`-scoped Home session
|
|
218
|
+
* cookie reaches anything under it - so an ungated public route in that space
|
|
219
|
+
* would hand the app a live Home session (account-takeover). Public domains
|
|
220
|
+
* must live outside the Realm domain.
|
|
221
|
+
*/
|
|
222
|
+
export interface RoutingViolation {
|
|
223
|
+
kind: "routing";
|
|
224
|
+
serviceName: string;
|
|
225
|
+
description: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type Violation =
|
|
229
|
+
| GrantViolation
|
|
230
|
+
| EvalErrorViolation
|
|
231
|
+
| WorkerCapabilityViolation
|
|
232
|
+
| AppKindViolation
|
|
233
|
+
| RoutingViolation;
|
|
234
|
+
|
|
235
|
+
interface CheckAppGrantsOptions {
|
|
236
|
+
/**
|
|
237
|
+
* Networks the consumer is allowed to join (e.g. `management-net` from
|
|
238
|
+
* a `host.management` grant, or peer networks from granted peer
|
|
239
|
+
* permissions). A `joinNetworks` entry not in this set produces a
|
|
240
|
+
* `network.join` violation.
|
|
241
|
+
*/
|
|
242
|
+
allowedNetworks?: Set<Network>;
|
|
243
|
+
/**
|
|
244
|
+
* Absolute host paths exempted from the absolute-mount gate: paths a
|
|
245
|
+
* resolved permission handed the app (a Silo volume, a broker socket
|
|
246
|
+
* dir, peer backup mounts) — the grant already authorized the mount.
|
|
247
|
+
*/
|
|
248
|
+
allowedAbsoluteHostPaths?: Set<string>;
|
|
249
|
+
/**
|
|
250
|
+
* Subset of `allowedAbsoluteHostPaths` the grant only covers read-only,
|
|
251
|
+
* mapped to the permission kind that granted it (so the violation names the
|
|
252
|
+
* real permission, not the mount gate it was exempted from).
|
|
253
|
+
*
|
|
254
|
+
* Two sources. The broker socket dirs (`host.logs`, `host.stats`,
|
|
255
|
+
* `host.spawn`'s socket, `home.apiAccess`'s socket) resolve to
|
|
256
|
+
* `readOnly: true` mounts because connecting to the socket needs no write
|
|
257
|
+
* access to the directory holding it. And a `host.silo` member that declared
|
|
258
|
+
* `volume: "read"` gets the shared directory read-only.
|
|
259
|
+
*
|
|
260
|
+
* Either way an app that mounts one writable gets a violation rather than a
|
|
261
|
+
* silently-corrected mount: rejecting keeps the source honest about what
|
|
262
|
+
* runs.
|
|
263
|
+
*/
|
|
264
|
+
readOnlyHostPaths?: Map<string, string>;
|
|
265
|
+
/**
|
|
266
|
+
* Silo volumes this app may mount, keyed hostPath + NUL + gid by
|
|
267
|
+
* `siloMountKey`. A `host.silo` use carries both halves of "same bytes" -
|
|
268
|
+
* which directory, and which group owns it - and both have to match a Silo
|
|
269
|
+
* the operator bound this app to. So a hand-written `gid`, a Silo path
|
|
270
|
+
* without the join, or a gid belonging to a *different* Silo all fail here.
|
|
271
|
+
*/
|
|
272
|
+
allowedSiloMounts?: Set<string>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Key for `allowedSiloMounts`. NUL-separated: a NUL can't appear in a
|
|
277
|
+
* host path, so the gid half can never be mistaken for part of one.
|
|
278
|
+
*/
|
|
279
|
+
export function siloMountKey(hostPath: string, gid: number): string {
|
|
280
|
+
return `${hostPath}\u0000${gid}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Walk every service in a resolved app state and return one violation per
|
|
285
|
+
* elevated thing the app does without a matching grant.
|
|
286
|
+
*
|
|
287
|
+
* Path-aware: a use is only authorized when there's a *declared*
|
|
288
|
+
* permission whose path P satisfies (a) `permsFlat[P].kind === use.kind`
|
|
289
|
+
* AND (b) `grants[P]?.data` matches `use.data`. Hardcoded uses (no
|
|
290
|
+
* declaring permission of that kind anywhere in `permsFlat`) are flagged
|
|
291
|
+
* unconditionally — there is no path through which to grant them.
|
|
292
|
+
* Orphan-keyed grants (a grant at a path with no declaration) cannot
|
|
293
|
+
* authorize anything. The descriptor contract drives the gate, not bare
|
|
294
|
+
* kind+value matching across an unkeyed grants array.
|
|
295
|
+
*/
|
|
296
|
+
export function checkAppGrants(
|
|
297
|
+
state: HomeApp,
|
|
298
|
+
grants: Grants,
|
|
299
|
+
permsFlat: Record<string, SerializablePermission>,
|
|
300
|
+
options?: CheckAppGrantsOptions,
|
|
301
|
+
): GrantViolation[] {
|
|
302
|
+
const violations: GrantViolation[] = [];
|
|
303
|
+
// Index declared permissions by kind so the inner loop is a hash hit
|
|
304
|
+
// instead of an O(n) scan over permsFlat per use.
|
|
305
|
+
const declarationsByKind = new Map<string, Array<[string, SerializablePermission]>>();
|
|
306
|
+
for (const [path, perm] of Object.entries(permsFlat)) {
|
|
307
|
+
const list = declarationsByKind.get(perm.kind) ?? [];
|
|
308
|
+
list.push([path, perm]);
|
|
309
|
+
declarationsByKind.set(perm.kind, list);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const [serviceName, svc] of Object.entries(state.services)) {
|
|
313
|
+
// A worker-supplied mount whose grant only covers read access. Checked
|
|
314
|
+
// ahead of the use loop because the path is exempt from the
|
|
315
|
+
// absolute-mount gate below — without this, mounting it writable would
|
|
316
|
+
// pass silently.
|
|
317
|
+
for (const v of svc.volumes ?? []) {
|
|
318
|
+
const grantedBy = options?.readOnlyHostPaths?.get(v.hostPath as string);
|
|
319
|
+
if (grantedBy === undefined) continue;
|
|
320
|
+
if (v.readOnly === true) continue;
|
|
321
|
+
violations.push({
|
|
322
|
+
kind: "grant",
|
|
323
|
+
permissionKind: grantedBy,
|
|
324
|
+
serviceName,
|
|
325
|
+
use: v.hostPath,
|
|
326
|
+
description: "write to a directory the grant only covers read-only",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// The absolute-mount gate. No permission grants a raw absolute path, so
|
|
330
|
+
// any bind mount outside the app's own `./` data dir must have come from
|
|
331
|
+
// a resolved permission's exemption (a Silo volume, a broker socket dir,
|
|
332
|
+
// a peer backup mount). Anything else is a hardcoded host mount — the
|
|
333
|
+
// types don't allow one, but a packed app can hand the VM whatever it
|
|
334
|
+
// wants, so the gate is here, not in the compiler. gid-carrying mounts
|
|
335
|
+
// are judged as `host.silo` uses below instead: the (hostPath, gid) pair
|
|
336
|
+
// has to match a bound Silo.
|
|
337
|
+
for (const v of svc.volumes ?? []) {
|
|
338
|
+
const hostPath = v.hostPath as string;
|
|
339
|
+
if (hostPath.startsWith("./")) continue;
|
|
340
|
+
if (v.gid !== undefined) continue;
|
|
341
|
+
if (options?.allowedAbsoluteHostPaths?.has(hostPath)) continue;
|
|
342
|
+
violations.push({
|
|
343
|
+
kind: "grant",
|
|
344
|
+
permissionKind: "host.mount",
|
|
345
|
+
serviceName,
|
|
346
|
+
use: hostPath,
|
|
347
|
+
description: "mount an absolute host path",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
for (const use of collectUses(svc)) {
|
|
351
|
+
// A Silo mount's grant data is the operator's chosen Silo id, so it can
|
|
352
|
+
// never `dataMatches` the use (which is the resolved directory + group).
|
|
353
|
+
// Home resolved both from the Silo record, so the pair itself is the
|
|
354
|
+
// check: same bytes, same group.
|
|
355
|
+
if (use.kind === "host.silo") {
|
|
356
|
+
const { hostPath, gid } = use.data as { hostPath: string; gid: number };
|
|
357
|
+
if (options?.allowedSiloMounts?.has(siloMountKey(hostPath, gid))) continue;
|
|
358
|
+
violations.push({
|
|
359
|
+
kind: "grant",
|
|
360
|
+
permissionKind: use.kind,
|
|
361
|
+
serviceName,
|
|
362
|
+
use: use.data,
|
|
363
|
+
description: PERMISSION_META["host.silo"].phrase,
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// Public domains match by set containment, not `dataMatches`. That
|
|
368
|
+
// comparator is JSON-string equality, so it would turn a reordered
|
|
369
|
+
// `hosts` array or a differently-keyed record into a spurious drop —
|
|
370
|
+
// and a router legitimately serves a subset of what was granted (a
|
|
371
|
+
// second service on the same grant, an app that routes only its
|
|
372
|
+
// canonical host). What must never pass is a host outside the set.
|
|
373
|
+
if (use.kind === "home.publicDomain") {
|
|
374
|
+
const { hosts, domainMode } = use.data as { hosts: string[]; domainMode: string };
|
|
375
|
+
const declarations = declarationsByKind.get(use.kind) ?? [];
|
|
376
|
+
const matched = declarations.some(([path]) => {
|
|
377
|
+
const grant = grants[path];
|
|
378
|
+
if (grant == null || !isHomeGrant(grant) || grant.kind !== use.kind) return false;
|
|
379
|
+
const data = grant.data as { hosts?: string[]; domainMode?: string };
|
|
380
|
+
// `domainMode` is checked, not just carried. It rides inside the
|
|
381
|
+
// value so the author can't wire the hosts and forget it — but a
|
|
382
|
+
// spread (`{ ...config.domain, domainMode: "all" }`) typechecks and
|
|
383
|
+
// would otherwise override the operator's redirect choice silently,
|
|
384
|
+
// which is the exact failure the one-field shape exists to prevent.
|
|
385
|
+
if (data.domainMode !== domainMode) return false;
|
|
386
|
+
const granted = new Set(data.hosts ?? []);
|
|
387
|
+
return hosts.every((h) => granted.has(h));
|
|
388
|
+
});
|
|
389
|
+
if (!matched) {
|
|
390
|
+
violations.push({
|
|
391
|
+
kind: "grant",
|
|
392
|
+
permissionKind: use.kind,
|
|
393
|
+
serviceName,
|
|
394
|
+
use: use.data,
|
|
395
|
+
description: PERMISSION_META["home.publicDomain"].phrase,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const declarations = declarationsByKind.get(use.kind) ?? [];
|
|
401
|
+
const matched = declarations.some(([path]) => {
|
|
402
|
+
const grant = grants[path];
|
|
403
|
+
return (
|
|
404
|
+
grant != null &&
|
|
405
|
+
isHostGrant(grant) &&
|
|
406
|
+
grant.kind === use.kind &&
|
|
407
|
+
dataMatches(grant.data, use.data)
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
if (!matched) {
|
|
411
|
+
violations.push({
|
|
412
|
+
kind: "grant",
|
|
413
|
+
permissionKind: use.kind,
|
|
414
|
+
serviceName,
|
|
415
|
+
use: use.data,
|
|
416
|
+
description: PERMISSION_META[use.kind as PermissionKind]?.phrase ?? use.kind,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const net of svc.joinNetworks ?? []) {
|
|
421
|
+
if (!options?.allowedNetworks?.has(net as Network)) {
|
|
422
|
+
violations.push({
|
|
423
|
+
kind: "grant",
|
|
424
|
+
permissionKind: "network.join",
|
|
425
|
+
serviceName,
|
|
426
|
+
use: [net],
|
|
427
|
+
description: "join network",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// App-level egress gate. `state.egress` lives next to `services` (the
|
|
433
|
+
// realm.ts author writes `egress: config.egress` from a `r.host.egress()`
|
|
434
|
+
// permission). Without this check an app could hardcode `egress: true`
|
|
435
|
+
// and the renderer would drop `internal: true` from the network — full
|
|
436
|
+
// outbound access with no consent flow. Require a declared `host.egress`
|
|
437
|
+
// at some path with a matching grant entry, mirroring how every per-
|
|
438
|
+
// service use is gated.
|
|
439
|
+
if (state.egress === true) {
|
|
440
|
+
const declarations = declarationsByKind.get("host.egress") ?? [];
|
|
441
|
+
const matched = declarations.some(([path]) => {
|
|
442
|
+
const grant = grants[path];
|
|
443
|
+
return grant != null && isHostGrant(grant) && grant.kind === "host.egress";
|
|
444
|
+
});
|
|
445
|
+
if (!matched) {
|
|
446
|
+
violations.push({
|
|
447
|
+
kind: "grant",
|
|
448
|
+
permissionKind: "host.egress",
|
|
449
|
+
use: true,
|
|
450
|
+
description: PERMISSION_META["host.egress"]?.phrase ?? "host.egress",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return violations;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* System apps have no routes: no `router` on any service and no
|
|
459
|
+
* `verifyAnonymousRequest` (anonymous gates admit callers to an app's
|
|
460
|
+
* routes). Genuine network exposure goes through `host.port`.
|
|
461
|
+
* `getTrustedHeaders` is banned alongside them for a different
|
|
462
|
+
* reason - see its violation below.
|
|
463
|
+
*
|
|
464
|
+
* The `createSystemApp` types make all three unrepresentable at authoring
|
|
465
|
+
* time; this is the authoritative check over the *resolved* state, which
|
|
466
|
+
* catches what the types can't — a `Service` class instance smuggling a
|
|
467
|
+
* router (flattened at resolve time, bypassing `SystemServiceDefinition`)
|
|
468
|
+
* and raw `trustedHeaders` / `hasAnonymousGate` keys returned straight
|
|
469
|
+
* from `getDesiredState` (both are declared keys of `homeAppSchema`, so a
|
|
470
|
+
* packed/JS bundle can set them without the factory). Runs in
|
|
471
|
+
* `createSystemApp`'s evaluate (throws) and again in Home's graph
|
|
472
|
+
* verification (drops the app). Emits `app-kind` violations so
|
|
473
|
+
* `logViolations` needs no new branch.
|
|
474
|
+
*/
|
|
475
|
+
export function checkSystemAppRoutes(state: HomeApp): AppKindViolation[] {
|
|
476
|
+
const violations: AppKindViolation[] = [];
|
|
477
|
+
for (const [serviceName, svc] of Object.entries(state.services)) {
|
|
478
|
+
if (svc.router != null) {
|
|
479
|
+
violations.push({
|
|
480
|
+
kind: "app-kind",
|
|
481
|
+
appKind: "system",
|
|
482
|
+
description: `service "${serviceName}" declares a router — routes are not allowed on a system app`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (state.trustedHeaders != null) {
|
|
487
|
+
violations.push({
|
|
488
|
+
kind: "app-kind",
|
|
489
|
+
appKind: "system",
|
|
490
|
+
// Not a routing argument: brokered identity also reaches a container
|
|
491
|
+
// through a page or widget loader, and a system app has those. It is a
|
|
492
|
+
// consent argument - a system app is seeded by the platform, with no
|
|
493
|
+
// operator install click to grant the disclosure.
|
|
494
|
+
description:
|
|
495
|
+
"getTrustedHeaders is not supported on a system app — a system app is " +
|
|
496
|
+
"seeded by the platform, so no operator ever consented to it learning " +
|
|
497
|
+
"who is signed in",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (state.hasAnonymousGate === true) {
|
|
501
|
+
violations.push({
|
|
502
|
+
kind: "app-kind",
|
|
503
|
+
appKind: "system",
|
|
504
|
+
description:
|
|
505
|
+
"verifyAnonymousRequest is not supported on a system app — anonymous " +
|
|
506
|
+
"gates admit callers to an app's routes, and a system app has none",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return violations;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Reject permissions the app's kind may not declare.
|
|
514
|
+
*
|
|
515
|
+
* Each permission carries an `appKinds` scope at its declaration site
|
|
516
|
+
* (`PermissionMeta`), so the rule travels with the kind rather than living
|
|
517
|
+
* in a table here. Two directions: a single app reaching for a fleet-wide
|
|
518
|
+
* aggregation (`peers.routedApps` and friends), and a system app reaching
|
|
519
|
+
* for something that only means one box (`host.silo`) or that would
|
|
520
|
+
* multiply across the fleet (`home.apiAccess`).
|
|
521
|
+
*
|
|
522
|
+
* Reads the declared `permissionsFlat`, not the resolved state, because
|
|
523
|
+
* the restriction is on what the source *asks for*. A permission behind an
|
|
524
|
+
* optional section still counts: the operator can turn it on later, and
|
|
525
|
+
* whether the app may ask at all shouldn't depend on today's config. That
|
|
526
|
+
* is the opposite call from `partitionHostKinds`, which is about what the
|
|
527
|
+
* app needs to run right now.
|
|
528
|
+
*
|
|
529
|
+
* Runs at author time in the factories (throws) and again in Home's graph
|
|
530
|
+
* verification (drops the app), the same pair as `checkSystemAppRoutes`.
|
|
531
|
+
*/
|
|
532
|
+
export function checkPermissionAppKinds(
|
|
533
|
+
appKind: AppKind,
|
|
534
|
+
permissionsFlat: Record<string, SerializablePermission>,
|
|
535
|
+
): AppKindViolation[] {
|
|
536
|
+
const violations: AppKindViolation[] = [];
|
|
537
|
+
for (const [path, perm] of Object.entries(permissionsFlat)) {
|
|
538
|
+
if (permissionAllowsAppKind(perm.kind, appKind)) continue;
|
|
539
|
+
const phrase = PERMISSION_META[perm.kind as PermissionKind]?.phrase;
|
|
540
|
+
const allowed = appKind === "single" ? "system" : "single";
|
|
541
|
+
const factory = allowed === "system" ? "createSystemApp" : "createApp";
|
|
542
|
+
violations.push({
|
|
543
|
+
kind: "app-kind",
|
|
544
|
+
appKind,
|
|
545
|
+
description:
|
|
546
|
+
`"${path}" declares ${perm.kind}${phrase ? ` (${phrase})` : ""}, which only a ` +
|
|
547
|
+
`${allowed} app may declare — move the app to ${factory}, or drop the permission`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return violations;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Is this hostname the Realm domain, or inside it?
|
|
555
|
+
*
|
|
556
|
+
* The one implementation of the reservation test, shared by the routing check
|
|
557
|
+
* below and by Home's `domain_claim` writer - which sees hostnames the routing
|
|
558
|
+
* check never does, because it reads the grant rather than the resolved state.
|
|
559
|
+
*
|
|
560
|
+
* `host` must already be normalized (the public-domain schema lowercases on the
|
|
561
|
+
* way in). `baseDomain` comes straight off `REALM_BASE_DOMAIN`, which nothing
|
|
562
|
+
* normalizes, so it is lowercased here: a mixed-case env value would otherwise
|
|
563
|
+
* turn the whole check into a no-op.
|
|
564
|
+
*/
|
|
565
|
+
export function isUnderBaseDomain(host: string, baseDomain: string): boolean {
|
|
566
|
+
const base = baseDomain.toLowerCase();
|
|
567
|
+
return host === base || host.endsWith(`.${base}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Reject any `router.publicDomain` that falls under the Realm `baseDomain`.
|
|
572
|
+
* The structured authenticated subdomain `{appId}-{service}.{workerId}.
|
|
573
|
+
* {baseDomain}` already covers that space, gated by Home ForwardAuth, and the
|
|
574
|
+
* `.{baseDomain}`-scoped session cookie reaches every host under it - so an
|
|
575
|
+
* ungated public route there would leak a live Home session to the app. A
|
|
576
|
+
* public domain must therefore live outside the Realm domain. No-op when the
|
|
577
|
+
* install is unconfigured (`baseDomain` null).
|
|
578
|
+
*/
|
|
579
|
+
export function checkPublicDomains(state: HomeApp, baseDomain: string | null): RoutingViolation[] {
|
|
580
|
+
if (!baseDomain) return [];
|
|
581
|
+
const violations: RoutingViolation[] = [];
|
|
582
|
+
for (const [serviceName, svc] of Object.entries(state.services)) {
|
|
583
|
+
for (const host of svc.router?.publicDomain?.hosts ?? []) {
|
|
584
|
+
if (isUnderBaseDomain(host, baseDomain)) {
|
|
585
|
+
violations.push({
|
|
586
|
+
kind: "routing",
|
|
587
|
+
serviceName,
|
|
588
|
+
description: `router.publicDomain "${host}" is under the Realm domain "${baseDomain}", which is reserved for authenticated subdomains. Use a domain outside "${baseDomain}" for public access — the authenticated subdomain is provisioned automatically.`,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return violations;
|
|
594
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { apply, type Resolved } from "./apply.ts";
|
|
2
|
+
import { containsRef, type Deferred } from "./ref.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serialize a tree of primitives + `Ref<string>` to a string, the shared
|
|
6
|
+
* engine behind `toJson` / `toYaml` / `toIni` / `toXml`.
|
|
7
|
+
*
|
|
8
|
+
* No refs → serialize eagerly and return a plain `string`. Refs present →
|
|
9
|
+
* defer via `apply()`: the Home resolves every ref, then re-runs
|
|
10
|
+
* `serialize` with the resolved (ref-free) tree and splices the result.
|
|
11
|
+
* Resolving *before* serializing is what lets format-specific escaping
|
|
12
|
+
* (JSON/YAML quoting) see the real value — a concat-spliced ref can't be
|
|
13
|
+
* escaped because its value isn't known until deploy time.
|
|
14
|
+
*/
|
|
15
|
+
export function serializeRefs<T>(
|
|
16
|
+
value: T,
|
|
17
|
+
serialize: (resolved: Resolved<T>) => string,
|
|
18
|
+
): string | Deferred<string> {
|
|
19
|
+
if (!containsRef(value)) return serialize(value as Resolved<T>);
|
|
20
|
+
return apply(value, serialize);
|
|
21
|
+
}
|