@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.
@@ -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.110",
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.59",
36
+ "@camstack/addon-admin-ui": "1.2.60",
37
37
  "@camstack/addon-agent-ui": "1.2.17",
38
- "@camstack/addon-auth": "1.2.18",
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.76",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.57",
43
- "@camstack/addon-post-analysis": "1.2.75",
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.90",
47
- "@camstack/types": "1.2.72",
48
- "@camstack/ui-library": "1.2.50",
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",