@onlineapps/service-common 1.1.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,96 @@ Waits for all infrastructure services to be reported as healthy by Registry.
67
67
  - `INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME` - Maximum wait time in ms
68
68
  - `INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL` - Check interval in ms
69
69
 
70
+ ### Scoped registry — `isVisible` / `sqlVisible` and friends
71
+
72
+ The single home of the registry visibility rule: *a registry row is visible to a
73
+ caller `{tenant_id, workspace_id}` iff it is `system`, or it is owned by exactly
74
+ that workspace.* Business services must not hand-write this predicate.
75
+
76
+ Normative contract: [`api/docs/biz/30-operations/scoped-registry.md`](../../docs/biz/30-operations/scoped-registry.md).
77
+
78
+ | Export | Purpose |
79
+ |---|---|
80
+ | `isVisible(record, ctx)` | pure predicate — works over SQL rows, Redis, config files, connector responses |
81
+ | `filterVisible(records, ctx)` | apply the predicate to a collection |
82
+ | `pickVisibleOne(records, ctx)` | read-time resolution; throws on 0 or >1 visible rows (no precedence, no `LIMIT 1`) |
83
+ | `sqlVisible(alias)` | the same rule as a `WHERE` fragment, for DB pushdown |
84
+ | `sqlVisibleParams(ctx)` | named replacements (`__vis_tid`, `__vis_wid`) for that fragment |
85
+ | `assertScopeTriple(record)` | write-time invariant: `scope='system' ⟺ owner columns NULL` |
86
+ | `assertUniqueInScope(existingVisible, candidate, keyFn)` | write-time collision guard — refuses a definition that would shadow a visible one |
87
+
88
+ ```js
89
+ const { sqlVisible, sqlVisibleParams } = require('@onlineapps/service-common');
90
+
91
+ const rows = await sequelize.query(
92
+ `SELECT uuid, code FROM ing_connector c WHERE ${sqlVisible('c')}`,
93
+ { replacements: sqlVisibleParams(ctx), type: QueryTypes.SELECT }
94
+ );
95
+ ```
96
+
97
+ The predicate is the source of truth; the SQL fragment is a pushdown
98
+ optimization. A parity unit test evaluates the fragment in-memory with SQL
99
+ three-valued logic and fails if the two renderings ever disagree.
100
+
101
+ All helpers fail fast: a missing or non-integer `tenant_id`/`workspace_id`
102
+ throws rather than degrading into "system rows only", which would silently hide
103
+ a workspace's own definitions.
104
+
105
+ ### Registry reader — `createRegistryReader(options)`
106
+
107
+ The single Redis-backed reader of Service Registry specifications. The Registry
108
+ (`api_services_registry`) is the only writer; every consumer — infrastructure or
109
+ business — reads the same two key shapes:
110
+
111
+ | Key | Type | Content |
112
+ |---|---|---|
113
+ | `<keyPrefix>services` | HASH | per-service summary |
114
+ | `<keyPrefix>service:<name>:spec` | STRING | full service specification |
115
+
116
+ Normative contract: [`api/docs/biz/30-operations/registration-wire.md`](../../docs/biz/30-operations/registration-wire.md)
117
+ §4 (storage model) and §7 (consumer contract); namespace per
118
+ [`api/docs/standards/redis-key-contract.md`](../../docs/standards/redis-key-contract.md).
119
+
120
+ ```js
121
+ const { createRegistryReader } = require('@onlineapps/service-common');
122
+
123
+ const reader = createRegistryReader({
124
+ keyPrefix: requireEnv('REDIS_REGISTRY_KEY_PREFIX'), // REQUIRED — never assumed
125
+ redisUrl: requireEnv('REDIS_URL'), // or: client: <connected node-redis v4 client>
126
+ logger // REQUIRED — no console fallback
127
+ });
128
+ await reader.connect();
129
+
130
+ const op = await reader.getOperation('biz-invoicing', 'create-invoice');
131
+ ```
132
+
133
+ | Export | Purpose |
134
+ |---|---|
135
+ | `connect()` | opens the owned connection (hard `connectTimeoutMs` ceiling), or validates an injected client |
136
+ | `getServiceSpec(name)` | full spec, or `null` when the service is not registered |
137
+ | `getOperation(name, key)` | one operation record, or `null` |
138
+ | `listSummary()` | parsed `<keyPrefix>services` hash |
139
+ | `listAll()` | `[{ name, summary, spec }]` for every summarised service |
140
+ | `listWorkspaceScopedServices()` | `[{ name, version, description }]` for `workspaceScoped === true`, sorted by name; `version`/`description` come from `spec.metadata` (§4: `metadata` is required) |
141
+ | `invalidateCache(name?)` | drop one service or the whole cache — for `registry.changes` listeners |
142
+ | `close()` | quits the connection **only** when the reader created it |
143
+
144
+ **Connection ownership:** pass exactly one of `redisUrl` (the reader owns and
145
+ closes the client) or `client` (the caller owns it; `close()` never quits it).
146
+
147
+ **Failure model — an outage is not a cache miss:**
148
+
149
+ | Situation | Answer |
150
+ |---|---|
151
+ | key absent | `null`, remembered for `negativeTtlMs` (default 30 s) |
152
+ | Redis transport error | the error **propagates** — never `null` |
153
+ | unparseable JSON | throws, naming the key |
154
+ | Redis unreachable at startup | `connect()` rejects after `connectTimeoutMs` (default 15 s) |
155
+
156
+ `keyPrefix` and `logger` are mandatory and `ttlMs` / `negativeTtlMs` /
157
+ `connectTimeoutMs` are validated: an invalid value throws instead of being
158
+ silently replaced by the module default (`src/defaults.js` owns the defaults).
159
+
70
160
  ## Architecture
71
161
 
72
162
  This library is used by:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@onlineapps/service-common",
3
- "version": "1.1.3",
4
- "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis/Postgres clients, business errors, runtime config)",
3
+ "version": "2.0.0",
4
+ "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis client, business errors, runtime config)",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
package/src/defaults.js CHANGED
@@ -20,6 +20,16 @@ module.exports = {
20
20
 
21
21
  infrastructureHealthQueueWaitMaxTimeMs: 60000,
22
22
  infrastructureHealthQueueWaitCheckIntervalMs: 2000,
23
+
24
+ // registryReader in-memory cache (see ./registryReader.js).
25
+ // Positive entries live 5 minutes; a "service not registered" answer is
26
+ // remembered for 30 s only, so a fresh registration becomes visible quickly.
27
+ registryReaderTtlMs: 300000,
28
+ registryReaderNegativeTtlMs: 30000,
29
+ // Hard ceiling for the initial connect when the reader owns the client, so a
30
+ // dead Redis fails startup instead of retrying forever (same 15 s ceiling as
31
+ // connectRedis in ./redisClient.js).
32
+ registryReaderConnectTimeoutMs: 15000,
23
33
  };
24
34
 
25
35
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // See: docs/standards/error-handling-contract.md
3
+ // See: docs/biz/70-contracts/error-handling.md
4
4
  const ERROR_TYPES = {
5
5
  TRANSIENT: 'TRANSIENT',
6
6
  BUSINESS: 'BUSINESS',
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // See: docs/standards/error-handling-contract.md
3
+ // See: docs/biz/70-contracts/error-handling.md
4
4
  const { BusinessError, isBusinessError } = require('./BusinessError');
5
5
 
6
6
  function businessErrorHandler(err, req, res, next) {
package/src/index.js CHANGED
@@ -17,11 +17,7 @@ const {
17
17
  createRedisClient,
18
18
  connectRedis
19
19
  } = require('./redisClient');
20
- const {
21
- buildPostgresUrl,
22
- createPostgresPool,
23
- connectPostgres
24
- } = require('./postgresClient');
20
+ const { createRegistryReader } = require('./registryReader');
25
21
  const {
26
22
  requireEnv,
27
23
  requireNumberEnv,
@@ -56,6 +52,18 @@ const {
56
52
  sensitiveFieldsForOperation,
57
53
  redactSensitiveDeep
58
54
  } = require('./redactSensitive');
55
+ const {
56
+ isVisible,
57
+ filterVisible,
58
+ pickVisibleOne,
59
+ sqlVisible,
60
+ sqlVisibleParams,
61
+ assertScopeTriple,
62
+ assertUniqueInScope,
63
+ ScopedRegistryError,
64
+ SCOPE_SYSTEM,
65
+ SCOPE_WORKSPACE
66
+ } = require('./scopedRegistry');
59
67
 
60
68
  module.exports = {
61
69
  // Infrastructure readiness utilities (used by both infrastructure and business services)
@@ -67,10 +75,9 @@ module.exports = {
67
75
  createRedisClient,
68
76
  connectRedis,
69
77
 
70
- // PostgreSQL utilities (shared between services and tests)
71
- buildPostgresUrl,
72
- createPostgresPool,
73
- connectPostgres,
78
+ // Service Registry reader (the one Redis-backed reader of registry specs)
79
+ // See: docs/biz/30-operations/registration-wire.md §4/§7
80
+ createRegistryReader,
74
81
 
75
82
  // Configuration helpers (NO FALLBACKS for critical infrastructure)
76
83
  requireEnv,
@@ -109,7 +116,20 @@ module.exports = {
109
116
  // See: docs/architecture/secretbox.md §8
110
117
  REDACTED_PLACEHOLDER,
111
118
  sensitiveFieldsForOperation,
112
- redactSensitiveDeep
119
+ redactSensitiveDeep,
120
+
121
+ // Scoped-registry visibility rule (system ∪ own workspace)
122
+ // See: docs/biz/30-operations/scoped-registry.md
123
+ isVisible,
124
+ filterVisible,
125
+ pickVisibleOne,
126
+ sqlVisible,
127
+ sqlVisibleParams,
128
+ assertScopeTriple,
129
+ assertUniqueInScope,
130
+ ScopedRegistryError,
131
+ SCOPE_SYSTEM,
132
+ SCOPE_WORKSPACE
113
133
  };
114
134
 
115
135
 
@@ -18,7 +18,7 @@
18
18
  * @returns {object} { tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }
19
19
  * @throws {Error} with .statusCode = 400, 401, or 403
20
20
  */
21
- // See: docs/standards/tenant-context-contract.md
21
+ // See: docs/biz/20-tenancy/tenant-context.md
22
22
  function extractTenantContext(auth, headers, options) {
23
23
  const { requireWorkspace = true } = options || {};
24
24
 
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * registryReader
5
+ * --------------
6
+ * The one Redis-backed reader of the Service Registry specifications.
7
+ *
8
+ * The Registry (`api_services_registry`) is the single writer; every consumer —
9
+ * infrastructure or business — reads the same two key shapes:
10
+ *
11
+ * `<keyPrefix>services` HASH — per-service summary
12
+ * `<keyPrefix>service:<name>:spec` STRING — full service specification
13
+ *
14
+ * The prefix is NEVER assumed. It is injected by the caller (the platform value
15
+ * lives in REDIS_REGISTRY_KEY_PREFIX), because a reader that hardcodes a
16
+ * namespace cannot be pointed at a second one — not in a test, not in a second
17
+ * environment.
18
+ *
19
+ * Failure model (fail-fast, architecture-principles §3/§4):
20
+ * - key absent → `null`, remembered for `negativeTtlMs`
21
+ * - Redis transport error → the error PROPAGATES (never `null`)
22
+ * - unparseable JSON → throws, naming the key
23
+ *
24
+ * An infrastructure outage and "this service is not registered" are different
25
+ * facts and must not collapse into the same answer.
26
+ *
27
+ * @see api/docs/biz/30-operations/registration-wire.md §4 Redis storage model
28
+ * @see api/docs/biz/30-operations/registration-wire.md §7 Consumer contract
29
+ * @see api/docs/standards/redis-key-contract.md — registry namespace
30
+ */
31
+
32
+ const { createClient } = require('redis');
33
+ const DEFAULTS = require('./defaults');
34
+
35
+ const CONTEXT = '[service-common][registryReader]';
36
+
37
+ function requireKeyPrefix(keyPrefix) {
38
+ if (typeof keyPrefix !== 'string' || keyPrefix.trim().length === 0) {
39
+ throw new Error(
40
+ `${CONTEXT} Missing required option - keyPrefix must be a non-empty string. `
41
+ + 'Fix: pass the registry key namespace explicitly, e.g. the value of REDIS_REGISTRY_KEY_PREFIX '
42
+ + `(got ${JSON.stringify(keyPrefix)}).`
43
+ );
44
+ }
45
+ return keyPrefix;
46
+ }
47
+
48
+ function requireLogger(logger) {
49
+ const missing = ['info', 'warn', 'error'].filter(
50
+ (method) => !logger || typeof logger[method] !== 'function'
51
+ );
52
+ if (missing.length > 0) {
53
+ throw new Error(
54
+ `${CONTEXT} Missing dependency - logger is required and must implement ${missing.join(', ')} `
55
+ + '(no console fallback). Fix: inject the service logger.'
56
+ );
57
+ }
58
+ return logger;
59
+ }
60
+
61
+ function requireTtl(value, name, fallbackDefault) {
62
+ if (value === undefined) return fallbackDefault;
63
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
64
+ throw new Error(
65
+ `${CONTEXT} Invalid option - ${name} must be a finite positive number of milliseconds `
66
+ + `(got ${JSON.stringify(value)}). Fix: omit it to use the module default, or pass a number > 0.`
67
+ );
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function requireClientContract(client) {
73
+ const missing = ['get', 'hGetAll'].filter((method) => typeof client[method] !== 'function');
74
+ if (missing.length > 0) {
75
+ throw new Error(
76
+ `${CONTEXT} Redis client contract mismatch - injected client is missing: ${missing.join(', ')}. `
77
+ + 'Fix: inject a connected node-redis v4 client.'
78
+ );
79
+ }
80
+ return client;
81
+ }
82
+
83
+ function requireNonEmptyString(value, label) {
84
+ if (typeof value !== 'string' || value.length === 0) {
85
+ throw new Error(
86
+ `${CONTEXT} Invalid argument - ${label} must be a non-empty string (got ${JSON.stringify(value)}).`
87
+ );
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function parseOrThrow(raw, key, what) {
93
+ try {
94
+ return JSON.parse(raw);
95
+ } catch (err) {
96
+ throw new Error(
97
+ `${CONTEXT} Corrupt registry data - ${what} at ${key} is not valid JSON (${err.message}). `
98
+ + 'Fix: the Registry is the single writer of this key — re-register the service.'
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create a registry reader.
105
+ *
106
+ * Exactly one connection source must be given:
107
+ * - `redisUrl` — the reader creates and owns the client (closed by `close()`)
108
+ * - `client` — an already connected node-redis v4 client owned by the caller
109
+ * (`close()` never quits it)
110
+ *
111
+ * @param {object} options
112
+ * @param {string} options.keyPrefix Registry key namespace (REQUIRED, no default)
113
+ * @param {string} [options.redisUrl] Redis connection URL
114
+ * @param {object} [options.client] Connected node-redis v4 client
115
+ * @param {object} options.logger Logger implementing info/warn/error (REQUIRED)
116
+ * @param {number} [options.ttlMs] TTL of a cached spec (default: defaults.registryReaderTtlMs)
117
+ * @param {number} [options.negativeTtlMs] TTL of a cached "not registered" (default: defaults.registryReaderNegativeTtlMs)
118
+ * @returns {object} reader
119
+ */
120
+ function createRegistryReader(options) {
121
+ if (!options || typeof options !== 'object') {
122
+ throw new Error(
123
+ `${CONTEXT} Missing options - createRegistryReader({ keyPrefix, redisUrl|client, logger }) is required.`
124
+ );
125
+ }
126
+
127
+ const { keyPrefix, redisUrl, client, logger, ttlMs, negativeTtlMs, connectTimeoutMs } = options;
128
+
129
+ requireKeyPrefix(keyPrefix);
130
+
131
+ const hasUrl = redisUrl !== undefined;
132
+ const hasClient = client !== undefined;
133
+ if (hasUrl === hasClient) {
134
+ throw new Error(
135
+ `${CONTEXT} Invalid connection source - pass exactly one of redisUrl or client `
136
+ + `(redisUrl: ${hasUrl ? 'set' : 'absent'}, client: ${hasClient ? 'set' : 'absent'}). `
137
+ + 'Fix: let the reader own the connection (redisUrl) or inject an existing one (client).'
138
+ );
139
+ }
140
+ if (hasUrl && (typeof redisUrl !== 'string' || redisUrl.trim().length === 0)) {
141
+ throw new Error(
142
+ `${CONTEXT} Invalid option - redisUrl must be a non-empty string (got ${JSON.stringify(redisUrl)}).`
143
+ );
144
+ }
145
+ if (hasClient) {
146
+ if (!client || typeof client !== 'object') {
147
+ throw new Error(`${CONTEXT} Invalid option - client must be a connected node-redis v4 client object.`);
148
+ }
149
+ requireClientContract(client);
150
+ }
151
+
152
+ const log = requireLogger(logger);
153
+ const positiveTtl = requireTtl(ttlMs, 'ttlMs', DEFAULTS.registryReaderTtlMs);
154
+ const negativeTtl = requireTtl(negativeTtlMs, 'negativeTtlMs', DEFAULTS.registryReaderNegativeTtlMs);
155
+ const connectTimeout = requireTtl(
156
+ connectTimeoutMs, 'connectTimeoutMs', DEFAULTS.registryReaderConnectTimeoutMs
157
+ );
158
+
159
+ const ownsClient = hasUrl;
160
+ let redis = hasClient ? client : null;
161
+ let connected = false;
162
+
163
+ /** @type {Map<string, {spec: object|null, expiresAt: number}>} */
164
+ const specCache = new Map();
165
+ let summaryCache = null;
166
+
167
+ const summaryKey = () => `${keyPrefix}services`;
168
+ const specKey = (serviceName) => `${keyPrefix}service:${serviceName}:spec`;
169
+
170
+ function assertConnected(method) {
171
+ if (!connected) {
172
+ throw new Error(
173
+ `${CONTEXT} Reader is not connected - ${method} was called before connect(). `
174
+ + 'Fix: await reader.connect() during service startup.'
175
+ );
176
+ }
177
+ }
178
+
179
+ async function connect() {
180
+ if (connected) return;
181
+
182
+ if (ownsClient) {
183
+ const created = createClient({ url: redisUrl, socket: { connectTimeout } });
184
+ created.on('error', (err) => {
185
+ log.error(`${CONTEXT} Redis client error: ${err.message}`);
186
+ });
187
+
188
+ // node-redis keeps retrying a refused connection forever, so the initial
189
+ // connect gets its own hard ceiling — startup must fail, not hang.
190
+ let timeoutHandle = null;
191
+ const deadline = new Promise((_resolve, reject) => {
192
+ timeoutHandle = setTimeout(() => reject(new Error(
193
+ `${CONTEXT} Redis connection timeout - no connection to ${redisUrl} within ${connectTimeout}ms. `
194
+ + 'Fix: check the Redis address and that the instance is running.'
195
+ )), connectTimeout);
196
+ });
197
+
198
+ try {
199
+ await Promise.race([created.connect(), deadline]);
200
+ } catch (err) {
201
+ try {
202
+ await created.disconnect();
203
+ } catch (_ignored) {
204
+ // The client never opened; nothing to release.
205
+ }
206
+ throw err;
207
+ } finally {
208
+ clearTimeout(timeoutHandle);
209
+ }
210
+ redis = created;
211
+ }
212
+
213
+ connected = true;
214
+ log.info(`${CONTEXT} Ready (keyPrefix="${keyPrefix}", ttl=${positiveTtl}ms, negativeTtl=${negativeTtl}ms)`);
215
+ }
216
+
217
+ async function getServiceSpec(serviceName) {
218
+ assertConnected('getServiceSpec()');
219
+ requireNonEmptyString(serviceName, 'serviceName');
220
+
221
+ const now = Date.now();
222
+ const cached = specCache.get(serviceName);
223
+ if (cached && cached.expiresAt > now) {
224
+ return cached.spec;
225
+ }
226
+
227
+ const key = specKey(serviceName);
228
+ // Transport errors propagate on purpose — see the failure model above.
229
+ const raw = await redis.get(key);
230
+
231
+ if (raw === null || raw === undefined) {
232
+ specCache.set(serviceName, { spec: null, expiresAt: now + negativeTtl });
233
+ return null;
234
+ }
235
+
236
+ const spec = parseOrThrow(raw, key, 'service spec');
237
+ specCache.set(serviceName, { spec, expiresAt: now + positiveTtl });
238
+ return spec;
239
+ }
240
+
241
+ async function getOperation(serviceName, operationKey) {
242
+ assertConnected('getOperation()');
243
+ requireNonEmptyString(serviceName, 'serviceName');
244
+ requireNonEmptyString(operationKey, 'operationKey');
245
+
246
+ const spec = await getServiceSpec(serviceName);
247
+ const operations = spec && spec.operations;
248
+ if (!operations || typeof operations !== 'object') {
249
+ return null;
250
+ }
251
+ if (!Object.prototype.hasOwnProperty.call(operations, operationKey)) {
252
+ return null;
253
+ }
254
+ return operations[operationKey];
255
+ }
256
+
257
+ async function listSummary() {
258
+ assertConnected('listSummary()');
259
+
260
+ const now = Date.now();
261
+ if (summaryCache && summaryCache.expiresAt > now) {
262
+ return summaryCache.services;
263
+ }
264
+
265
+ const key = summaryKey();
266
+ const raw = await redis.hGetAll(key);
267
+
268
+ const services = {};
269
+ for (const [name, json] of Object.entries(raw || {})) {
270
+ services[name] = parseOrThrow(json, key, `summary entry "${name}"`);
271
+ }
272
+
273
+ summaryCache = { services, expiresAt: now + positiveTtl };
274
+ return services;
275
+ }
276
+
277
+ async function listAll() {
278
+ assertConnected('listAll()');
279
+ const summary = await listSummary();
280
+ return Promise.all(
281
+ Object.keys(summary).map(async (name) => ({
282
+ name,
283
+ summary: summary[name],
284
+ spec: await getServiceSpec(name)
285
+ }))
286
+ );
287
+ }
288
+
289
+ async function listWorkspaceScopedServices() {
290
+ assertConnected('listWorkspaceScopedServices()');
291
+ const all = await listAll();
292
+
293
+ const scoped = [];
294
+ for (const { name, spec } of all) {
295
+ if (!spec || spec.workspaceScoped !== true) continue;
296
+
297
+ const metadata = spec.metadata;
298
+ const version = metadata && metadata.version;
299
+ const description = metadata && metadata.description;
300
+ if (typeof version !== 'string' || version.length === 0
301
+ || typeof description !== 'string' || description.length === 0) {
302
+ throw new Error(
303
+ `${CONTEXT} Invalid service spec - service "${name}" declares workspaceScoped: true but its `
304
+ + 'metadata block does not carry both version and description '
305
+ + '(registration-wire.md §4: metadata is required). Fix: correct config.json and re-register.'
306
+ );
307
+ }
308
+ scoped.push({ name, version, description });
309
+ }
310
+
311
+ scoped.sort((a, b) => a.name.localeCompare(b.name));
312
+ return scoped;
313
+ }
314
+
315
+ function invalidateCache(serviceName) {
316
+ if (serviceName) {
317
+ specCache.delete(serviceName);
318
+ return;
319
+ }
320
+ specCache.clear();
321
+ summaryCache = null;
322
+ }
323
+
324
+ async function close() {
325
+ if (ownsClient && redis && connected) {
326
+ try {
327
+ await redis.quit();
328
+ } catch (err) {
329
+ log.warn(`${CONTEXT} Redis quit error: ${err.message}`);
330
+ }
331
+ redis = null;
332
+ }
333
+ connected = false;
334
+ specCache.clear();
335
+ summaryCache = null;
336
+ }
337
+
338
+ return {
339
+ connect,
340
+ getServiceSpec,
341
+ getOperation,
342
+ listSummary,
343
+ listAll,
344
+ listWorkspaceScopedServices,
345
+ invalidateCache,
346
+ close,
347
+ get keyPrefix() { return keyPrefix; },
348
+ get ttlMs() { return positiveTtl; },
349
+ get negativeTtlMs() { return negativeTtl; },
350
+ get isConnected() { return connected; },
351
+ get cacheSize() { return specCache.size; }
352
+ };
353
+ }
354
+
355
+ module.exports = { createRegistryReader };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Error raised by the scoped-registry contract helpers.
5
+ *
6
+ * Message format follows ARCHITECTURE_PRINCIPLES §5:
7
+ * [ScopedRegistry] Problem - Expected/Fix.
8
+ *
9
+ * `details` carries machine-readable context so a business service can map it
10
+ * onto its own domain error codes without re-parsing the message.
11
+ */
12
+ class ScopedRegistryError extends Error {
13
+ constructor(message, details = {}) {
14
+ super(message);
15
+ this.name = 'ScopedRegistryError';
16
+ this.details = details;
17
+ Error.captureStackTrace(this, ScopedRegistryError);
18
+ }
19
+ }
20
+
21
+ module.exports = { ScopedRegistryError };
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Scoped Registry — the one place the registry visibility rule lives.
5
+ *
6
+ * Contract: api/docs/biz/30-operations/scoped-registry.md
7
+ *
8
+ * A registry row is visible to a caller {tenant_id, workspace_id} iff it is
9
+ * `system`, or it is owned by exactly that workspace.
10
+ *
11
+ * The rule is rendered two ways: `isVisible` (pure predicate, works over any
12
+ * source — SQL rows, Redis, config files, connector responses) and `sqlVisible`
13
+ * (WHERE fragment, a pushdown optimization for DB-backed registries). The
14
+ * predicate is the source of truth; a parity unit test asserts the two agree.
15
+ *
16
+ * Pure module: no config, no env, no state (ARCHITECTURE_PRINCIPLES §1, §3).
17
+ */
18
+
19
+ const { ScopedRegistryError } = require('./ScopedRegistryError');
20
+
21
+ const SCOPE_SYSTEM = 'system';
22
+ const SCOPE_WORKSPACE = 'workspace';
23
+ const KNOWN_SCOPES = [SCOPE_SYSTEM, SCOPE_WORKSPACE];
24
+
25
+ // SQL named replacements. Prefixed to avoid colliding with a caller's own
26
+ // replacement keys in the same query.
27
+ const PARAM_TENANT = '__vis_tid';
28
+ const PARAM_WORKSPACE = '__vis_wid';
29
+
30
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
31
+
32
+ /**
33
+ * Validate the caller context. Both identifiers are required integers — a
34
+ * missing or loosely-typed value must never degrade into "system rows only",
35
+ * which would silently hide a workspace's own definitions.
36
+ */
37
+ function requireContext(ctx) {
38
+ if (!ctx || typeof ctx !== 'object') {
39
+ throw new ScopedRegistryError(
40
+ '[ScopedRegistry] Missing caller context - Expected an object with integer tenant_id and workspace_id. '
41
+ + 'Fix: pass the ctx injected by the orchestrator.',
42
+ { ctx }
43
+ );
44
+ }
45
+ if (!Number.isInteger(ctx.tenant_id)) {
46
+ throw new ScopedRegistryError(
47
+ '[ScopedRegistry] Invalid caller context - tenant_id must be an integer. '
48
+ + `Fix: pass ctx.tenant_id from the injected tenant context (got ${JSON.stringify(ctx.tenant_id)}).`,
49
+ { tenant_id: ctx.tenant_id }
50
+ );
51
+ }
52
+ if (!Number.isInteger(ctx.workspace_id)) {
53
+ throw new ScopedRegistryError(
54
+ '[ScopedRegistry] Invalid caller context - workspace_id must be an integer. '
55
+ + `Fix: pass ctx.workspace_id from the injected tenant context (got ${JSON.stringify(ctx.workspace_id)}).`,
56
+ { workspace_id: ctx.workspace_id }
57
+ );
58
+ }
59
+ return { tenant_id: ctx.tenant_id, workspace_id: ctx.workspace_id };
60
+ }
61
+
62
+ function requireScope(record) {
63
+ if (!record || typeof record !== 'object') {
64
+ throw new ScopedRegistryError(
65
+ '[ScopedRegistry] Missing record - Expected an object carrying scope, owner_tenant_id, owner_workspace_id. '
66
+ + 'Fix: pass a registry row.',
67
+ { record }
68
+ );
69
+ }
70
+ if (!KNOWN_SCOPES.includes(record.scope)) {
71
+ throw new ScopedRegistryError(
72
+ `[ScopedRegistry] Unknown scope "${record.scope}" - Expected '${SCOPE_SYSTEM}' or '${SCOPE_WORKSPACE}'. `
73
+ + 'Fix: correct the row, or add the new scope to the contract first.',
74
+ { scope: record.scope }
75
+ );
76
+ }
77
+ return record.scope;
78
+ }
79
+
80
+ /**
81
+ * The canonical visibility rule (§2.1).
82
+ *
83
+ * @param {{scope: string, owner_tenant_id: ?number, owner_workspace_id: ?number}} record
84
+ * @param {{tenant_id: number, workspace_id: number}} ctx
85
+ * @returns {boolean}
86
+ */
87
+ function isVisible(record, ctx) {
88
+ const scope = requireScope(record);
89
+ const { tenant_id, workspace_id } = requireContext(ctx);
90
+
91
+ if (scope === SCOPE_SYSTEM) return true;
92
+
93
+ // A workspace row with NULL owners violates the §1.1 invariant. It is
94
+ // visible to nobody — matching SQL, where NULL = :tid yields NULL, not TRUE.
95
+ return record.owner_tenant_id === tenant_id
96
+ && record.owner_workspace_id === workspace_id;
97
+ }
98
+
99
+ /**
100
+ * Apply the rule to any in-memory collection (§2.1).
101
+ */
102
+ function filterVisible(records, ctx) {
103
+ if (!Array.isArray(records)) {
104
+ throw new ScopedRegistryError(
105
+ '[ScopedRegistry] Invalid records - Expected an array of registry rows. '
106
+ + `Fix: pass the collection to filter (got ${typeof records}).`,
107
+ { records }
108
+ );
109
+ }
110
+ requireContext(ctx);
111
+ return records.filter((record) => isVisible(record, ctx));
112
+ }
113
+
114
+ /**
115
+ * Read-time resolution with ambiguity fail-fast (§4.B).
116
+ *
117
+ * Never applies precedence: if both a system row and a workspace row are
118
+ * visible for one logical lookup, that is a collision that slipped past the
119
+ * write-time guard, and the caller gets an error rather than a silent winner.
120
+ */
121
+ function pickVisibleOne(records, ctx) {
122
+ const visible = filterVisible(records, ctx);
123
+
124
+ if (visible.length === 0) {
125
+ throw new ScopedRegistryError(
126
+ '[ScopedRegistry] No visible record - Expected exactly one row visible to this caller. '
127
+ + 'Fix: check the lookup key, or register the definition for this workspace.',
128
+ { visibleCount: 0, tenant_id: ctx.tenant_id, workspace_id: ctx.workspace_id }
129
+ );
130
+ }
131
+ if (visible.length > 1) {
132
+ throw new ScopedRegistryError(
133
+ `[ScopedRegistry] Ambiguous record - ${visible.length} rows are visible for a single lookup, expected 1. `
134
+ + 'Fix: remove the shadowing definition; workspace rows must not overlap system ones.',
135
+ {
136
+ visibleCount: visible.length,
137
+ scopes: visible.map((r) => r.scope),
138
+ tenant_id: ctx.tenant_id,
139
+ workspace_id: ctx.workspace_id
140
+ }
141
+ );
142
+ }
143
+ return visible[0];
144
+ }
145
+
146
+ /**
147
+ * The same rule rendered as a SQL WHERE fragment (§2.2).
148
+ *
149
+ * Bind the returned fragment with `sqlVisibleParams(ctx)`. The alias is
150
+ * interpolated, so it is restricted to a plain identifier.
151
+ *
152
+ * @param {string} alias table alias used in the query
153
+ * @returns {string}
154
+ */
155
+ function sqlVisible(alias) {
156
+ if (typeof alias !== 'string' || alias.trim() === '') {
157
+ throw new ScopedRegistryError(
158
+ '[ScopedRegistry] Missing table alias - Expected a non-empty identifier. '
159
+ + "Fix: pass the alias used in the query, e.g. sqlVisible('c').",
160
+ { alias }
161
+ );
162
+ }
163
+ if (!IDENTIFIER.test(alias)) {
164
+ throw new ScopedRegistryError(
165
+ `[ScopedRegistry] Invalid table alias "${alias}" - Expected a plain identifier [A-Za-z_][A-Za-z0-9_]*. `
166
+ + 'Fix: alias the table in the query and pass that identifier.',
167
+ { alias }
168
+ );
169
+ }
170
+
171
+ return `(${alias}.scope = '${SCOPE_SYSTEM}' `
172
+ + `OR (${alias}.owner_tenant_id = :${PARAM_TENANT} AND ${alias}.owner_workspace_id = :${PARAM_WORKSPACE}))`;
173
+ }
174
+
175
+ /**
176
+ * Named replacements for the fragment returned by `sqlVisible` (§2.2).
177
+ */
178
+ function sqlVisibleParams(ctx) {
179
+ const { tenant_id, workspace_id } = requireContext(ctx);
180
+ return { [PARAM_TENANT]: tenant_id, [PARAM_WORKSPACE]: workspace_id };
181
+ }
182
+
183
+ /**
184
+ * Enforce the scope triple invariant on write (§1.1):
185
+ * scope='system' ⟺ owner_tenant_id IS NULL AND owner_workspace_id IS NULL.
186
+ */
187
+ function assertScopeTriple(record) {
188
+ const scope = requireScope(record);
189
+ const hasTenant = record.owner_tenant_id !== null && record.owner_tenant_id !== undefined;
190
+ const hasWorkspace = record.owner_workspace_id !== null && record.owner_workspace_id !== undefined;
191
+
192
+ if (scope === SCOPE_SYSTEM && (hasTenant || hasWorkspace)) {
193
+ throw new ScopedRegistryError(
194
+ "[ScopedRegistry] Invalid scope triple - a 'system' row must have owner_tenant_id and owner_workspace_id NULL. "
195
+ + "Fix: clear both owner columns, or set scope='workspace'.",
196
+ { scope, owner_tenant_id: record.owner_tenant_id, owner_workspace_id: record.owner_workspace_id }
197
+ );
198
+ }
199
+ if (scope === SCOPE_WORKSPACE && !(hasTenant && hasWorkspace)) {
200
+ throw new ScopedRegistryError(
201
+ "[ScopedRegistry] Invalid scope triple - a 'workspace' row must have both owner_tenant_id and owner_workspace_id set. "
202
+ + "Fix: set both owner columns, or set scope='system'.",
203
+ { scope, owner_tenant_id: record.owner_tenant_id, owner_workspace_id: record.owner_workspace_id }
204
+ );
205
+ }
206
+ return record;
207
+ }
208
+
209
+ /**
210
+ * Write-time collision guard (§4.A).
211
+ *
212
+ * Called with the set of rows already visible to the writer. If the candidate's
213
+ * business natural key is taken, registration is refused — a workspace can
214
+ * never persist a definition that shadows a system one.
215
+ *
216
+ * @param {Array} existingVisible rows already visible to the caller
217
+ * @param {Object} candidate the row about to be written
218
+ * @param {Function} keyFn maps a row to its business natural key
219
+ */
220
+ function assertUniqueInScope(existingVisible, candidate, keyFn) {
221
+ if (!Array.isArray(existingVisible)) {
222
+ throw new ScopedRegistryError(
223
+ '[ScopedRegistry] Invalid existing set - Expected an array of rows already visible to the writer. '
224
+ + 'Fix: load the caller-visible set before registering.',
225
+ { existingVisible }
226
+ );
227
+ }
228
+ if (typeof keyFn !== 'function') {
229
+ throw new ScopedRegistryError(
230
+ '[ScopedRegistry] Missing key function - Expected a function mapping a row to its business natural key. '
231
+ + "Fix: pass e.g. (r) => `${r.code}/${r.version}`.",
232
+ { keyFn }
233
+ );
234
+ }
235
+
236
+ const candidateKey = keyFn(candidate);
237
+ const occupant = existingVisible.find((row) => keyFn(row) === candidateKey);
238
+
239
+ if (occupant) {
240
+ throw new ScopedRegistryError(
241
+ `[ScopedRegistry] Range occupied - "${candidateKey}" is already defined in scope "${occupant.scope}". `
242
+ + 'Fix: choose a different key, or reuse the existing definition.',
243
+ { key: candidateKey, occupiedByScope: occupant.scope }
244
+ );
245
+ }
246
+ return candidate;
247
+ }
248
+
249
+ module.exports = {
250
+ isVisible,
251
+ filterVisible,
252
+ pickVisibleOne,
253
+ sqlVisible,
254
+ sqlVisibleParams,
255
+ assertScopeTriple,
256
+ assertUniqueInScope,
257
+ ScopedRegistryError,
258
+ SCOPE_SYSTEM,
259
+ SCOPE_WORKSPACE
260
+ };
@@ -1,112 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * PostgreSQL Client Utilities
5
- *
6
- * Provides helper functions for building PostgreSQL connection URLs.
7
- *
8
- * NO FALLBACKS: Topology must be explicit via ENV/config. This module must not
9
- * embed docker-compose hostnames or credentials.
10
- */
11
-
12
- /**
13
- * Build a PostgreSQL connection URL from environment.
14
- *
15
- * @param {Object} options - Options
16
- * @param {Object} [options.env=process.env] - Environment variables
17
- * @returns {string} PostgreSQL connection URL
18
- */
19
- function buildPostgresUrl(options = {}) {
20
- const env = options.env || process.env || {};
21
-
22
- const url = env.POSTGRES_URL && typeof env.POSTGRES_URL === 'string' ? env.POSTGRES_URL.trim() : '';
23
- if (url) return url;
24
-
25
- const host = env.POSTGRES_HOST && typeof env.POSTGRES_HOST === 'string' ? env.POSTGRES_HOST.trim() : '';
26
- const port = env.POSTGRES_PORT && typeof env.POSTGRES_PORT === 'string' ? env.POSTGRES_PORT.trim() : '';
27
- const user = env.POSTGRES_USER && typeof env.POSTGRES_USER === 'string' ? env.POSTGRES_USER.trim() : '';
28
- const password = env.POSTGRES_PASSWORD && typeof env.POSTGRES_PASSWORD === 'string' ? env.POSTGRES_PASSWORD.trim() : '';
29
- const database = env.POSTGRES_DB && typeof env.POSTGRES_DB === 'string' ? env.POSTGRES_DB.trim() : '';
30
-
31
- const anyPartsProvided = Boolean(host || port || user || password || database);
32
- if (!anyPartsProvided) {
33
- throw new Error(
34
- '[service-common][postgres] Missing configuration - POSTGRES_URL is required, or provide all: POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.'
35
- );
36
- }
37
-
38
- const missing = [];
39
- if (!host) missing.push('POSTGRES_HOST');
40
- if (!port) missing.push('POSTGRES_PORT');
41
- if (!user) missing.push('POSTGRES_USER');
42
- if (!password) missing.push('POSTGRES_PASSWORD');
43
- if (!database) missing.push('POSTGRES_DB');
44
- if (missing.length > 0) {
45
- throw new Error(
46
- `[service-common][postgres] Missing configuration - Partial PostgreSQL config provided. Missing: ${missing.join(', ')}. ` +
47
- 'Fix: set POSTGRES_URL or set all POSTGRES_* parts.'
48
- );
49
- }
50
-
51
- // Do not attempt to do smart encoding here; POSTGRES_URL is the recommended option.
52
- return `postgres://${user}:${password}@${host}:${port}/${database}`;
53
- }
54
-
55
- /**
56
- * Create a PostgreSQL connection pool
57
- *
58
- * @param {Object} options - Options
59
- * @param {Object} [options.env=process.env] - Environment variables
60
- * @param {Object} [options.defaults] - Default connection parameters
61
- * @param {Object} [options.poolConfig] - Additional pool configuration (max, idleTimeoutMillis, etc.)
62
- * @returns {Object} PostgreSQL Pool instance
63
- */
64
- function createPostgresPool(options = {}) {
65
- const { Pool } = require('pg');
66
-
67
- const connectionString = buildPostgresUrl({
68
- env: options.env
69
- });
70
-
71
- const poolConfig = {
72
- connectionString,
73
- max: options.poolConfig?.max || 10,
74
- idleTimeoutMillis: options.poolConfig?.idleTimeoutMillis || 30000,
75
- connectionTimeoutMillis: options.poolConfig?.connectionTimeoutMillis || 2000,
76
- ...options.poolConfig
77
- };
78
-
79
- return new Pool(poolConfig);
80
- }
81
-
82
- /**
83
- * Connect to PostgreSQL and test connection
84
- *
85
- * @param {Object} pool - PostgreSQL Pool instance
86
- * @param {Object} logger - Logger object with info/error methods
87
- * @returns {Promise<void>}
88
- */
89
- async function connectPostgres(pool, logger) {
90
- if (!logger) {
91
- throw new Error('[service-common][postgres] Missing dependency - logger is required (no console fallback).');
92
- }
93
- try {
94
- await pool.query('SELECT NOW()');
95
- if (logger && logger.info) {
96
- logger.info('[PostgreSQL] Connected');
97
- }
98
- return true;
99
- } catch (error) {
100
- if (logger && logger.error) {
101
- logger.error('[PostgreSQL] Connection failed:', error);
102
- }
103
- throw error;
104
- }
105
- }
106
-
107
- module.exports = {
108
- buildPostgresUrl,
109
- createPostgresPool,
110
- connectPostgres
111
- };
112
-