@onlineapps/service-common 1.1.2 → 1.2.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 +35 -0
- package/onlineapps-service-common-1.2.0.tgz +0 -0
- package/package.json +1 -1
- package/src/errors/BusinessError.js +1 -1
- package/src/errors/errorMiddleware.js +1 -1
- package/src/index.js +37 -1
- package/src/jwt/extractTenantContext.js +1 -1
- package/src/redactSensitive.js +87 -0
- package/src/scopedRegistry/ScopedRegistryError.js +21 -0
- package/src/scopedRegistry/index.js +260 -0
package/README.md
CHANGED
|
@@ -67,6 +67,41 @@ 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
|
+
|
|
70
105
|
## Architecture
|
|
71
106
|
|
|
72
107
|
This library is used by:
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-common",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Common utilities for both infrastructure services and business services (JWT auth, Redis/Postgres clients, business errors, runtime config)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
package/src/index.js
CHANGED
|
@@ -51,6 +51,23 @@ const {
|
|
|
51
51
|
extractTenantContext,
|
|
52
52
|
ROLES_VERSION_PREFIX
|
|
53
53
|
} = require('./jwt');
|
|
54
|
+
const {
|
|
55
|
+
REDACTED_PLACEHOLDER,
|
|
56
|
+
sensitiveFieldsForOperation,
|
|
57
|
+
redactSensitiveDeep
|
|
58
|
+
} = require('./redactSensitive');
|
|
59
|
+
const {
|
|
60
|
+
isVisible,
|
|
61
|
+
filterVisible,
|
|
62
|
+
pickVisibleOne,
|
|
63
|
+
sqlVisible,
|
|
64
|
+
sqlVisibleParams,
|
|
65
|
+
assertScopeTriple,
|
|
66
|
+
assertUniqueInScope,
|
|
67
|
+
ScopedRegistryError,
|
|
68
|
+
SCOPE_SYSTEM,
|
|
69
|
+
SCOPE_WORKSPACE
|
|
70
|
+
} = require('./scopedRegistry');
|
|
54
71
|
|
|
55
72
|
module.exports = {
|
|
56
73
|
// Infrastructure readiness utilities (used by both infrastructure and business services)
|
|
@@ -98,7 +115,26 @@ module.exports = {
|
|
|
98
115
|
verifyAccessToken,
|
|
99
116
|
createJwtValidator,
|
|
100
117
|
extractTenantContext,
|
|
101
|
-
ROLES_VERSION_PREFIX
|
|
118
|
+
ROLES_VERSION_PREFIX,
|
|
119
|
+
|
|
120
|
+
// Sensitive-input redaction for observability sinks (SecretBox F1.5)
|
|
121
|
+
// See: docs/architecture/secretbox.md §8
|
|
122
|
+
REDACTED_PLACEHOLDER,
|
|
123
|
+
sensitiveFieldsForOperation,
|
|
124
|
+
redactSensitiveDeep,
|
|
125
|
+
|
|
126
|
+
// Scoped-registry visibility rule (system ∪ own workspace)
|
|
127
|
+
// See: docs/biz/30-operations/scoped-registry.md
|
|
128
|
+
isVisible,
|
|
129
|
+
filterVisible,
|
|
130
|
+
pickVisibleOne,
|
|
131
|
+
sqlVisible,
|
|
132
|
+
sqlVisibleParams,
|
|
133
|
+
assertScopeTriple,
|
|
134
|
+
assertUniqueInScope,
|
|
135
|
+
ScopedRegistryError,
|
|
136
|
+
SCOPE_SYSTEM,
|
|
137
|
+
SCOPE_WORKSPACE
|
|
102
138
|
};
|
|
103
139
|
|
|
104
140
|
|
|
@@ -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/
|
|
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,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sensitive-input redaction (SecretBox F1.5).
|
|
5
|
+
* Contract: api/docs/architecture/secretbox.md §8.
|
|
6
|
+
*
|
|
7
|
+
* Pure, dependency-free helpers used by every layer that logs, traces, or
|
|
8
|
+
* otherwise persists workflow/operation inputs (gateway, orchestrator, monitoring
|
|
9
|
+
* consumer). Single implementation — no duplication across layers.
|
|
10
|
+
*
|
|
11
|
+
* Operations declare sensitive input fields at operation level in operations.json:
|
|
12
|
+
* "sensitive_input_fields": ["value", ...]
|
|
13
|
+
* (NOT inside the JSON Schema — service-wrapper compiles input schemas with AJV
|
|
14
|
+
* strict:true, which throws on unknown keywords.)
|
|
15
|
+
*
|
|
16
|
+
* `redactSensitiveDeep` deep-clones the value and replaces any object key whose
|
|
17
|
+
* name is in `fieldNames` with the REDACTED placeholder, at any depth. It never
|
|
18
|
+
* mutates the input. Over-redaction (a sensitive field name matching elsewhere in
|
|
19
|
+
* the same payload) is acceptable and safe for observability sinks.
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT: this is for observability/log/trace copies only. The execution message
|
|
22
|
+
* that carries the value to its owning handler MUST NOT be redacted.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const REDACTED_PLACEHOLDER = '[REDACTED]';
|
|
26
|
+
const CIRCULAR_PLACEHOLDER = '[Circular]';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the sensitive input field names for one operation from a service spec
|
|
30
|
+
* (the `registry:service:<name>:spec` shape, or a local operations.json object).
|
|
31
|
+
* @param {object} serviceSpec - object with `.operations[<op>]`.
|
|
32
|
+
* @param {string} operationName
|
|
33
|
+
* @returns {string[]} field names (empty array when none / not found).
|
|
34
|
+
*/
|
|
35
|
+
function sensitiveFieldsForOperation(serviceSpec, operationName) {
|
|
36
|
+
const op =
|
|
37
|
+
serviceSpec &&
|
|
38
|
+
serviceSpec.operations &&
|
|
39
|
+
typeof serviceSpec.operations === 'object'
|
|
40
|
+
? serviceSpec.operations[operationName]
|
|
41
|
+
: undefined;
|
|
42
|
+
const list = op && op.sensitive_input_fields;
|
|
43
|
+
return Array.isArray(list) ? list.filter((f) => typeof f === 'string' && f.length > 0) : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _walk(node, fields, seen) {
|
|
47
|
+
if (node === null || typeof node !== 'object') return node;
|
|
48
|
+
// Do not recurse into non-plain objects; return a shallow copy where safe.
|
|
49
|
+
if (node instanceof Date) return new Date(node.getTime());
|
|
50
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(node)) return node;
|
|
51
|
+
if (seen.has(node)) return CIRCULAR_PLACEHOLDER;
|
|
52
|
+
seen.add(node);
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(node)) {
|
|
55
|
+
return node.map((el) => _walk(el, fields, seen));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const key of Object.keys(node)) {
|
|
60
|
+
if (fields.has(key)) {
|
|
61
|
+
out[key] = REDACTED_PLACEHOLDER;
|
|
62
|
+
} else {
|
|
63
|
+
out[key] = _walk(node[key], fields, seen);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Deep-clone `value`, replacing any object property named in `fieldNames` with
|
|
71
|
+
* the REDACTED placeholder. Does not mutate the input.
|
|
72
|
+
* @param {*} value - any JSON-ish value (object/array/primitive).
|
|
73
|
+
* @param {string[]|Set<string>} fieldNames - sensitive field names to redact.
|
|
74
|
+
* @returns {*} redacted deep clone (or the original value when there is nothing
|
|
75
|
+
* to redact — no sensitive fields means no mutation risk).
|
|
76
|
+
*/
|
|
77
|
+
function redactSensitiveDeep(value, fieldNames) {
|
|
78
|
+
const fields = fieldNames instanceof Set ? fieldNames : new Set(fieldNames || []);
|
|
79
|
+
if (fields.size === 0) return value;
|
|
80
|
+
return _walk(value, fields, new WeakSet());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
REDACTED_PLACEHOLDER,
|
|
85
|
+
sensitiveFieldsForOperation,
|
|
86
|
+
redactSensitiveDeep
|
|
87
|
+
};
|
|
@@ -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
|
+
};
|