@camstack/server 1.2.110 → 1.2.111
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/dist/api/core/cap-providers.js +32 -1
- package/dist/api/core/site-location.js +389 -0
- package/dist/api/trpc/generated-cap-routers.js +29 -0
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/core/agent/cluster-node-history-store.js +15 -0
- package/dist/core/auth/share-token.service.js +29 -0
- package/package.json +9 -9
|
@@ -77,11 +77,13 @@ const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
|
|
|
77
77
|
const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
|
|
78
78
|
const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
|
|
79
79
|
const collection_preference_js_1 = require("./collection-preference.js");
|
|
80
|
+
const site_location_js_1 = require("./site-location.js");
|
|
80
81
|
// ── system ──────────────────────────────────────────────────────────
|
|
81
82
|
function getRetention(registry) {
|
|
82
83
|
return (registry?.getSingleton('analysis-data-persistence')?.retention ?? null);
|
|
83
84
|
}
|
|
84
|
-
function buildSystemProvider(feature, registry) {
|
|
85
|
+
function buildSystemProvider(feature, registry, moleculer, logger) {
|
|
86
|
+
const siteLocation = buildSiteLocationService(moleculer, logger);
|
|
85
87
|
return {
|
|
86
88
|
info: async () => feature.getManifest(),
|
|
87
89
|
health: async () => ({ status: 'ok', uptime: process.uptime() }),
|
|
@@ -109,8 +111,37 @@ function buildSystemProvider(feature, registry) {
|
|
|
109
111
|
forceRetentionCleanup: async () => {
|
|
110
112
|
await getRetention(registry)?.forceCleanup();
|
|
111
113
|
},
|
|
114
|
+
getSiteLocation: async () => siteLocation.get(),
|
|
115
|
+
setSiteLocation: async (input) => siteLocation.set(input),
|
|
116
|
+
detectSiteLocation: async () => siteLocation.detect(),
|
|
112
117
|
};
|
|
113
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Site location, backed by the `system-settings` collection through the broker.
|
|
121
|
+
*
|
|
122
|
+
* Without a broker (unit tests that build the provider bare) the store fails
|
|
123
|
+
* closed: reads throw, so the derivation cannot fire and cannot invent a
|
|
124
|
+
* location out of a missing dependency.
|
|
125
|
+
*/
|
|
126
|
+
function buildSiteLocationService(moleculer, logger) {
|
|
127
|
+
const broker = moleculer?.broker;
|
|
128
|
+
const store = {
|
|
129
|
+
get: async (input) => {
|
|
130
|
+
if (broker === undefined)
|
|
131
|
+
throw new Error('settings-store unavailable: no broker');
|
|
132
|
+
return broker.call('settings-store.get', input);
|
|
133
|
+
},
|
|
134
|
+
set: async (input) => {
|
|
135
|
+
if (broker === undefined)
|
|
136
|
+
throw new Error('settings-store unavailable: no broker');
|
|
137
|
+
await broker.call('settings-store.set', input);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
return new site_location_js_1.SiteLocationService({
|
|
141
|
+
store,
|
|
142
|
+
...(logger !== undefined ? { logger } : {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
114
145
|
// ── network-quality ─────────────────────────────────────────────────
|
|
115
146
|
function buildNetworkQualityProvider(nq) {
|
|
116
147
|
return {
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Site location — the installation's coordinates, as a fact of the SITE.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is not an addon setting
|
|
6
|
+
*
|
|
7
|
+
* Latitude and longitude used to live in `pipeline-analytics`' global settings,
|
|
8
|
+
* because scene monitoring was the first thing to need sun-times. That made a
|
|
9
|
+
* property of the *building* a property of one analytics addon: a second
|
|
10
|
+
* consumer would either import the first addon's key (addons never import each
|
|
11
|
+
* other) or grow a second knob that disagrees with it — the mistake
|
|
12
|
+
* [D62](../../../../../docs/decisions/adr-0062.md) records. It lives here, on
|
|
13
|
+
* the hub, behind `system.getSiteLocation`, and every consumer reads that one
|
|
14
|
+
* authority over the transport.
|
|
15
|
+
*
|
|
16
|
+
* ## The default, and why it is derived exactly once
|
|
17
|
+
*
|
|
18
|
+
* With no coordinates the sun-times consumers fall back to a coarse **UTC**
|
|
19
|
+
* clock split, which is wrong by an hour or two at the edges for most of
|
|
20
|
+
* Europe. A hub can do better without asking: its public IP geolocates to
|
|
21
|
+
* within a few kilometres, which is far below the resolution sunrise needs.
|
|
22
|
+
*
|
|
23
|
+
* So the FIRST read that finds nothing stored derives one — and the derivation
|
|
24
|
+
* is one-shot in the strong sense:
|
|
25
|
+
*
|
|
26
|
+
* - one outbound request, `GEO_IP_TIMEOUT_MS` bounded, single-flighted so
|
|
27
|
+
* concurrent readers share it;
|
|
28
|
+
* - the OUTCOME is persisted either way. A success stores the coordinates
|
|
29
|
+
* with `source: 'derived-from-ip'`; a failure stores
|
|
30
|
+
* `derivationAttemptedAt` + `derivationError` and nothing ever retries on a
|
|
31
|
+
* read path again. A hub with no internet pays four seconds once, in its
|
|
32
|
+
* entire life;
|
|
33
|
+
* - nothing gates on it. Boot does not wait for it, and a caller that finds
|
|
34
|
+
* `location: null` degrades exactly as it did before this module existed.
|
|
35
|
+
*
|
|
36
|
+
* The only retry is an operator pressing detect (`detectSiteLocation`). That is
|
|
37
|
+
* deliberate: an automatic retry loop against a third-party service is how a
|
|
38
|
+
* homelab ends up rate-limited, and the value it would eventually fetch is one
|
|
39
|
+
* the operator can type in five seconds.
|
|
40
|
+
*
|
|
41
|
+
* ## Provenance is part of the value
|
|
42
|
+
*
|
|
43
|
+
* `source` distinguishes a guess from an operator's input, and the UI shows it.
|
|
44
|
+
* An operator who cannot tell the two apart will eventually trust the guess —
|
|
45
|
+
* and a derivation must never overwrite a typed value, which is why
|
|
46
|
+
* `SiteLocationService.detect` refuses when `source === 'operator-set'`.
|
|
47
|
+
*/
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.SiteLocationService = exports.GEO_IP_ENDPOINTS = exports.GEO_IP_TIMEOUT_MS = exports.SITE_LOCATION_KEY = void 0;
|
|
50
|
+
exports.isUsableCoordinate = isUsableCoordinate;
|
|
51
|
+
exports.toStatus = toStatus;
|
|
52
|
+
exports.parseGeoIpPayload = parseGeoIpPayload;
|
|
53
|
+
exports.geolocatePublicIp = geolocatePublicIp;
|
|
54
|
+
const types_1 = require("@camstack/types");
|
|
55
|
+
/** The `system-settings` row this module owns. */
|
|
56
|
+
exports.SITE_LOCATION_KEY = 'site-location';
|
|
57
|
+
const SYSTEM_SETTINGS_COLLECTION = 'system-settings';
|
|
58
|
+
/**
|
|
59
|
+
* The addon store the coordinates used to live in. Read ONCE, lazily, to
|
|
60
|
+
* migrate an operator who already typed them — see {@link readLegacyAddonCoordinates}.
|
|
61
|
+
*/
|
|
62
|
+
const LEGACY_ADDON_NAMESPACE = 'pipeline-analytics';
|
|
63
|
+
const LEGACY_ADDON_COLLECTION = 'addon-settings';
|
|
64
|
+
const LEGACY_ADDON_KEY = 'root';
|
|
65
|
+
const LEGACY_LAT_KEY = 'siteLatitude';
|
|
66
|
+
const LEGACY_LNG_KEY = 'siteLongitude';
|
|
67
|
+
/**
|
|
68
|
+
* Bound on the geo-IP request. Short on purpose: this sits on the first read of
|
|
69
|
+
* a lazily-derived default, so it is latency an operator can feel. Four seconds
|
|
70
|
+
* is long enough for a cold TLS handshake to a CDN and short enough that a
|
|
71
|
+
* captive-portal black hole does not look like a hang.
|
|
72
|
+
*/
|
|
73
|
+
exports.GEO_IP_TIMEOUT_MS = 4_000;
|
|
74
|
+
/**
|
|
75
|
+
* No-key, HTTPS geo-IP endpoints, tried in order until one parses.
|
|
76
|
+
*
|
|
77
|
+
* Both answer an unauthenticated GET with the caller's own public IP resolved
|
|
78
|
+
* to a city centroid. Neither is contacted unless a derivation actually runs,
|
|
79
|
+
* and the request carries nothing but the hub's IP — which the endpoint would
|
|
80
|
+
* see regardless, since seeing it is the entire mechanism.
|
|
81
|
+
*/
|
|
82
|
+
exports.GEO_IP_ENDPOINTS = [
|
|
83
|
+
'https://ipinfo.io/json',
|
|
84
|
+
'https://ipapi.co/json/',
|
|
85
|
+
];
|
|
86
|
+
// ── pure helpers ────────────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* `(0, 0)` is Null Island, not a homelab. Anything non-finite or out of range
|
|
89
|
+
* is treated as absent rather than clamped: a wrong-but-plausible coordinate
|
|
90
|
+
* produces a wrong sunrise silently, which is worse than the declared fallback.
|
|
91
|
+
*/
|
|
92
|
+
function isUsableCoordinate(lat, lng) {
|
|
93
|
+
if (typeof lat !== 'number' || typeof lng !== 'number')
|
|
94
|
+
return false;
|
|
95
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng))
|
|
96
|
+
return false;
|
|
97
|
+
if (lat < -90 || lat > 90 || lng < -180 || lng > 180)
|
|
98
|
+
return false;
|
|
99
|
+
return lat !== 0 || lng !== 0;
|
|
100
|
+
}
|
|
101
|
+
/** Project a stored row onto the wire shape. */
|
|
102
|
+
function toStatus(record) {
|
|
103
|
+
if (record === null) {
|
|
104
|
+
return { location: null, derivationAttemptedAt: null, derivationError: null };
|
|
105
|
+
}
|
|
106
|
+
const attempted = typeof record.derivationAttemptedAt === 'number';
|
|
107
|
+
const status = {
|
|
108
|
+
location: isUsableCoordinate(record.latitude, record.longitude)
|
|
109
|
+
? {
|
|
110
|
+
// The guard above proves both are numbers; the narrowing does not
|
|
111
|
+
// survive the property read, so re-assert with a finite fallback
|
|
112
|
+
// rather than a cast.
|
|
113
|
+
latitude: Number(record.latitude),
|
|
114
|
+
longitude: Number(record.longitude),
|
|
115
|
+
source: record.source === 'operator-set' ? 'operator-set' : 'derived-from-ip',
|
|
116
|
+
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
|
|
117
|
+
...(record.label !== undefined ? { label: record.label } : {}),
|
|
118
|
+
}
|
|
119
|
+
: null,
|
|
120
|
+
derivationAttemptedAt: attempted ? Number(record.derivationAttemptedAt) : null,
|
|
121
|
+
derivationError: typeof record.derivationError === 'string' ? record.derivationError : null,
|
|
122
|
+
};
|
|
123
|
+
return status;
|
|
124
|
+
}
|
|
125
|
+
function asRecord(raw) {
|
|
126
|
+
if (raw === null || raw === undefined || typeof raw !== 'object')
|
|
127
|
+
return null;
|
|
128
|
+
const obj = { ...raw };
|
|
129
|
+
const record = {
|
|
130
|
+
...(typeof obj['latitude'] === 'number' ? { latitude: obj['latitude'] } : {}),
|
|
131
|
+
...(typeof obj['longitude'] === 'number' ? { longitude: obj['longitude'] } : {}),
|
|
132
|
+
...(obj['source'] === 'operator-set' || obj['source'] === 'derived-from-ip'
|
|
133
|
+
? { source: obj['source'] }
|
|
134
|
+
: {}),
|
|
135
|
+
...(typeof obj['updatedAt'] === 'number' ? { updatedAt: obj['updatedAt'] } : {}),
|
|
136
|
+
...(typeof obj['label'] === 'string' ? { label: obj['label'] } : {}),
|
|
137
|
+
...(typeof obj['derivationAttemptedAt'] === 'number'
|
|
138
|
+
? { derivationAttemptedAt: obj['derivationAttemptedAt'] }
|
|
139
|
+
: {}),
|
|
140
|
+
...(typeof obj['derivationError'] === 'string'
|
|
141
|
+
? { derivationError: obj['derivationError'] }
|
|
142
|
+
: {}),
|
|
143
|
+
};
|
|
144
|
+
return record;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse either supported geo-IP payload.
|
|
148
|
+
*
|
|
149
|
+
* `ipinfo.io` answers `{ loc: "40.8518,14.2681", city, region, country }`;
|
|
150
|
+
* `ipapi.co` answers `{ latitude, longitude, city, country_code }`. Both are
|
|
151
|
+
* handled here rather than behind two adapters because the whole difference is
|
|
152
|
+
* three field names, and an adapter per vendor is how a 30-line fetch becomes a
|
|
153
|
+
* folder.
|
|
154
|
+
*/
|
|
155
|
+
function parseGeoIpPayload(payload) {
|
|
156
|
+
if (payload === null || typeof payload !== 'object')
|
|
157
|
+
return null;
|
|
158
|
+
const obj = payload;
|
|
159
|
+
let lat = obj['latitude'];
|
|
160
|
+
let lng = obj['longitude'];
|
|
161
|
+
const loc = obj['loc'];
|
|
162
|
+
if (typeof loc === 'string') {
|
|
163
|
+
const [rawLat, rawLng] = loc.split(',');
|
|
164
|
+
if (rawLat !== undefined && rawLng !== undefined) {
|
|
165
|
+
lat = Number(rawLat);
|
|
166
|
+
lng = Number(rawLng);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!isUsableCoordinate(lat, lng))
|
|
170
|
+
return null;
|
|
171
|
+
const city = obj['city'];
|
|
172
|
+
const country = obj['country'] ?? obj['country_code'];
|
|
173
|
+
const parts = [city, country].filter((p) => typeof p === 'string' && p.length > 0);
|
|
174
|
+
return {
|
|
175
|
+
latitude: Number(lat),
|
|
176
|
+
longitude: Number(lng),
|
|
177
|
+
...(parts.length > 0 ? { label: parts.join(', ') } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* One pass over {@link GEO_IP_ENDPOINTS}. Returns the first fix that parses.
|
|
182
|
+
*
|
|
183
|
+
* Every failure mode collapses to `null` plus a log line: this is a *default*,
|
|
184
|
+
* and a default that throws would take down whatever asked for it.
|
|
185
|
+
*/
|
|
186
|
+
async function geolocatePublicIp(fetchImpl, logger, endpoints = exports.GEO_IP_ENDPOINTS) {
|
|
187
|
+
for (const url of endpoints) {
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetchImpl(url, {
|
|
190
|
+
headers: { Accept: 'application/json' },
|
|
191
|
+
signal: AbortSignal.timeout(exports.GEO_IP_TIMEOUT_MS),
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
logger?.debug('geo-IP endpoint refused', { meta: { url, status: response.status } });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const fix = parseGeoIpPayload(await response.json());
|
|
198
|
+
if (fix !== null)
|
|
199
|
+
return fix;
|
|
200
|
+
logger?.debug('geo-IP endpoint answered without usable coordinates', { meta: { url } });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
logger?.debug('geo-IP endpoint failed', { meta: { url, error: (0, types_1.errMsg)(err) } });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reads and writes the site location, owning the one-shot derivation.
|
|
210
|
+
*
|
|
211
|
+
* Constructed per request by `buildSystemProvider` — the single-flight promise
|
|
212
|
+
* therefore bounds concurrent readers *within* a request, and the persisted
|
|
213
|
+
* `derivationAttemptedAt` bounds them across requests and across restarts. The
|
|
214
|
+
* durable marker is the real guarantee; the in-process one only stops a burst.
|
|
215
|
+
*/
|
|
216
|
+
class SiteLocationService {
|
|
217
|
+
deps;
|
|
218
|
+
static inFlight = null;
|
|
219
|
+
constructor(deps) {
|
|
220
|
+
this.deps = deps;
|
|
221
|
+
}
|
|
222
|
+
/** Test seam: the static single-flight outlives a request by design. */
|
|
223
|
+
static __resetForTests() {
|
|
224
|
+
SiteLocationService.inFlight = null;
|
|
225
|
+
}
|
|
226
|
+
now() {
|
|
227
|
+
return this.deps.now?.() ?? Date.now();
|
|
228
|
+
}
|
|
229
|
+
async read() {
|
|
230
|
+
try {
|
|
231
|
+
return asRecord(await this.deps.store.get({
|
|
232
|
+
collection: SYSTEM_SETTINGS_COLLECTION,
|
|
233
|
+
key: exports.SITE_LOCATION_KEY,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// A failed READ must never look like "not configured" — that would send
|
|
238
|
+
// the derivation off to overwrite a value that exists. Rethrow-as-null is
|
|
239
|
+
// exactly the D49 shape this repo has been bitten by, so the caller is
|
|
240
|
+
// told instead.
|
|
241
|
+
this.deps.logger?.warn('site location read failed', { meta: { error: (0, types_1.errMsg)(err) } });
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async write(record) {
|
|
246
|
+
await this.deps.store.set({
|
|
247
|
+
collection: SYSTEM_SETTINGS_COLLECTION,
|
|
248
|
+
key: exports.SITE_LOCATION_KEY,
|
|
249
|
+
value: record,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The coordinates an operator already typed into `pipeline-analytics`, if any.
|
|
254
|
+
*
|
|
255
|
+
* Lazy migration, read once on the first system-level read that finds nothing:
|
|
256
|
+
* those numbers are `operator-set` by definition, and deriving over them would
|
|
257
|
+
* silently replace a deliberate value with a guess. A failure here is not
|
|
258
|
+
* fatal — it just means the derivation runs.
|
|
259
|
+
*/
|
|
260
|
+
async readLegacyAddonCoordinates() {
|
|
261
|
+
try {
|
|
262
|
+
const raw = await this.deps.store.get({
|
|
263
|
+
namespace: LEGACY_ADDON_NAMESPACE,
|
|
264
|
+
collection: LEGACY_ADDON_COLLECTION,
|
|
265
|
+
key: LEGACY_ADDON_KEY,
|
|
266
|
+
});
|
|
267
|
+
if (raw === null || typeof raw !== 'object')
|
|
268
|
+
return null;
|
|
269
|
+
const obj = raw;
|
|
270
|
+
const lat = Number(obj[LEGACY_LAT_KEY]);
|
|
271
|
+
const lng = Number(obj[LEGACY_LNG_KEY]);
|
|
272
|
+
if (!isUsableCoordinate(lat, lng))
|
|
273
|
+
return null;
|
|
274
|
+
return { latitude: lat, longitude: lng };
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
this.deps.logger?.debug('legacy site coordinates read failed', {
|
|
278
|
+
meta: { error: (0, types_1.errMsg)(err) },
|
|
279
|
+
});
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Current value, deriving a default on the first read that finds nothing.
|
|
285
|
+
*
|
|
286
|
+
* Order: stored value → already-attempted marker (stop, no retry) → lazy
|
|
287
|
+
* migration from the old addon setting → geo-IP.
|
|
288
|
+
*/
|
|
289
|
+
async get() {
|
|
290
|
+
const record = await this.read();
|
|
291
|
+
if (record !== null && isUsableCoordinate(record.latitude, record.longitude)) {
|
|
292
|
+
return toStatus(record);
|
|
293
|
+
}
|
|
294
|
+
if (record !== null && typeof record.derivationAttemptedAt === 'number') {
|
|
295
|
+
// Spent. `detectSiteLocation` is the only way back.
|
|
296
|
+
return toStatus(record);
|
|
297
|
+
}
|
|
298
|
+
const existing = SiteLocationService.inFlight;
|
|
299
|
+
if (existing !== null)
|
|
300
|
+
return existing;
|
|
301
|
+
const run = this.deriveDefault().finally(() => {
|
|
302
|
+
SiteLocationService.inFlight = null;
|
|
303
|
+
});
|
|
304
|
+
SiteLocationService.inFlight = run;
|
|
305
|
+
return run;
|
|
306
|
+
}
|
|
307
|
+
async deriveDefault() {
|
|
308
|
+
const legacy = await this.readLegacyAddonCoordinates();
|
|
309
|
+
if (legacy !== null) {
|
|
310
|
+
this.deps.logger?.info('site location migrated from the pipeline-analytics addon setting — operator-set');
|
|
311
|
+
return this.persist(legacy, 'operator-set');
|
|
312
|
+
}
|
|
313
|
+
return this.derive();
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Run the geo-IP lookup and persist the outcome — success or failure.
|
|
317
|
+
*
|
|
318
|
+
* Persisting the FAILURE is the point: without it every read retries, which
|
|
319
|
+
* is a request per scene check against a free third-party endpoint.
|
|
320
|
+
*/
|
|
321
|
+
async derive() {
|
|
322
|
+
const fetchImpl = this.deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
323
|
+
const fix = await geolocatePublicIp(fetchImpl, this.deps.logger);
|
|
324
|
+
if (fix !== null) {
|
|
325
|
+
this.deps.logger?.info('site location derived from the public IP', {
|
|
326
|
+
meta: { label: fix.label ?? 'unknown', source: 'derived-from-ip' },
|
|
327
|
+
});
|
|
328
|
+
return this.persist(fix, 'derived-from-ip');
|
|
329
|
+
}
|
|
330
|
+
const failure = {
|
|
331
|
+
derivationAttemptedAt: this.now(),
|
|
332
|
+
derivationError: 'no geo-IP endpoint answered with usable coordinates',
|
|
333
|
+
};
|
|
334
|
+
// A branch that DROPS a default silently reads as "never happened".
|
|
335
|
+
this.deps.logger?.warn('site location could not be derived from the public IP — consumers fall back to the UTC clock split until an operator sets it. This will NOT be retried automatically.');
|
|
336
|
+
try {
|
|
337
|
+
await this.write(failure);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
// The marker is what makes it one-shot. Losing it costs a retry on the
|
|
341
|
+
// next read, never a wrong value.
|
|
342
|
+
this.deps.logger?.warn('site location failure marker could not be persisted', {
|
|
343
|
+
meta: { error: (0, types_1.errMsg)(err) },
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return toStatus(failure);
|
|
347
|
+
}
|
|
348
|
+
async persist(fix, source) {
|
|
349
|
+
const record = {
|
|
350
|
+
latitude: fix.latitude,
|
|
351
|
+
longitude: fix.longitude,
|
|
352
|
+
source,
|
|
353
|
+
updatedAt: this.now(),
|
|
354
|
+
...(fix.label !== undefined ? { label: fix.label } : {}),
|
|
355
|
+
derivationAttemptedAt: this.now(),
|
|
356
|
+
};
|
|
357
|
+
await this.write(record);
|
|
358
|
+
return toStatus(record);
|
|
359
|
+
}
|
|
360
|
+
/** Operator input. Clears the value when `input` is `null`. */
|
|
361
|
+
async set(input) {
|
|
362
|
+
if (input === null) {
|
|
363
|
+
// The derivation stays spent: clearing means "I do not want coordinates",
|
|
364
|
+
// not "guess again". Detect is the button for that.
|
|
365
|
+
const cleared = { derivationAttemptedAt: this.now() };
|
|
366
|
+
await this.write(cleared);
|
|
367
|
+
return toStatus(cleared);
|
|
368
|
+
}
|
|
369
|
+
return this.persist({ latitude: input.latitude, longitude: input.longitude }, 'operator-set');
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Explicit re-derivation. The only retry path.
|
|
373
|
+
*
|
|
374
|
+
* Refuses to overwrite an `operator-set` value — a detect button next to a
|
|
375
|
+
* typed coordinate must not be able to destroy it by mis-click. The operator
|
|
376
|
+
* clears first, then detects.
|
|
377
|
+
*/
|
|
378
|
+
async detect() {
|
|
379
|
+
const record = await this.read();
|
|
380
|
+
if (record !== null &&
|
|
381
|
+
record.source === 'operator-set' &&
|
|
382
|
+
isUsableCoordinate(record.latitude, record.longitude)) {
|
|
383
|
+
this.deps.logger?.info('site location detect skipped — an operator-set value already exists');
|
|
384
|
+
return toStatus(record);
|
|
385
|
+
}
|
|
386
|
+
return this.derive();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
exports.SiteLocationService = SiteLocationService;
|
|
@@ -4067,6 +4067,14 @@ function createCapRouter_llm(getProvider, _createRemoteProxy) {
|
|
|
4067
4067
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
4068
4068
|
return p.generateVision(input);
|
|
4069
4069
|
}),
|
|
4070
|
+
cancel: trpc_middleware_js_1.protectedProcedure
|
|
4071
|
+
.input(types_55.llmCapability.methods.cancel.input.loose())
|
|
4072
|
+
.output(types_55.llmCapability.methods.cancel.output)
|
|
4073
|
+
.mutation(async ({ input, ctx }) => {
|
|
4074
|
+
const p = requireCapProvider('llm', () => getProvider(ctx));
|
|
4075
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
4076
|
+
return p.cancel(input);
|
|
4077
|
+
}),
|
|
4070
4078
|
listProfileKinds: trpc_middleware_js_1.protectedProcedure
|
|
4071
4079
|
.input(types_55.llmCapability.methods.listProfileKinds.input.loose())
|
|
4072
4080
|
.output(types_55.llmCapability.methods.listProfileKinds.output)
|
|
@@ -8892,6 +8900,27 @@ function createCapRouter_system(getProvider, createRemoteProxy) {
|
|
|
8892
8900
|
const p = resolveProvider('system', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
8893
8901
|
return p.forceRetentionCleanup();
|
|
8894
8902
|
}),
|
|
8903
|
+
getSiteLocation: trpc_middleware_js_1.protectedProcedure
|
|
8904
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
8905
|
+
.output(types_109.systemCapability.methods.getSiteLocation.output)
|
|
8906
|
+
.query(async ({ input, ctx }) => {
|
|
8907
|
+
const p = resolveProvider('system', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
8908
|
+
return p.getSiteLocation();
|
|
8909
|
+
}),
|
|
8910
|
+
setSiteLocation: trpc_middleware_js_1.adminProcedure
|
|
8911
|
+
.input(types_109.systemCapability.methods.setSiteLocation.input)
|
|
8912
|
+
.output(types_109.systemCapability.methods.setSiteLocation.output)
|
|
8913
|
+
.mutation(async ({ input, ctx }) => {
|
|
8914
|
+
const p = requireCapProvider('system', () => getProvider(ctx));
|
|
8915
|
+
return p.setSiteLocation(input);
|
|
8916
|
+
}),
|
|
8917
|
+
detectSiteLocation: trpc_middleware_js_1.adminProcedure
|
|
8918
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
8919
|
+
.output(types_109.systemCapability.methods.detectSiteLocation.output)
|
|
8920
|
+
.mutation(async ({ input, ctx }) => {
|
|
8921
|
+
const p = resolveProvider('system', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
8922
|
+
return p.detectSiteLocation();
|
|
8923
|
+
}),
|
|
8895
8924
|
});
|
|
8896
8925
|
}
|
|
8897
8926
|
function createCapRouter_tamper(getProvider, createRemoteProxy) {
|
|
@@ -191,7 +191,7 @@ function buildServerProviders(services) {
|
|
|
191
191
|
const wrap = (fn) => (ctx) => fn(ctx);
|
|
192
192
|
return {
|
|
193
193
|
'network-quality': wrap(() => (0, cap_providers_js_1.buildNetworkQualityProvider)(services.networkQualityService)),
|
|
194
|
-
system: wrap(() => (0, cap_providers_js_1.buildSystemProvider)(services.featureService, services.capabilityRegistry)),
|
|
194
|
+
system: wrap(() => (0, cap_providers_js_1.buildSystemProvider)(services.featureService, services.capabilityRegistry, services.moleculer, services.loggingService.createLogger('site-location'))),
|
|
195
195
|
toast: wrap((ctx) => (0, cap_providers_js_1.buildToastProvider)(services.toastService, ctx)),
|
|
196
196
|
integrations: wrap(() => (0, cap_providers_js_1.buildIntegrationsProvider)(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry)),
|
|
197
197
|
nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService), services.loggingService.createLogger('nodes'))),
|
|
@@ -26,6 +26,21 @@ const RowDataSchema = zod_1.z.object({
|
|
|
26
26
|
lastActive: zod_1.z.number(),
|
|
27
27
|
descriptor: DescriptorSchema,
|
|
28
28
|
});
|
|
29
|
+
/**
|
|
30
|
+
* @durable class=ledger owner=cluster-agent
|
|
31
|
+
* write="one row per node the hub has ever seen online, GATED BY A READ-BACK.
|
|
32
|
+
* `snapshot()` runs on every 30 s topology pass but commits only when there is no
|
|
33
|
+
* existing row, when the descriptor differs field-by-field, or when the stored
|
|
34
|
+
* `lastActive` has aged past LAST_ACTIVE_HEARTBEAT_MS (5 min) — an unchanged online
|
|
35
|
+
* node therefore writes 288 times a day instead of 2,880, ~864/day for three rows
|
|
36
|
+
* (D131). Off that gate: `touch()` on `$node.disconnected` (the offline edge, always
|
|
37
|
+
* written), `recordPackages` on a node's own declaration (grow-only merge) and
|
|
38
|
+
* `prunePackages` on an explicit undeploy"
|
|
39
|
+
* retention="none — nothing ages a row out; the only deletion is the operator's
|
|
40
|
+
* `forget(nodeId)` behind the inline Forget-node action. Losing the table makes every
|
|
41
|
+
* node that is not in the live Moleculer window vanish from the topology instead of
|
|
42
|
+
* rendering OFFLINE, and empties the package roster the addon back-fill converges to."
|
|
43
|
+
*/
|
|
29
44
|
const COLLECTION = 'cluster-node-history';
|
|
30
45
|
/**
|
|
31
46
|
* How stale `lastActive` may get on an ONLINE node before an otherwise
|
|
@@ -60,7 +60,35 @@ exports.ShareTokenService = exports.ShareTokenRecordSchema = exports.ShareTokenS
|
|
|
60
60
|
*/
|
|
61
61
|
const crypto = __importStar(require("node:crypto"));
|
|
62
62
|
const zod_1 = require("zod");
|
|
63
|
+
/**
|
|
64
|
+
* @durable class=config owner=core-auth
|
|
65
|
+
* write="a user mints a share link; and a best-effort lastUsedAt touch on every successful validation"
|
|
66
|
+
* retention="none — no sweep and no TTL reaper. A row goes only when the owner or an admin revokes it. expiresAt is enforced at VALIDATION, so an expired token keeps its row and keeps being scanned; the table is bounded only by how many links people mint."
|
|
67
|
+
*/
|
|
63
68
|
const SHARE_TOKENS_COLLECTION = 'share_view_tokens';
|
|
69
|
+
/**
|
|
70
|
+
* The two predicates this service actually issues, indexed.
|
|
71
|
+
*
|
|
72
|
+
* The collection is KV-shaped — `(id TEXT PK, data TEXT)` — so `tokenHash` and
|
|
73
|
+
* `userId` live inside the `data` blob and the backend rewrites both predicates
|
|
74
|
+
* to `json_extract`. Declaring an index on a blob field makes the backend emit
|
|
75
|
+
* a matching EXPRESSION index (`sqlite-settings-backend.ts`, `ensureTable`),
|
|
76
|
+
* which is the only kind SQLite can use for these queries.
|
|
77
|
+
*
|
|
78
|
+
* Why it matters: `validate()` runs on EVERY request carrying a `csv_*` token,
|
|
79
|
+
* and without this it was a full-table scan that parsed the JSON of every row
|
|
80
|
+
* to compare one hash. Six rows today, so it never showed up — but it is the
|
|
81
|
+
* authentication path for share links, so it is the one that gets hit hardest
|
|
82
|
+
* the day a link is shared widely.
|
|
83
|
+
*
|
|
84
|
+
* Adding indexes is NOT a shape change: the column list is untouched, so
|
|
85
|
+
* `ensureTable` takes neither the rebuild nor the `ALTER TABLE` branch, and no
|
|
86
|
+
* row is migrated or lost.
|
|
87
|
+
*/
|
|
88
|
+
const SHARE_TOKENS_INDEXES = [
|
|
89
|
+
{ name: 'idx_share_view_tokens_hash', columns: ['tokenHash'] },
|
|
90
|
+
{ name: 'idx_share_view_tokens_user', columns: ['userId'] },
|
|
91
|
+
];
|
|
64
92
|
/** Wire prefix — `csv_` = CamStack Share View (cf. `cst_` scoped tokens). */
|
|
65
93
|
exports.SHARE_TOKEN_PREFIX = 'csv_';
|
|
66
94
|
// ── TTL / scope bounds (enforced at mint, re-checked by the router schema) ──
|
|
@@ -140,6 +168,7 @@ class ShareTokenService {
|
|
|
140
168
|
{ name: 'id', type: 'TEXT', primaryKey: true, notNull: true },
|
|
141
169
|
{ name: 'data', type: 'TEXT', notNull: true },
|
|
142
170
|
],
|
|
171
|
+
indexes: SHARE_TOKENS_INDEXES,
|
|
143
172
|
});
|
|
144
173
|
this.collectionDeclared = true;
|
|
145
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.111",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.60",
|
|
37
37
|
"@camstack/addon-agent-ui": "1.2.17",
|
|
38
|
-
"@camstack/addon-auth": "1.2.
|
|
38
|
+
"@camstack/addon-auth": "1.2.19",
|
|
39
39
|
"@camstack/addon-decoder-nodeav": "1.2.16",
|
|
40
40
|
"@camstack/addon-notifiers": "1.2.21",
|
|
41
|
-
"@camstack/addon-pipeline": "1.2.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.77",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.58",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.76",
|
|
44
44
|
"@camstack/sdk": "1.2.19",
|
|
45
45
|
"@camstack/shm-ring": "1.1.16",
|
|
46
|
-
"@camstack/system": "1.2.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.91",
|
|
47
|
+
"@camstack/types": "1.2.73",
|
|
48
|
+
"@camstack/ui-library": "1.2.51",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|