@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.
- package/bin/cliOperations.ts +76 -12
- package/bin/run.ts +10 -0
- package/bin/status.ts +1 -1
- package/components/Application.ts +146 -83
- package/components/Scope.ts +4 -0
- package/components/componentLoader.ts +7 -0
- package/components/operations.js +21 -1
- package/config/configUtils.ts +139 -5
- package/config-app.schema.json +70 -0
- package/dist/bin/cliOperations.js +76 -12
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/run.js +9 -0
- package/dist/bin/run.js.map +1 -1
- package/dist/bin/status.js +1 -1
- package/dist/bin/status.js.map +1 -1
- package/dist/components/Application.d.ts +12 -5
- package/dist/components/Application.js +121 -60
- package/dist/components/Application.js.map +1 -1
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +4 -0
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +7 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/operations.js +20 -1
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +31 -0
- package/dist/config/configUtils.js +127 -5
- package/dist/config/configUtils.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +12 -0
- package/dist/resources/DatabaseTransaction.js +97 -0
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/RequestTarget.js +13 -3
- package/dist/resources/RequestTarget.js.map +1 -1
- package/dist/resources/Resource.js +16 -0
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.js +35 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/metadata.d.ts +3 -0
- package/dist/resources/analytics/metadata.js +3 -0
- package/dist/resources/analytics/metadata.js.map +1 -1
- package/dist/resources/analytics/write.js +22 -0
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/defineResource.js +20 -7
- package/dist/resources/defineResource.js.map +1 -1
- package/dist/resources/jsResource.d.ts +24 -0
- package/dist/resources/jsResource.js +58 -2
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/openApi.js +45 -20
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/scheduler/CronExpression.d.ts +71 -0
- package/dist/resources/scheduler/CronExpression.js +367 -0
- package/dist/resources/scheduler/CronExpression.js.map +1 -0
- package/dist/resources/scheduler/engine.d.ts +91 -0
- package/dist/resources/scheduler/engine.js +767 -0
- package/dist/resources/scheduler/engine.js.map +1 -0
- package/dist/resources/scheduler/scheduler.d.ts +33 -0
- package/dist/resources/scheduler/scheduler.js +200 -0
- package/dist/resources/scheduler/scheduler.js.map +1 -0
- package/dist/security/auth.js +1 -0
- package/dist/security/auth.js.map +1 -1
- package/dist/security/jsLoader.js +8 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/keys.d.ts +32 -0
- package/dist/security/keys.js +147 -0
- package/dist/security/keys.js.map +1 -1
- package/dist/server/REST.js +67 -1
- package/dist/server/REST.js.map +1 -1
- package/dist/server/Server.d.ts +6 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +139 -14
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.js +3 -3
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/progressEmitter.js +5 -1
- package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
- package/dist/server/threads/threadServer.js +9 -5
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/common_utils.js +25 -0
- package/dist/utility/common_utils.js.map +1 -1
- package/dist/utility/install/installer.d.ts +9 -1
- package/dist/utility/install/installer.js +21 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/validation/configValidator.js +3 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/npm-shrinkwrap.json +272 -230
- package/package.json +3 -3
- package/resources/DESIGN.md +1 -1
- package/resources/DatabaseTransaction.ts +95 -0
- package/resources/RequestTarget.ts +12 -3
- package/resources/Resource.ts +18 -0
- package/resources/Table.ts +34 -3
- package/resources/analytics/metadata.ts +3 -0
- package/resources/analytics/write.ts +23 -0
- package/resources/defineResource.ts +17 -4
- package/resources/jsResource.ts +61 -2
- package/resources/openApi.ts +44 -19
- package/resources/scheduler/CronExpression.ts +394 -0
- package/resources/scheduler/engine.ts +812 -0
- package/resources/scheduler/scheduler.ts +236 -0
- package/security/auth.ts +1 -0
- package/security/jsLoader.ts +8 -0
- package/security/keys.ts +152 -0
- package/server/REST.ts +70 -1
- package/server/Server.ts +6 -0
- package/server/http.ts +122 -15
- package/server/operationsServer.ts +5 -3
- package/server/serverHelpers/progressEmitter.ts +5 -1
- package/server/threads/threadServer.js +9 -5
- package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
- package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
- package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
- package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
- package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
- package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
- package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
- package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
- package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
- package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
- package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
- package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
- package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
- package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
- package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
- package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/common_utils.ts +26 -0
- package/utility/install/installer.ts +26 -1
- 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
|
|
package/security/jsLoader.ts
CHANGED
|
@@ -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)
|
|
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;
|