@harperfast/harper 5.2.0-beta.4 → 5.2.0

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 (169) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/harper.ts +25 -6
  4. package/components/Scope.ts +52 -6
  5. package/components/componentLoader.ts +107 -9
  6. package/components/mcp/toolRegistry.ts +10 -0
  7. package/components/mcp/tools/application.ts +12 -5
  8. package/components/mcp/tools/operations.ts +3 -0
  9. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  10. package/components/mcp/tools/schemas/operations.ts +9 -0
  11. package/components/operations.js +1 -0
  12. package/components/operationsValidation.js +32 -2
  13. package/components/scopeMount.ts +150 -0
  14. package/config-root.schema.json +4 -0
  15. package/dataLayer/backupManifest.ts +102 -0
  16. package/dataLayer/blobBackup.ts +286 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +43 -0
  18. package/dataLayer/restoreMarker.ts +276 -0
  19. package/dataLayer/rocksdbBackup.ts +1100 -0
  20. package/dist/bin/backup.d.ts +9 -0
  21. package/dist/bin/backup.js +192 -0
  22. package/dist/bin/backup.js.map +1 -0
  23. package/dist/bin/cliOperations.d.ts +13 -0
  24. package/dist/bin/cliOperations.js +89 -70
  25. package/dist/bin/cliOperations.js.map +1 -1
  26. package/dist/bin/harper.d.ts +7 -0
  27. package/dist/bin/harper.js +27 -6
  28. package/dist/bin/harper.js.map +1 -1
  29. package/dist/components/Scope.d.ts +37 -1
  30. package/dist/components/Scope.js +48 -6
  31. package/dist/components/Scope.js.map +1 -1
  32. package/dist/components/componentLoader.d.ts +2 -6
  33. package/dist/components/componentLoader.js +91 -8
  34. package/dist/components/componentLoader.js.map +1 -1
  35. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  36. package/dist/components/mcp/toolRegistry.js +10 -0
  37. package/dist/components/mcp/toolRegistry.js.map +1 -1
  38. package/dist/components/mcp/tools/application.js +11 -5
  39. package/dist/components/mcp/tools/application.js.map +1 -1
  40. package/dist/components/mcp/tools/operations.js +3 -0
  41. package/dist/components/mcp/tools/operations.js.map +1 -1
  42. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  43. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  44. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  45. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  46. package/dist/components/operations.js +2 -0
  47. package/dist/components/operations.js.map +1 -1
  48. package/dist/components/operationsValidation.js +34 -2
  49. package/dist/components/operationsValidation.js.map +1 -1
  50. package/dist/components/scopeMount.d.ts +86 -0
  51. package/dist/components/scopeMount.js +131 -0
  52. package/dist/components/scopeMount.js.map +1 -0
  53. package/dist/dataLayer/backupManifest.d.ts +26 -0
  54. package/dist/dataLayer/backupManifest.js +97 -0
  55. package/dist/dataLayer/backupManifest.js.map +1 -0
  56. package/dist/dataLayer/blobBackup.d.ts +87 -0
  57. package/dist/dataLayer/blobBackup.js +282 -0
  58. package/dist/dataLayer/blobBackup.js.map +1 -0
  59. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  60. package/dist/dataLayer/harperBridge/ResourceBridge.js +33 -0
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  62. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  63. package/dist/dataLayer/restoreMarker.js +261 -0
  64. package/dist/dataLayer/restoreMarker.js.map +1 -0
  65. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  66. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  67. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  68. package/dist/resources/DatabaseTransaction.js +0 -6
  69. package/dist/resources/DatabaseTransaction.js.map +1 -1
  70. package/dist/resources/ResourceInterface.d.ts +0 -3
  71. package/dist/resources/ResourceInterface.js.map +1 -1
  72. package/dist/resources/Table.js +22 -22
  73. package/dist/resources/Table.js.map +1 -1
  74. package/dist/resources/blob.d.ts +8 -0
  75. package/dist/resources/blob.js +16 -7
  76. package/dist/resources/blob.js.map +1 -1
  77. package/dist/resources/databases.d.ts +42 -1
  78. package/dist/resources/databases.js +276 -40
  79. package/dist/resources/databases.js.map +1 -1
  80. package/dist/resources/transaction.js +0 -3
  81. package/dist/resources/transaction.js.map +1 -1
  82. package/dist/server/REST.js +25 -9
  83. package/dist/server/REST.js.map +1 -1
  84. package/dist/server/fastifyRoutes.js +15 -1
  85. package/dist/server/fastifyRoutes.js.map +1 -1
  86. package/dist/server/itc/serverHandlers.js +7 -1
  87. package/dist/server/itc/serverHandlers.js.map +1 -1
  88. package/dist/server/jobs/jobProcess.js +20 -1
  89. package/dist/server/jobs/jobProcess.js.map +1 -1
  90. package/dist/server/jobs/jobRunner.js +10 -0
  91. package/dist/server/jobs/jobRunner.js.map +1 -1
  92. package/dist/server/jobs/jobs.js +11 -0
  93. package/dist/server/jobs/jobs.js.map +1 -1
  94. package/dist/server/middlewareChain.d.ts +10 -1
  95. package/dist/server/middlewareChain.js +81 -21
  96. package/dist/server/middlewareChain.js.map +1 -1
  97. package/dist/server/serverHelpers/serverHandlers.js +8 -4
  98. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  99. package/dist/server/serverHelpers/serverUtilities.js +11 -0
  100. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  101. package/dist/server/static.js +11 -3
  102. package/dist/server/static.js.map +1 -1
  103. package/dist/utility/OperationFunctionCaller.js +25 -6
  104. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  105. package/dist/utility/hdbTerms.d.ts +11 -1
  106. package/dist/utility/hdbTerms.js +11 -1
  107. package/dist/utility/hdbTerms.js.map +1 -1
  108. package/dist/utility/logging/harper_logger.d.ts +40 -0
  109. package/dist/utility/logging/harper_logger.js +746 -14
  110. package/dist/utility/logging/harper_logger.js.map +1 -1
  111. package/dist/utility/operation_authorization.js +13 -3
  112. package/dist/utility/operation_authorization.js.map +1 -1
  113. package/npm-shrinkwrap.json +3 -2
  114. package/package.json +4 -1
  115. package/resources/DESIGN.md +2 -0
  116. package/resources/DatabaseTransaction.ts +0 -3
  117. package/resources/ResourceInterface.ts +0 -3
  118. package/resources/Table.ts +20 -20
  119. package/resources/blob.ts +16 -6
  120. package/resources/databases.ts +272 -42
  121. package/resources/transaction.ts +0 -3
  122. package/server/DESIGN.md +16 -0
  123. package/server/REST.ts +25 -9
  124. package/server/fastifyRoutes.ts +20 -1
  125. package/server/itc/serverHandlers.js +7 -1
  126. package/server/jobs/jobProcess.ts +18 -1
  127. package/server/jobs/jobRunner.ts +10 -0
  128. package/server/jobs/jobs.ts +11 -0
  129. package/server/middlewareChain.ts +79 -20
  130. package/server/serverHelpers/serverHandlers.js +8 -4
  131. package/server/serverHelpers/serverUtilities.ts +19 -0
  132. package/server/static.ts +12 -3
  133. package/static/defaultConfig.yaml +1 -0
  134. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-DoVWScmq.js} +2 -2
  135. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-DoVWScmq.js.map} +1 -1
  136. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-UZ2NsUOZ.js} +4 -4
  137. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-UZ2NsUOZ.js.map} +1 -1
  138. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-BUI_04o7.js} +2 -2
  139. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-BUI_04o7.js.map} +1 -1
  140. package/studio/web/assets/{applications-BDfH8urd.js → applications-D03NA7wW.js} +2 -2
  141. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-D03NA7wW.js.map} +1 -1
  142. package/studio/web/assets/{index-BHo3c2Gk.js → index-Bh_CNAHr.js} +6 -6
  143. package/studio/web/assets/index-Bh_CNAHr.js.map +1 -0
  144. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-Dx3MpyDC.js} +4 -4
  145. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-Dx3MpyDC.js.map} +1 -1
  146. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-0edoFTsb.js} +2 -2
  147. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-0edoFTsb.js.map} +1 -1
  148. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CwKhipK7.js} +2 -2
  149. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CwKhipK7.js.map} +1 -1
  150. package/studio/web/assets/{profile-Doj5FVDE.js → profile-DUfEPQtx.js} +2 -2
  151. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-DUfEPQtx.js.map} +1 -1
  152. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-DMPo4UjC.js} +2 -2
  153. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-DMPo4UjC.js.map} +1 -1
  154. package/studio/web/assets/{setup-DJwR0BHd.js → setup-B56Oz1_u.js} +2 -2
  155. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-B56Oz1_u.js.map} +1 -1
  156. package/studio/web/assets/{status-Br_AbsJs.js → status-BAod7p3o.js} +2 -2
  157. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BAod7p3o.js.map} +1 -1
  158. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-lQrBxfwM.js} +2 -2
  159. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-lQrBxfwM.js.map} +1 -1
  160. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-CrHCRjTK.js} +2 -2
  161. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-CrHCRjTK.js.map} +1 -1
  162. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-DoaBMEvU.js} +2 -2
  163. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-DoaBMEvU.js.map} +1 -1
  164. package/studio/web/index.html +1 -1
  165. package/utility/OperationFunctionCaller.ts +24 -3
  166. package/utility/hdbTerms.ts +11 -1
  167. package/utility/logging/harper_logger.ts +729 -15
  168. package/utility/operation_authorization.ts +31 -3
  169. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -29,6 +29,7 @@ import * as scheduler from '../resources/scheduler/scheduler.ts';
29
29
  import { restartWorkers, getWorkerIndex } from '../server/threads/manageThreads.js';
30
30
  import { resetRestartNeeded, subscribeToRestartRequests } from './requestRestart.ts';
31
31
  import { trackScopeClose } from './scopeShutdown.ts';
32
+ import { toScopeMount, nestScopeMount, type ScopeMount } from './scopeMount.ts';
32
33
  import { scopedImport } from '../security/jsLoader.ts';
33
34
  import { server } from '../server/Server.ts';
34
35
  import { Resources } from '../resources/Resources.ts';
@@ -63,6 +64,51 @@ let resources;
63
64
  * @param loadedPluginModules
64
65
  * @param loadedResources
65
66
  */
67
+ /**
68
+ * The application mount an operator declared for `appName` in the root config.
69
+ *
70
+ * Applications in the components root are loaded by directory scan, not from a root-config
71
+ * entry, so this is what makes a root-config entry authoritative for where an app is served:
72
+ *
73
+ * ```yaml
74
+ * my-app:
75
+ * host: api.example.com
76
+ * urlPath: /v1
77
+ * ```
78
+ *
79
+ * Works whether or not the entry also carries `package` — a payload-deployed app is mounted the
80
+ * same way as an installed one. A built-in plugin's config block is never an application mount;
81
+ * those keys (`http`, `mqtt`, …) configure the plugin itself.
82
+ */
83
+ function rootConfigMount(appName: string): ScopeMount | undefined {
84
+ if (Object.hasOwn(TRUSTED_RESOURCE_PLUGINS, appName)) return undefined;
85
+ return toScopeMount(getConfigObj()?.[appName]);
86
+ }
87
+
88
+ /**
89
+ * Resolves the mount for `appName`, or reports the failure and returns `undefined` if the
90
+ * configured `host`/`urlPath` is invalid. The caller must skip loading the application in that
91
+ * case rather than loading it anyway: an invalid mount is a request to CONSTRAIN where the app is
92
+ * served, so loading it unconstrained would silently drop the isolation the operator configured
93
+ * (review finding) — worse than not loading it at all. Isolated per-app so one bad mount doesn't
94
+ * take down every other application's load.
95
+ */
96
+ function tryRootConfigMount(appName: string): { ok: true; mount: ScopeMount | undefined } | { ok: false } {
97
+ try {
98
+ return { ok: true, mount: rootConfigMount(appName) };
99
+ } catch (error) {
100
+ (error as Error).message = `Not loading '${appName}': invalid routing configured: ${(error as Error).message}`;
101
+ errorReporter?.(error);
102
+ (getWorkerIndex() === 0 ? console : harperLogger).error(errorForLog(error as Error));
103
+ componentLifecycle.failed(
104
+ appName,
105
+ error as Error,
106
+ `Component '${appName}' failed to load due to invalid routing configuration`
107
+ );
108
+ return { ok: false };
109
+ }
110
+ }
111
+
66
112
  export async function loadComponentDirectories(loadedPluginModules?: Map<any, any>, loadedResources?: Resources) {
67
113
  if (loadedResources) resources = loadedResources;
68
114
  if (loadedPluginModules) loadedComponents = loadedPluginModules;
@@ -81,21 +127,32 @@ export async function loadComponentDirectories(loadedPluginModules?: Map<any, an
81
127
  if (appEntry.name.startsWith('.')) continue;
82
128
  const appName = appEntry.name;
83
129
  const appFolder = join(CF_ROUTES_DIR, appName);
130
+ const mountResult = tryRootConfigMount(appName);
131
+ if (!mountResult.ok) continue;
84
132
  cfsLoaded.push(
85
- loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, { isRoot: false, autoReload: false, appName })
133
+ loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, {
134
+ isRoot: false,
135
+ autoReload: false,
136
+ appName,
137
+ mount: mountResult.mount,
138
+ })
86
139
  );
87
140
  }
88
141
  }
89
142
  const hdbAppFolder = process.env.RUN_HDB_APP;
90
143
  if (hdbAppFolder) {
91
144
  if (getWorkerIndex() === 0) harperLogger.info?.('Loading application from ' + hdbAppFolder);
92
- cfsLoaded.push(
93
- loadComponent(hdbAppFolder, resources, hdbAppFolder, {
94
- isRoot: false,
95
- autoReload: Boolean(process.env.DEV_MODE),
96
- appName: hdbAppFolder,
97
- })
98
- );
145
+ const mountResult = tryRootConfigMount(basename(hdbAppFolder));
146
+ if (mountResult.ok) {
147
+ cfsLoaded.push(
148
+ loadComponent(hdbAppFolder, resources, hdbAppFolder, {
149
+ isRoot: false,
150
+ autoReload: Boolean(process.env.DEV_MODE),
151
+ appName: hdbAppFolder,
152
+ mount: mountResult.mount,
153
+ })
154
+ );
155
+ }
99
156
  }
100
157
  return Promise.all(cfsLoaded).then(() => {
101
158
  watchesSetup = true;
@@ -316,6 +373,10 @@ export interface LoadComponentOptions {
316
373
  // (e.g. the deploy pre-flight validation) so their deploy-lifecycle listeners don't accumulate
317
374
  // across deploys (#1462).
318
375
  collectScopes?: Set<Scope>;
376
+ // Routing the operator declared for this application in the root config (`host`/`urlPath` on
377
+ // the application's entry). Applied to every plugin scope this load creates, and inherited by
378
+ // components the application itself declares, so the whole subtree moves together.
379
+ mount?: ScopeMount;
319
380
  }
320
381
 
321
382
  /**
@@ -342,6 +403,7 @@ export async function loadComponent(
342
403
  isRoot,
343
404
  autoReload,
344
405
  appName,
406
+ mount,
345
407
  } = options;
346
408
  applicationScope.allowedPath ??= realpathSync(componentDirectory);
347
409
  if (providedLoadedComponents) loadedComponents = providedLoadedComponents;
@@ -485,6 +547,13 @@ export async function loadComponent(
485
547
  autoReload: false,
486
548
  appName: appName || componentName,
487
549
  collectScopes: options.collectScopes,
550
+ // `host`/`urlPath` on this entry route the component being loaded. For an
551
+ // application (no plugin module of its own) that entry is the only place an
552
+ // operator can say where the app is served — its own config.yaml declares the
553
+ // plugins, not the deployment. A nested component's own mount nests inside
554
+ // the parent's rather than replacing it, and the parent keeps hostname
555
+ // authority, so a child can't escape the host it is served on.
556
+ mount: nestScopeMount(mount, toScopeMount(componentConfig)),
488
557
  });
489
558
  componentFunctionality[componentName] = true;
490
559
  }
@@ -554,7 +623,10 @@ export async function loadComponent(
554
623
  // authoritative root-ness: only root-load scopes watch THE root config and
555
624
  // get the runtime env-config overlay (#1618) — an app component that happens
556
625
  // to ship a root-named config file does not
557
- isRoot
626
+ isRoot,
627
+ // A root-declared plugin reads its own `host`/`urlPath` straight from this
628
+ // config, so only an inherited application mount applies here.
629
+ mount
558
630
  );
559
631
 
560
632
  if (options.collectScopes) {
@@ -597,6 +669,32 @@ export async function loadComponent(
597
669
  );
598
670
  }
599
671
 
672
+ // `start`/`startOnMainThread` hand the plugin the raw, unmounted `server` directly —
673
+ // unlike the new Plugin API's `handleApplication(scope)`, which receives the mount-aware
674
+ // scope — so routes registered there would silently escape a configured host/urlPath
675
+ // mount and stay reachable unconstrained (review finding). Same isolation gap
676
+ // fastifyRoutes.ts already closes for its own legacy fallback (host-only there, since
677
+ // urlPath composes into its route prefix; here neither composes, so both are rejected).
678
+ // Refuse rather than imply an isolation the hook can't honor; contained to this
679
+ // component by the loader's per-component try/catch.
680
+ if (
681
+ mount &&
682
+ (typeof extensionModule.start === 'function' || typeof extensionModule.startOnMainThread === 'function')
683
+ ) {
684
+ const error = new Error(
685
+ `Component '${componentName}' is mounted (${[
686
+ mount.host && `host '${mount.host}'`,
687
+ mount.urlPath && `urlPath '${mount.urlPath}'`,
688
+ ]
689
+ .filter(Boolean)
690
+ .join(
691
+ ', '
692
+ )}), but its deprecated 'start'/'startOnMainThread' extension API receives the bare, unmounted server — routes registered there would stay reachable unconstrained. Upgrade to the new Plugin API (handleApplication(scope)) or drop the mount.`
693
+ );
694
+ componentLifecycle.failed(componentStatusName, error, `Component '${componentStatusName}' failed to load`);
695
+ throw error;
696
+ }
697
+
600
698
  if (isMainThread) {
601
699
  // `startOnMainThread` is one-time main-thread init: run it at most once per component
602
700
  // for the life of the process (first load / first deploy). On a reload of an
@@ -362,6 +362,16 @@ export function isSuperUser(user: AuthedUser | undefined): boolean {
362
362
  return user?.role?.permission?.super_user === true;
363
363
  }
364
364
 
365
+ /**
366
+ * True if the request carries an authenticated principal. Anonymous MCP sessions are a supported
367
+ * deployment (the public-docs case, #1609) — the adapter hands them through as `{ username: '' }`
368
+ * rather than rejecting them — so a listing filter that means "any real user" has to test for this
369
+ * explicitly. `user?.role` is not a proxy: an authenticated user may legitimately carry no role.
370
+ */
371
+ export function isAuthenticated(user: AuthedUser | undefined): boolean {
372
+ return typeof user?.username === 'string' && user.username.length > 0;
373
+ }
374
+
365
375
  /**
366
376
  * Class-level verb introspection. Mirrors `resources/openApi.ts:149-153` so
367
377
  * a class is considered to "have" a verb only if its prototype overrides the
@@ -27,6 +27,7 @@ import harperLogger from '../../../utility/logging/harper_logger.ts';
27
27
  import {
28
28
  addTool,
29
29
  clearProfileTools,
30
+ isAuthenticated,
30
31
  snapshotProfileTools,
31
32
  type AuthedUser,
32
33
  type ToolCallContext,
@@ -708,11 +709,17 @@ function makeVisibleTo(
708
709
  return function visibleTo(user: AuthedUser): boolean {
709
710
  // Super-user sees everything.
710
711
  if (user?.role?.permission?.super_user === true) return true;
711
- // Non-table Resources have no static permission gate runtime allow*
712
- // predicates enforce. Conservative default: don't list them for
713
- // non-super users; they can still call via tools/call if they know
714
- // the tool name (pass-through is documented behavior).
715
- if (!databaseName || !tableName) return false;
712
+ // A Resource with no backing table has no static permission gate to consult, so listing is
713
+ // open to any authenticated caller and the Resource's own `allow*` predicates enforce at call
714
+ // time hiding these while still accepting `tools/call` on them bought no security, only
715
+ // undiscoverable tools (#1940).
716
+ //
717
+ // Anonymous sessions stay excluded. They are a supported deployment (#1609) and reach here as
718
+ // `{ username: '' }`, so this must be tested explicitly. Custom `mcpTools` DO list to them, but
719
+ // that is an author opt-in — declaring `static mcpTools` is a deliberate act of publishing.
720
+ // Verb tools are generated for every exported Resource with no author action, so publishing an
721
+ // app's field names and docstrings to unauthenticated callers by default is not equivalent.
722
+ if (!databaseName || !tableName) return isAuthenticated(user);
716
723
  const perm = getUserTablePermissions(user, databaseName, tableName);
717
724
  if (!perm) return false;
718
725
  if (mode === 'read') return perm.read === true || perm.describe === true;
@@ -176,6 +176,9 @@ const DESTRUCTIVE_OPERATIONS: ReadonlySet<string> = new Set([
176
176
  'restart_service',
177
177
  'set_configuration',
178
178
  'remove_node',
179
+ 'restore_backup',
180
+ 'delete_backup',
181
+ 'purge_backups',
179
182
  'delete_deployment_payload',
180
183
  ]);
181
184
 
@@ -60,6 +60,9 @@ export const OPERATION_DESCRIPTIONS: Record<string, string> = {
60
60
  // list_deployments: components/deploymentOperations.ts:50 — Deployment history with filtering and pagination.
61
61
  list_deployments:
62
62
  'Lists deployments from system.hdb_deployment with optional filtering by project, status, and date range, plus pagination.',
63
+ // list_backups: dataLayer/rocksdbBackup.ts (listBackups) — Managed RocksDB backup metadata for a database.
64
+ list_backups:
65
+ 'Lists the managed RocksDB directory backups for a database (id, timestamp, size, file count). Requires super_user; RocksDB databases only.',
63
66
 
64
67
  // ─── DEFAULT_ALLOW: search_* ──────────────────────────────────────────
65
68
  // search_by_conditions: dataLayer/search.ts:6 — Multi-condition search with comparators.
@@ -101,6 +101,15 @@ export const OPERATION_INPUT_SCHEMAS: Record<string, object> = {
101
101
  properties: {},
102
102
  description: 'Lists deployed component versions in this Harper instance.',
103
103
  },
104
+ list_backups: {
105
+ type: 'object',
106
+ properties: {
107
+ database: {
108
+ type: 'string',
109
+ description: 'Database name whose managed RocksDB backups to list. Defaults to `data`.',
110
+ },
111
+ },
112
+ },
104
113
 
105
114
  // ─── search_* ─────────────────────────────────────────────────────────
106
115
  search_by_hash: {
@@ -410,6 +410,7 @@ async function deployComponent(req) {
410
410
  };
411
411
  }
412
412
  if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
413
+ if (req.host !== undefined) applicationConfig.host = req.host;
413
414
  // Persist credential references (never tokens) so every cold install of this component —
414
415
  // reboot, new peer, rollback — re-resolves the credential from the store.
415
416
  if (credentialReferences.length) applicationConfig.credentials = credentialReferences;
@@ -18,6 +18,11 @@ const PROJECT_FILE_NAME_REGEX = /^[a-zA-Z0-9-_]+$/;
18
18
  // containing `=` or a newline) from injecting extra assignments into a .env file.
19
19
  const ENV_KEY_REGEX = /^[\w.-]+$/;
20
20
 
21
+ // Compiled once and reused: a routing `host` is either a DNS hostname or a bare IPv6 literal, and
22
+ // re-compiling these per validation call would allocate on every deploy_component.
23
+ const HOSTNAME_SCHEMA = Joi.string().hostname();
24
+ const IPV6_SCHEMA = Joi.string().ip({ version: 'ipv6' });
25
+
21
26
  module.exports = {
22
27
  getDropCustomFunctionValidator,
23
28
  setCustomFunctionValidator,
@@ -457,10 +462,33 @@ function deployComponentValidator(req) {
457
462
  .min(1)
458
463
  .custom((value, helpers) => {
459
464
  if (value.includes('..')) return helpers.error('any.invalid');
465
+ // A component mount has no relative base and WHATWG clients strip '.' segments before
466
+ // sending the request, so a dot-segment mount would simply be unreachable.
467
+ if (value.split('/').includes('.')) return helpers.error('string.dotSegment');
468
+ return value;
469
+ })
470
+ .optional()
471
+ .messages({
472
+ 'any.invalid': '{#label} must not contain ".."',
473
+ 'string.dotSegment': '{#label} must not contain "." path segments',
474
+ }),
475
+ // Virtual hostname the component is served on. Like `urlPath`, this is deployment routing and
476
+ // belongs on the root-config entry, not in the component's own config.yaml. `hostname()`
477
+ // rejects a value carrying a port or path, which would never match the router's host compare.
478
+ // IPv6 literals are accepted in their bare form only — the router unwraps the brackets it
479
+ // finds in a Host header, so a bracketed value here would never match.
480
+ host: Joi.string()
481
+ .custom((value, helpers) => {
482
+ if (value.startsWith('[') || value.endsWith(']')) return helpers.error('string.bracketedHost');
460
483
  return value;
461
484
  })
485
+ .custom((value, helpers) => {
486
+ if (!HOSTNAME_SCHEMA.validate(value).error) return value;
487
+ // Accept a bare IPv6 literal, which `hostname()` rejects but the router can match.
488
+ return IPV6_SCHEMA.validate(value).error ? helpers.error('string.hostname') : value;
489
+ })
462
490
  .optional()
463
- .messages({ 'any.invalid': 'urlPath must not contain ".."' }),
491
+ .messages({ 'string.bracketedHost': '{#label} must not be bracketed; use the bare IPv6 literal' }),
464
492
  // Deploy credentials. The array is kind-heterogeneous: an entry's kind is implied by its
465
493
  // identifying key rather than a separate discriminator field, so a new kind is added as
466
494
  // another item alternative here without reshaping the field. Today: npm registry auth
@@ -490,7 +518,9 @@ function deployComponentValidator(req) {
490
518
  registryAuth: Joi.any().forbidden().messages({
491
519
  'any.unknown': `'registryAuth' has been renamed to 'credentials'`,
492
520
  }),
493
- }).with('urlPath', 'package');
521
+ })
522
+ .with('urlPath', 'package')
523
+ .with('host', 'package');
494
524
 
495
525
  return validator.validateBySchema(req, deployProjSchema);
496
526
  }
@@ -0,0 +1,150 @@
1
+ import Joi from 'joi';
2
+ import { InvalidBaseURLPathError, resolveBaseURLPath } from './resolveBaseURLPath.ts';
3
+ import harperLogger from '../utility/logging/harper_logger.ts';
4
+
5
+ /**
6
+ * The routing an operator declared for an application in the *root* config, e.g.
7
+ *
8
+ * ```yaml
9
+ * my-app:
10
+ * package: '@my/app'
11
+ * host: api.example.com
12
+ * urlPath: /v1
13
+ * ```
14
+ *
15
+ * Where an application is served is a deployment concern, not an application concern, so
16
+ * the root config is authoritative: a checked-in `config.yaml` cannot pin the hostname or
17
+ * mount point an operator has chosen. The mount reaches every plugin scope loaded for that
18
+ * application (and, transitively, plugins the application itself declares).
19
+ *
20
+ * The mount is applied at exactly one place: the routing boundary, where `Scope`'s `server`
21
+ * proxy registers a handler. The router strips it before the handler runs, so everything
22
+ * inside the application — entry URL paths, and the resource paths `graphqlSchema` and
23
+ * `jsResource` derive from them — stays mount-relative. Only code that emits an absolute URL
24
+ * back to the client (`Scope.externalBasePath`) or bypasses the routed chain (legacy fastify
25
+ * routes) needs to know the mount exists.
26
+ */
27
+ export interface ScopeMount {
28
+ host?: string;
29
+ urlPath?: string;
30
+ }
31
+
32
+ export class InvalidMountPathError extends Error {
33
+ constructor(urlPath: string) {
34
+ super(`An application mount urlPath must be an absolute path without '.' or '..' segments. Received: '${urlPath}'`);
35
+ }
36
+ }
37
+
38
+ export class InvalidMountHostError extends Error {
39
+ constructor(host: string) {
40
+ super(
41
+ `An application mount host must be a bare hostname or IPv6 literal, with no scheme, port, or path. Received: '${host}'`
42
+ );
43
+ }
44
+ }
45
+
46
+ // Compiled once and reused, mirroring operationsValidation.js's deploy_component 'host' schema:
47
+ // a routing host is either a DNS hostname or a bare IPv6 literal.
48
+ const HOSTNAME_SCHEMA = Joi.string().hostname();
49
+ const IPV6_SCHEMA = Joi.string().ip({ version: 'ipv6' });
50
+
51
+ /**
52
+ * Normalizes a mount prefix to a leading-slash, no-trailing-slash form ('/v1'), or
53
+ * undefined when it constrains nothing ('', '/', undefined) — matching
54
+ * `middlewareChain.normalizeUrlPath`, so a mount that means "the root" composes to
55
+ * exactly the plugin's own path rather than rewriting it.
56
+ *
57
+ * Dot segments are rejected rather than resolved. A plugin's `urlPath` may be plugin-name
58
+ * relative ('./x'), but a mount has no such base, and WHATWG clients strip '.' segments
59
+ * before sending the request — a '/.'-prefixed route would simply be unreachable.
60
+ */
61
+ export function normalizeMountPath(urlPath: string | undefined): string | undefined {
62
+ if (!urlPath) return undefined;
63
+ if (urlPath.includes('..')) throw new InvalidBaseURLPathError(urlPath);
64
+ let normalized = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
65
+ normalized = normalized.replace(/\/+$/, '');
66
+ if (normalized.split('/').includes('.')) throw new InvalidMountPathError(urlPath);
67
+ return normalized.length <= 1 ? undefined : normalized;
68
+ }
69
+
70
+ /**
71
+ * Hostnames are case-insensitive (RFC 4343) and clients send them lowercased, so a mount is
72
+ * held lowercased and compared that way. The bracket form of an IPv6 literal is unwrapped to
73
+ * match what the router extracts from the Host header.
74
+ *
75
+ * Validated against the same grammar as the `deploy_component` operation's `host` field (a bare
76
+ * DNS hostname or IPv6 literal) — the root config is hand-edited YAML with no equivalent gate,
77
+ * so a value that would never match a Host header (a port, a scheme, a path suffix, malformed
78
+ * IDN) must be rejected here rather than silently mounting the application unreachably.
79
+ */
80
+ export function normalizeMountHost(host: string | undefined): string | undefined {
81
+ if (!host) return undefined;
82
+ const unbracketed = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
83
+ const normalized = unbracketed.toLowerCase();
84
+ if (HOSTNAME_SCHEMA.validate(normalized).error && IPV6_SCHEMA.validate(normalized).error) {
85
+ throw new InvalidMountHostError(host);
86
+ }
87
+ return normalized;
88
+ }
89
+
90
+ /**
91
+ * Reads a mount off a root-config entry, or undefined when the entry declares no routing at
92
+ * all — so an unmounted application takes exactly the code path it did before mounts existed.
93
+ *
94
+ * `host`/`urlPath` present but not a string (e.g. `host: 9926`, a likely typo for a quoted
95
+ * value) is rejected rather than silently treated as absent: this fail-closed behavior only
96
+ * works if a wrong-typed value fails the same way a wrong-format one does (review finding) —
97
+ * `null`/`undefined` are still "not declared", since that's how YAML represents an omitted key.
98
+ */
99
+ export function toScopeMount(config: unknown): ScopeMount | undefined {
100
+ if (!config || typeof config !== 'object') return undefined;
101
+ const { host, urlPath } = config as ScopeMount;
102
+ if (host !== undefined && host !== null && typeof host !== 'string') throw new InvalidMountHostError(String(host));
103
+ if (urlPath !== undefined && urlPath !== null && typeof urlPath !== 'string')
104
+ throw new InvalidMountPathError(String(urlPath));
105
+ const mountHost = normalizeMountHost(typeof host === 'string' ? host : undefined);
106
+ const mountPath = normalizeMountPath(typeof urlPath === 'string' ? urlPath : undefined);
107
+ if (!mountHost && !mountPath) return undefined;
108
+ return { host: mountHost, urlPath: mountPath };
109
+ }
110
+
111
+ /**
112
+ * Composes an application mount with a plugin's own `urlPath`.
113
+ *
114
+ * The plugin part is resolved first (`resolveBaseURLPath` semantics: `'.'`/`'./x'` namespace
115
+ * under the plugin name) and the mount is then prefixed, so app-internal structure survives
116
+ * relocation: mount `/v1` + `static: { urlPath: assets }` → `/v1/assets/`. Composing rather
117
+ * than replacing matters because a plugin's `urlPath` doubles as its app-internal base path;
118
+ * replacing it would silently relocate app-internal URLs and collapse distinct plugins onto
119
+ * one path. The result is already absolute and slash-terminated, making it a fixed point of
120
+ * `resolveBaseURLPath` — a consumer that resolves it again cannot compound the prefix.
121
+ */
122
+ export function composeMountedUrlPath(
123
+ mountPath: string | undefined,
124
+ pluginName: string,
125
+ pluginUrlPath: string | undefined
126
+ ): string | undefined {
127
+ if (!mountPath) return pluginUrlPath;
128
+ return `${mountPath}${resolveBaseURLPath(pluginName, pluginUrlPath)}`;
129
+ }
130
+
131
+ /**
132
+ * Nests a child component's own mount inside the mount its parent application was given.
133
+ *
134
+ * The parent keeps hostname authority — a child cannot escape the host it is served on — while
135
+ * paths compose, so a parent at `/v1` containing a child mounted at `/child` puts the child at
136
+ * `/v1/child`. Returns whichever side is defined when only one is. A child `host` that differs
137
+ * from the parent's is silently discarded by that authority rule; logged so the operator who
138
+ * wrote it isn't left guessing why it had no effect.
139
+ */
140
+ export function nestScopeMount(parent: ScopeMount | undefined, child: ScopeMount | undefined): ScopeMount | undefined {
141
+ if (!parent) return child;
142
+ if (!child) return parent;
143
+ if (parent.host && child.host && parent.host !== child.host) {
144
+ harperLogger.warn(
145
+ `Component mount host '${child.host}' is ignored because it is nested under an application mounted on host '${parent.host}' — a child's host cannot override its parent's.`
146
+ );
147
+ }
148
+ const urlPath = child.urlPath ? `${parent.urlPath ?? ''}${child.urlPath}` : parent.urlPath;
149
+ return { host: parent.host ?? child.host, urlPath: urlPath || undefined };
150
+ }
@@ -486,6 +486,10 @@
486
486
  },
487
487
  "prefetchWrites": { "type": "boolean", "description": "Load data prior to write transactions. Default: true" },
488
488
  "path": { "type": "string", "description": "Directory for all database files. Default: <rootPath>/database" },
489
+ "backupPath": {
490
+ "type": "string",
491
+ "description": "Directory for managed database backups (RocksDB directory backups created by create_backup), one subdirectory per database. Default: <rootPath>/backup"
492
+ },
489
493
  "blobPaths": {
490
494
  "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
491
495
  "description": "Path or array of paths for blob storage. Default: <rootPath>/blobs"
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * Per-backup completion manifest for RocksDB managed backups.
9
+ *
10
+ * `create_backup` is two-phase: the engine backup (`rootStore.backup()`) resolves — and becomes
11
+ * visible to `list_backups`/`verify_backup`/`restore_backup` — before the blob snapshot is copied.
12
+ * Without a completion record, a blob-snapshot failure (or a crash between the two phases) leaves an
13
+ * engine backup on disk that lists and verifies as healthy while silently missing its blobs, and a
14
+ * concurrent restore could select a backup id whose blob snapshot is still being written and treat
15
+ * it as an intentional engine-only backup.
16
+ *
17
+ * A manifest at `<backupDir>/manifests/<backupId>.json` is written (atomically, temp + rename) only
18
+ * after *both* phases are durably in place, and records whether blobs were captured. Consumers treat
19
+ * a backup id with no manifest as incomplete — not usable for restore, and hidden from the listing.
20
+ * The `manifests/` directory is Harper-managed and ignored by the rocksdb-js binding (same as the
21
+ * sibling `blobs/` and `transaction_logs/` directories).
22
+ */
23
+
24
+ const MANIFEST_DIR = 'manifests';
25
+
26
+ export type BackupManifest = {
27
+ /** The RocksDB backup id this manifest completes. */
28
+ backupId: number;
29
+ /** Whether the backup captured the database's file-backed blobs (false = engine-only). */
30
+ blobs: boolean;
31
+ /** Epoch-ms timestamp when both phases completed. */
32
+ completedAt: number;
33
+ };
34
+
35
+ function manifestDir(backupDir: string): string {
36
+ return join(backupDir, MANIFEST_DIR);
37
+ }
38
+
39
+ function manifestPath(backupDir: string, backupId: number): string {
40
+ return join(manifestDir(backupDir), `${backupId}.json`);
41
+ }
42
+
43
+ /**
44
+ * Write a backup's completion manifest atomically. Call only after the engine backup and (when
45
+ * included) the blob snapshot are both durable — its presence is what marks the backup usable.
46
+ */
47
+ export async function writeBackupManifest(backupDir: string, backupId: number, blobs: boolean): Promise<void> {
48
+ const dir = manifestDir(backupDir);
49
+ await mkdir(dir, { recursive: true });
50
+ const manifest: BackupManifest = { backupId, blobs, completedAt: Date.now() };
51
+ const tempPath = join(dir, `.tmp-${backupId}.json`);
52
+ await writeFile(tempPath, JSON.stringify(manifest));
53
+ await rename(tempPath, manifestPath(backupDir, backupId)); // atomic publish
54
+ }
55
+
56
+ /** Read a backup's completion manifest, or null when it has none (incomplete / not yet written). */
57
+ export async function readBackupManifest(backupDir: string, backupId: number): Promise<BackupManifest | null> {
58
+ try {
59
+ return JSON.parse(await readFile(manifestPath(backupDir, backupId), 'utf8')) as BackupManifest;
60
+ } catch (error: any) {
61
+ if (error.code === 'ENOENT') return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /** Whether a backup id has a completion manifest (i.e. its creation finished). */
67
+ export function isBackupComplete(backupDir: string, backupId: number): boolean {
68
+ return existsSync(manifestPath(backupDir, backupId));
69
+ }
70
+
71
+ /** Remove a backup's manifest (paired with deleting the backup); best-effort. */
72
+ export async function deleteBackupManifest(backupDir: string, backupId: number): Promise<void> {
73
+ await rm(manifestPath(backupDir, backupId), { force: true });
74
+ }
75
+
76
+ /** Remove manifests for every backup id not in `keepIds` (paired with `backups.purge`). */
77
+ export async function purgeBackupManifests(backupDir: string, keepIds: Set<number>): Promise<void> {
78
+ const dir = manifestDir(backupDir);
79
+ if (!existsSync(dir)) return;
80
+ for (const name of await readdir(dir)) {
81
+ const match = /^(\d+)\.json$/.exec(name);
82
+ if (match && !keepIds.has(Number(match[1]))) await rm(join(dir, name), { force: true });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read every completion manifest in a backup directory, keyed by backup id. Backup ids without a
88
+ * manifest (incomplete) are absent from the map. A manifest that fails to parse is skipped.
89
+ */
90
+ export async function readAllManifests(backupDir: string): Promise<Map<number, BackupManifest>> {
91
+ const dir = manifestDir(backupDir);
92
+ const manifests = new Map<number, BackupManifest>();
93
+ if (!existsSync(dir)) return manifests;
94
+ for (const name of await readdir(dir)) {
95
+ const match = /^(\d+)\.json$/.exec(name);
96
+ if (!match) continue;
97
+ const backupId = Number(match[1]);
98
+ const manifest = await readBackupManifest(backupDir, backupId).catch(() => null);
99
+ if (manifest) manifests.set(backupId, manifest);
100
+ }
101
+ return manifests;
102
+ }