@jini-ai/cms 0.5.4 → 0.5.5
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/navigation/agent-tools.js +20 -3
- package/dist/navigation/index.d.ts +3 -1
- package/dist/navigation/index.js +2 -0
- package/dist/navigation/menu-html.d.ts +31 -0
- package/dist/navigation/menu-html.js +53 -0
- package/dist/navigation/menu-service.d.ts +9 -3
- package/dist/navigation/menu-service.js +15 -3
- package/dist/navigation/tool-registrations.d.ts +5 -0
- package/dist/navigation/tool-registrations.js +32 -2
- package/dist/navigation/types.d.ts +6 -0
- package/dist/widgets/resolvers/menu.d.ts +8 -0
- package/dist/widgets/resolvers/menu.js +21 -6
- package/package.json +3 -3
- package/dist/http/settings/cms-adapter.d.ts +0 -20
- package/dist/http/settings/cms-adapter.js +0 -124
- package/dist/http/settings/contracts.d.ts +0 -135
- package/dist/http/settings/contracts.js +0 -2
- package/dist/http/settings/http.d.ts +0 -10
- package/dist/http/settings/http.js +0 -211
- package/dist/http/settings/index.d.ts +0 -5
- package/dist/http/settings/index.js +0 -4
- package/dist/http/settings/shared.d.ts +0 -68
- package/dist/http/settings/shared.js +0 -54
- package/dist/http/settings/stream.d.ts +0 -24
- package/dist/http/settings/stream.js +0 -147
- package/dist/settings/agent-tools.d.ts +0 -10
- package/dist/settings/agent-tools.js +0 -204
- package/dist/settings/agent-value-write-tools.d.ts +0 -22
- package/dist/settings/agent-value-write-tools.js +0 -154
- package/dist/settings/agent-writable-preferences.d.ts +0 -67
- package/dist/settings/agent-writable-preferences.js +0 -132
- package/dist/settings/agent-write-denylist.d.ts +0 -19
- package/dist/settings/agent-write-denylist.js +0 -15
- package/dist/settings/change-feed.d.ts +0 -83
- package/dist/settings/change-feed.js +0 -61
- package/dist/settings/definitions-dispatch.d.ts +0 -56
- package/dist/settings/definitions-dispatch.js +0 -86
- package/dist/settings/dictionaries/index.d.ts +0 -48
- package/dist/settings/dictionaries/index.js +0 -69
- package/dist/settings/dictionaries/settings-dialog.ar.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.ar.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.bn.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.bn.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.de.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.de.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.es.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.es.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.fa.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.fa.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.fr.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.fr.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.hi.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.hi.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.hu.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.hu.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.id.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.id.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.it.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.it.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.ja.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.ja.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.ko.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.ko.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.pl.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.pl.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.ru.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.ru.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.th.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.th.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.tr.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.tr.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.uk.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.uk.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.ur.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.ur.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -27
- package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts +0 -3
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -27
- package/dist/settings/ensure-definitions.d.ts +0 -88
- package/dist/settings/ensure-definitions.js +0 -91
- package/dist/settings/errors.d.ts +0 -42
- package/dist/settings/errors.js +0 -46
- package/dist/settings/express/cms-adapter.d.ts +0 -20
- package/dist/settings/express/cms-adapter.js +0 -124
- package/dist/settings/express/contracts.d.ts +0 -135
- package/dist/settings/express/contracts.js +0 -2
- package/dist/settings/express/http.d.ts +0 -10
- package/dist/settings/express/http.js +0 -211
- package/dist/settings/express/shared.d.ts +0 -68
- package/dist/settings/express/shared.js +0 -54
- package/dist/settings/express/stream.d.ts +0 -24
- package/dist/settings/express/stream.js +0 -147
- package/dist/settings/ports.d.ts +0 -118
- package/dist/settings/ports.js +0 -2
- package/dist/settings/principal-lookup.d.ts +0 -9
- package/dist/settings/principal-lookup.js +0 -2
- package/dist/settings/purge-service.d.ts +0 -54
- package/dist/settings/purge-service.js +0 -106
- package/dist/settings/repo.memory.d.ts +0 -119
- package/dist/settings/repo.memory.js +0 -199
- package/dist/settings/settings.d.ts +0 -139
- package/dist/settings/settings.js +0 -259
- package/dist/settings/tool-registrations.d.ts +0 -51
- package/dist/settings/tool-registrations.js +0 -250
- package/dist/settings/types.d.ts +0 -125
- package/dist/settings/types.js +0 -3
- package/dist/settings/ui-tab-definitions.d.ts +0 -41
- package/dist/settings/ui-tab-definitions.js +0 -148
- package/dist/settings/write-service.d.ts +0 -320
- package/dist/settings/write-service.js +0 -683
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, } from "../index.js";
|
|
2
|
-
import { respondToSettingsError, resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
|
|
3
|
-
import { openSettingsChangeStream } from "./stream.js";
|
|
4
|
-
const VALUE_ERRORS = [
|
|
5
|
-
{ matches: (e) => e instanceof PrincipalNotFoundError, status: 404, code: "PRINCIPAL_NOT_FOUND" },
|
|
6
|
-
{ matches: (e) => e instanceof DefinitionNotFoundError, status: 404, code: "DEFINITION_NOT_FOUND" },
|
|
7
|
-
{ matches: (e) => e instanceof DefinitionTombstonedError, status: 409, code: "DEFINITION_TOMBSTONED" },
|
|
8
|
-
{ matches: (e) => e instanceof ScopeNotAllowedError, status: 400, code: "SCOPE_NOT_ALLOWED" },
|
|
9
|
-
{ matches: (e) => e instanceof ValueValidationFailedError, status: 400, code: "VALUE_VALIDATION_FAILED" },
|
|
10
|
-
{ matches: (e) => e instanceof ForbiddenError, status: 403, code: "FORBIDDEN" },
|
|
11
|
-
];
|
|
12
|
-
const DEFINITION_ERRORS = [
|
|
13
|
-
{ matches: (e) => e instanceof SecretNotSupportedError, status: 400, code: "SECRET_NOT_SUPPORTED" },
|
|
14
|
-
{ matches: (e) => e instanceof DefinitionInvalidError, status: 400, code: "DEFINITION_INVALID" },
|
|
15
|
-
{ matches: (e) => e instanceof RenameRetypeConflictError, status: 409, code: "RENAME_RETYPE_CONFLICT" },
|
|
16
|
-
{ matches: (e) => e instanceof AliasDepthExceededError, status: 409, code: "ALIAS_DEPTH_EXCEEDED" },
|
|
17
|
-
{ matches: (e) => e instanceof DefinitionTombstonedError, status: 409, code: "DEFINITION_TOMBSTONED" },
|
|
18
|
-
{ matches: (e) => e instanceof ForbiddenError, status: 403, code: "FORBIDDEN" },
|
|
19
|
-
];
|
|
20
|
-
const RESET_ERRORS = VALUE_ERRORS.filter((m) => m.code === "FORBIDDEN");
|
|
21
|
-
const scopes = ["global", "workspace", "user"];
|
|
22
|
-
function validation(res, error) {
|
|
23
|
-
res.status(400).json({ error, code: "VALIDATION_ERROR" });
|
|
24
|
-
}
|
|
25
|
-
function denied(res, principalId, permission, reason) {
|
|
26
|
-
res.status(403).json({ error: `principal '${principalId}' is not authorized for '${permission}' (${reason})`,
|
|
27
|
-
code: "FORBIDDEN", details: { permission, reason } });
|
|
28
|
-
}
|
|
29
|
-
function bodyOf(req) {
|
|
30
|
-
return req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
31
|
-
}
|
|
32
|
-
/** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules. */
|
|
33
|
-
/** Injected policy and services preserve the settings HTTP response envelopes.
|
|
34
|
-
* O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
|
|
35
|
-
* @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
|
|
36
|
-
*/
|
|
37
|
-
export function registerSettingsRoutes(required, optional = {}) {
|
|
38
|
-
const { app, workspaceId, service, routes, permissions } = required;
|
|
39
|
-
const streams = new Set();
|
|
40
|
-
let disposed = false;
|
|
41
|
-
const wrap = (handler, mappings = []) => async (req, res) => {
|
|
42
|
-
if (disposed) {
|
|
43
|
-
res.status(503).json({ error: "settings routes are disposed", code: "UNAVAILABLE" });
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (String(req.params[routes.workspaceParam] ?? "") !== workspaceId) {
|
|
47
|
-
res.status(404).json({ error: "workspace was not found" });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
let principal;
|
|
51
|
-
try {
|
|
52
|
-
await required.ready;
|
|
53
|
-
principal = await required.principalResolver({ request: req, response: res });
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
// Readiness/principal failures preserve the authentication boundary's existing 401 envelope.
|
|
57
|
-
res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (!principal) {
|
|
61
|
-
res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
await handler(req, res, principal.id);
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
if (res.headersSent) {
|
|
69
|
-
// Reporting cannot prevent ending a response whose headers have already been sent.
|
|
70
|
-
try {
|
|
71
|
-
optional.onError?.({ operation: "settings-request", error });
|
|
72
|
-
}
|
|
73
|
-
catch { /* Host diagnostics are best-effort. */ }
|
|
74
|
-
finally {
|
|
75
|
-
res.end();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
else
|
|
79
|
-
respondToSettingsError({ response: res, error, mappings });
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
const check = async (res, principalId, permission, entityType) => {
|
|
83
|
-
const result = await required.authorize({ principalId, permission, workspaceId, entityType });
|
|
84
|
-
if (!result.allowed)
|
|
85
|
-
denied(res, principalId, permission, result.reason);
|
|
86
|
-
return result.allowed;
|
|
87
|
-
};
|
|
88
|
-
const readTarget = async (res, principalId, requestedPrincipalId) => {
|
|
89
|
-
const result = await resolveUserLayerReadTarget({ deps: { workspaceId, authorize: required.authorize, permission: permissions.readOtherUser },
|
|
90
|
-
input: { callerPrincipalId: principalId, requestedPrincipalId } });
|
|
91
|
-
if (!result.allowed)
|
|
92
|
-
res.status(403).json({
|
|
93
|
-
error: `principal '${principalId}' is not authorized to read another principal's user-layer value (${result.reason})`,
|
|
94
|
-
code: "FORBIDDEN", details: { permission: permissions.readOtherUser, reason: result.reason },
|
|
95
|
-
});
|
|
96
|
-
return result;
|
|
97
|
-
};
|
|
98
|
-
app.get(routes.definitions, wrap(async (_req, res, principalId) => {
|
|
99
|
-
if (!await check(res, principalId, permissions.readDefinitions, "setting-definition"))
|
|
100
|
-
return;
|
|
101
|
-
const definitions = await service.listDefinitions({ workspaceId });
|
|
102
|
-
const data = definitions.map(({ namespace, key, ownerKind, scopes, status, version }) => ({ namespace, key, ownerKind, scopes, status, version }))
|
|
103
|
-
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
|
|
104
|
-
res.json({ data });
|
|
105
|
-
}));
|
|
106
|
-
app.get(routes.effective, wrap(async (req, res, principalId) => {
|
|
107
|
-
if (!await check(res, principalId, permissions.read, "setting-value"))
|
|
108
|
-
return;
|
|
109
|
-
const namespace = String(req.query.namespace ?? "");
|
|
110
|
-
if (!namespace) {
|
|
111
|
-
validation(res, "'namespace' query param is required");
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const target = await readTarget(res, principalId, req.query.principalId ? String(req.query.principalId) : principalId);
|
|
115
|
-
if (!target.allowed)
|
|
116
|
-
return;
|
|
117
|
-
res.json({ data: await service.effective({ namespace, workspaceId, principalId: target.principalId }) });
|
|
118
|
-
}));
|
|
119
|
-
app.get(routes.raw, wrap(async (req, res, principalId) => {
|
|
120
|
-
if (!await check(res, principalId, permissions.readRaw, "setting-value"))
|
|
121
|
-
return;
|
|
122
|
-
const namespace = String(req.query.namespace ?? "");
|
|
123
|
-
const key = String(req.query.key ?? "");
|
|
124
|
-
if (!namespace || !key) {
|
|
125
|
-
validation(res, "'namespace' and 'key' query params are required");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const target = await readTarget(res, principalId, req.query.principalId ? String(req.query.principalId) : undefined);
|
|
129
|
-
if (!target.allowed)
|
|
130
|
-
return;
|
|
131
|
-
const result = await service.raw({ namespace, key, workspaceId, principalId: target.principalId });
|
|
132
|
-
if (!result) {
|
|
133
|
-
res.status(404).json({ error: `definition '${namespace}.${key}' was not found`, code: "DEFINITION_NOT_FOUND" });
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
res.json(result);
|
|
137
|
-
}));
|
|
138
|
-
app.post(routes.definitions, wrap(async (req, res, principalId) => {
|
|
139
|
-
if (!await check(res, principalId, permissions.manageDefinitions, "setting-definition"))
|
|
140
|
-
return;
|
|
141
|
-
const items = bodyOf(req).definitions;
|
|
142
|
-
if (!Array.isArray(items)) {
|
|
143
|
-
validation(res, "'definitions' must be an array");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
const result = await service.definitions({ items, callerPrincipalId: principalId, authWorkspaceId: workspaceId });
|
|
147
|
-
if ("unknownOp" in result) {
|
|
148
|
-
validation(res, `unknown op '${result.unknownOp}'`);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
res.json(result);
|
|
152
|
-
}, DEFINITION_ERRORS));
|
|
153
|
-
const mutate = (operation) => async (req, res, callerPrincipalId) => {
|
|
154
|
-
const body = bodyOf(req);
|
|
155
|
-
const namespace = String(body.namespace ?? "");
|
|
156
|
-
const key = String(body.key ?? "");
|
|
157
|
-
const scope = body.scope;
|
|
158
|
-
if (!namespace || !key || !scopes.includes(scope) || (operation === "set" && !("valueJson" in body))) {
|
|
159
|
-
validation(res, operation === "set" ? "namespace, key, scope (global|workspace|user), and valueJson are required" : "namespace, key, and scope (global|workspace|user) are required");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const target = resolveTargetWorkspaceId({ workspaceId, input: { bodyWorkspaceId: body.workspaceId, scope } });
|
|
163
|
-
if (!target.ok) {
|
|
164
|
-
validation(res, target.error);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
const principalId = body.principalId ? String(body.principalId) : undefined;
|
|
168
|
-
const permission = settingsWritePermission({ permissions, scope, callerPrincipalId, targetPrincipalId: principalId });
|
|
169
|
-
if (!await check(res, callerPrincipalId, permission, "setting-value"))
|
|
170
|
-
return;
|
|
171
|
-
const input = { namespace, key, scope, workspaceId: target.workspaceId, principalId, callerPrincipalId, authWorkspaceId: workspaceId };
|
|
172
|
-
const result = operation === "set" ? await service.set({ ...input, value: body.valueJson }) : await service.clear(input);
|
|
173
|
-
res.json({ key: `${namespace}.${key}`, scope, value: "value" in result ? result.value : null, revisionSeq: result.revisionSeq });
|
|
174
|
-
};
|
|
175
|
-
app.put(routes.value, wrap(mutate("set"), VALUE_ERRORS));
|
|
176
|
-
app.delete(routes.value, wrap(mutate("clear"), VALUE_ERRORS.filter((m) => m.code !== "VALUE_VALIDATION_FAILED")));
|
|
177
|
-
app.post(routes.reset, wrap(async (req, res, callerPrincipalId) => {
|
|
178
|
-
const body = bodyOf(req);
|
|
179
|
-
const namespace = String(body.namespace ?? "");
|
|
180
|
-
const scope = body.scope;
|
|
181
|
-
if (!namespace || !scopes.includes(scope)) {
|
|
182
|
-
validation(res, "namespace and scope (global|workspace|user) are required");
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const target = resolveTargetWorkspaceId({ workspaceId, input: { bodyWorkspaceId: body.workspaceId, scope } });
|
|
186
|
-
if (!target.ok) {
|
|
187
|
-
validation(res, target.error);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (!await check(res, callerPrincipalId, permissions.reset[scope], "setting-namespace"))
|
|
191
|
-
return;
|
|
192
|
-
const result = await service.reset({ namespace, scope, workspaceId: target.workspaceId, callerPrincipalId, authWorkspaceId: workspaceId });
|
|
193
|
-
res.json({ namespace, ...result });
|
|
194
|
-
}, RESET_ERRORS));
|
|
195
|
-
app.get(routes.events, wrap(async (req, res, principalId) => {
|
|
196
|
-
if (!await check(res, principalId, permissions.read, "setting-value"))
|
|
197
|
-
return;
|
|
198
|
-
const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
|
|
199
|
-
changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
|
|
200
|
-
onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
|
|
201
|
-
if (close) {
|
|
202
|
-
if (disposed)
|
|
203
|
-
close();
|
|
204
|
-
else if (!res.destroyed && !res.writableEnded)
|
|
205
|
-
streams.add(close);
|
|
206
|
-
}
|
|
207
|
-
}));
|
|
208
|
-
return { dispose: () => { disposed = true; for (const close of streams)
|
|
209
|
-
close(); streams.clear(); } };
|
|
210
|
-
}
|
|
211
|
-
//# sourceMappingURL=http.js.map
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import type { Response } from "express";
|
|
2
|
-
import type { SettingScope } from "../index.js";
|
|
3
|
-
import type { Authorize, SettingsPermissions } from "./contracts.js";
|
|
4
|
-
export type TargetWorkspaceResolution = {
|
|
5
|
-
ok: true;
|
|
6
|
-
workspaceId: string | undefined;
|
|
7
|
-
} | {
|
|
8
|
-
ok: false;
|
|
9
|
-
error: string;
|
|
10
|
-
};
|
|
11
|
-
/** Pin writes to the authorized workspace, rejecting a conflicting body target.
|
|
12
|
-
* Global scope has no target workspace. Pure; O(1) time/space for string ids.
|
|
13
|
-
* @example resolveTargetWorkspaceId({ workspaceId, input: { scope: "workspace", bodyWorkspaceId } });
|
|
14
|
-
*/
|
|
15
|
-
export declare function resolveTargetWorkspaceId(required: {
|
|
16
|
-
workspaceId: string;
|
|
17
|
-
input: {
|
|
18
|
-
bodyWorkspaceId: unknown;
|
|
19
|
-
scope: SettingScope;
|
|
20
|
-
};
|
|
21
|
-
}): TargetWorkspaceResolution;
|
|
22
|
-
export type UserLayerReadTarget = {
|
|
23
|
-
allowed: true;
|
|
24
|
-
principalId: string | undefined;
|
|
25
|
-
} | {
|
|
26
|
-
allowed: false;
|
|
27
|
-
reason: string;
|
|
28
|
-
};
|
|
29
|
-
/** Require the injected cross-principal grant only when a read targets another user.
|
|
30
|
-
* Returns the denial reason without reading values; one authorization effect at most.
|
|
31
|
-
* @complexity O(1) excluding the authorizer.
|
|
32
|
-
* @example resolveUserLayerReadTarget({ deps: { workspaceId, authorize, permission }, input });
|
|
33
|
-
*/
|
|
34
|
-
export declare function resolveUserLayerReadTarget(required: {
|
|
35
|
-
deps: {
|
|
36
|
-
workspaceId: string;
|
|
37
|
-
authorize: Authorize;
|
|
38
|
-
permission: string;
|
|
39
|
-
};
|
|
40
|
-
input: {
|
|
41
|
-
requestedPrincipalId: string | undefined;
|
|
42
|
-
callerPrincipalId: string;
|
|
43
|
-
};
|
|
44
|
-
}): Promise<UserLayerReadTarget>;
|
|
45
|
-
export interface SettingsErrorMapping {
|
|
46
|
-
readonly matches: (error: unknown) => boolean;
|
|
47
|
-
readonly status: number;
|
|
48
|
-
readonly code: string;
|
|
49
|
-
}
|
|
50
|
-
/** Write the first matching status/code, or a fixed 500 that does not disclose unknown errors.
|
|
51
|
-
* @complexity O(mapping count) time, O(1) space; response write effect.
|
|
52
|
-
* @example respondToSettingsError({ response, error, mappings });
|
|
53
|
-
*/
|
|
54
|
-
export declare function respondToSettingsError(required: {
|
|
55
|
-
response: Pick<Response, "status" | "json">;
|
|
56
|
-
error: unknown;
|
|
57
|
-
mappings: readonly SettingsErrorMapping[];
|
|
58
|
-
}): void;
|
|
59
|
-
/** Select caller policy for the CMS scope and self/other-user split; pure, O(1).
|
|
60
|
-
* @example settingsWritePermission({ permissions, scope: "user", callerPrincipalId, targetPrincipalId });
|
|
61
|
-
*/
|
|
62
|
-
export declare function settingsWritePermission(required: {
|
|
63
|
-
permissions: SettingsPermissions;
|
|
64
|
-
scope: SettingScope;
|
|
65
|
-
callerPrincipalId: string;
|
|
66
|
-
targetPrincipalId: string | undefined;
|
|
67
|
-
}): string;
|
|
68
|
-
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/** Pin writes to the authorized workspace, rejecting a conflicting body target.
|
|
2
|
-
* Global scope has no target workspace. Pure; O(1) time/space for string ids.
|
|
3
|
-
* @example resolveTargetWorkspaceId({ workspaceId, input: { scope: "workspace", bodyWorkspaceId } });
|
|
4
|
-
*/
|
|
5
|
-
export function resolveTargetWorkspaceId(required) {
|
|
6
|
-
const { workspaceId, input } = required;
|
|
7
|
-
const named = input.bodyWorkspaceId === undefined || input.bodyWorkspaceId === null || input.bodyWorkspaceId === ""
|
|
8
|
-
? undefined : String(input.bodyWorkspaceId);
|
|
9
|
-
if (named !== undefined && named !== workspaceId) {
|
|
10
|
-
return { ok: false, error: `workspaceId '${named}' does not match this route's workspace; a settings write cannot target another workspace` };
|
|
11
|
-
}
|
|
12
|
-
return { ok: true, workspaceId: input.scope === "global" ? undefined : workspaceId };
|
|
13
|
-
}
|
|
14
|
-
/** Require the injected cross-principal grant only when a read targets another user.
|
|
15
|
-
* Returns the denial reason without reading values; one authorization effect at most.
|
|
16
|
-
* @complexity O(1) excluding the authorizer.
|
|
17
|
-
* @example resolveUserLayerReadTarget({ deps: { workspaceId, authorize, permission }, input });
|
|
18
|
-
*/
|
|
19
|
-
export async function resolveUserLayerReadTarget(required) {
|
|
20
|
-
const { deps, input } = required;
|
|
21
|
-
if (!input.requestedPrincipalId || input.requestedPrincipalId === input.callerPrincipalId) {
|
|
22
|
-
return { allowed: true, principalId: input.requestedPrincipalId };
|
|
23
|
-
}
|
|
24
|
-
const result = await deps.authorize({ principalId: input.callerPrincipalId, permission: deps.permission,
|
|
25
|
-
workspaceId: deps.workspaceId, entityType: "setting-value" });
|
|
26
|
-
return result.allowed ? { allowed: true, principalId: input.requestedPrincipalId } : { allowed: false, reason: result.reason };
|
|
27
|
-
}
|
|
28
|
-
/** Write the first matching status/code, or a fixed 500 that does not disclose unknown errors.
|
|
29
|
-
* @complexity O(mapping count) time, O(1) space; response write effect.
|
|
30
|
-
* @example respondToSettingsError({ response, error, mappings });
|
|
31
|
-
*/
|
|
32
|
-
export function respondToSettingsError(required) {
|
|
33
|
-
const { response, error, mappings } = required;
|
|
34
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
-
for (const mapping of mappings) {
|
|
36
|
-
if (mapping.matches(error)) {
|
|
37
|
-
response.status(mapping.status).json({ error: message, code: mapping.code });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
response.status(500).json({ error: "internal error", code: "INTERNAL_ERROR" });
|
|
42
|
-
}
|
|
43
|
-
/** Select caller policy for the CMS scope and self/other-user split; pure, O(1).
|
|
44
|
-
* @example settingsWritePermission({ permissions, scope: "user", callerPrincipalId, targetPrincipalId });
|
|
45
|
-
*/
|
|
46
|
-
export function settingsWritePermission(required) {
|
|
47
|
-
const { permissions, scope, callerPrincipalId, targetPrincipalId } = required;
|
|
48
|
-
if (scope === "global")
|
|
49
|
-
return permissions.writeGlobal;
|
|
50
|
-
if (scope === "workspace")
|
|
51
|
-
return permissions.writeWorkspace;
|
|
52
|
-
return targetPrincipalId == null || targetPrincipalId === callerPrincipalId ? permissions.writeUserSelf : permissions.writeUserOther;
|
|
53
|
-
}
|
|
54
|
-
//# sourceMappingURL=shared.js.map
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { Request, Response } from "express";
|
|
2
|
-
import type { Authorize, SettingsChangeFeed, SettingsHttpOptions, Scheduler } from "./contracts.js";
|
|
3
|
-
interface StreamRequired {
|
|
4
|
-
request: Request;
|
|
5
|
-
response: Response;
|
|
6
|
-
workspaceId: string;
|
|
7
|
-
principalId: string;
|
|
8
|
-
changeFeed: SettingsChangeFeed;
|
|
9
|
-
authorize: Authorize;
|
|
10
|
-
permission: string;
|
|
11
|
-
scheduler: Scheduler;
|
|
12
|
-
onClose: (required: {
|
|
13
|
-
close: () => void;
|
|
14
|
-
}) => void;
|
|
15
|
-
isDisposed: () => boolean;
|
|
16
|
-
}
|
|
17
|
-
/** Starts after HTTP authorization. All scheduling and storage are injected. */
|
|
18
|
-
/** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
|
|
19
|
-
* O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
|
|
20
|
-
* Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
|
|
21
|
-
*/
|
|
22
|
-
export declare function openSettingsChangeStream(required: StreamRequired, optional?: SettingsHttpOptions): Promise<() => void>;
|
|
23
|
-
export {};
|
|
24
|
-
//# sourceMappingURL=stream.d.ts.map
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/** Starts after HTTP authorization. All scheduling and storage are injected. */
|
|
2
|
-
/** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
|
|
3
|
-
* O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
|
|
4
|
-
* Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
|
|
5
|
-
*/
|
|
6
|
-
export async function openSettingsChangeStream(required, optional = {}) {
|
|
7
|
-
const { request, response, workspaceId, principalId, changeFeed, scheduler } = required;
|
|
8
|
-
const pageSize = optional.revisionPageSize ?? 200;
|
|
9
|
-
const intervals = [optional.pollIntervalMs ?? 1_000, optional.keepaliveIntervalMs ?? 25_000, optional.reauthorizeIntervalMs ?? 30_000];
|
|
10
|
-
if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
|
|
11
|
-
throw new RangeError("settings feed page size and intervals must be positive");
|
|
12
|
-
}
|
|
13
|
-
let closed = false;
|
|
14
|
-
let ticking = false;
|
|
15
|
-
let checkingAuthorization = false;
|
|
16
|
-
const cancellations = [];
|
|
17
|
-
const reportError = (operation, error) => {
|
|
18
|
-
// Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
|
|
19
|
-
try {
|
|
20
|
-
if (optional.onError)
|
|
21
|
-
optional.onError({ operation, error });
|
|
22
|
-
else
|
|
23
|
-
console.error(`[settings] change feed ${operation} failed`, error);
|
|
24
|
-
}
|
|
25
|
-
catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
|
|
26
|
-
};
|
|
27
|
-
const recover = (operation, effect) => {
|
|
28
|
-
try {
|
|
29
|
-
effect();
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
reportError(operation, error);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
const release = () => {
|
|
36
|
-
// Every resource gets its own recovery boundary so one failing cancellation cannot leak others.
|
|
37
|
-
for (const cancel of cancellations.splice(0))
|
|
38
|
-
recover("cancel", cancel);
|
|
39
|
-
recover("request-listener", () => { request.off("close", close); });
|
|
40
|
-
recover("response-listener", () => { response.off("close", close); });
|
|
41
|
-
};
|
|
42
|
-
const close = () => {
|
|
43
|
-
if (closed)
|
|
44
|
-
return;
|
|
45
|
-
closed = true;
|
|
46
|
-
release();
|
|
47
|
-
recover("close", () => { required.onClose({ close }); });
|
|
48
|
-
if (!response.writableEnded && !response.destroyed)
|
|
49
|
-
recover("end", () => { response.end(); });
|
|
50
|
-
};
|
|
51
|
-
request.on("close", close);
|
|
52
|
-
response.on("close", close);
|
|
53
|
-
try {
|
|
54
|
-
const head = await changeFeed.head({});
|
|
55
|
-
if (closed || required.isDisposed() || response.destroyed || response.writableEnded) {
|
|
56
|
-
close();
|
|
57
|
-
return close;
|
|
58
|
-
}
|
|
59
|
-
const resumeFrom = Number(request.headers["last-event-id"]);
|
|
60
|
-
let cursor = Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, head) : head;
|
|
61
|
-
let lastEmittedId = cursor;
|
|
62
|
-
const viewer = { workspaceId, principalId };
|
|
63
|
-
response.writeHead(200, {
|
|
64
|
-
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
|
|
65
|
-
...(request.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
|
|
66
|
-
});
|
|
67
|
-
const tick = async () => {
|
|
68
|
-
if (closed || ticking)
|
|
69
|
-
return;
|
|
70
|
-
ticking = true;
|
|
71
|
-
try {
|
|
72
|
-
// Snapshot head before the page: concurrent writes must never be skipped.
|
|
73
|
-
const ledgerHead = await changeFeed.head({});
|
|
74
|
-
if (closed)
|
|
75
|
-
return;
|
|
76
|
-
const batch = await changeFeed.collect({ sinceSeq: cursor, limit: pageSize, viewer });
|
|
77
|
-
if (closed)
|
|
78
|
-
return;
|
|
79
|
-
const examined = batch.examinedCount < pageSize ? ledgerHead : batch.cursor;
|
|
80
|
-
cursor = Math.max(cursor, examined, batch.cursor);
|
|
81
|
-
if (!batch.namespaces.length)
|
|
82
|
-
return;
|
|
83
|
-
// The internal global head is never disclosed as an event id.
|
|
84
|
-
lastEmittedId = Math.max(lastEmittedId, batch.cursor);
|
|
85
|
-
response.write(`id: ${lastEmittedId}\nevent: settings-changed\ndata: ${JSON.stringify({ namespaces: batch.namespaces })}\n\n`);
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
if (!closed)
|
|
89
|
-
reportError("poll", error);
|
|
90
|
-
}
|
|
91
|
-
finally {
|
|
92
|
-
ticking = false;
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
const reauthorize = async () => {
|
|
96
|
-
if (closed || checkingAuthorization)
|
|
97
|
-
return;
|
|
98
|
-
checkingAuthorization = true;
|
|
99
|
-
try {
|
|
100
|
-
const result = await required.authorize({ principalId, workspaceId, permission: required.permission, entityType: "setting-value" });
|
|
101
|
-
if (!closed && !result.allowed)
|
|
102
|
-
close();
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
if (!closed)
|
|
106
|
-
reportError("re-authorization", error);
|
|
107
|
-
}
|
|
108
|
-
finally {
|
|
109
|
-
checkingAuthorization = false;
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
const schedule = (intervalMs, callback) => {
|
|
113
|
-
const cancel = scheduler.every({ intervalMs, callback });
|
|
114
|
-
if (closed)
|
|
115
|
-
recover("cancel", cancel);
|
|
116
|
-
else
|
|
117
|
-
cancellations.push(cancel);
|
|
118
|
-
};
|
|
119
|
-
schedule(intervals[0], () => { void tick(); });
|
|
120
|
-
schedule(intervals[1], () => {
|
|
121
|
-
if (closed)
|
|
122
|
-
return;
|
|
123
|
-
try {
|
|
124
|
-
response.write(": keepalive\n\n");
|
|
125
|
-
}
|
|
126
|
-
catch (error) {
|
|
127
|
-
reportError("keepalive", error);
|
|
128
|
-
close();
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
schedule(intervals[2], () => { void reauthorize(); });
|
|
132
|
-
if (!closed)
|
|
133
|
-
response.write(": connected\n\n");
|
|
134
|
-
return close;
|
|
135
|
-
}
|
|
136
|
-
catch (error) {
|
|
137
|
-
// Cancel schedules even when opening a feed fails. Let the HTTP wrapper map the error.
|
|
138
|
-
if (response.headersSent)
|
|
139
|
-
close();
|
|
140
|
-
else {
|
|
141
|
-
closed = true;
|
|
142
|
-
release();
|
|
143
|
-
}
|
|
144
|
-
throw error;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
//# sourceMappingURL=stream.js.map
|
package/dist/settings/ports.d.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
-
import type { SettingDefinitionRecord, SettingRevisionRecord, SettingValueRecord } from "./types.js";
|
|
3
|
-
/**
|
|
4
|
-
* @file Port contract for the `settings` library.
|
|
5
|
-
*
|
|
6
|
-
* Purpose:
|
|
7
|
-
* Dependency-inversion seam (port/adapter rule-of-two) for the 5 settings tables.
|
|
8
|
-
* Persistence deliberately has no principal-lookup method: target-principal checks use
|
|
9
|
-
* the separate host-owned SettingsPrincipalLookupPort, keeping membership/status policy
|
|
10
|
-
* outside the settings persistence contract.
|
|
11
|
-
*
|
|
12
|
-
* `transaction` gives `write-service.ts`/`purge-service.ts` the same-tx
|
|
13
|
-
* guarantee depend on (value/definition row + its revision row
|
|
14
|
-
* must commit together, or not at all).
|
|
15
|
-
*
|
|
16
|
-
* Interfaces and types only — no feature logic.
|
|
17
|
-
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
18
|
-
*/
|
|
19
|
-
export interface SettingsRepoPort {
|
|
20
|
-
findActiveDefinition(required: {
|
|
21
|
-
namespace: string;
|
|
22
|
-
key: string;
|
|
23
|
-
workspaceId: UUID | null;
|
|
24
|
-
}): Promise<SettingDefinitionRecord | null>;
|
|
25
|
-
findDefinitionBySettingId(required: {
|
|
26
|
-
settingId: UUID;
|
|
27
|
-
version?: number;
|
|
28
|
-
}): Promise<SettingDefinitionRecord | null>;
|
|
29
|
-
listActiveDefinitions(required: {
|
|
30
|
-
workspaceId: UUID | null;
|
|
31
|
-
}): Promise<SettingDefinitionRecord[]>;
|
|
32
|
-
saveDefinition(record: SettingDefinitionRecord): Promise<void>;
|
|
33
|
-
getGlobalValue(settingId: UUID): Promise<SettingValueRecord | null>;
|
|
34
|
-
getWorkspaceValue(required: {
|
|
35
|
-
workspaceId: UUID;
|
|
36
|
-
settingId: UUID;
|
|
37
|
-
}): Promise<SettingValueRecord | null>;
|
|
38
|
-
getUserValue(required: {
|
|
39
|
-
workspaceId: UUID;
|
|
40
|
-
principalId: UUID;
|
|
41
|
-
settingId: UUID;
|
|
42
|
-
}): Promise<SettingValueRecord | null>;
|
|
43
|
-
saveGlobalValue(record: SettingValueRecord): Promise<void>;
|
|
44
|
-
saveWorkspaceValue(record: SettingValueRecord): Promise<void>;
|
|
45
|
-
saveUserValue(record: SettingValueRecord): Promise<void>;
|
|
46
|
-
listWorkspaceValues(required: {
|
|
47
|
-
workspaceId: UUID;
|
|
48
|
-
}): Promise<SettingValueRecord[]>;
|
|
49
|
-
listUserValues(required: {
|
|
50
|
-
workspaceId: UUID;
|
|
51
|
-
principalId: UUID;
|
|
52
|
-
}): Promise<SettingValueRecord[]>;
|
|
53
|
-
/**
|
|
54
|
-
* Every `setting_values_user` row for a workspace, across ALL principals
|
|
55
|
-
* (not just one) — needed by `purge-service.ts`'s full-tenant teardown path,
|
|
56
|
-
* which must clear the FK-blocking `setting_values_user` rows
|
|
57
|
-
* for every principal in the workspace, not just one caller-known id.
|
|
58
|
-
*/
|
|
59
|
-
listUserValuesByWorkspace(required: {
|
|
60
|
-
workspaceId: UUID;
|
|
61
|
-
}): Promise<SettingValueRecord[]>;
|
|
62
|
-
deleteWorkspaceValue(required: {
|
|
63
|
-
workspaceId: UUID;
|
|
64
|
-
settingId: UUID;
|
|
65
|
-
}): Promise<void>;
|
|
66
|
-
deleteUserValue(required: {
|
|
67
|
-
workspaceId: UUID;
|
|
68
|
-
principalId: UUID;
|
|
69
|
-
settingId: UUID;
|
|
70
|
-
}): Promise<void>;
|
|
71
|
-
/** Appends a revision and returns its assigned `seq` (used to stamp the paired value row). */
|
|
72
|
-
appendRevision(record: Omit<SettingRevisionRecord, "seq">): Promise<number>;
|
|
73
|
-
listRevisions(required: {
|
|
74
|
-
settingId: UUID;
|
|
75
|
-
}): Promise<SettingRevisionRecord[]>;
|
|
76
|
-
/**
|
|
77
|
-
* Revisions with `seq` strictly greater than `sinceSeq`, oldest first, capped at `limit`, and
|
|
78
|
-
* restricted to those `workspaceId` could possibly care about.
|
|
79
|
-
*
|
|
80
|
-
* Exists for change detection across PROCESSES. `seq` is a monotonic autoincrement in the shared
|
|
81
|
-
* database, so a poller in one process observes writes made by another process (e.g. an agent
|
|
82
|
-
* daemon) — a separate OS process with its own connection — without any IPC between them.
|
|
83
|
-
*
|
|
84
|
-
* `limit` bounds a client that reconnects after a long absence: it drains in pages rather than
|
|
85
|
-
* loading an unbounded backlog into memory.
|
|
86
|
-
*
|
|
87
|
-
* **`workspaceId` is a page-sizing predicate, not the disclosure boundary.** The ledger is global
|
|
88
|
-
* — one store holds every workspace's revisions — so without it a `limit`-sized page is filled by
|
|
89
|
-
* whichever tenant writes fastest, and a quiet workspace's own change waits behind a busy
|
|
90
|
-
* neighbour's backlog. That is an observable cross-tenant timing channel and, at sustained write
|
|
91
|
-
* rates above `limit` per poll interval, a feed that never catches up at all. Implementations must
|
|
92
|
-
* match rows whose `workspaceId` is either `null` (platform definitions and `global`-scope values,
|
|
93
|
-
* which every workspace resolves through) or equal to the argument — deliberately a SUPERSET of
|
|
94
|
-
* what `change-feed.ts#isRevisionVisibleTo` permits, which stays the sole authority on disclosure.
|
|
95
|
-
*/
|
|
96
|
-
listRevisionsSince(required: {
|
|
97
|
-
sinceSeq: number;
|
|
98
|
-
limit: number;
|
|
99
|
-
workspaceId: UUID;
|
|
100
|
-
}): Promise<SettingRevisionRecord[]>;
|
|
101
|
-
/** The newest assigned `seq`, or 0 when the ledger is empty — a subscriber's starting cursor. */
|
|
102
|
-
maxRevisionSeq(): Promise<number>;
|
|
103
|
-
/**
|
|
104
|
-
* Runs `fn` with the guarantee that all repo calls inside it commit or roll
|
|
105
|
-
* back together. Every adapter must honour this — it is what makes a
|
|
106
|
-
* composite write like `resetNamespace` all-or-nothing, and the contract
|
|
107
|
-
* suite asserts it against all of them rather than only the durable one.
|
|
108
|
-
*
|
|
109
|
-
* **Not reentrant.** Calling this while another `transaction` on the same
|
|
110
|
-
* repo is open must fail loudly, never silently join. An adapter cannot tell
|
|
111
|
-
* a genuine nested call from an unrelated concurrent one, and joining a
|
|
112
|
-
* stranger's transaction makes it inherit that transaction's rollback. A
|
|
113
|
-
* caller that needs several writes to commit together opens ONE transaction
|
|
114
|
-
* and passes `skipTransaction` to the inner writes.
|
|
115
|
-
*/
|
|
116
|
-
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
117
|
-
}
|
|
118
|
-
//# sourceMappingURL=ports.d.ts.map
|
package/dist/settings/ports.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type { Principal } from "@jini-ai/core";
|
|
2
|
-
/** Resolve only an active principal in the requested workspace. The host owns status and membership policy. */
|
|
3
|
-
export interface SettingsPrincipalLookupPort {
|
|
4
|
-
findActiveById(required: {
|
|
5
|
-
workspaceId: string;
|
|
6
|
-
id: string;
|
|
7
|
-
}): Promise<Principal | null>;
|
|
8
|
-
}
|
|
9
|
-
//# sourceMappingURL=principal-lookup.d.ts.map
|