portal-mojo 0.2.8 → 0.2.9

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 (149) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +6 -2
  4. package/docs/account.md +4 -3
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/calendar.md +1 -1
  22. package/docs/client.md +159 -26
  23. package/docs/collection-multiselect.md +2 -2
  24. package/docs/combobox.md +2 -2
  25. package/docs/dataview.md +1 -1
  26. package/docs/datepicker.md +3 -3
  27. package/docs/daterange.md +1 -1
  28. package/docs/datetimepicker.md +1 -1
  29. package/docs/defineModel.md +40 -7
  30. package/docs/detail-primitives.md +1 -1
  31. package/docs/feedback.md +9 -8
  32. package/docs/file-upload-ux.md +2 -2
  33. package/docs/idioms.md +1 -1
  34. package/docs/loading.md +2 -2
  35. package/docs/location-address.md +2 -1
  36. package/docs/markdown.md +3 -3
  37. package/docs/menus-and-access.md +21 -1
  38. package/docs/multiselect-dropdown.md +1 -1
  39. package/docs/popover.md +1 -1
  40. package/docs/record-feed.md +2 -2
  41. package/docs/taginput.md +2 -1
  42. package/docs/timepicker.md +1 -1
  43. package/docs/timezone-select.md +4 -3
  44. package/docs/user-menu.md +4 -3
  45. package/docs/worldmap.md +2 -2
  46. package/package.json +3 -2
  47. package/src/account/AccountModal.tsx +3 -2
  48. package/src/account/index.ts +2 -2
  49. package/src/admin/core/index.ts +1 -1
  50. package/src/admin/dns/models.ts +1 -1
  51. package/src/admin/domains/infrastructure.ts +12 -2
  52. package/src/admin/domains/security.ts +3 -1
  53. package/src/admin/edge/BlocklistPage.tsx +110 -0
  54. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  55. package/src/admin/edge/VhostDetail.tsx +166 -0
  56. package/src/admin/edge/VhostWizard.tsx +273 -0
  57. package/src/admin/edge/VhostsPage.tsx +47 -0
  58. package/src/admin/edge/api.ts +56 -0
  59. package/src/admin/edge/index.ts +15 -0
  60. package/src/admin/edge/models.ts +395 -0
  61. package/src/admin/edge/permissions.ts +13 -0
  62. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  63. package/src/admin/identity/users/sections/actions.tsx +3 -3
  64. package/src/admin/index.ts +1 -0
  65. package/src/admin/phonehub/api.ts +5 -3
  66. package/src/admin/public/infrastructure.ts +1 -0
  67. package/src/admin/registry.ts +2 -1
  68. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  69. package/src/admin/storage/file-renditions.ts +12 -0
  70. package/src/admin/storage/index.ts +2 -0
  71. package/src/admin/storage/rendition-settings.ts +264 -0
  72. package/src/charts/exportChart.ts +1 -1
  73. package/src/client/action-result.ts +23 -61
  74. package/src/client/active-group.ts +9 -3
  75. package/src/client/auth.ts +18 -4
  76. package/src/client/client.ts +49 -10
  77. package/src/client/errors.ts +57 -2
  78. package/src/client/group-context.ts +2 -0
  79. package/src/client/group.tsx +112 -20
  80. package/src/client/hooks.ts +7 -3
  81. package/src/client/mock.ts +958 -14
  82. package/src/client/model.ts +15 -3
  83. package/src/client/renditions-mock.ts +212 -0
  84. package/src/client/scoped.ts +19 -5
  85. package/src/styles/components/account.css +304 -0
  86. package/src/styles/components/admin-assistant.css +28 -0
  87. package/src/styles/components/admin-bouncer.css +53 -0
  88. package/src/styles/components/admin-cloudwatch.css +23 -0
  89. package/src/styles/components/admin-dashboard.css +16 -0
  90. package/src/styles/components/admin-devices.css +260 -0
  91. package/src/styles/components/admin-dns-certificates.css +28 -0
  92. package/src/styles/components/admin-dns-registrar.css +1 -0
  93. package/src/styles/components/admin-dns.css +43 -0
  94. package/src/styles/components/admin-edge.css +1 -0
  95. package/src/styles/components/admin-incidents.css +14 -0
  96. package/src/styles/components/admin-jobs.css +237 -0
  97. package/src/styles/components/admin-media-rendering.css +38 -0
  98. package/src/styles/components/admin-messaging.css +3 -0
  99. package/src/styles/components/admin-monitoring.css +152 -0
  100. package/src/styles/components/admin-network.css +406 -0
  101. package/src/styles/components/admin-phonehub.css +61 -0
  102. package/src/styles/components/admin-push.css +30 -0
  103. package/src/styles/components/admin-rules.css +23 -0
  104. package/src/styles/components/admin-security.css +37 -0
  105. package/src/styles/components/admin-shortlinks.css +8 -0
  106. package/src/styles/components/admin-signin.css +64 -0
  107. package/src/styles/components/admin-storage.css +4 -0
  108. package/src/styles/components/auth.css +162 -0
  109. package/src/styles/components/calendar.css +228 -0
  110. package/src/styles/components/charts-c2.css +249 -0
  111. package/src/styles/components/collection-multiselect.css +114 -0
  112. package/src/styles/components/collection-select.css +63 -0
  113. package/src/styles/components/combobox.css +42 -0
  114. package/src/styles/components/dataview.css +139 -0
  115. package/src/styles/components/datepicker.css +106 -0
  116. package/src/styles/components/daterange.css +119 -0
  117. package/src/styles/components/datetimepicker.css +230 -0
  118. package/src/styles/components/detail-primitives.css +246 -0
  119. package/src/styles/components/detailview.css +69 -0
  120. package/src/styles/components/drawer.css +111 -0
  121. package/src/styles/components/file-upload.css +112 -0
  122. package/src/styles/components/form-wizard.css +11 -0
  123. package/src/styles/components/formview.css +74 -0
  124. package/src/styles/components/group-admin.css +302 -0
  125. package/src/styles/components/guardrail.css +29 -0
  126. package/src/styles/components/idioms.css +84 -0
  127. package/src/styles/components/image-editor.css +80 -0
  128. package/src/styles/components/loading.css +200 -0
  129. package/src/styles/components/location-address.css +32 -0
  130. package/src/styles/components/markdown.css +192 -0
  131. package/src/styles/components/multiselect-dropdown.css +92 -0
  132. package/src/styles/components/popover.css +35 -0
  133. package/src/styles/components/record-feed.css +161 -0
  134. package/src/styles/components/right-panel.css +37 -0
  135. package/src/styles/components/tabs.css +26 -0
  136. package/src/styles/components/taginput.css +132 -0
  137. package/src/styles/components/timepicker.css +282 -0
  138. package/src/styles/components/user-admin.css +189 -0
  139. package/src/styles/components/user-menu.css +98 -0
  140. package/src/styles/components/worldmap.css +97 -0
  141. package/src/styles/core.css +807 -0
  142. package/src/styles/index.css +69 -0
  143. package/src/styles/required-tokens.json +32 -0
  144. package/src/ui/FormWizard.tsx +9 -3
  145. package/src/ui/ThemeProvider.tsx +1 -1
  146. package/src/ui/detail/index.ts +2 -2
  147. package/src/ui/index.ts +3 -2
  148. package/src/ui/loading.tsx +1 -1
  149. package/src/ui/toast.tsx +1 -1
@@ -0,0 +1,395 @@
1
+ import { defineModel, type Params } from '../../client/runtime';
2
+ import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS } from '../dns/models';
3
+ import { EDGE_BLOCKLIST_MANAGE_PERMS, EDGE_BLOCKLIST_VIEW_PERMS } from './permissions';
4
+
5
+ // Consumer contract: django-mojo `mojo/apps/edge/{models,rest}/` and
6
+ // `mojo/apps/edge/validators.py`. Every validator below mirrors a server rule
7
+ // so the form can refuse early; the server stays the authority and its message
8
+ // is what a failed save shows.
9
+
10
+ /** Vhost, route and upstream clauses are the server's DNS clauses, verbatim. */
11
+ export { DNS_MANAGE_PERMISSIONS as EDGE_MANAGE_PERMS, DNS_VIEW_PERMISSIONS as EDGE_VIEW_PERMS } from '../dns/models';
12
+
13
+ export type VhostKind = 'api' | 'site' | 'site_api' | 'redirect';
14
+ export const VHOST_KINDS: readonly VhostKind[] = ['api', 'site', 'site_api', 'redirect'];
15
+
16
+ export interface VhostKindCard { value: VhostKind; title: string; description: string; icon: string }
17
+ /** The wizard's first step: a product shape in plain words, never a raw enum. */
18
+ export const VHOST_KIND_CARDS: readonly VhostKindCard[] = [
19
+ { value: 'api', title: 'API host', description: 'Every request on this name goes to one declared upstream.', icon: 'bi-hdd-rack' },
20
+ { value: 'site', title: 'Static site or SPA', description: 'Serves a published site. Nothing is proxied.', icon: 'bi-window' },
21
+ { value: 'site_api', title: 'Site + API paths', description: 'Serves a site, and sends chosen path prefixes to upstreams.', icon: 'bi-diagram-3' },
22
+ { value: 'redirect', title: 'Redirect', description: 'Sends every request to another host, permanently.', icon: 'bi-arrow-return-right' },
23
+ ];
24
+
25
+ export function vhostKindTitle(kind: string): string {
26
+ return VHOST_KIND_CARDS.find((card) => card.value === kind)?.title ?? kind;
27
+ }
28
+
29
+ export interface EdgeDomainBasic { id: number; name: string; provider: string; status: string; expires: number | null }
30
+ export interface EdgeUpstreamBasic { id: number; name: string; kind: string }
31
+ export interface EdgeCertificateBasic { id: number; common_name: string; status: string; not_after: number | null }
32
+ export interface EdgeGroupBasic { id: number; name: string }
33
+ export interface EdgeVhostBasic { id: number; kind: string; is_enabled: boolean; server_name: string | null }
34
+
35
+ export interface EdgeVhostRow {
36
+ id: number;
37
+ created: number;
38
+ kind: string;
39
+ pool: string;
40
+ is_enabled: boolean;
41
+ server_name: string | null;
42
+ domain: EdgeDomainBasic | null;
43
+ // `default` graph only.
44
+ modified?: number;
45
+ label?: string;
46
+ spa?: boolean;
47
+ body_size_mb?: number;
48
+ quiet_paths?: string[];
49
+ serve_static?: boolean;
50
+ /** Read-only here: shown, never sent. */
51
+ mojosec_policy?: Record<string, unknown>;
52
+ redirect_to?: string | null;
53
+ upstream?: EdgeUpstreamBasic | null;
54
+ certificate?: EdgeCertificateBasic | null;
55
+ }
56
+
57
+ export interface EdgeRouteRow {
58
+ id: number;
59
+ created: number;
60
+ modified: number;
61
+ path_prefix: string;
62
+ vhost: EdgeVhostBasic | null;
63
+ upstream: EdgeUpstreamBasic | null;
64
+ }
65
+
66
+ export interface EdgeUpstreamRow {
67
+ id: number;
68
+ created: number;
69
+ modified: number;
70
+ name: string;
71
+ kind: string;
72
+ host: string | null;
73
+ port: number | null;
74
+ socket_path: string | null;
75
+ is_enabled: boolean;
76
+ group: EdgeGroupBasic | null;
77
+ }
78
+
79
+ export interface EdgeBlocklistRow {
80
+ id: number;
81
+ created: number;
82
+ modified: number;
83
+ kind: string;
84
+ value: string;
85
+ mode: string;
86
+ note: string;
87
+ }
88
+
89
+ function rowObject(value: unknown): Record<string, unknown> {
90
+ return value != null && typeof value === 'object' && !Array.isArray(value)
91
+ ? value as Record<string, unknown>
92
+ : {};
93
+ }
94
+
95
+ function projectDomain(value: unknown): EdgeDomainBasic | null {
96
+ if (value == null || typeof value !== 'object') return null;
97
+ const raw = rowObject(value);
98
+ return { id: raw.id as number, name: raw.name as string, provider: raw.provider as string, status: raw.status as string, expires: (raw.expires ?? null) as number | null };
99
+ }
100
+
101
+ function projectUpstream(value: unknown): EdgeUpstreamBasic | null {
102
+ if (value == null || typeof value !== 'object') return null;
103
+ const raw = rowObject(value);
104
+ return { id: raw.id as number, name: raw.name as string, kind: raw.kind as string };
105
+ }
106
+
107
+ function projectCertificate(value: unknown): EdgeCertificateBasic | null {
108
+ if (value == null || typeof value !== 'object') return null;
109
+ const raw = rowObject(value);
110
+ return { id: raw.id as number, common_name: raw.common_name as string, status: raw.status as string, not_after: (raw.not_after ?? null) as number | null };
111
+ }
112
+
113
+ function projectVhostBasic(value: unknown): EdgeVhostBasic | null {
114
+ if (value == null || typeof value !== 'object') return null;
115
+ const raw = rowObject(value);
116
+ return { id: raw.id as number, kind: raw.kind as string, is_enabled: raw.is_enabled === true, server_name: (raw.server_name ?? null) as string | null };
117
+ }
118
+
119
+ function projectGroup(value: unknown): EdgeGroupBasic | null {
120
+ if (value == null || typeof value !== 'object') return null;
121
+ const raw = rowObject(value);
122
+ return { id: raw.id as number, name: raw.name as string };
123
+ }
124
+
125
+ /** Positive projection: a key the graph does not name never reaches a row. */
126
+ export function sanitizeEdgeVhostRow(row: unknown): EdgeVhostRow {
127
+ const raw = rowObject(row);
128
+ return {
129
+ id: raw.id as number, created: raw.created as number, kind: raw.kind as string,
130
+ pool: raw.pool as string, is_enabled: raw.is_enabled === true,
131
+ server_name: (raw.server_name ?? null) as string | null,
132
+ domain: projectDomain(raw.domain),
133
+ ...('modified' in raw ? { modified: raw.modified as number } : {}),
134
+ ...('label' in raw ? { label: raw.label as string } : {}),
135
+ ...('spa' in raw ? { spa: raw.spa === true } : {}),
136
+ ...('body_size_mb' in raw ? { body_size_mb: raw.body_size_mb as number } : {}),
137
+ ...('quiet_paths' in raw ? { quiet_paths: Array.isArray(raw.quiet_paths) ? raw.quiet_paths.filter((path): path is string => typeof path === 'string') : [] } : {}),
138
+ ...('serve_static' in raw ? { serve_static: raw.serve_static === true } : {}),
139
+ ...('mojosec_policy' in raw ? { mojosec_policy: { ...rowObject(raw.mojosec_policy) } } : {}),
140
+ ...('redirect_to' in raw ? { redirect_to: (raw.redirect_to ?? null) as string | null } : {}),
141
+ ...('upstream' in raw ? { upstream: projectUpstream(raw.upstream) } : {}),
142
+ ...('certificate' in raw ? { certificate: projectCertificate(raw.certificate) } : {}),
143
+ };
144
+ }
145
+
146
+ export function sanitizeEdgeRouteRow(row: unknown): EdgeRouteRow {
147
+ const raw = rowObject(row);
148
+ return {
149
+ id: raw.id as number, created: raw.created as number, modified: raw.modified as number,
150
+ path_prefix: raw.path_prefix as string,
151
+ vhost: projectVhostBasic(raw.vhost), upstream: projectUpstream(raw.upstream),
152
+ };
153
+ }
154
+
155
+ export function sanitizeEdgeUpstreamRow(row: unknown): EdgeUpstreamRow {
156
+ const raw = rowObject(row);
157
+ return {
158
+ id: raw.id as number, created: raw.created as number, modified: raw.modified as number,
159
+ name: raw.name as string, kind: raw.kind as string,
160
+ host: (raw.host ?? null) as string | null, port: (raw.port ?? null) as number | null,
161
+ socket_path: (raw.socket_path ?? null) as string | null,
162
+ is_enabled: raw.is_enabled === true, group: projectGroup(raw.group),
163
+ };
164
+ }
165
+
166
+ export function sanitizeEdgeBlocklistRow(row: unknown): EdgeBlocklistRow {
167
+ const raw = rowObject(row);
168
+ return {
169
+ id: raw.id as number, created: raw.created as number, modified: raw.modified as number,
170
+ kind: raw.kind as string, value: raw.value as string, mode: raw.mode as string,
171
+ note: typeof raw.note === 'string' ? raw.note : '',
172
+ };
173
+ }
174
+
175
+ const COMMON = new Set(['start', 'size', 'search']);
176
+
177
+ function normalizeListParams(params: Params, filters: ReadonlySet<string>, sorts: ReadonlySet<string>, graph: string): Params {
178
+ const safe: Params = { graph };
179
+ for (const [key, value] of Object.entries(params)) {
180
+ if (value == null || value === '') continue;
181
+ if (COMMON.has(key) || filters.has(key)) safe[key] = value;
182
+ }
183
+ if (typeof params.sort === 'string' && sorts.has(params.sort.replace(/^-/, ''))) safe.sort = params.sort;
184
+ return safe;
185
+ }
186
+
187
+ export function normalizeVhostListParams(params: Params): Params {
188
+ return normalizeListParams(
189
+ params,
190
+ new Set(['kind', 'pool', 'is_enabled', 'domain', 'domain__group']),
191
+ new Set(['label', 'kind', 'pool', 'created', 'is_enabled']),
192
+ params.graph === 'default' ? 'default' : 'list',
193
+ );
194
+ }
195
+
196
+ export function normalizeRouteListParams(params: Params): Params {
197
+ return normalizeListParams(params, new Set(['vhost']), new Set(['path_prefix', 'created']), 'default');
198
+ }
199
+
200
+ export function normalizeUpstreamListParams(params: Params): Params {
201
+ return normalizeListParams(params, new Set(['is_enabled', 'kind']), new Set(['name', 'kind', 'created', 'is_enabled']), 'default');
202
+ }
203
+
204
+ export function normalizeBlocklistListParams(params: Params): Params {
205
+ return normalizeListParams(params, new Set(['kind', 'mode', 'mode__in']), new Set(['created', 'kind', 'value', 'mode']), 'default');
206
+ }
207
+
208
+ export const EdgeVhostModel = defineModel<EdgeVhostRow>({
209
+ name: 'edge_vhost', endpoint: '/api/edge/vhost',
210
+ permissions: { view: DNS_VIEW_PERMISSIONS, manage: DNS_MANAGE_PERMISSIONS, delete: DNS_MANAGE_PERMISSIONS },
211
+ normalizeListParams: normalizeVhostListParams,
212
+ sanitizeRow: sanitizeEdgeVhostRow,
213
+ });
214
+
215
+ export const EdgeRouteModel = defineModel<EdgeRouteRow>({
216
+ name: 'edge_route', endpoint: '/api/edge/route',
217
+ permissions: { view: DNS_VIEW_PERMISSIONS, manage: DNS_MANAGE_PERMISSIONS, delete: DNS_MANAGE_PERMISSIONS },
218
+ normalizeListParams: normalizeRouteListParams,
219
+ sanitizeRow: sanitizeEdgeRouteRow,
220
+ });
221
+
222
+ export const EdgeUpstreamModel = defineModel<EdgeUpstreamRow>({
223
+ name: 'edge_upstream', endpoint: '/api/edge/upstream',
224
+ permissions: { view: DNS_VIEW_PERMISSIONS, manage: DNS_MANAGE_PERMISSIONS },
225
+ normalizeListParams: normalizeUpstreamListParams,
226
+ sanitizeRow: sanitizeEdgeUpstreamRow,
227
+ });
228
+
229
+ export const EdgeBlocklistModel = defineModel<EdgeBlocklistRow>({
230
+ name: 'edge_blocklist', endpoint: '/api/edge/blocklist',
231
+ permissions: { view: EDGE_BLOCKLIST_VIEW_PERMS, manage: EDGE_BLOCKLIST_MANAGE_PERMS, delete: EDGE_BLOCKLIST_MANAGE_PERMS },
232
+ normalizeListParams: normalizeBlocklistListParams,
233
+ sanitizeRow: sanitizeEdgeBlocklistRow,
234
+ });
235
+
236
+ // ── Client mirrors of the server validators ───────────────────────────
237
+
238
+ const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
239
+ const REQUEST_PATH_RE = /^\/[A-Za-z0-9._/-]{0,127}$/;
240
+ const UA_PATTERN_RE = /^[A-Za-z0-9()[\]|?^.*+\\/_-]{1,256}$/;
241
+ const POOL_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
242
+
243
+ /** `validators.server_name_for`: the name is derived, never typed. */
244
+ export function deriveServerName(domainName: string, label: string): string {
245
+ return label === '' ? domainName : `${label}.${domainName}`;
246
+ }
247
+
248
+ /** Returns the refusal, or null when the value is acceptable. */
249
+ export function validateVhostLabel(label: string): string | null {
250
+ if (label === '' || label === '*') return null;
251
+ return LABEL_RE.test(label) ? null : "Leave empty for the apex, use '*' for the wildcard, or enter one DNS label of lowercase letters, digits and hyphens.";
252
+ }
253
+
254
+ function validateServerName(name: string): string | null {
255
+ if (!name) return 'Enter a host name.';
256
+ if (name.length > 253) return 'The host name is too long.';
257
+ const parts = (name.startsWith('*.') ? name.slice(2) : name).split('.');
258
+ if (parts.length < 2) return `${name} is not a fully qualified domain name.`;
259
+ return parts.every((part) => LABEL_RE.test(part)) ? null : `${name} is not a valid host name.`;
260
+ }
261
+
262
+ /** A redirect target is a host: no scheme, path, port or wildcard. */
263
+ export function validateRedirectTarget(target: string): string | null {
264
+ if (!target) return 'A redirect needs a target host.';
265
+ if (target.startsWith('*.')) return 'A redirect target cannot be a wildcard.';
266
+ return validateServerName(target);
267
+ }
268
+
269
+ /** Shared by quiet paths and route prefixes (`_validate_request_path`). */
270
+ export function validateRequestPath(path: string, what = 'A path'): string | null {
271
+ if (!REQUEST_PATH_RE.test(path)) return `${what} must start with '/' and use only letters, digits, '.', '_', '-' and '/' (max 128 characters).`;
272
+ if (path.includes('//')) return `${what} may not contain '//'.`;
273
+ if (path.split('/').some((part) => part === '..')) return `${what} may not contain a '..' segment.`;
274
+ return null;
275
+ }
276
+
277
+ export function validateRoutePrefix(prefix: string): string | null {
278
+ const refusal = validateRequestPath(prefix, 'A route prefix');
279
+ if (refusal) return refusal;
280
+ return prefix === '/' ? "A route prefix cannot be '/'. Use an API host for a whole-host proxy." : null;
281
+ }
282
+
283
+ export function validateBodySizeMb(value: unknown): string | null {
284
+ if (typeof value !== 'number' || !Number.isInteger(value)) return 'Body size must be a whole number of megabytes.';
285
+ return value >= 1 && value <= 4096 ? null : 'Body size must be between 1 and 4096 megabytes.';
286
+ }
287
+
288
+ export function validatePoolName(pool: string): string | null {
289
+ return POOL_RE.test(pool) ? null : "A pool is lowercase letters, digits, '-' or '_'.";
290
+ }
291
+
292
+ /** Port of `validators.certificate_covers`, both wildcard rules included. */
293
+ export function certificateCovers(certificate: { common_name?: string | null; sans?: readonly unknown[] | null }, serverName: string): boolean {
294
+ const names: string[] = [];
295
+ if (certificate.common_name) names.push(certificate.common_name);
296
+ for (const san of certificate.sans ?? []) if (typeof san === 'string') names.push(san);
297
+ const target = serverName.toLowerCase();
298
+ for (const name of names) {
299
+ const candidate = name.toLowerCase();
300
+ if (candidate === target) return true;
301
+ if (candidate.startsWith('*.')) {
302
+ const suffix = candidate.slice(1);
303
+ if (target.endsWith(suffix)) {
304
+ const remainder = target.slice(0, -suffix.length);
305
+ if (remainder && !remainder.includes('.')) return true;
306
+ }
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /** The server stores `ip` rows normalized; this only refuses what cannot parse. */
313
+ export function validateBlocklistValue(kind: string, value: string): string | null {
314
+ if (!value) return 'Enter a value.';
315
+ if (kind === 'ip') {
316
+ // A first pass only. It must never refuse what the server accepts, so
317
+ // a dotted mask (10.0.0.0/255.0.0.0) and an IPv6 zone (fe80::1%eth0)
318
+ // pass here and the server has the last word.
319
+ const [address = '', prefix, ...rest] = value.split('/');
320
+ if (rest.length) return `${value} is not an IP address or CIDR network.`;
321
+ const dotted = (text: string) => { const quad = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text); return !!quad && quad.slice(1).every((octet) => Number(octet) <= 255); };
322
+ const isV4 = dotted(address);
323
+ const isV6 = !isV4 && address.includes(':') && /^[0-9A-Fa-f:.]{2,45}(%[^%]+)?$/.test(address);
324
+ if (!isV4 && !isV6) return `${value} is not an IP address or CIDR network.`;
325
+ if (prefix !== undefined && !(/^\d+$/.test(prefix) ? Number(prefix) <= (isV4 ? 32 : 128) : isV4 && dotted(prefix))) return `${value} is not an IP address or CIDR network.`;
326
+ return null;
327
+ }
328
+ if (!UA_PATTERN_RE.test(value)) return 'A user-agent pattern may use letters, digits and the regex characters ()[]|?^.*+-/_\\ only (max 256 characters, no spaces, quotes or braces).';
329
+ if ((value.length - value.replace(/\\+$/, '').length) % 2 === 1) return 'A user-agent pattern cannot end with an unescaped backslash.';
330
+ // Whether it compiles is the server's call: it compiles with Python, and
331
+ // JavaScript's RegExp disagrees in both directions (`(?i)bot`, `\\q`).
332
+ return null;
333
+ }
334
+
335
+ export interface VhostDraft {
336
+ kind: VhostKind;
337
+ domain?: number | null;
338
+ label: string;
339
+ certificate: number | null;
340
+ pool: string;
341
+ is_enabled: boolean;
342
+ upstream?: number | null;
343
+ serve_static?: boolean;
344
+ quiet_paths?: string[];
345
+ body_size_mb?: number;
346
+ spa?: boolean;
347
+ redirect_to?: string;
348
+ }
349
+
350
+ /**
351
+ * Kind-discriminated allowlist. A knob the chosen kind does not carry is sent
352
+ * as its empty value, so a draft that visited another shape cannot leak that
353
+ * shape's fields and an edit that changes kind clears what the old kind held.
354
+ */
355
+ export function buildVhostPayload(draft: VhostDraft, opts: { create: boolean }): Record<string, unknown> {
356
+ const kind = draft.kind;
357
+ const proxies = kind === 'api' || kind === 'site_api';
358
+ const payload: Record<string, unknown> = {
359
+ ...(opts.create ? { domain: draft.domain } : {}),
360
+ label: draft.label, kind, certificate: draft.certificate, pool: draft.pool, is_enabled: draft.is_enabled,
361
+ upstream: kind === 'api' ? draft.upstream ?? null : null,
362
+ redirect_to: kind === 'redirect' ? draft.redirect_to ?? '' : null,
363
+ spa: kind === 'site' || kind === 'site_api' ? draft.spa === true : false,
364
+ serve_static: proxies ? draft.serve_static === true : false,
365
+ quiet_paths: proxies ? [...(draft.quiet_paths ?? [])] : [],
366
+ };
367
+ // A redirect serves no body; the server default stands.
368
+ if (kind !== 'redirect' && draft.body_size_mb != null) payload.body_size_mb = draft.body_size_mb;
369
+ return payload;
370
+ }
371
+
372
+ export interface RouteDraft { path_prefix: string; upstream: number | null }
373
+
374
+ /** The gate both the knobs step and the review step apply to a site_api draft. */
375
+ export function validateRouteDrafts(rows: readonly RouteDraft[]): string | null {
376
+ if (!rows.length) return 'Add at least one route. A site with API paths needs a path prefix to proxy.';
377
+ const seen = new Set<string>();
378
+ for (const row of rows) {
379
+ const refusal = validateRoutePrefix(row.path_prefix);
380
+ if (refusal) return refusal;
381
+ if (row.upstream == null) return `Choose an upstream for ${row.path_prefix}.`;
382
+ if (seen.has(row.path_prefix)) return `${row.path_prefix} is listed twice.`;
383
+ seen.add(row.path_prefix);
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /** site_api only: a quiet path must sit under a declared route prefix. */
389
+ export function quietPathUncovered(paths: readonly string[], prefixes: readonly string[]): string | null {
390
+ return paths.find((path) => !prefixes.some((prefix) => path.startsWith(prefix))) ?? null;
391
+ }
392
+
393
+ export function upstreamTarget(row: Pick<EdgeUpstreamRow, 'kind' | 'host' | 'port' | 'socket_path'>): string {
394
+ return row.kind === 'unix' ? `unix:${row.socket_path ?? ''}` : `${row.host ?? ''}:${row.port ?? ''}`;
395
+ }
@@ -0,0 +1,13 @@
1
+ import { SECURITY_MANAGE_PERMS } from '../security-permissions';
2
+
3
+ // Imported by the security domain's section file, so this module stays free
4
+ // of the DNS models; the vhost clauses are re-exported from ./models.
5
+
6
+ /**
7
+ * `BlocklistEntry.RestMeta.VIEW_PERMS = ["view_security","manage_security","security"]`.
8
+ * Wider than `SECURITY_VIEW_PERMS`, which omits `manage_security`: manage does
9
+ * not imply view on the client, so the server's own clause is spelled out.
10
+ */
11
+ export const EDGE_BLOCKLIST_VIEW_PERMS = ['sys.view_security', 'sys.manage_security', 'sys.security'];
12
+ /** SAVE_PERMS and DELETE_PERMS are both `["manage_security","security"]`. */
13
+ export const EDGE_BLOCKLIST_MANAGE_PERMS = SECURITY_MANAGE_PERMS;
@@ -10,6 +10,9 @@
10
10
  // · disable/enable is a plain is_active save — reversible, hence
11
11
  // act-immediately + grace-window UNDO TOAST (no confirm)
12
12
  // · no DELETE (CAN_DELETE false server-side)
13
+ // · the list is NOT owner-scoped for a users/manage_users caller: without
14
+ // ?user= they are served every user's keys, so this page pins
15
+ // user=<me> on every list and export (#7190)
13
16
  // NOTE: the group-scoped /api/group/apikey surface 500s on the live dev
14
17
  // backend — this user-key surface is the one that works today.
15
18
  import { useQueryClient } from '@tanstack/react-query';
@@ -18,6 +21,7 @@ import {
18
21
  ArmedButton,
19
22
  type Column, type FilterDef,
20
23
  } from 'portal-mojo/ui';
24
+ import { useMe } from '../../../client/runtime';
21
25
  import { showSecretDialog } from '../../credentials';
22
26
  import { ApiKeyModel, useGenerateUserApiKey, type ApiKeyRow } from './models';
23
27
 
@@ -126,6 +130,7 @@ export function PersonalApiKeysPage() {
126
130
  const save = ApiKeyModel.useSave();
127
131
  const revoke = ApiKeyModel.useAction('revoke');
128
132
  const generateKey = useGenerateUserApiKey();
133
+ const { data: me } = useMe();
129
134
 
130
135
  const generate = async () => {
131
136
  const data = await formModal(ApiKeyModel.forms.generate!);
@@ -195,9 +200,14 @@ export function PersonalApiKeysPage() {
195
200
  ));
196
201
  };
197
202
 
203
+ // No table before the signed-in user is known: holding only the list
204
+ // would leave Export live, and that would go out unscoped.
205
+ if (!me) return <div className="detail-loading"><span className="skel skel-block" /></div>;
206
+
198
207
  return (
199
208
  <ModelTable<ApiKeyRow>
200
209
  model={ApiKeyModel}
210
+ fixedParams={{ user: me.id }}
201
211
  eyebrow="Account"
202
212
  title="API Keys"
203
213
  searchPlaceholder="Search key labels…"
@@ -364,9 +364,9 @@ export function useUserAdminActions(
364
364
  // One POST carries the action + the optional field, exactly like the
365
365
  // source (fields save before action handlers run server-side). Raw
366
366
  // mojoCall on purpose: mojoAction/useAction post `{action: payload}`
367
- // ONLY — they cannot carry the sibling plain field. An inside-the-200
368
- // refusal here would need readActionResult; today this route refuses
369
- // at the envelope level (status:false), which already rejects.
367
+ // ONLY — they cannot carry the sibling plain field. A raw mojoCall
368
+ // rejects a flat inside-the-200 refusal too (ActionRefusedError,
369
+ // #5922), and this route's envelope-level status:false already did.
370
370
  const body: Record<string, unknown> = { disable_totp: true };
371
371
  if (data.clear_requirement === true) body.requires_mfa = false;
372
372
  try {
@@ -17,6 +17,7 @@ export * from './jobs';
17
17
  export * from './network';
18
18
  export * from './dns';
19
19
  export * from './storage';
20
+ export * from './edge';
20
21
  export * from './shortlinks';
21
22
  export * from './messaging';
22
23
  export * from './phonehub';
@@ -42,10 +42,12 @@ export async function savePhoneConfigImperative(qc:QueryClient,id:number|null,sc
42
42
  const row=sanitizePhoneConfigRow(response.data as PhoneConfigRow);
43
43
  qc.setQueryData(PhoneConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneConfigModel.keys.root});return row;
44
44
  }
45
- // PhoneConfig returns `success` as its provider verdict; envelope `status`
46
- // only says the REST action ran. A failed test resolves for inline display.
45
+ // PhoneConfig returns `success` as its provider verdict, flat on the wire
46
+ // (the action dict verbatim). A failed test is an answer, not a refusal:
47
+ // `refusal:'return'` keeps unwrap from rejecting it, so it resolves for
48
+ // inline display.
47
49
  export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string;testMode:boolean;errorCode:string|null}>{
48
- const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1}});
50
+ const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1},refusal:'return'});
49
51
  const data=(response.data??response) as Record<string,unknown>;
50
52
  const status=data.success===true;
51
53
  return {status,testMode:data.test_mode===true,errorCode:typeof data.error==='string'?data.error:null,message:typeof data.message==='string'?data.message:status?'Connection verified.':'The server did not confirm the provider connection.'};
@@ -2,3 +2,4 @@ import '../dns/setup';
2
2
  export * from '../domains/infrastructure';
3
3
  export * from '../dns';
4
4
  export * from '../storage';
5
+ export * from '../edge';
@@ -7,7 +7,7 @@ import {
7
7
  SECURITY_OPERATIONS_ADMIN_SECTION, BOUNCER_ADMIN_SECTION,
8
8
  DEVICE_INTEL_ADMIN_SECTION, GEOIP_ADMIN_SECTION, NETWORK_SECURITY_ADMIN_SECTION,
9
9
  } from './domains/security';
10
- import { DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION } from './domains/infrastructure';
10
+ import { DNS_ADMIN_SECTION, EDGE_ADMIN_SECTION, STORAGE_ADMIN_SECTION } from './domains/infrastructure';
11
11
  import {
12
12
  SHORTLINKS_ADMIN_SECTION, EMAIL_ADMIN_SECTION, PUBLIC_MESSAGES_ADMIN_SECTION,
13
13
  PUSH_ADMIN_SECTION, PHONE_HUB_ADMIN_SECTION,
@@ -35,6 +35,7 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
35
35
  NETWORK_SECURITY_ADMIN_SECTION,
36
36
  DNS_ADMIN_SECTION,
37
37
  STORAGE_ADMIN_SECTION,
38
+ EDGE_ADMIN_SECTION,
38
39
  SHORTLINKS_ADMIN_SECTION,
39
40
  EMAIL_ADMIN_SECTION,
40
41
  PUBLIC_MESSAGES_ADMIN_SECTION,