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
@@ -1,4 +1,5 @@
1
1
  import { fleetMock } from './fleet-mock';
2
+ import { describeRenditions, validateRenditionSetting } from './renditions-mock';
2
3
  // In-memory django-mojo mock. Speaks the EXACT wire contract the real client
3
4
  // uses — envelope {status, data|rows..., message}, start/size paging, sort
4
5
  // with '-' prefix, search, and Django-style lookups (field, field__in,
@@ -679,6 +680,8 @@ function buildSettings(): MockSetting[] {
679
680
  // PostgreSQL unique_together permits repeated NULL group values. Keep
680
681
  // that live quirk executable instead of enforcing a stricter mock.
681
682
  { id: 404, created: now - 5 * 86400, modified: now - 5 * 86400, key: 'SITE_NAME', value: 'NativeMojo fallback', is_secret: false, group: null, secretValue: null },
683
+ // #7729: one rendition override so Media rendering shows a changed field beside greyed defaults on first load.
684
+ { id: 405, created: now - 3 * 86400, modified: now - 3 * 86400, key: 'FILEMAN_RENDITIONS_VIDEO', value: JSON.stringify({ video_hevc: { crf: 24 } }), is_secret: false, group: null, secretValue: null },
682
685
  ];
683
686
  }
684
687
 
@@ -741,6 +744,109 @@ function buildAcmeDelegations(): MockAcmeDelegation[] {
741
744
  ];
742
745
  }
743
746
 
747
+ // ── Edge — vhosts, routes, upstreams, fleet blocklist ────────────────
748
+ // Sources: mojo/apps/edge/models/{vhost,route,upstream,blocklist}.py,
749
+ // mojo/apps/edge/rest/, mojo/apps/edge/validators.py.
750
+ interface MockEdgeUpstream {
751
+ id: number;
752
+ created: number;
753
+ modified: number;
754
+ group: number | null;
755
+ name: string;
756
+ kind: string;
757
+ host: string | null;
758
+ port: number | null;
759
+ socket_path: string | null;
760
+ is_enabled: boolean;
761
+ [field: string]: unknown;
762
+ }
763
+
764
+ interface MockEdgeVhost {
765
+ id: number;
766
+ created: number;
767
+ modified: number;
768
+ domain: number;
769
+ label: string;
770
+ kind: string;
771
+ upstream: number | null;
772
+ certificate: number | null;
773
+ pool: string;
774
+ spa: boolean;
775
+ body_size_mb: number;
776
+ quiet_paths: string[];
777
+ serve_static: boolean;
778
+ mojosec_policy: Record<string, unknown>;
779
+ redirect_to: string | null;
780
+ is_enabled: boolean;
781
+ [field: string]: unknown;
782
+ }
783
+
784
+ interface MockEdgeRoute {
785
+ id: number;
786
+ created: number;
787
+ modified: number;
788
+ vhost: number;
789
+ path_prefix: string;
790
+ upstream: number;
791
+ [field: string]: unknown;
792
+ }
793
+
794
+ interface MockEdgeBlocklistEntry {
795
+ id: number;
796
+ created: number;
797
+ modified: number;
798
+ kind: string;
799
+ value: string;
800
+ mode: string;
801
+ note: string;
802
+ [field: string]: unknown;
803
+ }
804
+
805
+ function buildEdgeUpstreams(): MockEdgeUpstream[] {
806
+ const now = Math.floor(Date.now() / 1000);
807
+ return [
808
+ { id: 8601, created: now - 300 * 86400, modified: now - 30 * 86400, group: null, name: 'mojo-api', kind: 'http', host: '127.0.0.1', port: 8001, socket_path: null, is_enabled: true },
809
+ { id: 8602, created: now - 300 * 86400, modified: now - 30 * 86400, group: null, name: 'mojo-asgi', kind: 'unix', host: null, port: null, socket_path: '/run/mojo/asgi.sock', is_enabled: true },
810
+ { id: 8603, created: now - 90 * 86400, modified: now - 5 * 86400, group: 1, name: 'acme-backend', kind: 'http', host: '10.0.4.12', port: 8080, socket_path: null, is_enabled: true },
811
+ // Retired: rows that reach it stop being served; the select filters it out.
812
+ { id: 8604, created: now - 400 * 86400, modified: now - 60 * 86400, group: null, name: 'legacy-api', kind: 'http', host: '10.0.9.9', port: 9000, socket_path: null, is_enabled: false },
813
+ { id: 8605, created: now - 50 * 86400, modified: now - 50 * 86400, group: 2, name: 'globex-backend', kind: 'http', host: '10.0.7.20', port: 8080, socket_path: null, is_enabled: true },
814
+ ];
815
+ }
816
+
817
+ function buildEdgeVhosts(): MockEdgeVhost[] {
818
+ const now = Math.floor(Date.now() / 1000);
819
+ const base = { pool: 'default', spa: false, body_size_mb: 50, quiet_paths: [] as string[], serve_static: false, mojosec_policy: {}, redirect_to: null, upstream: null };
820
+ return [
821
+ { ...base, id: 8701, created: now - 80 * 86400, modified: now - 3 * 86400, domain: 8201, label: 'api', kind: 'api', upstream: 8603, certificate: 8401, quiet_paths: ['/health'], serve_static: true, is_enabled: true },
822
+ { ...base, id: 8702, created: now - 70 * 86400, modified: now - 9 * 86400, domain: 8201, label: 'www', kind: 'site', certificate: 8401, spa: true, is_enabled: true },
823
+ { ...base, id: 8703, created: now - 40 * 86400, modified: now - 2 * 86400, domain: 8201, label: 'app', kind: 'site_api', certificate: 8401, spa: true, quiet_paths: ['/api/health'], body_size_mb: 200, mojosec_policy: { version: 1, impossible_path_families: ['php_runtime', 'wordpress'], response_class: 'site_api' }, is_enabled: true },
824
+ { ...base, id: 8704, created: now - 70 * 86400, modified: now - 70 * 86400, domain: 8201, label: '', kind: 'redirect', certificate: 8401, redirect_to: 'www.acme.example', is_enabled: true },
825
+ // A disabled duplicate of 8702's name: a staged replacement.
826
+ { ...base, id: 8705, created: now - 86400, modified: now - 3600, domain: 8201, label: 'www', kind: 'site', certificate: 8401, is_enabled: false },
827
+ // House row: platform property, hidden from non-superuser lists.
828
+ { ...base, id: 8706, created: now - 200 * 86400, modified: now - 20 * 86400, domain: 8209, label: 'portal', kind: 'api', upstream: 8601, certificate: 8408, is_enabled: true },
829
+ ];
830
+ }
831
+
832
+ function buildEdgeRoutes(): MockEdgeRoute[] {
833
+ const now = Math.floor(Date.now() / 1000);
834
+ return [
835
+ { id: 8801, created: now - 40 * 86400, modified: now - 40 * 86400, vhost: 8703, path_prefix: '/api', upstream: 8603 },
836
+ { id: 8802, created: now - 40 * 86400, modified: now - 2 * 86400, vhost: 8703, path_prefix: '/ws', upstream: 8602 },
837
+ ];
838
+ }
839
+
840
+ function buildEdgeBlocklist(): MockEdgeBlocklistEntry[] {
841
+ const now = Math.floor(Date.now() / 1000);
842
+ return [
843
+ { id: 8901, created: now - 30 * 86400, modified: now - 12 * 86400, kind: 'ip', value: '203.0.113.0/24', mode: 'enforce', note: 'Credential stuffing, March' },
844
+ { id: 8902, created: now - 2 * 86400, modified: now - 2 * 86400, kind: 'ua', value: 'sqlmap', mode: 'log', note: 'Watching before enforcing' },
845
+ { id: 8903, created: now - 20 * 86400, modified: now - 20 * 86400, kind: 'ip', value: '198.51.100.7/32', mode: 'allow', note: 'Partner monitor, exempt' },
846
+ { id: 8904, created: now - 60 * 86400, modified: now - 10 * 86400, kind: 'ua', value: 'curl/7\\.', mode: 'off', note: 'Parked: broke a customer integration' },
847
+ ];
848
+ }
849
+
744
850
  function buildDnsRecords(): Map<number, MockDnsRecord[]> {
745
851
  return new Map([
746
852
  [8201, [
@@ -4237,6 +4343,10 @@ const db = {
4237
4343
  dnsCertificates: buildDnsCertificates(),
4238
4344
  acmeDelegations: buildAcmeDelegations(),
4239
4345
  dnsRecords: buildDnsRecords(),
4346
+ edgeUpstreams: buildEdgeUpstreams(),
4347
+ edgeVhosts: buildEdgeVhosts(),
4348
+ edgeRoutes: buildEdgeRoutes(),
4349
+ edgeBlocklist: buildEdgeBlocklist(),
4240
4350
  metricPermissions: new Map<string, { view_permissions: string | string[] | null; write_permissions: string | string[] | null }>([
4241
4351
  ['global', { view_permissions: 'view_metrics', write_permissions: ['write_metrics', 'metrics'] }],
4242
4352
  ['group-1', { view_permissions: ['view_metrics', 'metrics'], write_permissions: null }],
@@ -4530,10 +4640,10 @@ function runUserAction(user: MockUser, action: string, value: unknown, caller?:
4530
4640
  if (!user.is_active) {
4531
4641
  // The INSIDE-THE-200 refusal shape (flat): the handler's dict
4532
4642
  // reaches the wire verbatim with `success:false` — no
4533
- // envelope-level `status:false`, so unwrap passes it through
4534
- // and client/action-result normalizes it into
4535
- // ActionRefusedError. This is the contract's executable
4536
- // refusal fixture; keep it flat.
4643
+ // envelope-level `status:false`. unwrap rejects it with
4644
+ // ActionRefusedError (#5922); mojoAction/useAction opt out
4645
+ // and read it themselves to name the action. This is the
4646
+ // contract's executable refusal fixture; keep it flat.
4537
4647
  return { success: false, code: 'ALREADY_DISABLED', error: 'User is already disabled' };
4538
4648
  }
4539
4649
  if (!USER_DISABLE_REASONS.has(reason)) {
@@ -5045,9 +5155,10 @@ function userSaveRefusal(user: MockUser, body: Record<string, unknown>, caller:
5045
5155
  return null;
5046
5156
  }
5047
5157
 
5048
- function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUser): unknown {
5158
+ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUser, callerAuthTime?: number): unknown {
5049
5159
  const refusal = userSaveRefusal(user, body, caller);
5050
5160
  if (refusal) return refusal;
5161
+ let passwordChanged = false;
5051
5162
  const fields: Record<string, unknown> = {};
5052
5163
  const actionEntries: [string, unknown][] = [];
5053
5164
  for (const [key, value] of Object.entries(body)) {
@@ -5062,6 +5173,7 @@ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUs
5062
5173
  if (pw.length < 8) {
5063
5174
  return { status: false, error: 'Password is too weak. Use a longer password or include a mix of uppercase, lowercase, numbers, and special characters', error_code: 400 };
5064
5175
  }
5176
+ passwordChanged = true;
5065
5177
  passwordlessUsers.delete(user.id);
5066
5178
  }
5067
5179
  // set_org parity: the FK arrives as an id (or null to clear), serializes
@@ -5126,7 +5238,16 @@ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUs
5126
5238
  actionResp = resp;
5127
5239
  }
5128
5240
  }
5129
- return actionResp ?? { status: true, data: serializeUser(user, 'default') };
5241
+ if (actionResp) return actionResp;
5242
+ const resp: Record<string, unknown> = { status: true, data: serializeUser(user, 'default') };
5243
+ // on_rest_save_and_respond parity: a changed password ends every other
5244
+ // session, and the caller's OWN change answers with a replacement pair
5245
+ // beside `data`, keeping the session's auth_time. An admin setting someone
5246
+ // else's password gets none.
5247
+ if (passwordChanged && caller && caller.id === user.id) {
5248
+ resp.tokens = tokenPair(user, ACCESS_TTL, callerAuthTime);
5249
+ }
5250
+ return resp;
5130
5251
  }
5131
5252
 
5132
5253
  // ── Group save + POST_SAVE_ACTIONS ────────────────────────────────────
@@ -6458,6 +6579,12 @@ function userFromBearer(headers: Record<string, string> | undefined): MockUser |
6458
6579
  return db.users.find((u) => u.id === Number(payload.uid));
6459
6580
  }
6460
6581
 
6582
+ /** The bearer's auth_time claim, when it carries one. */
6583
+ function authTimeFromBearer(headers: Record<string, string> | undefined): number | undefined {
6584
+ const payload = decodeMockJwt((headers?.['Authorization'] ?? '').replace(/^Bearer /, ''));
6585
+ return payload && typeof payload.auth_time === 'number' ? payload.auth_time : undefined;
6586
+ }
6587
+
6461
6588
  function hasGlobalPermission(user: MockUser | undefined, permissions: string[]): boolean {
6462
6589
  if (!user) return false;
6463
6590
  if (user.is_superuser) return true;
@@ -6642,12 +6769,22 @@ function storageFetch(path: string, opts: MockFetchOpts): Record<string, unknown
6642
6769
  && !path.startsWith('/api/fileman/manager')
6643
6770
  && !path.startsWith('/api/fileman/file')
6644
6771
  && path !== '/api/fileman/upload/initiate'
6772
+ && path !== '/api/fileman/renditions/options'
6645
6773
  && !path.startsWith('/api/shortlink/link')
6646
6774
  && !path.startsWith('/api/shortlink/history')) return undefined;
6647
6775
  const caller = userFromBearer(opts.headers);
6648
6776
  if (!caller) return permissionDenied(401);
6649
6777
  const method = (opts.method ?? 'GET').toUpperCase();
6650
6778
 
6779
+ // #7729 — the rendition descriptor (django-mojo fileman #7728). Read-only;
6780
+ // writes go through /api/settings like any other Setting row.
6781
+ if (path === '/api/fileman/renditions/options') {
6782
+ if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
6783
+ if (!hasGlobalPermission(caller, ['manage_settings', 'manage_files', 'files', 'groups'])) return permissionDenied();
6784
+ const globalRows = db.settings.filter((row) => row.group == null && !row.is_secret).map((row) => ({ key: row.key, value: row.value }));
6785
+ return { status: true, data: describeRenditions(globalRows) };
6786
+ }
6787
+
6651
6788
  if (path === '/api/fileman/upload/initiate') {
6652
6789
  if (method !== 'POST') return { status: false, error: 'Method not allowed', error_code: 405 };
6653
6790
  const body = opts.body ?? {};
@@ -7075,6 +7212,800 @@ function permissionDenied(code = 403): Record<string, unknown> {
7075
7212
  return { status: false, error: 'permission denied', error_code: code };
7076
7213
  }
7077
7214
 
7215
+ // ══ Edge — wire implementation (mojo/apps/edge) ═════════════════════
7216
+
7217
+ /** `BlocklistEntry.RestMeta.VIEW_PERMS`; SAVE and DELETE share the manage clause. */
7218
+ const EDGE_BLOCKLIST_VIEW_GRANTS = ['view_security', 'manage_security', 'security'];
7219
+ const EDGE_BLOCKLIST_MANAGE_GRANTS = ['manage_security', 'security'];
7220
+ /** `settings.EDGE_POOLS` default. */
7221
+ const EDGE_DECLARED_POOLS = ['default'];
7222
+ const EDGE_SOCKET_BASE = '/run/mojo';
7223
+ const EDGE_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
7224
+ const EDGE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
7225
+ const EDGE_UPSTREAM_HOST_RE = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
7226
+ const EDGE_PATH_RE = /^\/[A-Za-z0-9._/-]{0,127}$/;
7227
+ const EDGE_UA_RE = /^[A-Za-z0-9()[\]|?^.*+\\/_-]{1,256}$/;
7228
+ const EDGE_MOJOSEC_FAMILIES = ['admin_tools', 'php_runtime', 'secret_files', 'wordpress'];
7229
+ const EDGE_MOJOSEC_CLASSES = ['reverse_proxy', 'spa_fallback', 'static_site', 'site_api', 'redirect'];
7230
+ const EDGE_VHOST_KINDS = ['api', 'site', 'site_api', 'redirect'];
7231
+
7232
+ function edgeRefuse(error: string, code = 400): Record<string, unknown> {
7233
+ return { status: false, error, error_code: code };
7234
+ }
7235
+
7236
+ function edgePlatformOnly(what: string): Record<string, unknown> {
7237
+ return edgeRefuse(`${what} is restricted to platform administrators`, 403);
7238
+ }
7239
+
7240
+ function edgeServerName(domainName: string, label: string): string {
7241
+ return label === '' ? domainName : `${label}.${domainName}`;
7242
+ }
7243
+
7244
+ function edgeServerNameError(name: string): string | null {
7245
+ if (!name) return 'a vhost requires a server name';
7246
+ if (name.length > 253) return 'server name is too long';
7247
+ const parts = (name.startsWith('*.') ? name.slice(2) : name).split('.');
7248
+ if (parts.length < 2) return `${name} is not a fully qualified domain name`;
7249
+ return parts.every((part) => EDGE_LABEL_RE.test(part)) ? null : `${name} is not a valid server name`;
7250
+ }
7251
+
7252
+ function edgeRequestPathError(path: unknown, what: string): string | null {
7253
+ if (typeof path !== 'string' || !EDGE_PATH_RE.test(path)) return `${what} must start with '/' and use only letters, digits, '.', '_', '-' and '/' (max 128 characters)`;
7254
+ if (path.includes('//')) return `${what} may not contain '//'`;
7255
+ if (path.split('/').some((part) => part === '..')) return `${what} may not contain a '..' segment`;
7256
+ return null;
7257
+ }
7258
+
7259
+ function edgeCertificateCovers(certificate: MockDnsCertificate, serverName: string): boolean {
7260
+ const target = serverName.toLowerCase();
7261
+ for (const name of [certificate.common_name, ...certificate.sans]) {
7262
+ if (typeof name !== 'string' || !name) continue;
7263
+ const candidate = name.toLowerCase();
7264
+ if (candidate === target) return true;
7265
+ if (candidate.startsWith('*.')) {
7266
+ const suffix = candidate.slice(1);
7267
+ if (target.endsWith(suffix)) {
7268
+ const remainder = target.slice(0, -suffix.length);
7269
+ if (remainder && !remainder.includes('.')) return true;
7270
+ }
7271
+ }
7272
+ }
7273
+ return false;
7274
+ }
7275
+
7276
+ function edgeMojosecPolicy(value: unknown): { policy: Record<string, unknown> } | { error: string } {
7277
+ if (value == null || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0)) return { policy: {} };
7278
+ if (typeof value !== 'object' || Array.isArray(value)) return { error: 'mojosec_policy must be an object' };
7279
+ const raw = value as Record<string, unknown>;
7280
+ const keys = Object.keys(raw).sort().join(',');
7281
+ if (keys !== 'impossible_path_families,response_class,version') return { error: 'mojosec_policy requires version, impossible_path_families, and response_class only' };
7282
+ if (typeof raw.version !== 'number' || !Number.isInteger(raw.version) || raw.version < 1 || raw.version > 65535) return { error: 'mojosec_policy version must be 1-65535' };
7283
+ const families = raw.impossible_path_families;
7284
+ if (!Array.isArray(families) || families.length > 4 || families.some((family) => !EDGE_MOJOSEC_FAMILIES.includes(family as string)) || new Set(families).size !== families.length) {
7285
+ return { error: 'mojosec_policy impossible_path_families contains an unknown or duplicate family' };
7286
+ }
7287
+ if (!EDGE_MOJOSEC_CLASSES.includes(raw.response_class as string)) return { error: 'mojosec_policy response_class is not registered' };
7288
+ return { policy: { version: raw.version, impossible_path_families: [...families].sort(), response_class: raw.response_class } };
7289
+ }
7290
+
7291
+ /** `validators.validate_vhost`, in the server's order and with its messages. */
7292
+ function edgeValidateVhost(row: MockEdgeVhost, persisted: boolean): string | null {
7293
+ const label = row.label ?? '';
7294
+ if (label !== '' && label !== '*' && (typeof label !== 'string' || !EDGE_LABEL_RE.test(label))) {
7295
+ return "label must be empty (apex), '*', or a single DNS label of letters, digits and hyphens";
7296
+ }
7297
+ if (!row.pool || !EDGE_NAME_RE.test(row.pool)) return "pool must be lowercase letters, digits, '-' or '_'";
7298
+ if (!EDGE_DECLARED_POOLS.includes(row.pool)) return `${row.pool} is not a declared pool (${[...EDGE_DECLARED_POOLS].sort().join(', ')})`;
7299
+ if (typeof row.body_size_mb !== 'number' || !Number.isInteger(row.body_size_mb)) return 'body_size_mb must be an integer';
7300
+ if (row.body_size_mb < 1 || row.body_size_mb > 4096) return 'body_size_mb must be between 1 and 4096';
7301
+ const policy = edgeMojosecPolicy(row.mojosec_policy);
7302
+ if ('error' in policy) return policy.error;
7303
+ row.mojosec_policy = policy.policy;
7304
+ if (!EDGE_VHOST_KINDS.includes(row.kind)) return `unknown vhost kind '${row.kind}'`;
7305
+ if (Object.keys(row.mojosec_policy).length) {
7306
+ const expected = row.kind === 'api' ? 'reverse_proxy' : row.kind === 'site' ? (row.spa ? 'spa_fallback' : 'static_site') : row.kind;
7307
+ if (row.mojosec_policy.response_class !== expected) return `mojosec_policy response_class for ${row.kind} must be ${expected}`;
7308
+ }
7309
+ if (row.kind === 'api') {
7310
+ // An id that names no upstream cannot be stored; the wording for that case is this mock's own.
7311
+ if (row.upstream == null || !db.edgeUpstreams.some((candidate) => candidate.id === row.upstream)) return 'an api vhost requires an upstream';
7312
+ } else if (row.upstream != null) return `a ${row.kind} vhost has no whole-host upstream (site_api proxies per-route)`;
7313
+ if (row.kind === 'redirect') {
7314
+ if (typeof row.redirect_to !== 'string' || !row.redirect_to) return 'a redirect vhost requires redirect_to';
7315
+ if (row.redirect_to.startsWith('*.')) return 'a redirect target cannot be a wildcard';
7316
+ const refusal = edgeServerNameError(row.redirect_to);
7317
+ if (refusal) return refusal;
7318
+ } else if (row.redirect_to) return `a ${row.kind} vhost has no redirect target`;
7319
+ if (row.spa && !['site', 'site_api'].includes(row.kind)) return `spa applies to site and site_api vhosts, not ${row.kind}`;
7320
+ if (row.serve_static && !['api', 'site_api'].includes(row.kind)) return `serve_static applies to api and site_api vhosts, not ${row.kind}`;
7321
+ const quietPaths = row.quiet_paths ?? [];
7322
+ if (!Array.isArray(quietPaths)) return 'quiet_paths must be a list of paths';
7323
+ if (quietPaths.length && !['api', 'site_api'].includes(row.kind)) return `quiet_paths applies to api and site_api vhosts, not ${row.kind}`;
7324
+ for (const path of quietPaths) {
7325
+ const refusal = edgeRequestPathError(path, 'a quiet path');
7326
+ if (refusal) return refusal;
7327
+ }
7328
+ if (new Set(quietPaths).size !== quietPaths.length) return 'quiet_paths contains a duplicate';
7329
+ const routes = persisted ? db.edgeRoutes.filter((route) => route.vhost === row.id) : [];
7330
+ if (row.kind === 'site_api') {
7331
+ // Prefixes load only for a stored row, so a CREATE carrying quiet
7332
+ // paths always reports "none declared".
7333
+ const prefixes = routes.map((route) => route.path_prefix);
7334
+ for (const path of quietPaths) {
7335
+ if (!prefixes.some((prefix) => path.startsWith(prefix))) {
7336
+ return `quiet path ${path} is not under any route prefix (${[...prefixes].sort().join(', ') || 'none declared'})`;
7337
+ }
7338
+ }
7339
+ } else if (routes.length) {
7340
+ return `a ${row.kind} vhost cannot carry routes — delete them before changing kind`;
7341
+ }
7342
+ const domain = db.dnsDomains.find((candidate) => candidate.id === row.domain);
7343
+ if (!domain) return 'a vhost requires a domain';
7344
+ const serverName = edgeServerName(domain.name, label);
7345
+ const nameRefusal = edgeServerNameError(serverName);
7346
+ if (nameRefusal) return nameRefusal;
7347
+ // The column is NOT NULL even for a disabled row, on create and on update.
7348
+ if (row.certificate == null || !db.dnsCertificates.some((candidate) => candidate.id === row.certificate)) return 'a vhost requires a certificate';
7349
+ if (row.is_enabled) {
7350
+ const certificate = db.dnsCertificates.find((candidate) => candidate.id === row.certificate);
7351
+ if (!certificate) return 'a vhost requires a certificate';
7352
+ if (certificate.domain !== row.domain) return "the certificate must belong to this vhost's domain";
7353
+ if (!edgeCertificateCovers(certificate, serverName)) return `certificate ${certificate.common_name} does not cover ${serverName}`;
7354
+ // `edge_vhost_unique_enabled_server_name`.
7355
+ if (db.edgeVhosts.some((other) => other.id !== row.id && other.is_enabled && other.domain === row.domain && other.label === label)) {
7356
+ return `an enabled vhost already serves ${serverName}`;
7357
+ }
7358
+ }
7359
+ return null;
7360
+ }
7361
+
7362
+ function edgeNumberOrNull(value: unknown): number | null {
7363
+ if (value == null || value === '') return null;
7364
+ const id = typeof value === 'object' ? Number((value as Record<string, unknown>).id) : Number(value);
7365
+ return Number.isFinite(id) ? id : null;
7366
+ }
7367
+
7368
+ /** Copies the writable fields from a request body; `alias_of` and ids are pinned. */
7369
+ function edgeApplyVhostBody(row: MockEdgeVhost, body: Record<string, unknown>): void {
7370
+ if ('label' in body) row.label = body.label == null ? '' : String(body.label);
7371
+ if ('kind' in body) row.kind = String(body.kind);
7372
+ if ('upstream' in body) row.upstream = edgeNumberOrNull(body.upstream);
7373
+ if ('certificate' in body) row.certificate = edgeNumberOrNull(body.certificate);
7374
+ if ('pool' in body) row.pool = String(body.pool ?? '');
7375
+ if ('spa' in body) row.spa = body.spa === true;
7376
+ if ('body_size_mb' in body) row.body_size_mb = body.body_size_mb as number;
7377
+ if ('quiet_paths' in body) row.quiet_paths = (body.quiet_paths ?? []) as string[];
7378
+ if ('serve_static' in body) row.serve_static = body.serve_static === true;
7379
+ if ('mojosec_policy' in body) row.mojosec_policy = (body.mojosec_policy ?? {}) as Record<string, unknown>;
7380
+ if ('redirect_to' in body) row.redirect_to = body.redirect_to == null || body.redirect_to === '' ? null : String(body.redirect_to);
7381
+ if ('is_enabled' in body) row.is_enabled = body.is_enabled === true;
7382
+ }
7383
+
7384
+ function serializeEdgeUpstream(row: MockEdgeUpstream, graph = 'default'): Record<string, unknown> {
7385
+ if (graph === 'basic') return { id: row.id, name: row.name, kind: row.kind };
7386
+ const group = row.group == null ? null : db.groups.find((candidate) => candidate.id === row.group);
7387
+ return {
7388
+ id: row.id, created: row.created, modified: row.modified, name: row.name, kind: row.kind,
7389
+ host: row.host, port: row.port, socket_path: row.socket_path, is_enabled: row.is_enabled,
7390
+ group: group ? groupBasic(group) : null,
7391
+ };
7392
+ }
7393
+
7394
+ function serializeEdgeVhost(row: MockEdgeVhost, graph = 'default'): Record<string, unknown> {
7395
+ const domain = db.dnsDomains.find((candidate) => candidate.id === row.domain);
7396
+ const serverName = domain ? edgeServerName(domain.name, row.label) : null;
7397
+ if (graph === 'basic') return { id: row.id, kind: row.kind, is_enabled: row.is_enabled, server_name: serverName };
7398
+ // `domain: basic` carries no group: house-ness is not readable off a vhost.
7399
+ const domainBasic = domain ? { id: domain.id, name: domain.name, provider: domain.provider, status: domain.status, expires: domain.expires } : null;
7400
+ if (graph === 'list') {
7401
+ return { id: row.id, created: row.created, kind: row.kind, pool: row.pool, is_enabled: row.is_enabled, server_name: serverName, domain: domainBasic };
7402
+ }
7403
+ const upstream = row.upstream == null ? null : db.edgeUpstreams.find((candidate) => candidate.id === row.upstream);
7404
+ const certificate = row.certificate == null ? null : db.dnsCertificates.find((candidate) => candidate.id === row.certificate);
7405
+ return {
7406
+ id: row.id, created: row.created, modified: row.modified, label: row.label, kind: row.kind,
7407
+ pool: row.pool, spa: row.spa, body_size_mb: row.body_size_mb, quiet_paths: [...row.quiet_paths],
7408
+ serve_static: row.serve_static, mojosec_policy: { ...row.mojosec_policy }, redirect_to: row.redirect_to,
7409
+ is_enabled: row.is_enabled, server_name: serverName, domain: domainBasic,
7410
+ upstream: upstream ? serializeEdgeUpstream(upstream, 'basic') : null,
7411
+ certificate: certificate ? { id: certificate.id, common_name: certificate.common_name, status: certificate.status, not_after: certificate.not_after } : null,
7412
+ };
7413
+ }
7414
+
7415
+ function serializeEdgeRoute(row: MockEdgeRoute): Record<string, unknown> {
7416
+ const vhost = db.edgeVhosts.find((candidate) => candidate.id === row.vhost);
7417
+ const upstream = db.edgeUpstreams.find((candidate) => candidate.id === row.upstream);
7418
+ return {
7419
+ id: row.id, created: row.created, modified: row.modified, path_prefix: row.path_prefix,
7420
+ vhost: vhost ? serializeEdgeVhost(vhost, 'basic') : null,
7421
+ upstream: upstream ? serializeEdgeUpstream(upstream, 'basic') : null,
7422
+ };
7423
+ }
7424
+
7425
+ function serializeEdgeBlocklistEntry(row: MockEdgeBlocklistEntry): Record<string, unknown> {
7426
+ return { id: row.id, created: row.created, modified: row.modified, kind: row.kind, value: row.value, mode: row.mode, note: row.note };
7427
+ }
7428
+
7429
+ /** `ipaddress.IPv4Address`: four decimal octets, none written with a leading zero. */
7430
+ function edgeIpv4Strict(text: string): number | null {
7431
+ const octets = text.split('.');
7432
+ if (octets.length !== 4 || octets.some((octet) => !/^[0-9]{1,3}$/.test(octet) || (octet.length > 1 && octet.startsWith('0')) || Number(octet) > 255)) return null;
7433
+ return octets.reduce((value, octet) => value * 256 + Number(octet), 0);
7434
+ }
7435
+
7436
+ function edgeParseIpv6(text: string): number[] | null {
7437
+ if (!/^[0-9A-Fa-f:.]+$/.test(text) || !text.includes(':')) return null;
7438
+ let source = text;
7439
+ const tail = source.slice(source.lastIndexOf(':') + 1);
7440
+ if (tail.includes('.')) {
7441
+ const v4 = edgeIpv4Strict(tail);
7442
+ if (v4 == null) return null;
7443
+ source = `${source.slice(0, source.lastIndexOf(':') + 1)}${Math.floor(v4 / 65536).toString(16)}:${(v4 % 65536).toString(16)}`;
7444
+ }
7445
+ const halves = source.split('::');
7446
+ if (halves.length > 2) return null;
7447
+ const parse = (part: string): number[] | null => {
7448
+ if (part === '') return [];
7449
+ const groups = part.split(':');
7450
+ if (groups.some((group) => !/^[0-9A-Fa-f]{1,4}$/.test(group))) return null;
7451
+ return groups.map((group) => parseInt(group, 16));
7452
+ };
7453
+ const head = parse(halves[0]!);
7454
+ const rest = halves.length === 2 ? parse(halves[1]!) : [];
7455
+ if (!head || !rest) return null;
7456
+ if (halves.length === 1) return head.length === 8 ? head : null;
7457
+ if (head.length + rest.length > 7) return null;
7458
+ return [...head, ...new Array<number>(8 - head.length - rest.length).fill(0), ...rest];
7459
+ }
7460
+
7461
+ function edgeFormatIpv6(groups: number[]): string {
7462
+ let bestStart = -1;
7463
+ let bestLength = 0;
7464
+ for (let index = 0; index < 8;) {
7465
+ if (groups[index] !== 0) { index += 1; continue; }
7466
+ let end = index;
7467
+ while (end < 8 && groups[end] === 0) end += 1;
7468
+ if (end - index > bestLength) { bestStart = index; bestLength = end - index; }
7469
+ index = end;
7470
+ }
7471
+ const hex = groups.map((group) => group.toString(16));
7472
+ if (bestLength < 2) return hex.join(':');
7473
+ return `${hex.slice(0, bestStart).join(':')}::${hex.slice(bestStart + bestLength).join(':')}`;
7474
+ }
7475
+
7476
+ /** `str(ipaddress.ip_network(value, strict=False))`, or null when it cannot parse. */
7477
+ function edgeNormalizeIpNetwork(value: string): string | null {
7478
+ const pieces = value.split('/');
7479
+ if (pieces.length > 2) return null;
7480
+ const [address = '', prefixRaw] = pieces;
7481
+ const numeric = edgeIpv4Strict(address);
7482
+ if (numeric != null) {
7483
+ let prefix = 32;
7484
+ if (prefixRaw !== undefined) {
7485
+ if (/^[0-9]+$/.test(prefixRaw)) prefix = Number(prefixRaw);
7486
+ else {
7487
+ // A netmask (255.255.0.0) or, failing that, a hostmask (0.0.255.255).
7488
+ const mask = edgeIpv4Strict(prefixRaw);
7489
+ if (mask == null) return null;
7490
+ const ones = (bits: number) => { const zeros = Math.log2(2 ** 32 - bits); return Number.isInteger(zeros) ? 32 - zeros : null; };
7491
+ const asNetmask = ones(mask);
7492
+ const asHostmask = ones(2 ** 32 - 1 - mask);
7493
+ if (asNetmask != null) prefix = asNetmask;
7494
+ else if (asHostmask != null) prefix = asHostmask;
7495
+ else return null;
7496
+ }
7497
+ }
7498
+ if (prefix > 32) return null;
7499
+ const size = 2 ** (32 - prefix);
7500
+ const network = Math.floor(numeric / size) * size;
7501
+ return `${[24, 16, 8, 0].map((shift) => Math.floor(network / 2 ** shift) % 256).join('.')}/${prefix}`;
7502
+ }
7503
+ // An IPv6 zone (`fe80::1%eth0`) survives only when no host bits are masked off.
7504
+ const zoneAt = address.indexOf('%');
7505
+ const zone = zoneAt < 0 ? null : address.slice(zoneAt + 1);
7506
+ if (zone != null && (!zone || zone.includes('%'))) return null;
7507
+ const groups = edgeParseIpv6(zoneAt < 0 ? address : address.slice(0, zoneAt));
7508
+ if (prefixRaw !== undefined && !/^[0-9]+$/.test(prefixRaw)) return null;
7509
+ const prefix = prefixRaw === undefined ? 128 : Number(prefixRaw);
7510
+ if (!groups || prefix > 128) return null;
7511
+ const masked = groups.map((group, index) => {
7512
+ const keep = Math.max(0, Math.min(16, prefix - index * 16));
7513
+ return keep === 16 ? group : group - (group % 2 ** (16 - keep));
7514
+ });
7515
+ const kept = zone != null && masked.every((group, index) => group === groups[index]);
7516
+ return `${edgeFormatIpv6(masked)}${kept ? `%${zone}` : ''}/${prefix}`;
7517
+ }
7518
+
7519
+ /**
7520
+ * Whether Python's `re.compile` accepts a pattern drawn from the user-agent
7521
+ * alphabet, which is what the server runs. JavaScript's RegExp disagrees both
7522
+ * ways (`\q`, `[]`, `a*+`, `(?i)a`), so this follows Python's parser instead.
7523
+ * Returns null when it compiles; the reason wording is this mock's own.
7524
+ */
7525
+ function edgePythonRegexError(pattern: string): string | null {
7526
+ const OCT = '01234567';
7527
+ const isHex = (text: string) => /^[0-9a-fA-F]+$/.test(text);
7528
+ const isLetter = (char: string) => /^[A-Za-z]$/.test(char);
7529
+ const isDigit = (char: string) => char >= '0' && char <= '9';
7530
+ let at = 0;
7531
+ let groups = 1;
7532
+ const closed = new Set<number>();
7533
+ const conditionRefs: number[] = [];
7534
+ let flags = '';
7535
+ class Refusal extends Error {}
7536
+ const fail = (reason: string): never => { throw new Refusal(`${reason} at position ${at}`); };
7537
+ const take = (count: number, allowed: (char: string) => boolean) => {
7538
+ let taken = '';
7539
+ while (taken.length < count && at < pattern.length && allowed(pattern[at]!)) taken += pattern[at++];
7540
+ return taken;
7541
+ };
7542
+ /** After a backslash. Returns a code point, 'category', 'at' or 'atom'. */
7543
+ const escape = (inClass: boolean): number | 'category' | 'at' | 'atom' => {
7544
+ const char = pattern[at++];
7545
+ if (char === undefined) return fail('bad escape (end of pattern)');
7546
+ if ('dDsSwW'.includes(char)) return 'category';
7547
+ if (char === 'x' || char === 'u' || char === 'U') {
7548
+ const want = char === 'x' ? 2 : char === 'u' ? 4 : 8;
7549
+ const digits = take(want, isHex);
7550
+ if (digits.length !== want) return fail(`incomplete escape \\${char}${digits}`);
7551
+ const value = parseInt(digits, 16);
7552
+ if (value > 0x10ffff) return fail(`bad escape \\${char}${digits}`);
7553
+ return value;
7554
+ }
7555
+ if (char === 'N') return fail('missing {');
7556
+ if (inClass) {
7557
+ if (char === 'b') return 8;
7558
+ if (OCT.includes(char)) {
7559
+ const value = parseInt(char + take(2, (next) => OCT.includes(next)), 8);
7560
+ if (value > 0o377) return fail('octal escape value outside of range 0-0o377');
7561
+ return value;
7562
+ }
7563
+ if (isDigit(char)) return fail(`bad escape \\${char}`);
7564
+ } else {
7565
+ if ('AbBZ'.includes(char)) return 'at';
7566
+ if (char === '0') return parseInt(`0${take(2, (next) => OCT.includes(next))}`, 8);
7567
+ if (isDigit(char)) {
7568
+ let digits = char;
7569
+ if (at < pattern.length && isDigit(pattern[at]!)) {
7570
+ digits += pattern[at++];
7571
+ if (OCT.includes(digits[0]!) && OCT.includes(digits[1]!) && at < pattern.length && OCT.includes(pattern[at]!)) {
7572
+ digits += pattern[at++];
7573
+ const value = parseInt(digits, 8);
7574
+ if (value > 0o377) return fail('octal escape value outside of range 0-0o377');
7575
+ return value;
7576
+ }
7577
+ }
7578
+ const group = Number(digits);
7579
+ if (group >= groups) return fail(`invalid group reference ${group}`);
7580
+ if (!closed.has(group)) return fail('cannot refer to an open group');
7581
+ return 'atom';
7582
+ }
7583
+ }
7584
+ const control = { a: 7, f: 12, n: 10, r: 13, t: 9, v: 11 }[char];
7585
+ if (control !== undefined) return control;
7586
+ if (isLetter(char)) return fail(`bad escape \\${char}`);
7587
+ return char.charCodeAt(0);
7588
+ };
7589
+ const charClass = () => {
7590
+ if (pattern[at] === '^') at += 1;
7591
+ let members = 0;
7592
+ for (;;) {
7593
+ const char = pattern[at++];
7594
+ if (char === undefined) return fail('unterminated character set');
7595
+ if (char === ']' && members > 0) return;
7596
+ const low = char === '\\' ? escape(true) : char.charCodeAt(0);
7597
+ members += 1;
7598
+ if (pattern[at] !== '-') continue;
7599
+ at += 1;
7600
+ const next = pattern[at++];
7601
+ if (next === undefined) return fail('unterminated character set');
7602
+ if (next === ']') return;
7603
+ const high = next === '\\' ? escape(true) : next.charCodeAt(0);
7604
+ if (typeof low !== 'number' || typeof high !== 'number' || high < low) return fail('bad character range');
7605
+ }
7606
+ };
7607
+ const sequence = (first: boolean): void => {
7608
+ // 'none' (nothing yet), 'at' (an anchor), 'repeat' or 'atom'.
7609
+ let last: 'none' | 'at' | 'repeat' | 'atom' = 'none';
7610
+ while (at < pattern.length) {
7611
+ const char = pattern[at]!;
7612
+ if (char === '|' || char === ')') return;
7613
+ at += 1;
7614
+ if (char === '\\') {
7615
+ const kind = escape(false);
7616
+ last = kind === 'at' ? 'at' : 'atom';
7617
+ } else if (char === '[') {
7618
+ charClass();
7619
+ last = 'atom';
7620
+ } else if (char === '*' || char === '+' || char === '?') {
7621
+ if (last === 'none' || last === 'at') return fail('nothing to repeat');
7622
+ if (last === 'repeat') return fail('multiple repeat');
7623
+ if (pattern[at] === '?' || pattern[at] === '+') at += 1;
7624
+ last = 'repeat';
7625
+ } else if (char === '^') {
7626
+ last = 'at';
7627
+ } else if (char === '(') {
7628
+ let group: number | null = null;
7629
+ if (pattern[at] === '?') {
7630
+ at += 1;
7631
+ const next = pattern[at++];
7632
+ if (next === undefined) return fail('unexpected end of pattern');
7633
+ if (next === '(') {
7634
+ const end = pattern.indexOf(')', at);
7635
+ if (end < 0) return fail('missing ), unterminated name');
7636
+ const name = pattern.slice(at, end);
7637
+ at = end + 1;
7638
+ if (!name) return fail('missing group name');
7639
+ if (!/^[0-9]+$/.test(name)) return fail(/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? `unknown group name '${name}'` : `bad character in group name '${name}'`);
7640
+ const reference = Number(name);
7641
+ if (!reference) return fail('bad group number');
7642
+ conditionRefs.push(reference);
7643
+ sequence(false);
7644
+ if (pattern[at] === '|') {
7645
+ at += 1;
7646
+ sequence(false);
7647
+ if (pattern[at] === '|') return fail('conditional backref with more than two branches');
7648
+ }
7649
+ if (pattern[at] !== ')') return fail('missing ), unterminated subpattern');
7650
+ at += 1;
7651
+ last = 'atom';
7652
+ continue;
7653
+ }
7654
+ if (!'aiLmsux'.includes(next)) return fail(`unknown extension ?${next}`);
7655
+ let seen = next;
7656
+ while (at < pattern.length && 'aiLmsux'.includes(pattern[at]!)) seen += pattern[at++];
7657
+ if (pattern[at] !== ')') return fail(at < pattern.length && isLetter(pattern[at]!) ? 'unknown flag' : 'missing -, : or )');
7658
+ at += 1;
7659
+ if (seen.includes('L')) return fail("bad inline flags: cannot use 'L' flag with a str pattern");
7660
+ if ((flags + seen).includes('a') && (flags + seen).includes('u')) return fail("bad inline flags: flags 'a', 'u' and 'L' are incompatible");
7661
+ if (!first || last !== 'none') return fail('global flags not at the start of the expression');
7662
+ flags += seen;
7663
+ continue;
7664
+ }
7665
+ group = groups;
7666
+ groups += 1;
7667
+ alternation(false);
7668
+ if (pattern[at] !== ')') return fail('missing ), unterminated subpattern');
7669
+ at += 1;
7670
+ closed.add(group);
7671
+ last = 'atom';
7672
+ } else {
7673
+ last = 'atom';
7674
+ }
7675
+ }
7676
+ };
7677
+ const alternation = (top: boolean): void => {
7678
+ let branch = 0;
7679
+ for (;;) {
7680
+ sequence(top && branch === 0);
7681
+ if (pattern[at] !== '|') return;
7682
+ at += 1;
7683
+ branch += 1;
7684
+ }
7685
+ };
7686
+ try {
7687
+ alternation(true);
7688
+ if (at < pattern.length) fail('unbalanced parenthesis');
7689
+ for (const reference of conditionRefs) if (reference >= groups) fail(`invalid group reference ${reference}`);
7690
+ return null;
7691
+ } catch (error) {
7692
+ if (error instanceof Refusal) return error.message;
7693
+ throw error;
7694
+ }
7695
+ }
7696
+
7697
+ /** `validators.validate_blocklist_entry`; normalizes an `ip` value in place. */
7698
+ function edgeValidateBlocklistEntry(entry: MockEdgeBlocklistEntry): string | null {
7699
+ if (!['ip', 'ua'].includes(entry.kind)) return `unknown blocklist kind '${entry.kind}'`;
7700
+ if (!['allow', 'off', 'log', 'enforce'].includes(entry.mode)) return `unknown blocklist mode '${entry.mode}'`;
7701
+ if (typeof entry.value !== 'string' || !entry.value) return 'a blocklist entry requires a value';
7702
+ if (entry.kind === 'ip') {
7703
+ const network = edgeNormalizeIpNetwork(entry.value);
7704
+ if (network == null) return `'${entry.value}' is not an IP address or CIDR network`;
7705
+ entry.value = network;
7706
+ } else {
7707
+ if (!EDGE_UA_RE.test(entry.value)) return 'a user-agent pattern may use letters, digits and the regex characters ()[]|?^.*+-/_\\ only (max 256 characters, no spaces, quotes or braces)';
7708
+ if ((entry.value.length - entry.value.replace(/\\+$/, '').length) % 2 === 1) return 'a user-agent pattern cannot end with an unescaped backslash';
7709
+ const reason = edgePythonRegexError(entry.value);
7710
+ if (reason) return `user-agent pattern does not compile: ${reason}`;
7711
+ }
7712
+ // `edge_blocklist_kind_value_uniq`.
7713
+ if (db.edgeBlocklist.some((other) => other.id !== entry.id && other.kind === entry.kind && other.value === entry.value)) {
7714
+ return `a ${entry.kind} blocklist entry for ${entry.value} already exists`;
7715
+ }
7716
+ return null;
7717
+ }
7718
+
7719
+ /** `validators.validate_upstream`. */
7720
+ function edgeValidateUpstream(row: MockEdgeUpstream): string | null {
7721
+ if (!row.name || !EDGE_NAME_RE.test(row.name)) return "upstream name must be lowercase letters, digits, '-' or '_'";
7722
+ if (row.kind === 'http') {
7723
+ if (row.socket_path) return 'an http upstream has no socket path';
7724
+ const host = row.host ?? '';
7725
+ if (!EDGE_UPSTREAM_HOST_RE.test(host)) return 'upstream host must be a hostname or IPv4 address (letters, digits, dots and hyphens only)';
7726
+ if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(host)) return 'link-local addresses cannot be an upstream';
7727
+ if (typeof row.port !== 'number' || !Number.isInteger(row.port)) return 'upstream port must be an integer';
7728
+ if (row.port < 1 || row.port > 65535) return 'upstream port must be between 1 and 65535';
7729
+ } else if (row.kind === 'unix') {
7730
+ if (row.host || row.port) return 'a unix upstream has no host or port';
7731
+ const path = row.socket_path ?? '';
7732
+ if (!path) return 'a unix upstream requires a socket path';
7733
+ if (/[\n\r;\0]/.test(path)) return 'socket path contains an illegal character';
7734
+ const resolved: string[] = [];
7735
+ for (const part of path.split('/')) {
7736
+ if (part === '' || part === '.') continue;
7737
+ if (part === '..') resolved.pop(); else resolved.push(part);
7738
+ }
7739
+ const real = `/${resolved.join('/')}`;
7740
+ if (!path.startsWith('/') || !(real === EDGE_SOCKET_BASE || real.startsWith(`${EDGE_SOCKET_BASE}/`))) return `socket path must resolve under ${EDGE_SOCKET_BASE}`;
7741
+ } else return `unknown upstream kind '${row.kind}'`;
7742
+ // `edge_upstream_group_name_uniq` / `edge_upstream_house_name_uniq`.
7743
+ if (db.edgeUpstreams.some((other) => other.id !== row.id && other.group === row.group && other.name === row.name)) {
7744
+ return `an upstream named ${row.name} already exists in this scope`;
7745
+ }
7746
+ return null;
7747
+ }
7748
+
7749
+ function edgeNextId(rows: readonly { id: number }[], floor: number): number {
7750
+ return Math.max(floor, ...rows.map((row) => row.id)) + 1;
7751
+ }
7752
+
7753
+ function edgeFetch(path: string, opts: MockFetchOpts): Record<string, unknown> | undefined {
7754
+ if (!path.startsWith('/api/edge/')) return undefined;
7755
+ const method = (opts.method ?? 'GET').toUpperCase();
7756
+ const caller = userFromBearer(opts.headers);
7757
+ const now = Math.floor(Date.now() / 1000);
7758
+ const body = opts.body ?? {};
7759
+ const domainGroup = (domainId: number): number | null | undefined => {
7760
+ const domain = db.dnsDomains.find((candidate) => candidate.id === domainId);
7761
+ return domain ? domain.group : undefined;
7762
+ };
7763
+ /** The model permission for a row whose tenancy resolves to `group`. */
7764
+ const scopedCan = (group: number | null | undefined, manage: boolean): boolean => (group == null
7765
+ ? hasGlobalPermission(caller, manage ? DNS_MANAGE_GRANTS : DNS_VIEW_GRANTS)
7766
+ : dnsMemberCan(caller, group, manage));
7767
+
7768
+ // ── Upstream declare / retire — platform administrators only ──
7769
+ if (path === '/api/edge/upstream/declare' || path === '/api/edge/upstream/retire') {
7770
+ if (!caller) return permissionDenied(401);
7771
+ if (method !== 'POST') return edgeRefuse('Method not allowed', 405);
7772
+ const declaring = path.endsWith('/declare');
7773
+ // The gate runs first: a caller who may not act learns that before
7774
+ // the payload is parsed.
7775
+ if (!caller.is_superuser) return edgePlatformOnly(declaring ? 'Declaring an edge upstream' : 'Retiring an edge upstream');
7776
+ if (!declaring) {
7777
+ if (body.upstream == null || body.upstream === '') return edgeRefuse('missing required parameter: upstream');
7778
+ const row = db.edgeUpstreams.find((candidate) => candidate.id === Number(body.upstream));
7779
+ if (!row) return edgeRefuse('Upstream not found', 404);
7780
+ row.is_enabled = false;
7781
+ row.modified = now;
7782
+ return { status: true, data: serializeEdgeUpstream(row), graph: 'default' };
7783
+ }
7784
+ for (const required of ['name', 'kind']) {
7785
+ if (body[required] == null || body[required] === '') return edgeRefuse(`missing required parameter: ${required}`);
7786
+ }
7787
+ if (body.kind !== 'http' && body.kind !== 'unix') return edgeRefuse("kind must be 'http' or 'unix'");
7788
+ let group: number | null = null;
7789
+ if (body.group) {
7790
+ if (!db.groups.some((candidate) => candidate.id === Number(body.group))) return edgeRefuse('Group not found', 404);
7791
+ group = Number(body.group);
7792
+ }
7793
+ let port: number | null = null;
7794
+ if (body.port != null) {
7795
+ port = Number(body.port);
7796
+ if (!Number.isInteger(port) || body.port === '' || typeof body.port === 'boolean') return edgeRefuse('port must be an integer');
7797
+ }
7798
+ const row: MockEdgeUpstream = {
7799
+ id: edgeNextId(db.edgeUpstreams, 8600), created: now, modified: now, group,
7800
+ name: String(body.name), kind: body.kind,
7801
+ host: body.host == null ? null : String(body.host), port,
7802
+ socket_path: body.socket_path == null ? null : String(body.socket_path), is_enabled: true,
7803
+ };
7804
+ const refusal = edgeValidateUpstream(row);
7805
+ if (refusal) return edgeRefuse(refusal);
7806
+ db.edgeUpstreams.push(row);
7807
+ return { status: true, data: serializeEdgeUpstream(row), graph: 'default' };
7808
+ }
7809
+
7810
+ // ── Upstreams — read, and `is_enabled` only on write ──
7811
+ const upstreamMatch = path.match(/^\/api\/edge\/upstream(?:\/(\d+))?$/);
7812
+ if (upstreamMatch) {
7813
+ if (!caller) return permissionDenied(401);
7814
+ if (upstreamMatch[1]) {
7815
+ const row = db.edgeUpstreams.find((candidate) => candidate.id === Number(upstreamMatch[1]));
7816
+ if (!row) return edgeRefuse('Upstream not found', 404);
7817
+ if (method === 'DELETE') return edgeRefuse('Upstream deletion is not allowed', 403);
7818
+ if (!scopedCan(row.group, method !== 'GET')) return permissionDenied();
7819
+ if (method === 'POST') {
7820
+ // NO_SAVE_FIELDS pins everything that decides where traffic goes.
7821
+ if ('is_enabled' in body) { row.is_enabled = body.is_enabled === true; row.modified = now; }
7822
+ }
7823
+ return { status: true, data: serializeEdgeUpstream(row), graph: 'default' };
7824
+ }
7825
+ if (method !== 'GET') return edgeRefuse('Upstream creation is not allowed', 403);
7826
+ if (!dnsCollectionCan(caller, opts)) return permissionDenied();
7827
+ const groupId = requestGroupId(opts);
7828
+ // An active group sees its own rows plus the shared house rows.
7829
+ const rows = groupId > 0 ? db.edgeUpstreams.filter((row) => row.group === groupId || row.group == null) : db.edgeUpstreams;
7830
+ const { group: _group, ...params } = opts.params ?? {};
7831
+ const result = listRows(rows as unknown as Record<string, unknown>[], params, (row) => `${row.name} ${row.kind}`, 'name');
7832
+ return { ...result, graph: 'default', data: (result.data as unknown as MockEdgeUpstream[]).map((row) => serializeEdgeUpstream(row)) };
7833
+ }
7834
+
7835
+ // ── Vhosts ──
7836
+ const vhostMatch = path.match(/^\/api\/edge\/vhost(?:\/(\d+))?$/);
7837
+ if (vhostMatch) {
7838
+ if (!caller) return permissionDenied(401);
7839
+ const graph = String(opts.params?.graph ?? (vhostMatch[1] ? 'default' : 'list'));
7840
+ if (vhostMatch[1]) {
7841
+ const row = db.edgeVhosts.find((candidate) => candidate.id === Number(vhostMatch[1]));
7842
+ if (!row) return edgeRefuse('Vhost not found', 404);
7843
+ const group = domainGroup(row.domain);
7844
+ // Model permission first, then the house guard: no status-code
7845
+ // oracle over the platform's own serving inventory.
7846
+ if (!scopedCan(group, false)) return permissionDenied();
7847
+ if (group == null && !caller.is_superuser) return edgePlatformOnly('House vhosts');
7848
+ if (method !== 'GET' && !scopedCan(group, true)) return permissionDenied();
7849
+ if (method === 'DELETE') {
7850
+ db.edgeVhosts = db.edgeVhosts.filter((candidate) => candidate.id !== row.id);
7851
+ db.edgeRoutes = db.edgeRoutes.filter((route) => route.vhost !== row.id);
7852
+ return { status: 'deleted' };
7853
+ }
7854
+ if (method === 'POST') {
7855
+ if ('domain' in body && edgeNumberOrNull(body.domain) !== row.domain) return edgeRefuse('a vhost cannot be moved to another domain');
7856
+ const draft: MockEdgeVhost = { ...row, quiet_paths: [...row.quiet_paths], mojosec_policy: { ...row.mojosec_policy } };
7857
+ edgeApplyVhostBody(draft, body);
7858
+ const refusal = edgeValidateVhost(draft, true);
7859
+ if (refusal) return edgeRefuse(refusal);
7860
+ Object.assign(row, draft, { modified: now });
7861
+ }
7862
+ return { status: true, data: serializeEdgeVhost(row, graph), graph };
7863
+ }
7864
+ if (method === 'DELETE') return edgeRefuse('DELETE not allowed on the collection', 403);
7865
+ if (method === 'POST') {
7866
+ const domainId = edgeNumberOrNull(body.domain);
7867
+ const group = domainId == null ? undefined : domainGroup(domainId);
7868
+ if (domainId != null && group === null && !caller.is_superuser) return edgePlatformOnly('Creating a vhost on a house domain');
7869
+ if (!scopedCan(group, true)) return permissionDenied();
7870
+ if (domainId == null || group === undefined) return edgeRefuse('a vhost requires a domain');
7871
+ const draft: MockEdgeVhost = {
7872
+ id: edgeNextId(db.edgeVhosts, 8700), created: now, modified: now, domain: domainId,
7873
+ label: '', kind: 'site', upstream: null, certificate: null, pool: 'default', spa: false,
7874
+ body_size_mb: 50, quiet_paths: [], serve_static: false, mojosec_policy: {}, redirect_to: null, is_enabled: true,
7875
+ };
7876
+ edgeApplyVhostBody(draft, body);
7877
+ const refusal = edgeValidateVhost(draft, false);
7878
+ if (refusal) return edgeRefuse(refusal);
7879
+ db.edgeVhosts.push(draft);
7880
+ return { status: true, data: serializeEdgeVhost(draft, 'default'), graph: 'default' };
7881
+ }
7882
+ if (!dnsCollectionCan(caller, opts)) return permissionDenied();
7883
+ const groupId = requestGroupId(opts);
7884
+ const { group: _group, domain__group: domainGroupFilter, ...params } = opts.params ?? {};
7885
+ let rows = groupId > 0 ? db.edgeVhosts.filter((row) => domainGroup(row.domain) === groupId) : db.edgeVhosts;
7886
+ // House rows never reach a non-superuser list, global grant or not.
7887
+ if (!caller.is_superuser) rows = rows.filter((row) => domainGroup(row.domain) != null);
7888
+ if (domainGroupFilter != null && domainGroupFilter !== '') rows = rows.filter((row) => domainGroup(row.domain) === Number(domainGroupFilter));
7889
+ const result = listRows(rows as unknown as Record<string, unknown>[], params, (row) => `${row.label} ${row.kind} ${row.pool}`, 'label');
7890
+ return { ...result, graph, data: (result.data as unknown as MockEdgeVhost[]).map((row) => serializeEdgeVhost(row, graph)) };
7891
+ }
7892
+
7893
+ // ── Routes — site_api proxied prefixes ──
7894
+ const routeMatch = path.match(/^\/api\/edge\/route(?:\/(\d+))?$/);
7895
+ if (routeMatch) {
7896
+ if (!caller) return permissionDenied(401);
7897
+ const vhostGroup = (vhostId: number): number | null | undefined => {
7898
+ const vhost = db.edgeVhosts.find((candidate) => candidate.id === vhostId);
7899
+ return vhost ? domainGroup(vhost.domain) : undefined;
7900
+ };
7901
+ const validate = (route: MockEdgeRoute): string | null => {
7902
+ const prefixRefusal = edgeRequestPathError(route.path_prefix, 'a route prefix');
7903
+ if (prefixRefusal) return prefixRefusal;
7904
+ if (route.path_prefix === '/') return "a route prefix cannot be '/' — use kind=api for a whole-host proxy";
7905
+ const vhost = db.edgeVhosts.find((candidate) => candidate.id === route.vhost);
7906
+ if (!vhost) return 'a route requires a vhost';
7907
+ if (vhost.kind !== 'site_api') return `routes belong to site_api vhosts, not ${vhost.kind}`;
7908
+ const upstream = db.edgeUpstreams.find((candidate) => candidate.id === route.upstream);
7909
+ if (!upstream) return 'a route requires an upstream';
7910
+ if (upstream.group != null && upstream.group !== domainGroup(vhost.domain)) return "the upstream must be a shared one or belong to this vhost's group";
7911
+ // `edge_route_vhost_prefix_uniq`.
7912
+ if (db.edgeRoutes.some((other) => other.id !== route.id && other.vhost === route.vhost && other.path_prefix === route.path_prefix)) {
7913
+ return `this vhost already has a route for ${route.path_prefix}`;
7914
+ }
7915
+ return null;
7916
+ };
7917
+ if (routeMatch[1]) {
7918
+ const row = db.edgeRoutes.find((candidate) => candidate.id === Number(routeMatch[1]));
7919
+ if (!row) return edgeRefuse('VhostRoute not found', 404);
7920
+ const group = vhostGroup(row.vhost);
7921
+ if (!scopedCan(group, false)) return permissionDenied();
7922
+ if (group == null && !caller.is_superuser) return edgePlatformOnly('House vhost routes');
7923
+ if (method !== 'GET' && !scopedCan(group, true)) return permissionDenied();
7924
+ if (method === 'DELETE') {
7925
+ db.edgeRoutes = db.edgeRoutes.filter((candidate) => candidate.id !== row.id);
7926
+ return { status: 'deleted' };
7927
+ }
7928
+ if (method === 'POST') {
7929
+ const draft: MockEdgeRoute = { ...row };
7930
+ if ('path_prefix' in body) draft.path_prefix = String(body.path_prefix ?? '');
7931
+ if ('upstream' in body) draft.upstream = edgeNumberOrNull(body.upstream) ?? 0;
7932
+ if ('vhost' in body) draft.vhost = edgeNumberOrNull(body.vhost) ?? 0;
7933
+ const refusal = validate(draft);
7934
+ if (refusal) return edgeRefuse(refusal);
7935
+ Object.assign(row, draft, { modified: now });
7936
+ }
7937
+ return { status: true, data: serializeEdgeRoute(row), graph: 'default' };
7938
+ }
7939
+ if (method === 'DELETE') return edgeRefuse('DELETE not allowed on the collection', 403);
7940
+ if (method === 'POST') {
7941
+ const vhostId = edgeNumberOrNull(body.vhost);
7942
+ const group = vhostId == null ? undefined : vhostGroup(vhostId);
7943
+ if (vhostId != null && group === null && !caller.is_superuser) return edgePlatformOnly('Creating a route on a house vhost');
7944
+ if (!scopedCan(group, true)) return permissionDenied();
7945
+ const draft: MockEdgeRoute = {
7946
+ id: edgeNextId(db.edgeRoutes, 8800), created: now, modified: now, vhost: vhostId ?? 0,
7947
+ path_prefix: String(body.path_prefix ?? ''), upstream: edgeNumberOrNull(body.upstream) ?? 0,
7948
+ };
7949
+ const refusal = validate(draft);
7950
+ if (refusal) return edgeRefuse(refusal);
7951
+ db.edgeRoutes.push(draft);
7952
+ return { status: true, data: serializeEdgeRoute(draft), graph: 'default' };
7953
+ }
7954
+ if (!dnsCollectionCan(caller, opts)) return permissionDenied();
7955
+ const groupId = requestGroupId(opts);
7956
+ const { group: _group, ...params } = opts.params ?? {};
7957
+ let rows = groupId > 0 ? db.edgeRoutes.filter((row) => vhostGroup(row.vhost) === groupId) : db.edgeRoutes;
7958
+ if (!caller.is_superuser) rows = rows.filter((row) => vhostGroup(row.vhost) != null);
7959
+ const result = listRows(rows as unknown as Record<string, unknown>[], params, (row) => String(row.path_prefix), 'path_prefix');
7960
+ return { ...result, graph: 'default', data: (result.data as unknown as MockEdgeRoute[]).map(serializeEdgeRoute) };
7961
+ }
7962
+
7963
+ // ── Fleet blocklist — GLOBAL security grants only; `?group=` opens nothing ──
7964
+ const blocklistMatch = path.match(/^\/api\/edge\/blocklist(?:\/(\d+))?$/);
7965
+ if (blocklistMatch) {
7966
+ if (!caller) return permissionDenied(401);
7967
+ if (!hasGlobalPermission(caller, EDGE_BLOCKLIST_VIEW_GRANTS)) return permissionDenied();
7968
+ const canManage = hasGlobalPermission(caller, EDGE_BLOCKLIST_MANAGE_GRANTS);
7969
+ const apply = (entry: MockEdgeBlocklistEntry): string | null => {
7970
+ if ('kind' in body) entry.kind = String(body.kind);
7971
+ if ('value' in body) entry.value = body.value == null ? '' : String(body.value);
7972
+ if ('mode' in body) entry.mode = String(body.mode);
7973
+ if ('note' in body) entry.note = body.note == null ? '' : String(body.note);
7974
+ return edgeValidateBlocklistEntry(entry);
7975
+ };
7976
+ if (blocklistMatch[1]) {
7977
+ const row = db.edgeBlocklist.find((candidate) => candidate.id === Number(blocklistMatch[1]));
7978
+ if (!row) return edgeRefuse('BlocklistEntry not found', 404);
7979
+ if (method !== 'GET' && !canManage) return permissionDenied();
7980
+ if (method === 'DELETE') {
7981
+ db.edgeBlocklist = db.edgeBlocklist.filter((candidate) => candidate.id !== row.id);
7982
+ return { status: 'deleted' };
7983
+ }
7984
+ if (method === 'POST') {
7985
+ const draft: MockEdgeBlocklistEntry = { ...row };
7986
+ const refusal = apply(draft);
7987
+ if (refusal) return edgeRefuse(refusal);
7988
+ Object.assign(row, draft, { modified: now });
7989
+ }
7990
+ return { status: true, data: serializeEdgeBlocklistEntry(row), graph: 'default' };
7991
+ }
7992
+ if (method === 'DELETE') return edgeRefuse('DELETE not allowed on the collection', 403);
7993
+ if (method === 'POST') {
7994
+ if (!canManage) return permissionDenied();
7995
+ const draft: MockEdgeBlocklistEntry = { id: edgeNextId(db.edgeBlocklist, 8900), created: now, modified: now, kind: 'ip', value: '', mode: 'log', note: '' };
7996
+ const refusal = apply(draft);
7997
+ if (refusal) return edgeRefuse(refusal);
7998
+ db.edgeBlocklist.push(draft);
7999
+ return { status: true, data: serializeEdgeBlocklistEntry(draft), graph: 'default' };
8000
+ }
8001
+ const { group: _group, ...params } = opts.params ?? {};
8002
+ const result = listRows(db.edgeBlocklist as unknown as Record<string, unknown>[], params, (row) => `${row.value} ${row.note}`, 'kind');
8003
+ return { ...result, graph: 'default', data: (result.data as unknown as MockEdgeBlocklistEntry[]).map(serializeEdgeBlocklistEntry) };
8004
+ }
8005
+ return undefined;
8006
+ }
8007
+ // ══ end Edge wire ══════════════════════════════════════════════════
8008
+
7078
8009
  // ══ Jobs engine — wire implementation ════════════════════════════════
7079
8010
 
7080
8011
  /** `requires_global_perms('view_jobs','manage_jobs','jobs')`. */
@@ -8311,7 +9242,7 @@ async function phoneHubFetch(path:string,opts:MockFetchOpts):Promise<unknown|und
8311
9242
  const smsMatch=path.match(/^\/api\/phonehub\/sms(?:\/(\d+))?$/);
8312
9243
  if(smsMatch){const id=smsMatch[1]?Number(smsMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,SMS_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.sms=db.sms.filter(row=>row.id!==id);return {status:'deleted'};}if(method==='POST'&&!hasGlobalPermission(caller,SMS_SAVE_GRANTS))return permissionDenied();if(method==='GET'&&!hasGlobalPermission(caller,SMS_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.sms.find(item=>item.id===id);if(!row)return {status:false,error:'SMS not found',error_code:404};if(method==='POST'){for(const key of ['status','error_message','delivered_at'] as const)if(key in (opts.body??{}))(row as unknown as Record<string,unknown>)[key]=opts.body?.[key];row.modified=Math.floor(Date.now()/1000);}return {status:true,data:smsWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const now=Math.floor(Date.now()/1000);const row:MockSms={id:Math.max(0,...db.sms.map(item=>item.id))+1,created:now,modified:now,direction:String(body.direction??'outbound'),from_number:String(body.from_number??''),to_number:String(body.to_number??''),body:String(body.body??''),status:String(body.status??'queued'),provider:body.provider?String(body.provider):null,provider_message_id:null,error_code:null,error_message:null,metadata:{},is_test:Boolean(body.is_test),sent_at:null,delivered_at:null,user:body.user==null?null:Number(body.user),group:body.group==null?null:Number(body.group)};db.sms.unshift(row);return {status:true,data:smsWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['direction','status','provider','group','user'],['created','direction','status','provider','sent_at','delivered_at'],'default','-created',true);return messagingList(db.sms as unknown as Record<string,unknown>[],params,row=>`${row.from_number} ${row.to_number} ${row.body}`,'default',row=>smsWire(row as unknown as MockSms),'-created');}
8313
9244
  const configMatch=path.match(/^\/api\/phonehub\/config(?:\/(\d+))?$/);
8314
- if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){if(row.test_mode)return {status:true,data:{success:true,test_mode:true,message:'Config is in test mode - provider not tested'}};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return {status:true,data:ready?{success:true,message:`${row.provider} connection succeeded`}:{success:false,message:`${row.provider} credentials are incomplete`,error:'missing_credentials'}};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
9245
+ if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){/* Flat, as on the real wire: rest.py returns the action dict verbatim. */if(row.test_mode)return {success:true,test_mode:true,message:'Config is in test mode - provider not tested'};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return ready?{success:true,message:`${row.provider} connection succeeded`}:{success:false,message:`${row.provider} credentials are incomplete`,error:'missing_credentials'};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
8315
9246
  return undefined;
8316
9247
  }
8317
9248
  async function messagingFetch(path:string,opts:MockFetchOpts):Promise<unknown|undefined>{
@@ -8585,6 +9516,8 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
8585
9516
  if (storageResult !== undefined) return storageResult;
8586
9517
  const cloudWatchResult = cloudWatchFetch(path, opts);
8587
9518
  if (cloudWatchResult !== undefined) return cloudWatchResult;
9519
+ const edgeResult = edgeFetch(path, opts);
9520
+ if (edgeResult !== undefined) return edgeResult;
8588
9521
  const accountResult = accountSelfFetch(path, method, opts);
8589
9522
  if (accountResult !== undefined) return accountResult;
8590
9523
  if (path === '/api/auth/generate_api_key') {
@@ -9470,6 +10403,11 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9470
10403
  if (!row) return { status: false, error: 'Setting not found', error_code: 404 };
9471
10404
  if (opts.method === 'DELETE') return { status: false, error: 'DELETE not allowed: Setting', error_code: 403 };
9472
10405
  if (opts.method === 'POST' && opts.body) {
10406
+ if ('value' in opts.body && !row.is_secret) {
10407
+ // Registered validators run before the row mutates (Setting.on_rest_pre_save → config.validate).
10408
+ const refusal = validateRenditionSetting(typeof opts.body.key === 'string' ? opts.body.key : row.key, opts.body.value);
10409
+ if (refusal) return { status: false, error: refusal, error_code: 400 };
10410
+ }
9473
10411
  for (const [field, value] of Object.entries(opts.body)) {
9474
10412
  if (field === 'is_secret') row.is_secret = Boolean(value);
9475
10413
  else if (field === 'value') {
@@ -9494,6 +10432,10 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9494
10432
  else if (field === 'group') row.group = value == null || value === '' ? null : Number(value);
9495
10433
  }
9496
10434
  if (!row.key) return { status: false, error: 'key is required', error_code: 400 };
10435
+ if (!row.is_secret) {
10436
+ const refusal = validateRenditionSetting(row.key, row.value);
10437
+ if (refusal) return { status: false, error: refusal, error_code: 400 };
10438
+ }
9497
10439
  if (row.group != null && db.settings.some((candidate) => candidate.group === row.group && candidate.key === row.key)) return { status: false, error: 'Setting with this Key and Group already exists.', error_code: 400 };
9498
10440
  db.settings.push(row);
9499
10441
  return { status: true, data: serializeSetting(row), graph: 'default' };
@@ -10320,7 +11262,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10320
11262
  const body = opts.body ?? {};
10321
11263
  if (Object.keys(body).some((key) => USER_ACTIONS.has(key))
10322
11264
  && !hasGlobalPermission(user, ['users', 'manage_users'])) return permissionDenied();
10323
- return saveUser(user, body, user);
11265
+ return saveUser(user, body, user, authTimeFromBearer(opts.headers));
10324
11266
  }
10325
11267
  return { status: true, data: meDict(user) };
10326
11268
  }
@@ -10661,16 +11603,18 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10661
11603
  if (path === '/api/account/api_keys') {
10662
11604
  const caller = userFromBearer(opts.headers);
10663
11605
  if (!caller) return permissionDenied(401);
11606
+ // Server parity (rest.py on_rest_list): a users/manage_users caller
11607
+ // gets every user's rows unless ?user= narrows them; anyone else gets
11608
+ // their own, and ?user=<other> is ANDed on — an empty 200, not a 403.
10664
11609
  const canManageOthers = hasGlobalPermission(caller, ['users', 'manage_users']);
10665
- const requestedUser = opts.params?.user == null ? null : Number(opts.params.user);
10666
- if (requestedUser != null && requestedUser !== caller.id && !canManageOthers) return permissionDenied();
10667
- const params: Params = { ...(opts.params ?? {}), user: requestedUser ?? caller.id };
11610
+ const visible = canManageOthers ? db.apiKeys : db.apiKeys.filter((k) => k.user === caller.id);
11611
+ const params: Params = { ...(opts.params ?? {}) };
10668
11612
  const search = (k: Record<string, unknown>) => String(k.label ?? '');
10669
11613
  if (params.download_format) {
10670
- const full = listRows(db.apiKeys as unknown as Record<string, unknown>[], { ...params, start: 0, size: db.apiKeys.length }, search, '-id');
11614
+ const full = listRows(visible as unknown as Record<string, unknown>[], { ...params, start: 0, size: db.apiKeys.length }, search, '-id');
10671
11615
  return exportRows((full.data as unknown as MockApiKey[]).map(serializeApiKey), params, 'UserAPIKey');
10672
11616
  }
10673
- const result = listRows(db.apiKeys as unknown as Record<string, unknown>[], params, search, '-id');
11617
+ const result = listRows(visible as unknown as Record<string, unknown>[], params, search, '-id');
10674
11618
  return { ...result, data: (result.data as unknown as MockApiKey[]).map(serializeApiKey) };
10675
11619
  }
10676
11620
  // ══ Jobs engine — /api/jobs/* (mojo/apps/jobs) ═══════════════════
@@ -10751,7 +11695,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10751
11695
  if (caller.id !== id && !hasGlobalPermission(caller, ['users', 'manage_users'])) return permissionDenied();
10752
11696
  if (Object.keys(opts.body).some((key) => USER_ACTIONS.has(key))
10753
11697
  && !hasGlobalPermission(caller, ['users', 'manage_users'])) return permissionDenied();
10754
- return saveUser(user, opts.body, userFromBearer(opts.headers));
11698
+ return saveUser(user, opts.body, userFromBearer(opts.headers), authTimeFromBearer(opts.headers));
10755
11699
  }
10756
11700
  return { status: true, data: serializeUser(user, 'default'), graph: 'default' };
10757
11701
  }