@harperfast/harper 5.2.0-beta.1 → 5.2.0-beta.2

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 (137) hide show
  1. package/bin/cliOperations.ts +76 -12
  2. package/bin/run.ts +10 -0
  3. package/bin/status.ts +1 -1
  4. package/components/Application.ts +146 -83
  5. package/components/Scope.ts +4 -0
  6. package/components/componentLoader.ts +7 -0
  7. package/components/operations.js +21 -1
  8. package/config/configUtils.ts +139 -5
  9. package/config-app.schema.json +70 -0
  10. package/dist/bin/cliOperations.js +76 -12
  11. package/dist/bin/cliOperations.js.map +1 -1
  12. package/dist/bin/run.js +9 -0
  13. package/dist/bin/run.js.map +1 -1
  14. package/dist/bin/status.js +1 -1
  15. package/dist/bin/status.js.map +1 -1
  16. package/dist/components/Application.d.ts +12 -5
  17. package/dist/components/Application.js +121 -60
  18. package/dist/components/Application.js.map +1 -1
  19. package/dist/components/Scope.d.ts +1 -0
  20. package/dist/components/Scope.js +4 -0
  21. package/dist/components/Scope.js.map +1 -1
  22. package/dist/components/componentLoader.js +7 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/operations.js +20 -1
  25. package/dist/components/operations.js.map +1 -1
  26. package/dist/config/configUtils.d.ts +31 -0
  27. package/dist/config/configUtils.js +127 -5
  28. package/dist/config/configUtils.js.map +1 -1
  29. package/dist/resources/DatabaseTransaction.d.ts +12 -0
  30. package/dist/resources/DatabaseTransaction.js +97 -0
  31. package/dist/resources/DatabaseTransaction.js.map +1 -1
  32. package/dist/resources/RequestTarget.js +13 -3
  33. package/dist/resources/RequestTarget.js.map +1 -1
  34. package/dist/resources/Resource.js +16 -0
  35. package/dist/resources/Resource.js.map +1 -1
  36. package/dist/resources/Table.js +35 -2
  37. package/dist/resources/Table.js.map +1 -1
  38. package/dist/resources/analytics/metadata.d.ts +3 -0
  39. package/dist/resources/analytics/metadata.js +3 -0
  40. package/dist/resources/analytics/metadata.js.map +1 -1
  41. package/dist/resources/analytics/write.js +22 -0
  42. package/dist/resources/analytics/write.js.map +1 -1
  43. package/dist/resources/defineResource.js +20 -7
  44. package/dist/resources/defineResource.js.map +1 -1
  45. package/dist/resources/jsResource.d.ts +24 -0
  46. package/dist/resources/jsResource.js +58 -2
  47. package/dist/resources/jsResource.js.map +1 -1
  48. package/dist/resources/openApi.js +45 -20
  49. package/dist/resources/openApi.js.map +1 -1
  50. package/dist/resources/scheduler/CronExpression.d.ts +71 -0
  51. package/dist/resources/scheduler/CronExpression.js +367 -0
  52. package/dist/resources/scheduler/CronExpression.js.map +1 -0
  53. package/dist/resources/scheduler/engine.d.ts +91 -0
  54. package/dist/resources/scheduler/engine.js +767 -0
  55. package/dist/resources/scheduler/engine.js.map +1 -0
  56. package/dist/resources/scheduler/scheduler.d.ts +33 -0
  57. package/dist/resources/scheduler/scheduler.js +200 -0
  58. package/dist/resources/scheduler/scheduler.js.map +1 -0
  59. package/dist/security/auth.js +1 -0
  60. package/dist/security/auth.js.map +1 -1
  61. package/dist/security/jsLoader.js +8 -0
  62. package/dist/security/jsLoader.js.map +1 -1
  63. package/dist/security/keys.d.ts +32 -0
  64. package/dist/security/keys.js +147 -0
  65. package/dist/security/keys.js.map +1 -1
  66. package/dist/server/REST.js +67 -1
  67. package/dist/server/REST.js.map +1 -1
  68. package/dist/server/Server.d.ts +6 -0
  69. package/dist/server/Server.js.map +1 -1
  70. package/dist/server/http.d.ts +2 -0
  71. package/dist/server/http.js +139 -14
  72. package/dist/server/http.js.map +1 -1
  73. package/dist/server/operationsServer.js +3 -3
  74. package/dist/server/operationsServer.js.map +1 -1
  75. package/dist/server/serverHelpers/progressEmitter.js +5 -1
  76. package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
  77. package/dist/server/threads/threadServer.js +9 -5
  78. package/dist/server/threads/threadServer.js.map +1 -1
  79. package/dist/utility/common_utils.js +25 -0
  80. package/dist/utility/common_utils.js.map +1 -1
  81. package/dist/utility/install/installer.d.ts +9 -1
  82. package/dist/utility/install/installer.js +21 -0
  83. package/dist/utility/install/installer.js.map +1 -1
  84. package/dist/validation/configValidator.js +3 -0
  85. package/dist/validation/configValidator.js.map +1 -1
  86. package/npm-shrinkwrap.json +272 -230
  87. package/package.json +3 -3
  88. package/resources/DESIGN.md +1 -1
  89. package/resources/DatabaseTransaction.ts +95 -0
  90. package/resources/RequestTarget.ts +12 -3
  91. package/resources/Resource.ts +18 -0
  92. package/resources/Table.ts +34 -3
  93. package/resources/analytics/metadata.ts +3 -0
  94. package/resources/analytics/write.ts +23 -0
  95. package/resources/defineResource.ts +17 -4
  96. package/resources/jsResource.ts +61 -2
  97. package/resources/openApi.ts +44 -19
  98. package/resources/scheduler/CronExpression.ts +394 -0
  99. package/resources/scheduler/engine.ts +812 -0
  100. package/resources/scheduler/scheduler.ts +236 -0
  101. package/security/auth.ts +1 -0
  102. package/security/jsLoader.ts +8 -0
  103. package/security/keys.ts +152 -0
  104. package/server/REST.ts +70 -1
  105. package/server/Server.ts +6 -0
  106. package/server/http.ts +122 -15
  107. package/server/operationsServer.ts +5 -3
  108. package/server/serverHelpers/progressEmitter.ts +5 -1
  109. package/server/threads/threadServer.js +9 -5
  110. package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
  111. package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
  112. package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
  113. package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
  114. package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
  115. package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
  116. package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
  117. package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
  118. package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
  119. package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
  120. package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
  121. package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
  122. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
  123. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
  124. package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
  125. package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
  126. package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
  127. package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
  128. package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
  129. package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
  130. package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
  131. package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
  132. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
  133. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
  134. package/studio/web/index.html +1 -1
  135. package/utility/common_utils.ts +26 -0
  136. package/utility/install/installer.ts +26 -1
  137. package/validation/configValidator.ts +3 -0
@@ -0,0 +1,236 @@
1
+ import { isAbsolute, join } from 'node:path';
2
+ import { getWorkerIndex } from '../../server/threads/manageThreads.js';
3
+ import { ClientError } from '../../utility/errors/hdbError.ts';
4
+ import { convertToMS } from '../../utility/common_utils.ts';
5
+ import harperLogger from '../../utility/logging/harper_logger.ts';
6
+ import { CronExpression, validateTimezone } from './CronExpression.ts';
7
+ import {
8
+ registerComponentJobs,
9
+ safeErrorMessage,
10
+ startSchedulerEngine,
11
+ unregisterComponentJobs,
12
+ type JobRunContext,
13
+ type ScheduledJob,
14
+ } from './engine.ts';
15
+
16
+ const schedulerLogger = harperLogger.forComponent('scheduler');
17
+
18
+ export class SchedulerConfigError extends ClientError {
19
+ constructor(message: string) {
20
+ super(message, 400);
21
+ this.name = 'SchedulerConfigError';
22
+ }
23
+ }
24
+
25
+ const MIN_INTERVAL_MS = 1000;
26
+ // Generous for any real maintenance cadence while keeping fire-time
27
+ // arithmetic comfortably inside Date range
28
+ const MAX_INTERVAL_MS = 365 * 24 * 60 * 60 * 1000;
29
+ const KNOWN_JOB_KEYS = new Set(['name', 'cron', 'interval', 'timezone', 'handler']);
30
+
31
+ interface SchedulerJobConfig {
32
+ name?: unknown;
33
+ cron?: unknown;
34
+ interval?: unknown;
35
+ timezone?: unknown;
36
+ handler?: unknown;
37
+ }
38
+
39
+ /**
40
+ * Built-in `scheduler` plugin (issue #951): lets a component declare recurring
41
+ * jobs in its config and have core invoke a designated export on that
42
+ * schedule, exactly once per cluster (leader election and failover live in
43
+ * ./engine.ts).
44
+ *
45
+ * ```yaml
46
+ * scheduler:
47
+ * jobs:
48
+ * - name: daily-metrics-snapshot
49
+ * cron: '0 2 * * *'
50
+ * timezone: America/New_York # optional; defaults to the server timezone
51
+ * handler: ./jobs.ts#snapshotMetrics
52
+ * - name: sync-exchange-rates
53
+ * interval: 15m # simple cadence instead of cron
54
+ * handler: ./jobs.ts#syncExchangeRates
55
+ * ```
56
+ *
57
+ * The handler reference is `<module path>#<named export>` relative to the
58
+ * component directory (omit `#...` to use the module's default export). The
59
+ * handler is invoked with a {@link JobRunContext} and may return a promise.
60
+ *
61
+ * Handlers should be idempotent: leadership failover (the lease has no
62
+ * compare-and-set) and DST fall-back can occasionally deliver the same logical
63
+ * occurrence twice. Conversely, catch-up only makes up the single most recent
64
+ * missed occurrence of a cron job — a leader down for an extended outage does
65
+ * not backfill every occurrence it missed.
66
+ */
67
+ export async function handleApplication(scope): Promise<void> {
68
+ // Validation runs UNCONDITIONALLY — on every worker and on deploy
69
+ // pre-flight validation loads, which land on an arbitrary worker. Gating
70
+ // validation behind worker 0 would let a bad config pass pre-flight
71
+ // nondeterministically and then fail cluster-wide at the next restart
72
+ // (review finding). Only ACTIVATION is gated below.
73
+ const config = scope.options.getAll() ?? {};
74
+ // null covers the common "all jobs commented out" edit, which YAML parses
75
+ // as `jobs: null` — that must degrade like an absent key, not fail the
76
+ // whole component load (audit finding)
77
+ if (config.jobs == null) {
78
+ schedulerLogger.warn?.(`Component ${scope.appName} has a scheduler block with no jobs`);
79
+ return;
80
+ }
81
+ if (!Array.isArray(config.jobs)) {
82
+ throw new SchedulerConfigError(`scheduler.jobs in component ${scope.appName} must be an array of job entries`);
83
+ }
84
+
85
+ const jobs: ScheduledJob[] = [];
86
+ const seenNames = new Set<string>();
87
+ for (const jobConfig of config.jobs as SchedulerJobConfig[]) {
88
+ const job = await buildJob(scope, jobConfig);
89
+ if (seenNames.has(job.name)) {
90
+ throw new SchedulerConfigError(`Duplicate scheduler job name "${job.name}" in component ${scope.appName}`);
91
+ }
92
+ seenNames.add(job.name);
93
+ jobs.push(job);
94
+ }
95
+
96
+ // Activation gates: one worker owns scheduling for the whole node
97
+ // (getWorkerIndex() === 0 is correct in every threading mode, including
98
+ // threads:0 where the main thread acts as worker 0), and a deploy
99
+ // pre-flight validation scope must never touch the live engine — it can
100
+ // share a running component's identity, so registering from it would
101
+ // displace the real component's jobs (review finding).
102
+ if (getWorkerIndex() !== 0) {
103
+ schedulerLogger.debug?.('Scheduler config validated; activation skipped on non-primary worker');
104
+ return;
105
+ }
106
+ if (scope.isTransientValidation) {
107
+ schedulerLogger.debug?.(`Scheduler config validated for ${scope.appName}; activation skipped for validation load`);
108
+ return;
109
+ }
110
+
111
+ registerComponentJobs(scope.appName, jobs);
112
+ // A closing scope (worker shutdown or redeploy) must take its timers with it
113
+ scope.on('close', () => unregisterComponentJobs(scope.appName));
114
+ // Scope only requests a restart on option CHANGES; deleting the whole
115
+ // scheduler: block emits 'remove', which nothing else consumes — without
116
+ // this, a dev-watch session keeps firing jobs whose config is gone
117
+ // (audit finding)
118
+ scope.options.on('remove', () => scope.requestRestart());
119
+ startSchedulerEngine();
120
+ schedulerLogger.trace?.(`Registered ${jobs.length} scheduled job(s) for component ${scope.appName}`);
121
+ }
122
+
123
+ async function buildJob(scope, jobConfig: SchedulerJobConfig): Promise<ScheduledJob> {
124
+ const componentName = scope.appName;
125
+ if (typeof jobConfig !== 'object' || jobConfig === null) {
126
+ throw new SchedulerConfigError(`Each scheduler.jobs entry in component ${componentName} must be an object`);
127
+ }
128
+ const { name, cron, interval, timezone, handler } = jobConfig;
129
+ // The schema declares additionalProperties: false but is IDE-only; without
130
+ // this runtime check a misspelled OPTIONAL key (timeZone:, timzone:) is
131
+ // silently dropped and the job runs with different behavior — e.g. a cron
132
+ // evaluated in the server timezone instead of the intended one
133
+ // (audit finding)
134
+ const unknownKeys = Object.keys(jobConfig).filter((key) => !KNOWN_JOB_KEYS.has(key));
135
+ if (unknownKeys.length > 0) {
136
+ throw new SchedulerConfigError(
137
+ `Scheduler job entry in component ${componentName} has unrecognized key(s): ${unknownKeys.join(', ')} (allowed: name, cron, interval, timezone, handler)`
138
+ );
139
+ }
140
+ if (typeof name !== 'string' || name.length === 0) {
141
+ throw new SchedulerConfigError(`Every scheduler job in component ${componentName} needs a non-empty string name`);
142
+ }
143
+ if ((cron === undefined) === (interval === undefined)) {
144
+ throw new SchedulerConfigError(
145
+ `Scheduler job "${name}" in component ${componentName} must declare exactly one of "cron" or "interval"`
146
+ );
147
+ }
148
+ if (typeof handler !== 'string' || handler.length === 0) {
149
+ throw new SchedulerConfigError(
150
+ `Scheduler job "${name}" in component ${componentName} needs a handler like "./jobs.ts#myExport"`
151
+ );
152
+ }
153
+
154
+ const job: ScheduledJob = {
155
+ name,
156
+ componentName,
157
+ handler: await resolveHandler(scope, name, handler),
158
+ };
159
+ if (cron !== undefined) {
160
+ if (typeof cron !== 'string') {
161
+ throw new SchedulerConfigError(`Scheduler job "${name}" in component ${componentName}: "cron" must be a string`);
162
+ }
163
+ job.cron = new CronExpression(cron);
164
+ if (timezone !== undefined) {
165
+ if (typeof timezone !== 'string') {
166
+ throw new SchedulerConfigError(
167
+ `Scheduler job "${name}" in component ${componentName}: "timezone" must be an IANA timezone string`
168
+ );
169
+ }
170
+ job.timezone = validateTimezone(timezone);
171
+ }
172
+ } else {
173
+ if (timezone !== undefined) {
174
+ throw new SchedulerConfigError(
175
+ `Scheduler job "${name}" in component ${componentName}: "timezone" only applies to cron schedules`
176
+ );
177
+ }
178
+ // convertToMS silently treats any unrecognized unit as seconds (e.g.
179
+ // '500ms' would become 500 seconds); restrict to the documented forms
180
+ if (typeof interval === 'string' && !/^\d+(\.\d+)?[smhd]?$/.test(interval.trim())) {
181
+ throw new SchedulerConfigError(
182
+ `Scheduler job "${name}" in component ${componentName}: interval "${interval}" is not a supported duration — use a number of seconds or a value like 90s, 5m, 1h, 1d`
183
+ );
184
+ }
185
+ const intervalMs = convertToMS(typeof interval === 'string' ? interval.trim() : interval);
186
+ // Both bounds matter: values that overflow Date arithmetic (YAML .inf,
187
+ // 1e309, or absurdly large finite numbers) would otherwise produce an
188
+ // Invalid Date downstream, whose NaN delay Node's setTimeout coerces to
189
+ // ~1ms — turning a "practically never" interval into a hot loop
190
+ // (review finding)
191
+ if (!Number.isFinite(intervalMs) || intervalMs < MIN_INTERVAL_MS || intervalMs > MAX_INTERVAL_MS) {
192
+ throw new SchedulerConfigError(
193
+ `Scheduler job "${name}" in component ${componentName}: interval "${interval}" must be between 1 second and 365 days (e.g. 90s, 5m, 1h, 1d)`
194
+ );
195
+ }
196
+ job.intervalMs = intervalMs;
197
+ }
198
+ return job;
199
+ }
200
+
201
+ /**
202
+ * Resolve a `<module path>#<named export>` handler reference to a callable,
203
+ * loading the module inside the component's own realm via scope.import so the
204
+ * handler sees the same globals (tables, databases, …) as the rest of the
205
+ * component's code. Resolving eagerly makes a bad reference fail the component
206
+ * load — visible at deploy time — instead of failing silently at first fire.
207
+ */
208
+ async function resolveHandler(
209
+ scope,
210
+ jobName: string,
211
+ handlerReference: string
212
+ ): Promise<(context: JobRunContext) => unknown> {
213
+ const hashIndex = handlerReference.indexOf('#');
214
+ const modulePath = hashIndex >= 0 ? handlerReference.slice(0, hashIndex) : handlerReference;
215
+ const exportName = hashIndex >= 0 ? handlerReference.slice(hashIndex + 1) : 'default';
216
+ const absolutePath = isAbsolute(modulePath) ? modulePath : join(scope.directory, modulePath);
217
+ let handlerModule;
218
+ try {
219
+ handlerModule = await scope.import(absolutePath);
220
+ } catch (error) {
221
+ // User modules can throw primitives or frozen errors, so wrap (with
222
+ // cause) instead of mutating the caught value's message
223
+ const loadError = new SchedulerConfigError(
224
+ `Scheduler job "${jobName}" in component ${scope.appName}: could not load handler module "${modulePath}": ${safeErrorMessage(error)}`
225
+ );
226
+ loadError.cause = error;
227
+ throw loadError;
228
+ }
229
+ const handler = handlerModule?.[exportName];
230
+ if (typeof handler !== 'function') {
231
+ throw new SchedulerConfigError(
232
+ `Scheduler job "${jobName}" in component ${scope.appName}: "${modulePath}" has no function export named "${exportName}"`
233
+ );
234
+ }
235
+ return handler;
236
+ }
package/security/auth.ts CHANGED
@@ -424,6 +424,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
424
424
  started = true;
425
425
  const { port, securePort }: any = scope.options.getAll() as { port?: number; securePort?: number };
426
426
  const httpOpts = port || securePort ? ({ port, securePort } as any) : ({ port: 'all' } as any);
427
+ httpOpts.name = 'authentication';
427
428
  scope.server.http(authentication, httpOpts);
428
429
  }
429
430
 
@@ -3,6 +3,8 @@ import { contextStorage, transaction } from '../resources/transaction.ts';
3
3
  import { RequestTarget } from '../resources/RequestTarget.ts';
4
4
  import { tables, databases } from '../resources/databases.ts';
5
5
  import { models as harperModelsSingleton } from '../resources/models/Models.ts';
6
+ import { defineTable, types } from '../resources/defineTable.ts';
7
+ import { defineResource, t, schemaOf, projectTableFragment } from '../resources/defineResource.ts';
6
8
  import { readFile } from 'node:fs/promises';
7
9
  import { dirname, isAbsolute } from 'node:path';
8
10
  import { pathToFileURL, fileURLToPath } from 'node:url';
@@ -805,6 +807,12 @@ function getHarperExports(scope: ApplicationScope) {
805
807
  Resource,
806
808
  tables,
807
809
  databases,
810
+ defineTable,
811
+ types,
812
+ defineResource,
813
+ t,
814
+ schemaOf,
815
+ projectTableFragment,
808
816
  // `harper.models` — same singleton that's surfaced as the top-level
809
817
  // `models` package export (see `resources/models/Models.ts`). The
810
818
  // registry it reads from is populated at boot by
package/security/keys.ts CHANGED
@@ -820,6 +820,139 @@ if (typeof globalThis.Bun === 'undefined') {
820
820
 
821
821
  let caCerts = new Map();
822
822
 
823
+ const SECLEVEL_PATTERN = /@SECLEVEL=(\d+)/i;
824
+ /** Split a cipher string into its suite list and its explicit `@SECLEVEL` (undefined when unset). */
825
+ function parseCipherString(ciphers) {
826
+ const match = SECLEVEL_PATTERN.exec(ciphers);
827
+ return {
828
+ suite: ciphers.replace(SECLEVEL_PATTERN, '').trim() || undefined,
829
+ level: match ? Number(match[1]) : undefined,
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Whether a certificate (record or `tls[]` entry) can affect the given listener, mirroring
835
+ * createTLSSelector's tolerant selection: no `uses` is a generic certificate, `'https'` is the
836
+ * legacy generic use, and an authority matters exactly when the listener verifies client chains.
837
+ */
838
+ function ciphersCandidateRelevant(usesRaw, isAuthority, servesCertificate, type, verifiesClientCerts) {
839
+ if (isAuthority && verifiesClientCerts) return true;
840
+ if (!servesCertificate) return false;
841
+ // normalize: stored as scalar in legacy/manual entries, expected array
842
+ const uses = Array.isArray(usesRaw) ? usesRaw : usesRaw ? [usesRaw] : [];
843
+ return uses.length === 0 || uses.includes(type) || uses.includes('https');
844
+ }
845
+
846
+ /**
847
+ * Resolve the single cipher string that actually governs a TLS listener.
848
+ *
849
+ * OpenSSL takes the cipher list — and any `@SECLEVEL=n` embedded in it, which controls
850
+ * client-certificate chain verification — from the context the server was created with. A context
851
+ * swapped in later by the SNI callback does not carry its own cipher list onto the connection,
852
+ * so per-certificate `ciphers` — whether on a `tls` array entry or a certificate record — cannot
853
+ * take effect on their own; a listener has exactly one effective cipher string. Historically only
854
+ * `tls.ciphers ?? tls[0].ciphers` was applied and every other configured value was silently
855
+ * ignored, including a CA record needing a relaxed security level to verify legacy client chains
856
+ * (e.g. SHA-1-signed CAs requiring `DEFAULT@SECLEVEL=0`, which fail with
857
+ * `authorizationError: UNSPECIFIED` at the default level).
858
+ *
859
+ * Resolution composes rather than picks a winner, because `@SECLEVEL` and the suite list are
860
+ * separable OpenSSL commands:
861
+ * 1. Candidates come from the listener's config layers in priority order (`configLayers`, e.g.
862
+ * `operationsApi.tls` before root `tls` — an object's `ciphers` directly, an array's entries
863
+ * filtered by {@link ciphersCandidateRelevant}) and then from relevant certificate records.
864
+ * 2. The suite list comes from the highest-priority suite-bearing candidate — a CA needing a
865
+ * relaxed level must not replace or broaden the listener's configured suites.
866
+ * 3. The security level is the minimum explicit `@SECLEVEL` across candidates (a chain that needs
867
+ * the relaxed level fails outright without it; the others merely also accept it). Candidates
868
+ * without an explicit `@SECLEVEL` keep the runtime default — no level is assumed for them,
869
+ * since the OpenSSL default varies across Node builds.
870
+ * Anything composed across sources or dropped (extra suite lists) is logged as a warning.
871
+ */
872
+ export function resolveEffectiveTlsCiphers(configLayers, certRecords, type, verifiesClientCerts): string | undefined {
873
+ const candidates = [];
874
+ for (const { source, config } of configLayers ?? []) {
875
+ if (!config) continue;
876
+ if (Array.isArray(config)) {
877
+ for (let index = 0; index < config.length; index++) {
878
+ const entry = config[index];
879
+ if (!entry?.ciphers) continue;
880
+ const isAuthority = Boolean(entry.certificateAuthority);
881
+ const servesCertificate = Boolean(entry.certificate) || !isAuthority;
882
+ if (ciphersCandidateRelevant(entry.uses, isAuthority, servesCertificate, type, verifiesClientCerts)) {
883
+ candidates.push({ source: `${source}[${index}]`, ciphers: entry.ciphers });
884
+ }
885
+ }
886
+ } else if (config.ciphers) {
887
+ // an object layer's ciphers is the listener config's own knob — always relevant
888
+ candidates.push({ source: `${source}.ciphers`, ciphers: config.ciphers });
889
+ }
890
+ }
891
+ for (const cert of certRecords ?? []) {
892
+ if (!cert?.ciphers) continue;
893
+ // authority records are never served as listener certs — they matter only for verification
894
+ if (
895
+ ciphersCandidateRelevant(cert.uses, Boolean(cert.is_authority), !cert.is_authority, type, verifiesClientCerts)
896
+ ) {
897
+ candidates.push({ source: `certificate '${cert.name}'`, ciphers: cert.ciphers });
898
+ }
899
+ }
900
+ if (candidates.length === 0) return undefined;
901
+
902
+ const parsed = candidates.map((candidate) => ({ ...candidate, ...parseCipherString(candidate.ciphers) }));
903
+ const suiteBearing = parsed.filter((candidate) => candidate.suite);
904
+ const suiteSource = suiteBearing[0];
905
+ let levelSource;
906
+ for (const candidate of parsed) {
907
+ if (candidate.level !== undefined && (levelSource === undefined || candidate.level < levelSource.level)) {
908
+ levelSource = candidate;
909
+ }
910
+ }
911
+ // no suite anywhere means a bare @SECLEVEL override — anchor it to DEFAULT
912
+ const suite = suiteSource?.suite ?? 'DEFAULT';
913
+ const effective = levelSource === undefined ? suite : `${suite}@SECLEVEL=${levelSource.level}`;
914
+
915
+ const notes = [];
916
+ const droppedSuites = suiteBearing.filter((candidate) => candidate.suite !== suiteSource.suite);
917
+ if (droppedSuites.length) {
918
+ notes.push(
919
+ `suites from ${suiteSource.source} ('${suiteSource.suite}'); ignoring suite lists from ${droppedSuites
920
+ .map((candidate) => `${candidate.source} ('${candidate.suite}')`)
921
+ .join(', ')}`
922
+ );
923
+ }
924
+ if (levelSource !== undefined && levelSource.source !== suiteSource?.source) {
925
+ notes.push(`security level ${levelSource.level} required by ${levelSource.source}`);
926
+ }
927
+ if (notes.length) {
928
+ logger.warn?.(
929
+ `Composed TLS cipher configuration for the '${type}' listener ('${effective}'): ${notes.join('; ')} — a listener has a single effective cipher string`
930
+ );
931
+ }
932
+ return effective;
933
+ }
934
+
935
+ /**
936
+ * Resolve the effective cipher string for a listener from the live config and certificate table.
937
+ * Safe to call before the certificate table exists (install, early boot) — config-only then.
938
+ */
939
+ export function getEffectiveTlsCiphers(type, mtlsOptions?): string | undefined {
940
+ let certRecords;
941
+ try {
942
+ certRecords = databases?.system?.hdb_certificate?.search([]);
943
+ } catch (error) {
944
+ logger.trace?.('Certificate table not available while resolving TLS ciphers', error);
945
+ }
946
+ const configLayers = [];
947
+ // the operations API listener has its own tls section (merged separately by config
948
+ // composition, may inherit the root certificate while overriding ciphers) — it outranks root
949
+ if (type === 'operations-api') {
950
+ configLayers.push({ source: 'operationsApi.tls', config: envManager.get(CONFIG_PARAMS.OPERATIONSAPI_TLS) });
951
+ }
952
+ configLayers.push({ source: 'tls', config: envManager.get('tls') });
953
+ return resolveEffectiveTlsCiphers(configLayers, certRecords, type, Boolean(mtlsOptions));
954
+ }
955
+
823
956
  /**
824
957
  * Create a TLS selector that will choose the best TLS configuration/context for a given hostname
825
958
  * @param type
@@ -952,6 +1085,25 @@ export function createTLSSelector(type, mtlsOptions?, liveReload = true): any {
952
1085
  logger.error?.('Error applying TLS for', cert.name, error);
953
1086
  }
954
1087
  }
1088
+ // The listener's cipher string (and its @SECLEVEL, which governs client-cert chain
1089
+ // verification) is fixed at server creation and cannot be swapped by rebuilding SNI
1090
+ // contexts. If a rebuild finds the effective value has changed (e.g. a certificate
1091
+ // record with `ciphers` was added), the listener won't honor it until restart — warn
1092
+ // instead of silently serving with the stale value.
1093
+ if (server && server.appliedCiphers !== undefined) {
1094
+ // use the same verifies-client-certs flag the listener was created with (http servers
1095
+ // derive it from more than the selector's mtlsOptions) so this compares like with like
1096
+ const effectiveCiphers =
1097
+ getEffectiveTlsCiphers(type, server.verifiesClientCerts ?? Boolean(mtlsOptions)) ?? null;
1098
+ // latch per distinct value: rebuilds recur (cert-table changes, key reloads) and the
1099
+ // pending change shouldn't re-warn on every cycle until the restart happens
1100
+ if (effectiveCiphers !== server.appliedCiphers && server.lastWarnedCiphers !== effectiveCiphers) {
1101
+ server.lastWarnedCiphers = effectiveCiphers;
1102
+ logger.warn?.(
1103
+ `TLS cipher configuration for the '${type}' listener is now '${effectiveCiphers}' but the listener was started with '${server.appliedCiphers}' — a restart is required to apply it`
1104
+ );
1105
+ }
1106
+ }
955
1107
  server?.secureContextsListeners.forEach((listener) => listener());
956
1108
  resolve(defaultContext);
957
1109
  } catch (error) {
package/server/REST.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { serialize, serializeMessage, getDeserializer } from '../server/serverHelpers/contentTypes.ts';
2
4
  import { addAnalyticsListener, recordAction, recordActionBinary } from '../resources/analytics/write.ts';
3
5
  import * as harperLogger from '../utility/logging/harper_logger.ts';
@@ -8,6 +10,10 @@ import { IterableEventQueue } from '../resources/IterableEventQueue.ts';
8
10
  import { transaction } from '../resources/transaction.ts';
9
11
  import { Headers, mergeHeaders } from '../server/serverHelpers/Headers.ts';
10
12
  import { generateJsonApi } from '../resources/openApi.ts';
13
+ import { getConfigPath } from '../config/configUtils.ts';
14
+ import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
15
+ import { ASIDE_STAGING_DIR } from '../components/Application.ts';
16
+ import { restartNeeded } from '../components/requestRestart.ts';
11
17
 
12
18
  import { Request } from '../server/serverHelpers/Request.ts';
13
19
  import { RequestTarget } from '../resources/RequestTarget';
@@ -71,6 +77,45 @@ function finalizeResponse(responseData, headers, status, request) {
71
77
  return responseData;
72
78
  }
73
79
 
80
+ /**
81
+ * A component deployed to disk (via `deploy_component`) but not yet loaded into this
82
+ * server's live router — because Harper hasn't restarted since — produces a route miss
83
+ * indistinguishable from a URL that never existed. If the URL's first segment names a
84
+ * components-root directory, surface that distinction instead of a generic 404 (harper#674).
85
+ * `name` is decoded and checked for path-traversal characters before being joined into a
86
+ * filesystem path. Uses `stat`/`isDirectory` (not `access`) so a stray non-directory file
87
+ * directly under the components root (e.g. `README.md`, `.DS_Store`) can't be mistaken for
88
+ * a deployed component.
89
+ */
90
+ async function findInactiveComponent(url: string): Promise<string | undefined> {
91
+ const firstSegment = url.split(/[/?]/, 1)[0];
92
+ if (!firstSegment) return undefined;
93
+ let name: string;
94
+ try {
95
+ name = decodeURIComponent(firstSegment);
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ if (
100
+ !name ||
101
+ name === '.' ||
102
+ name === '..' ||
103
+ name === 'node_modules' ||
104
+ name === ASIDE_STAGING_DIR ||
105
+ name.includes('/') ||
106
+ name.includes('\\')
107
+ )
108
+ return undefined;
109
+ const componentsRoot = getConfigPath(CONFIG_PARAMS.COMPONENTSROOT);
110
+ if (!componentsRoot || typeof componentsRoot !== 'string') return undefined;
111
+ try {
112
+ const stats = await stat(join(componentsRoot, name));
113
+ return stats.isDirectory() ? name : undefined;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
74
119
  async function http(request: Request, nextHandler) {
75
120
  const headersObject = request.headers.asObject;
76
121
  const isSse = headersObject.accept === 'text/event-stream';
@@ -88,7 +133,31 @@ async function http(request: Request, nextHandler) {
88
133
  let resource: typeof Resource;
89
134
  if (url !== OPENAPI_DOMAIN) {
90
135
  const entry = resources.getMatch(url, isSse ? 'sse' : 'rest');
91
- if (!entry) return nextHandler(request); // no resource handler found
136
+ if (!entry) {
137
+ // Only surface the actionable "needs a restart" 404 when a restart is genuinely
138
+ // pending — i.e. a component was deployed (restart:false) since this server last
139
+ // loaded its routes, so restartNeeded() is set. Without a pending restart, a
140
+ // directory under componentsRoot is either an already-active component (which would
141
+ // have matched a route above) or not a live component at all, so fall back to the
142
+ // generic 404 rather than claiming a restart would activate it. (harper#674)
143
+ //
144
+ // Also gated on an authenticated super_user: this check runs before any resource is
145
+ // matched, so no auth gate has run yet for this request. Only reveal the actionable
146
+ // message to a super_user — otherwise a caller could use the response difference
147
+ // (actionable vs. generic 404) as an oracle to probe which component directories
148
+ // exist on disk. `getComponents` (utility/operation_authorization.ts) already treats
149
+ // "which components are deployed" as super_user-only information; match that here.
150
+ if (restartNeeded() && request?.user?.role?.permission?.super_user) {
151
+ const inactiveComponent = await findInactiveComponent(url);
152
+ if (inactiveComponent) {
153
+ throw new ClientError(
154
+ `Component '${inactiveComponent}' is deployed but Harper may need to be restarted before its routes are active.`,
155
+ 404
156
+ );
157
+ }
158
+ }
159
+ return nextHandler(request); // no resource handler found
160
+ }
92
161
  request.handlerPath = entry.path;
93
162
  target = new RequestTarget(entry.relativeURL); // TODO: We don't want to have to remove the forward slash and then re-add it
94
163
  if (entry.params) Object.assign(target, entry.params); // bind parameterised path segments (e.g. :id, *rest)
package/server/Server.ts CHANGED
@@ -87,6 +87,12 @@ export interface HttpOptions extends ServerOptions {
87
87
  headers?: boolean;
88
88
  };
89
89
  lastModified?: boolean;
90
+ /**
91
+ * Header name -> value, applied as defaults to HTTP responses on app ports (e.g.
92
+ * X-Frame-Options, X-Content-Type-Options). A header the application/route already set
93
+ * on a response always takes precedence over the configured value.
94
+ */
95
+ securityHeaders?: Record<string, string | number | boolean>;
90
96
  }
91
97
  export interface ContentTypeHandler {
92
98
  serialize(data: any): Buffer | string;