@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.
Files changed (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,137 @@
1
+ import type { ComponentProps } from "preact";
2
+ import { Dialog as DialogPrimitive } from "@ark-ui/react/dialog";
3
+
4
+ import { cn, type ElementProps } from "../utils.ts";
5
+ import { Button } from "./button.tsx";
6
+ import { XBoldIcon } from "./icons.tsx";
7
+
8
+ function Dialog(props: ComponentProps<typeof DialogPrimitive.Root>) {
9
+ return <DialogPrimitive.Root {...props} />;
10
+ }
11
+
12
+ function DialogTrigger({ ...props }: ComponentProps<typeof DialogPrimitive.Trigger>) {
13
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
14
+ }
15
+
16
+ function DialogClose({ ...props }: ComponentProps<typeof DialogPrimitive.CloseTrigger>) {
17
+ return <DialogPrimitive.CloseTrigger data-slot="dialog-close" {...props} />;
18
+ }
19
+
20
+ function DialogOverlay({ className, ...props }: ComponentProps<typeof DialogPrimitive.Backdrop>) {
21
+ return (
22
+ <DialogPrimitive.Backdrop
23
+ data-slot="dialog-overlay"
24
+ className={cn(
25
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
26
+ className,
27
+ )}
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+
33
+ function DialogContent({
34
+ className,
35
+ children,
36
+ showCloseButton = true,
37
+ ...props
38
+ }: ComponentProps<typeof DialogPrimitive.Content> & {
39
+ showCloseButton?: boolean;
40
+ }) {
41
+ return (
42
+ <>
43
+ <DialogOverlay />
44
+ <DialogPrimitive.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
45
+ <DialogPrimitive.Content
46
+ data-slot="dialog-content"
47
+ className={cn(
48
+ "relative grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-2xl bg-background p-4 text-sm shadow-lg duration-100 outline-none sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
49
+ className,
50
+ )}
51
+ {...props}
52
+ >
53
+ {children}
54
+ {showCloseButton && (
55
+ <DialogPrimitive.CloseTrigger asChild data-slot="dialog-close">
56
+ <Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
57
+ <XBoldIcon />
58
+ <span className="sr-only">Close</span>
59
+ </Button>
60
+ </DialogPrimitive.CloseTrigger>
61
+ )}
62
+ </DialogPrimitive.Content>
63
+ </DialogPrimitive.Positioner>
64
+ </>
65
+ );
66
+ }
67
+
68
+ function DialogHeader({ className, ...props }: ElementProps<"div">) {
69
+ return (
70
+ <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
71
+ );
72
+ }
73
+
74
+ function DialogFooter({
75
+ className,
76
+ showCloseButton = false,
77
+ children,
78
+ ...props
79
+ }: ElementProps<"div"> & {
80
+ showCloseButton?: boolean;
81
+ }) {
82
+ return (
83
+ <div
84
+ data-slot="dialog-footer"
85
+ className={cn(
86
+ "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-2xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
87
+ className,
88
+ )}
89
+ {...props}
90
+ >
91
+ {children}
92
+ {showCloseButton && (
93
+ <DialogPrimitive.CloseTrigger asChild>
94
+ <Button variant="outline">Close</Button>
95
+ </DialogPrimitive.CloseTrigger>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ function DialogTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
102
+ return (
103
+ <DialogPrimitive.Title
104
+ data-slot="dialog-title"
105
+ className={cn("text-base leading-none font-medium", className)}
106
+ {...props}
107
+ />
108
+ );
109
+ }
110
+
111
+ function DialogDescription({
112
+ className,
113
+ ...props
114
+ }: ComponentProps<typeof DialogPrimitive.Description>) {
115
+ return (
116
+ <DialogPrimitive.Description
117
+ data-slot="dialog-description"
118
+ className={cn(
119
+ "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
120
+ className,
121
+ )}
122
+ {...props}
123
+ />
124
+ );
125
+ }
126
+
127
+ export {
128
+ Dialog,
129
+ DialogClose,
130
+ DialogContent,
131
+ DialogDescription,
132
+ DialogFooter,
133
+ DialogHeader,
134
+ DialogOverlay,
135
+ DialogTitle,
136
+ DialogTrigger,
137
+ };
@@ -0,0 +1,317 @@
1
+ // The operator's half of `home.publicDomain`: which hostnames the app is
2
+ // served on, and whether the extras redirect to the first or serve alongside
3
+ // it. Like `silo-picker.tsx`, the author declares the leaf, the operator
4
+ // supplies the elevated value, and the control lives in the permission's row.
5
+ //
6
+ // `pushRoutes` short circuits on a single host, so with one domain both modes
7
+ // emit byte-identical Caddy config; the mode checkbox appears at two, where the
8
+ // choice starts to mean something, and the stored mode rides through hidden.
9
+ //
10
+ // This does not know the Realm base domain: the reservation rule ("no host
11
+ // under the Realm domain") and cross-app uniqueness are both server-side, where
12
+ // the messages are complete sentences naming the fix, and a client copy would
13
+ // be a second rule to keep in step.
14
+
15
+ import { useEffect, useId, useRef } from "preact/hooks";
16
+ import { useSignal } from "@preact/signals";
17
+ import type { DomainMode, PublicDomainGrantData } from "../../schema/public-domain.ts";
18
+ import { Badge } from "./badge.tsx";
19
+ import { Button } from "./button.tsx";
20
+ import { Checkbox } from "./checkbox.tsx";
21
+ import { Input } from "./input.tsx";
22
+ import { Label } from "./label.tsx";
23
+ import { PlusIcon, TrashIcon } from "./icons.tsx";
24
+ import {
25
+ addHosts,
26
+ dedupeHosts,
27
+ modeSentence,
28
+ moveToFront,
29
+ removeAt,
30
+ wwwSuggestions,
31
+ } from "./domain-list.ts";
32
+
33
+ interface Props {
34
+ /** The stored grant record. Undefined until the operator touches it. */
35
+ value: PublicDomainGrantData | undefined;
36
+ onChange: (next: PublicDomainGrantData) => void;
37
+ /**
38
+ * Turn the permission on, called when a hostname is added.
39
+ *
40
+ * Adding a domain you own IS the grant, so making the operator find the
41
+ * switch first is asking them to say the same thing twice - and a hostname
42
+ * typed under an off switch is dropped at submit by `descriptorsToGrants`
43
+ * with nothing to show for it. Only adding does this. Removing a row leaves
44
+ * the switch alone, so tidying up a parked list can't silently re-grant.
45
+ */
46
+ onEnable?: () => void;
47
+ /**
48
+ * Whether the permission's switch is on. Not a lock - adding is live either
49
+ * way, because adding is what turns it on. It greys the rows and the mode
50
+ * checkbox, which is the only cue that a list under an off switch publishes
51
+ * nothing and is dropped at submit by `descriptorsToGrants`.
52
+ */
53
+ enabled?: boolean;
54
+ disabled?: boolean;
55
+ /**
56
+ * Text sits in the add field that hasn't been turned into a row. The panel
57
+ * gates Install/Save on it, so "typed a domain, hit Install, got an app with
58
+ * no domain" can't happen. Never true for an empty list: blank is a perfectly
59
+ * valid grant.
60
+ */
61
+ onPendingChange?: (pending: boolean) => void;
62
+ }
63
+
64
+ export function DomainEditor({
65
+ value,
66
+ onChange,
67
+ onEnable,
68
+ enabled = true,
69
+ disabled,
70
+ onPendingChange,
71
+ }: Props) {
72
+ const draft = useSignal("");
73
+ const errors = useSignal<string[]>([]);
74
+ // Blur-commit has to know whether focus went somewhere inside this control:
75
+ // clicking Add, a trash button or the mode checkbox blurs the field first,
76
+ // and committing there would apply the click to a list one edit behind.
77
+ const root = useRef<HTMLDivElement>(null);
78
+ // `relatedTarget` alone doesn't answer that. Safari and Firefox don't focus
79
+ // a <button> on mousedown, so it arrives null for exactly the clicks this
80
+ // guard exists for. Track the pointer as well: focus clears the flag, a
81
+ // pointerdown inside sets it, and the blur that follows reads and clears it.
82
+ const pointerInside = useRef(false);
83
+ // Not a literal: an app may declare two publicDomain leaves, and duplicate
84
+ // ids would point every label at the first control's inputs.
85
+ const uid = useId();
86
+
87
+ // The parent owns identity, so keep the latest callback in a ref rather than
88
+ // in an effect's dependency list - the panels pass an inline arrow.
89
+ const notify = useRef(onPendingChange);
90
+ notify.current = onPendingChange;
91
+ useEffect(() => {
92
+ notify.current?.(!disabled && draft.value.trim().length > 0);
93
+ }, [disabled, draft.value]);
94
+ // A leaf can leave the tree with text still in it (step back, optional
95
+ // section switched off), and a gate nobody can clear is worse than no gate.
96
+ useEffect(() => () => notify.current?.(false), []);
97
+
98
+ // Seeded duplicates are rendered as-is: silently rewriting an untouched form
99
+ // is its own bug, and both rows are removable.
100
+ const hosts = value?.hosts ?? [];
101
+ const mode: DomainMode = value?.domainMode ?? "redirect";
102
+
103
+ function emit(nextHosts: string[], nextMode: DomainMode) {
104
+ // Any change to the list retires the last error. `commit` re-sets its own
105
+ // errors after calling through here, so its message survives; a remove, a
106
+ // reorder or a mode flip clears a line about a list that no longer exists.
107
+ errors.value = [];
108
+ // The mode is carried through, never derived from the list length. Writing
109
+ // `nextHosts.length >= 2 ? nextMode : "redirect"` would silently downgrade
110
+ // a stored "all" the moment the operator pulls a host out to retype it.
111
+ onChange({ hosts: dedupeHosts(nextHosts), domainMode: nextMode });
112
+ }
113
+
114
+ // Adding is the one mutation that grants. See `onEnable`.
115
+ function add(nextHosts: string[]) {
116
+ onEnable?.();
117
+ emit(nextHosts, mode);
118
+ }
119
+
120
+ function commit() {
121
+ const raw = draft.value;
122
+ // Nothing typed is nothing to answer for. Deliberately leaves `errors`
123
+ // alone: this runs on every blur of an empty field, and clearing here would
124
+ // wipe the line explaining why the last commit produced no row.
125
+ if (!raw.trim()) {
126
+ draft.value = "";
127
+ return;
128
+ }
129
+ const result = addHosts(hosts, raw);
130
+ // What failed to parse stays in the field - it is a typo to fix, and
131
+ // clearing it would leave an error line with nothing to correct. A
132
+ // duplicate leaves nothing behind: that name is already on the list.
133
+ draft.value = result.rejected.join(" ");
134
+ if (result.added.length > 0) add(result.hosts);
135
+ // Last, because `add` goes through `emit`, which clears errors.
136
+ errors.value = result.errors;
137
+ }
138
+
139
+ // Purely additive: offered only while a sibling is missing, and gone once it
140
+ // lands. Not a checkbox - that reads as a stored link between the two names,
141
+ // and `hosts` is a flat list.
142
+ const siblings = wwwSuggestions(hosts);
143
+ const sentence = modeSentence(hosts, mode);
144
+ // Occurrence ordinal, so a key survives a reorder. A bare host collides when
145
+ // a seeded grant holds the same name twice; a bare index changes for every
146
+ // row after a removal, which makes Preact tear down and rebuild the tail.
147
+ const ordinals = new Map<string, number>();
148
+ const rows = hosts.map((host, i) => {
149
+ const nth = ordinals.get(host) ?? 0;
150
+ ordinals.set(host, nth + 1);
151
+ return { host, i, key: `${host}#${nth}` };
152
+ });
153
+
154
+ return (
155
+ <div
156
+ ref={root}
157
+ class="mt-2 flex flex-col gap-2"
158
+ data-slot="domain-editor"
159
+ onPointerDown={() => (pointerInside.current = true)}
160
+ >
161
+ <Label htmlFor={`${uid}-add`} className="text-xs font-medium text-text-muted">
162
+ Hostnames
163
+ </Label>
164
+
165
+ {rows.length > 0 && (
166
+ // Greyed, not hidden, while the permission is off: the list is still
167
+ // the operator's and flipping the switch back on returns it, but
168
+ // `descriptorsToGrants` drops it at submit, so it must not read as live.
169
+ <ul class={`flex flex-col gap-1 ${enabled ? "" : "opacity-50"}`}>
170
+ {rows.map(({ host, i, key }) => (
171
+ <li
172
+ key={key}
173
+ data-testid="domain-row"
174
+ data-host={host}
175
+ class="flex items-center gap-2 rounded-xl border border-border bg-surface-tinted px-2.5 py-1"
176
+ >
177
+ <span class="min-w-0 flex-1 truncate font-mono text-xs text-text-dark">{host}</span>
178
+ {i === 0 && rows.length > 1 && (
179
+ <Badge variant="outline" className="shrink-0">
180
+ Primary
181
+ </Badge>
182
+ )}
183
+ {i > 0 && (
184
+ <Button
185
+ size="xs"
186
+ variant="ghost"
187
+ disabled={disabled || !enabled}
188
+ data-testid={`domain-make-primary-${host}`}
189
+ onClick={() => emit(moveToFront(hosts, i), mode)}
190
+ >
191
+ Make primary
192
+ </Button>
193
+ )}
194
+ <Button
195
+ size="icon-xs"
196
+ variant="ghost"
197
+ disabled={disabled || !enabled}
198
+ aria-label={`Remove ${host}`}
199
+ data-testid={`domain-remove-${host}`}
200
+ onClick={() => emit(removeAt(hosts, i), mode)}
201
+ >
202
+ <TrashIcon />
203
+ </Button>
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ )}
208
+
209
+ {hosts.length > 0 && !enabled && (
210
+ <p class="text-xs text-perm leading-snug" data-testid="domain-parked">
211
+ Not published while this permission is off. Turn it on, or add a hostname to turn it on.
212
+ </p>
213
+ )}
214
+
215
+ {sentence && enabled && <p class="text-xs text-muted-foreground leading-snug">{sentence}</p>}
216
+
217
+ <div class="flex items-center gap-2">
218
+ <Input
219
+ id={`${uid}-add`}
220
+ data-slot="domain-input"
221
+ // The placeholder is the discovery mechanism once a row exists: an
222
+ // empty field sitting under a list is what says "list", without
223
+ // helper text nobody reads.
224
+ placeholder={hosts.length === 0 ? "photos.example.com" : "Add another hostname…"}
225
+ value={draft.value}
226
+ disabled={disabled}
227
+ onFocus={() => (pointerInside.current = false)}
228
+ onValueChange={(v) => {
229
+ draft.value = v;
230
+ // Typing is the operator answering the error; keep it on screen no
231
+ // longer than that. `commit` sets `errors` by writing the signal
232
+ // directly, which doesn't come back through here, so it survives.
233
+ if (errors.value.length > 0) errors.value = [];
234
+ }}
235
+ onKeyDown={(e: KeyboardEvent) => {
236
+ // Comma too: it is how a list is written, and `splitHosts` already
237
+ // treats it as a separator on paste.
238
+ if (e.key !== "Enter" && e.key !== ",") return;
239
+ e.preventDefault();
240
+ commit();
241
+ }}
242
+ // Committing on the way out is what stops a typed domain from being
243
+ // silently dropped at Install. Skipped when the click landed inside
244
+ // the control, so Add, a row button or the mode checkbox acts on the
245
+ // list the operator was looking at. Both signals are needed: focus
246
+ // covers keyboard traversal, the pointer flag the browsers that leave
247
+ // `relatedTarget` null on a button click.
248
+ onBlur={(e: FocusEvent) => {
249
+ const next = e.relatedTarget as Node | null;
250
+ const inside = pointerInside.current || (next != null && root.current?.contains(next));
251
+ pointerInside.current = false;
252
+ if (inside) return;
253
+ commit();
254
+ }}
255
+ />
256
+ {/* Enter alone is a secret. The button is what tells an operator who
257
+ has never seen this screen that names get added one at a time. */}
258
+ <Button
259
+ size="sm"
260
+ variant="outline"
261
+ disabled={disabled || draft.value.trim().length === 0}
262
+ data-testid="domain-add"
263
+ onClick={commit}
264
+ >
265
+ Add
266
+ </Button>
267
+ </div>
268
+
269
+ {errors.value.map((message) => (
270
+ <p key={message} class="text-xs text-danger">
271
+ {message}
272
+ </p>
273
+ ))}
274
+
275
+ {siblings.length > 0 && (
276
+ <div class="flex flex-wrap items-center gap-1">
277
+ {siblings.map((sibling) => (
278
+ <Button
279
+ key={sibling}
280
+ size="xs"
281
+ variant="ghost"
282
+ disabled={disabled}
283
+ data-testid={`domain-add-www-${sibling}`}
284
+ onClick={() => add([...hosts, sibling])}
285
+ >
286
+ <PlusIcon />
287
+ Add {sibling}
288
+ </Button>
289
+ ))}
290
+ </div>
291
+ )}
292
+
293
+ {hosts.length > 1 && (
294
+ <div class="flex items-center gap-2">
295
+ <Checkbox
296
+ id={`${uid}-mode`}
297
+ checked={mode === "all"}
298
+ disabled={disabled || !enabled}
299
+ data-testid="domain-mode-all"
300
+ onCheckedChange={(details: { checked: boolean | "indeterminate" }) =>
301
+ emit(hosts, details.checked === true ? "all" : "redirect")
302
+ }
303
+ />
304
+ <Label htmlFor={`${uid}-mode`} className="text-xs text-muted-foreground">
305
+ Serve the app at every domain
306
+ </Label>
307
+ </div>
308
+ )}
309
+
310
+ <p class="text-xs text-muted-foreground leading-snug">
311
+ {hosts.length === 0
312
+ ? "Point this name at this worker. Leave it blank to keep the app on its Realm subdomain."
313
+ : "Point each name at this worker."}
314
+ </p>
315
+ </div>
316
+ );
317
+ }
@@ -0,0 +1,205 @@
1
+ // The list rules behind the domain editor, with no JSX and no Preact in them,
2
+ // so `deno test` can cover the parsing and ordering the control depends on.
3
+ //
4
+ // Every host that leaves here has been through `publicDomainHostSchema` - the
5
+ // same schema the grant write parses against - so the editor never invents a
6
+ // second hostname rule, and an error line is verbatim what the server would
7
+ // have said.
8
+
9
+ import {
10
+ dedupeHosts,
11
+ publicDomainHostSchema,
12
+ type DomainMode,
13
+ } from "../../schema/public-domain.ts";
14
+
15
+ export { dedupeHosts };
16
+
17
+ /**
18
+ * Split one field's contents into candidate hostnames.
19
+ *
20
+ * Whitespace, commas and semicolons all separate, which covers a list pasted
21
+ * out of a registrar's UI as well as one typed by hand. There is no `onPaste`
22
+ * handler: the field holds the blob and committing splits it, so both routes
23
+ * are the same code path.
24
+ */
25
+ export function splitHosts(raw: string): string[] {
26
+ return raw.split(/[\s,;]+/).filter((part) => part.length > 0);
27
+ }
28
+
29
+ export type ParsedHost = { ok: true; host: string } | { ok: false; error: string };
30
+
31
+ // Scheme, per RFC 3986: a letter then letters, digits, +, - or dot.
32
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
33
+
34
+ /**
35
+ * The hostname out of something pasted from a browser's address bar.
36
+ *
37
+ * Only fires when there is an actual `scheme://` in front. A bare
38
+ * `example.com/photos` is left alone and rejected by the schema, because
39
+ * without a scheme there is nothing that reliably marks where the host ends,
40
+ * and quietly discarding half of what someone typed is worse than saying no.
41
+ *
42
+ * This is a convenience layer, never a security one: whatever comes out still
43
+ * goes through `publicDomainHostSchema`, which is the DNS-charset gate.
44
+ * Stripping in the schema itself would widen what the server accepts on
45
+ * the API path, which is exactly what that gate exists to prevent.
46
+ */
47
+ export function stripUrlNoise(raw: string): string {
48
+ const trimmed = raw.trim();
49
+ if (!URL_SCHEME.test(trimmed)) return trimmed;
50
+ let rest = trimmed.replace(URL_SCHEME, "");
51
+ // The authority ends at the first path, query or fragment delimiter.
52
+ const end = rest.search(/[/?#]/);
53
+ if (end !== -1) rest = rest.slice(0, end);
54
+ // `user:pass@host` - the host is what follows the last `@`, which is also
55
+ // how a browser reads it. Taking the front half would name the wrong host.
56
+ const at = rest.lastIndexOf("@");
57
+ if (at !== -1) rest = rest.slice(at + 1);
58
+ return rest.replace(/:\d+$/, "");
59
+ }
60
+
61
+ /**
62
+ * One candidate, normalized and validated by the grant schema itself. The
63
+ * failure message is the schema's own, so the operator reads the same sentence
64
+ * whether the host was rejected here or at the API.
65
+ */
66
+ export function parseHost(raw: string): ParsedHost {
67
+ const parsed = publicDomainHostSchema.safeParse(stripUrlNoise(raw));
68
+ if (parsed.success) return { ok: true, host: parsed.data };
69
+ return {
70
+ ok: false,
71
+ error: parsed.error.issues[0]?.message ?? "is not a hostname",
72
+ };
73
+ }
74
+
75
+ export interface AddResult {
76
+ /** The list to emit: `current` plus everything that landed, in order. */
77
+ hosts: string[];
78
+ /** What actually landed. Empty means nothing changed. */
79
+ added: string[];
80
+ /** Candidates that failed to parse, verbatim, so the field can keep them. */
81
+ rejected: string[];
82
+ /** One complete sentence per thing the operator should know about. */
83
+ errors: string[];
84
+ }
85
+
86
+ /**
87
+ * Add a field's worth of candidates to an existing list.
88
+ *
89
+ * Normalize-then-compare against the already-normalized list is exactly the
90
+ * comparison `domain_claim`'s primary key makes, so what the form calls a
91
+ * duplicate is what the server would have called one.
92
+ */
93
+ export function addHosts(current: readonly string[], raw: string): AddResult {
94
+ const candidates = splitHosts(raw);
95
+ const seen = new Set(current);
96
+ const added: string[] = [];
97
+ const rejected: string[] = [];
98
+ const duplicates: string[] = [];
99
+ const errors: string[] = [];
100
+ for (const candidate of candidates) {
101
+ const parsed = parseHost(candidate);
102
+ if (!parsed.ok) {
103
+ // The same bad name twice is one thing to fix, not two. Saying it twice
104
+ // is noise, and it also collides on the message-keyed error rows the
105
+ // editor renders.
106
+ if (!rejected.includes(candidate)) {
107
+ rejected.push(candidate);
108
+ errors.push(`"${candidate}" ${parsed.error}`);
109
+ }
110
+ continue;
111
+ }
112
+ if (seen.has(parsed.host)) {
113
+ if (!duplicates.includes(parsed.host)) duplicates.push(parsed.host);
114
+ continue;
115
+ }
116
+ seen.add(parsed.host);
117
+ added.push(parsed.host);
118
+ }
119
+ // A duplicate alongside names that DID land is noise: the operator asked for
120
+ // "these domains", and these domains is what they have. A commit where
121
+ // nothing landed at all is the opposite - the field empties, no row appears,
122
+ // and silence there is indistinguishable from the paste being swallowed.
123
+ if (duplicates.length > 0 && added.length === 0) {
124
+ errors.push(
125
+ duplicates.length === 1
126
+ ? `"${duplicates[0]}" is already listed.`
127
+ : "Those hostnames are already listed.",
128
+ );
129
+ }
130
+ return { hosts: [...current, ...added], added, rejected, errors };
131
+ }
132
+
133
+ /**
134
+ * The `www.` sibling worth offering as a one-click shortcut, or null.
135
+ *
136
+ * Two labels is the entire rule, which makes it wrong for `example.co.uk` -
137
+ * that is an apex to its owner and a three-label name to this function, so no
138
+ * shortcut is offered. Telling the two apart needs a public-suffix list, and
139
+ * shipping (or fetching) one to decorate a checkbox is not a trade worth
140
+ * making. Nothing is lost: `www.example.co.uk` can still be typed into the
141
+ * field like any other host.
142
+ */
143
+ export function wwwSibling(host: string): string | null {
144
+ if (host.startsWith("www.")) return null;
145
+ if (host.split(".").length !== 2) return null;
146
+ return `www.${host}`;
147
+ }
148
+
149
+ /**
150
+ * Every `www.` variant the list is missing, in list order.
151
+ *
152
+ * Scans the whole list rather than just the canonical host: a second brand
153
+ * domain wants its `www.` exactly as much as the first one does, and keying
154
+ * the offer to `hosts[0]` silently drops it for every name added after. One
155
+ * button each, so what is offered is always what is missing.
156
+ */
157
+ export function wwwSuggestions(hosts: readonly string[]): string[] {
158
+ const have = new Set(hosts);
159
+ const out: string[] = [];
160
+ for (const host of hosts) {
161
+ const sibling = wwwSibling(host);
162
+ if (sibling === null || have.has(sibling) || out.includes(sibling)) continue;
163
+ out.push(sibling);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * What the current list and mode actually do, in the operator's own hostnames.
170
+ *
171
+ * Naming both ends is the point. "Other domains redirect here" leaves the
172
+ * direction to be inferred from row order, and getting it backwards (www
173
+ * canonical, apex redirecting to it) is the single most likely mistake here -
174
+ * it is what you get by typing `www.` first. Spelled out, a backwards setup
175
+ * reads wrong at a glance.
176
+ *
177
+ * Null below two hosts: `pushRoutes` short-circuits there, so both modes emit
178
+ * identical config and there is nothing true to say.
179
+ */
180
+ export function modeSentence(hosts: readonly string[], mode: DomainMode): string | null {
181
+ // Deduped first: the editor renders a seeded duplicate as two rows on
182
+ // purpose, and counting those would produce "a.com redirects to a.com."
183
+ // The routes are built from the deduped list, so this describes them.
184
+ const unique = dedupeHosts([...hosts]);
185
+ if (unique.length < 2) return null;
186
+ const [primary, ...rest] = unique;
187
+ if (mode === "all") return `All ${unique.length} hostnames serve the app.`;
188
+ if (rest.length === 1) return `${rest[0]} redirects to ${primary}.`;
189
+ return `The other ${rest.length} hostnames redirect to ${primary}.`;
190
+ }
191
+
192
+ /** Promote one host to canonical. Out-of-range indexes leave the list alone. */
193
+ export function moveToFront(hosts: readonly string[], index: number): string[] {
194
+ if (index <= 0 || index >= hosts.length) return [...hosts];
195
+ const next = [...hosts];
196
+ const [moved] = next.splice(index, 1);
197
+ next.unshift(moved!);
198
+ return next;
199
+ }
200
+
201
+ /** Drop one row. Out-of-range indexes leave the list alone. */
202
+ export function removeAt(hosts: readonly string[], index: number): string[] {
203
+ if (index < 0 || index >= hosts.length) return [...hosts];
204
+ return hosts.filter((_, i) => i !== index);
205
+ }