@jini-ai/core 0.4.7 → 0.4.9
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/CHANGELOG.md +11 -0
- package/dist/agent-tools.d.ts +1 -1
- package/dist/approval.d.ts +15 -0
- package/dist/approval.js +87 -22
- package/dist/batch-tool-inputs.d.ts +147 -0
- package/dist/batch-tool-inputs.js +257 -0
- package/dist/feature-flags.d.ts +21 -0
- package/dist/feature-flags.js +17 -0
- package/dist/fetch/index.d.ts +2 -1
- package/dist/fetch/index.js +1 -0
- package/dist/fetch/loader-read.d.ts +16 -0
- package/dist/fetch/loader-read.js +22 -0
- package/dist/fetch/types.d.ts +12 -2
- package/dist/gated-mutations/gateway.js +32 -27
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -1
- package/dist/object-store.d.ts +115 -0
- package/dist/object-store.js +70 -0
- package/dist/origin-validation.js +64 -57
- package/dist/primitives/http.d.ts +2 -2
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/paths.js +17 -10
- package/dist/primitives/tasks.d.ts +10 -0
- package/dist/primitives/tasks.js +8 -0
- package/dist/provider-catalog.d.ts +76 -0
- package/dist/provider-catalog.js +6 -0
- package/dist/provider-registry.d.ts +144 -0
- package/dist/provider-registry.js +138 -0
- package/dist/records.d.ts +24 -0
- package/dist/records.js +25 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +57 -34
- package/dist/registration-kit.d.ts +7 -4
- package/dist/registration-kit.js +81 -62
- package/dist/settings/agent-value-write-tools.d.ts +19 -0
- package/dist/settings/agent-value-write-tools.js +131 -70
- package/dist/settings/ensure-definitions.d.ts +4 -2
- package/dist/settings/ensure-definitions.js +79 -63
- package/dist/settings/express/cms-adapter.d.ts +2 -2
- package/dist/settings/express/cms-adapter.js +34 -22
- package/dist/settings/express/http-request.d.ts +33 -0
- package/dist/settings/express/http-request.js +49 -0
- package/dist/settings/express/http.d.ts +2 -2
- package/dist/settings/express/http.js +16 -39
- package/dist/settings/express/stream.d.ts +2 -2
- package/dist/settings/express/stream.js +49 -27
- package/dist/settings/repo.memory.js +7 -4
- package/dist/settings/settings.js +54 -60
- package/dist/settings/tool-registrations.d.ts +2 -3
- package/dist/settings/tool-registrations.js +64 -39
- package/dist/settings/write-service.d.ts +19 -0
- package/dist/settings/write-service.js +34 -0
- package/dist/target-scope.d.ts +52 -0
- package/dist/target-scope.js +82 -0
- package/dist/text/escape.js +2 -2
- package/dist/text/sanitize-untrusted-text.d.ts +5 -0
- package/dist/text/sanitize-untrusted-text.js +55 -35
- package/dist/tool-exposure.d.ts +79 -0
- package/dist/tool-exposure.js +163 -0
- package/dist/tool-input-errors.d.ts +23 -0
- package/dist/tool-input-errors.js +24 -0
- package/dist/tool-input-normalization.d.ts +37 -0
- package/dist/tool-input-normalization.js +188 -0
- package/dist/tool-metadata-types.d.ts +26 -0
- package/dist/tool-metadata-types.js +2 -0
- package/dist/tool-metadata.d.ts +2 -23
- package/dist/tool-registry.d.ts +38 -22
- package/dist/tool-registry.js +35 -20
- package/package.json +20 -2
- package/plugin-directory.js +72 -33
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { resolveDefinitionRaw } from "./settings.js";
|
|
2
2
|
import { SCOPE_BIT } from "./types.js";
|
|
3
|
-
import { reconcileDefinitionDefault, registerDefinitions } from "./write-service.js";
|
|
3
|
+
import { extendDefinitionScopes, reconcileDefinitionDefault, registerDefinitions } from "./write-service.js";
|
|
4
4
|
/** Boot-time infra work is trusted by construction — mirrors every other
|
|
5
5
|
* `ensure*SettingDefinitions`'s identical shim. */
|
|
6
6
|
const alwaysAllowBoot = async () => ({ allowed: true, reason: "system_boot" });
|
|
7
7
|
/**
|
|
8
8
|
* Idempotently registers every definition in `input.definitions` under
|
|
9
9
|
* `input.namespace`, and reconciles the stored default of any that already
|
|
10
|
-
* exist to the default source declares.
|
|
10
|
+
* exist to the default source declares. Explicit core scope bits are added without removing
|
|
11
|
+
* existing scopes or moving stored values. Safe to call on every boot.
|
|
11
12
|
*
|
|
12
13
|
* The reconcile half matters as much as the register half: registration is
|
|
13
14
|
* once-only, so without it a `defaultValue` edited in source never reaches an
|
|
@@ -31,71 +32,86 @@ const alwaysAllowBoot = async () => ({ allowed: true, reason: "system_boot" });
|
|
|
31
32
|
* lookup plus at most one registration or default reconciliation.
|
|
32
33
|
*/
|
|
33
34
|
export async function ensureSettingDefinitions(deps, input) {
|
|
34
|
-
const ownerKind = input.ownerKind ?? "core";
|
|
35
35
|
const workspaceId = input.ownerKind === "site" ? input.workspaceId : null;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
const target = {
|
|
37
|
+
namespace: input.namespace,
|
|
38
|
+
ownerKind: input.ownerKind ?? "core",
|
|
39
|
+
workspaceId,
|
|
40
|
+
authWorkspaceId: workspaceId ?? input.systemPrincipalId,
|
|
41
|
+
systemPrincipalId: input.systemPrincipalId,
|
|
42
|
+
writeDeps: {
|
|
43
|
+
repo: deps.settingsRepo,
|
|
44
|
+
clock: deps.clock,
|
|
45
|
+
ids: deps.ids,
|
|
46
|
+
authorize: alwaysAllowBoot,
|
|
47
|
+
principals: deps.principals,
|
|
48
|
+
},
|
|
43
49
|
};
|
|
44
50
|
for (const def of input.definitions) {
|
|
45
|
-
const existing = await resolveDefinitionRaw({ repo: deps.settingsRepo }, { namespace:
|
|
46
|
-
if (existing)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// non-`core` row is operator-owned; and a row whose identity differs
|
|
56
|
-
// from the slot we asked for means `resolveDefinitionRaw` followed an
|
|
57
|
-
// alias, so rewriting it would silently change a DIFFERENT definition's
|
|
58
|
-
// default. `reconcileDefinitionDefault` would throw on the first two —
|
|
59
|
-
// this skips them quietly because boot must not fail on them.
|
|
60
|
-
const isSameSlot = existing.namespace === input.namespace && existing.key === def.key;
|
|
61
|
-
if (ownerKind === "core" && existing.status === "active" && existing.ownerKind === "core" && isSameSlot) {
|
|
62
|
-
await reconcileDefinitionDefault({
|
|
63
|
-
deps: writeDeps,
|
|
64
|
-
input: {
|
|
65
|
-
namespace: input.namespace,
|
|
66
|
-
key: def.key,
|
|
67
|
-
workspaceId: null,
|
|
68
|
-
defaultValue: def.defaultValue,
|
|
69
|
-
callerPrincipalId: input.systemPrincipalId,
|
|
70
|
-
authWorkspaceId,
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
continue;
|
|
51
|
+
const existing = await resolveDefinitionRaw({ repo: deps.settingsRepo }, { namespace: target.namespace, key: def.key, workspaceId });
|
|
52
|
+
if (!existing)
|
|
53
|
+
await registerMissingDefinition(target, def);
|
|
54
|
+
// Core registration is once-only, but the DEFAULT is not. Without this, a
|
|
55
|
+
// `defaultValue` changed in source is unreachable in any install that
|
|
56
|
+
// has already booted — the stored `default_json` from first boot keeps
|
|
57
|
+
// winning, for every settings-dialog tab. See `reconcileDefinitionDefault`
|
|
58
|
+
// for the bug this closes.
|
|
59
|
+
else if (isReconcilableCoreSlot(existing, { ownerKind: target.ownerKind, namespace: target.namespace, key: def.key })) {
|
|
60
|
+
await reconcileExistingCoreDefinition(target, def);
|
|
75
61
|
}
|
|
76
|
-
await registerDefinitions({
|
|
77
|
-
deps: writeDeps,
|
|
78
|
-
input: {
|
|
79
|
-
callerPrincipalId: input.systemPrincipalId,
|
|
80
|
-
// Platform (core) definitions aren't scoped to any one workspace, but
|
|
81
|
-
// `registerDefinitions`'s authorization check still needs a workspace
|
|
82
|
-
// context to authorize against — the seeded workspace mirrors every
|
|
83
|
-
// other boot-time registrar's identical `authWorkspaceId` shape.
|
|
84
|
-
authWorkspaceId,
|
|
85
|
-
definitions: [
|
|
86
|
-
{
|
|
87
|
-
namespace: input.namespace,
|
|
88
|
-
key: def.key,
|
|
89
|
-
ownerKind,
|
|
90
|
-
workspaceId,
|
|
91
|
-
schema: def.schema,
|
|
92
|
-
defaultValue: def.defaultValue,
|
|
93
|
-
scopes: def.scopes ?? SCOPE_BIT.workspace,
|
|
94
|
-
secret: false,
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
62
|
}
|
|
100
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Three slots are deliberately left alone rather than reconciled:
|
|
66
|
+
* a non-`active` row (deprecated/tombstoned) is not ours to rewrite; a
|
|
67
|
+
* non-`core` row is operator-owned; and a row whose identity differs
|
|
68
|
+
* from the slot we asked for means `resolveDefinitionRaw` followed an
|
|
69
|
+
* alias, so rewriting it would silently change a DIFFERENT definition's
|
|
70
|
+
* default. `reconcileDefinitionDefault` would throw on the first two —
|
|
71
|
+
* this skips them quietly because boot must not fail on them. A site
|
|
72
|
+
* target never reconciles: its stored default belongs to the site.
|
|
73
|
+
*/
|
|
74
|
+
function isReconcilableCoreSlot(existing, slot) {
|
|
75
|
+
const isSameSlot = existing.namespace === slot.namespace && existing.key === slot.key;
|
|
76
|
+
return slot.ownerKind === "core" && existing.status === "active" && existing.ownerKind === "core" && isSameSlot;
|
|
77
|
+
}
|
|
78
|
+
async function reconcileExistingCoreDefinition(target, def) {
|
|
79
|
+
const slot = {
|
|
80
|
+
namespace: target.namespace,
|
|
81
|
+
key: def.key,
|
|
82
|
+
workspaceId: null,
|
|
83
|
+
callerPrincipalId: target.systemPrincipalId,
|
|
84
|
+
authWorkspaceId: target.authWorkspaceId,
|
|
85
|
+
};
|
|
86
|
+
// Existing installations need newly declared personal layers too, not just fresh boots.
|
|
87
|
+
if (def.scopes !== undefined) {
|
|
88
|
+
await extendDefinitionScopes({ deps: target.writeDeps, input: { ...slot, scopes: def.scopes } });
|
|
89
|
+
}
|
|
90
|
+
await reconcileDefinitionDefault({ deps: target.writeDeps, input: { ...slot, defaultValue: def.defaultValue } });
|
|
91
|
+
}
|
|
92
|
+
async function registerMissingDefinition(target, def) {
|
|
93
|
+
await registerDefinitions({
|
|
94
|
+
deps: target.writeDeps,
|
|
95
|
+
input: {
|
|
96
|
+
callerPrincipalId: target.systemPrincipalId,
|
|
97
|
+
// Platform (core) definitions aren't scoped to any one workspace, but
|
|
98
|
+
// `registerDefinitions`'s authorization check still needs a workspace
|
|
99
|
+
// context to authorize against — the seeded workspace mirrors every
|
|
100
|
+
// other boot-time registrar's identical `authWorkspaceId` shape.
|
|
101
|
+
authWorkspaceId: target.authWorkspaceId,
|
|
102
|
+
definitions: [
|
|
103
|
+
{
|
|
104
|
+
namespace: target.namespace,
|
|
105
|
+
key: def.key,
|
|
106
|
+
ownerKind: target.ownerKind,
|
|
107
|
+
workspaceId: target.workspaceId,
|
|
108
|
+
schema: def.schema,
|
|
109
|
+
defaultValue: def.defaultValue,
|
|
110
|
+
scopes: def.scopes ?? SCOPE_BIT.workspace,
|
|
111
|
+
secret: false,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
101
117
|
//# sourceMappingURL=ensure-definitions.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type SettingsWriteServiceDeps, type SettingsRepoPort } from "../index.js";
|
|
2
2
|
import type { SettingsService, SettingsPermissions, SettingsChangeFeed } from "./contracts.js";
|
|
3
|
-
/** Keep the CMS write chokepoint while translating its permission vocabulary to host policy.
|
|
4
|
-
|
|
3
|
+
/** Keep the CMS write chokepoint while translating its permission vocabulary to host policy.
|
|
4
|
+
* Bind the CMS ledger and write chokepoint without copying their domain logic.
|
|
5
5
|
* Effective reads scan definitions and resolve each namespace key; mutations inherit CMS transactions/errors.
|
|
6
6
|
* @complexity O(1) setup. Per operation: O(definitions + keys) reads, or O(batch size) writes.
|
|
7
7
|
* @example createCmsSettingsService({ deps: cmsPorts, permissions });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getEffective, resolveDefinition, set, clear, resetNamespace, registerDefinitions, NON_REGISTER_DEFINITION_OPS, parseNonRegisterDefinitionOp, collectChangedNamespaces, } from "../index.js";
|
|
2
|
-
/** Keep the CMS write chokepoint while translating its permission vocabulary to host policy.
|
|
3
|
-
|
|
2
|
+
/** Keep the CMS write chokepoint while translating its permission vocabulary to host policy.
|
|
3
|
+
* Bind the CMS ledger and write chokepoint without copying their domain logic.
|
|
4
4
|
* Effective reads scan definitions and resolve each namespace key; mutations inherit CMS transactions/errors.
|
|
5
5
|
* @complexity O(1) setup. Per operation: O(definitions + keys) reads, or O(batch size) writes.
|
|
6
6
|
* @example createCmsSettingsService({ deps: cmsPorts, permissions });
|
|
@@ -76,35 +76,47 @@ async function applyDefinitions(required) {
|
|
|
76
76
|
const toRegister = [];
|
|
77
77
|
const applied = [];
|
|
78
78
|
for (const raw of items) {
|
|
79
|
-
const item = raw;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const key = String(item.key ?? "");
|
|
83
|
-
const ownerKind = item.ownerKind;
|
|
84
|
-
const workspaceId = ownerKind === "site" ? authWorkspaceId : null;
|
|
85
|
-
const defaultValue = (item.defaultJson ?? null);
|
|
86
|
-
if (op === "register") {
|
|
87
|
-
toRegister.push({ namespace, key, ownerKind, workspaceId, schema: item.schemaJson,
|
|
88
|
-
defaultValue, scopes: Number(item.scopes), secret: Boolean(item.secret ?? false) });
|
|
79
|
+
const item = parseDefinitionItem(raw, { authWorkspaceId });
|
|
80
|
+
if (item.op === "register") {
|
|
81
|
+
toRegister.push(toRegisterInput(item));
|
|
89
82
|
}
|
|
90
83
|
else {
|
|
91
|
-
const parsedOp = parseNonRegisterDefinitionOp({ op });
|
|
84
|
+
const parsedOp = parseNonRegisterDefinitionOp({ op: item.op });
|
|
92
85
|
if (!parsedOp)
|
|
93
|
-
return { unknownOp: op };
|
|
94
|
-
|
|
95
|
-
...(item.newNamespace === undefined ? {} : { newNamespace: item.newNamespace }),
|
|
96
|
-
...(item.newKey === undefined ? {} : { newKey: item.newKey }),
|
|
97
|
-
...(item.schemaJson === undefined ? {} : { schemaJson: item.schemaJson }),
|
|
98
|
-
...(item.coercionJson === undefined ? {} : { coercionJson: item.coercionJson }),
|
|
99
|
-
};
|
|
100
|
-
await NON_REGISTER_DEFINITION_OPS[parsedOp]({ ctx: { deps, callerPrincipalId, authWorkspaceId }, item: opItem });
|
|
86
|
+
return { unknownOp: item.op };
|
|
87
|
+
await NON_REGISTER_DEFINITION_OPS[parsedOp]({ ctx: { deps, callerPrincipalId, authWorkspaceId }, item: toOpItem(item) });
|
|
101
88
|
}
|
|
102
|
-
applied.push({ key: `${namespace}.${key}`, op, status: "applied" });
|
|
89
|
+
applied.push({ key: `${item.namespace}.${item.key}`, op: item.op, status: "applied" });
|
|
103
90
|
}
|
|
104
91
|
if (toRegister.length)
|
|
105
92
|
await registerDefinitions({ deps, input: { definitions: toRegister, callerPrincipalId, authWorkspaceId } });
|
|
106
93
|
return { applied };
|
|
107
94
|
}
|
|
95
|
+
/** Site definitions live in the caller's workspace; every other owner is platform-wide. Pure, O(1). */
|
|
96
|
+
function parseDefinitionItem(raw, context) {
|
|
97
|
+
const ownerKind = raw.ownerKind;
|
|
98
|
+
return {
|
|
99
|
+
op: String(raw.op ?? "register"),
|
|
100
|
+
namespace: String(raw.namespace ?? ""),
|
|
101
|
+
key: String(raw.key ?? ""),
|
|
102
|
+
ownerKind,
|
|
103
|
+
workspaceId: ownerKind === "site" ? context.authWorkspaceId : null,
|
|
104
|
+
defaultValue: (raw.defaultJson ?? null),
|
|
105
|
+
raw,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function toRegisterInput(item) {
|
|
109
|
+
const { namespace, key, ownerKind, workspaceId, defaultValue, raw } = item;
|
|
110
|
+
return { namespace, key, ownerKind, workspaceId, schema: raw.schemaJson,
|
|
111
|
+
defaultValue, scopes: Number(raw.scopes), secret: Boolean(raw.secret ?? false) };
|
|
112
|
+
}
|
|
113
|
+
/** Op-specific fields are forwarded only when present, so CMS defaults still apply to omitted ones. */
|
|
114
|
+
const OPTIONAL_OP_FIELDS = ["newNamespace", "newKey", "schemaJson", "coercionJson"];
|
|
115
|
+
function toOpItem(item) {
|
|
116
|
+
const { namespace, key, ownerKind, workspaceId, defaultValue, raw } = item;
|
|
117
|
+
const present = OPTIONAL_OP_FIELDS.filter((field) => raw[field] !== undefined).map((field) => [field, raw[field]]);
|
|
118
|
+
return { namespace, key, ownerKind, workspaceId, defaultJson: defaultValue, ...Object.fromEntries(present) };
|
|
119
|
+
}
|
|
108
120
|
/** Adapt the ledger through CMS tenant/principal disclosure rules, returning names and cursors only.
|
|
109
121
|
* @complexity O(1) setup; collect is O(page size), with namespace lookup per visible revision.
|
|
110
122
|
* @example createCmsSettingsChangeFeed({ repo });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Request, Response } from "express";
|
|
2
|
+
import type { SettingScope } from "../index.js";
|
|
3
|
+
import type { SettingsHttpOptions, SettingsHttpRequired } from "./contracts.js";
|
|
4
|
+
import { type SettingsErrorMapping } from "./shared.js";
|
|
5
|
+
/**
|
|
6
|
+
* @file Request-level steps of the settings HTTP registrar (`http.ts`): the
|
|
7
|
+
* authentication gate, the failure envelope and value-write body parsing.
|
|
8
|
+
* Pure or response-only; no service, authorization or storage effects.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SETTING_SCOPES: readonly string[];
|
|
11
|
+
export declare function unauthenticated(res: Response): void;
|
|
12
|
+
/** Readiness/principal failures preserve the authentication boundary's existing 401 envelope, so both collapse to null. */
|
|
13
|
+
export declare function resolvePrincipal(required: Pick<SettingsHttpRequired, "ready" | "principalResolver">, context: {
|
|
14
|
+
request: Request;
|
|
15
|
+
response: Response;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
id: string;
|
|
18
|
+
} | null>;
|
|
19
|
+
/** Map a handler failure to its envelope, or end a response whose headers have already been sent. */
|
|
20
|
+
export declare function failRequest(required: {
|
|
21
|
+
response: Response;
|
|
22
|
+
error: unknown;
|
|
23
|
+
mappings: readonly SettingsErrorMapping[];
|
|
24
|
+
}, optional: SettingsHttpOptions): void;
|
|
25
|
+
export type ValueMutationOperation = "set" | "clear";
|
|
26
|
+
export declare const VALUE_MUTATION_REQUIRED: Record<ValueMutationOperation, string>;
|
|
27
|
+
/** The fields a value write needs, or null when one is missing or the scope is unknown. Pure, O(1). */
|
|
28
|
+
export declare function readValueMutation(body: Record<string, unknown>, operation: ValueMutationOperation): {
|
|
29
|
+
namespace: string;
|
|
30
|
+
key: string;
|
|
31
|
+
scope: SettingScope;
|
|
32
|
+
} | null;
|
|
33
|
+
//# sourceMappingURL=http-request.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { respondToSettingsError } from "./shared.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file Request-level steps of the settings HTTP registrar (`http.ts`): the
|
|
4
|
+
* authentication gate, the failure envelope and value-write body parsing.
|
|
5
|
+
* Pure or response-only; no service, authorization or storage effects.
|
|
6
|
+
*/
|
|
7
|
+
export const SETTING_SCOPES = ["global", "workspace", "user"];
|
|
8
|
+
export function unauthenticated(res) {
|
|
9
|
+
res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
|
|
10
|
+
}
|
|
11
|
+
/** Readiness/principal failures preserve the authentication boundary's existing 401 envelope, so both collapse to null. */
|
|
12
|
+
export async function resolvePrincipal(required, context) {
|
|
13
|
+
try {
|
|
14
|
+
await required.ready;
|
|
15
|
+
return await required.principalResolver(context);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Map a handler failure to its envelope, or end a response whose headers have already been sent. */
|
|
22
|
+
export function failRequest(required, optional) {
|
|
23
|
+
const { response, error, mappings } = required;
|
|
24
|
+
if (!response.headersSent) {
|
|
25
|
+
respondToSettingsError({ response, error, mappings });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Reporting cannot prevent ending a response whose headers have already been sent.
|
|
29
|
+
try {
|
|
30
|
+
optional.onError?.({ operation: "settings-request", error });
|
|
31
|
+
}
|
|
32
|
+
catch { /* Host diagnostics are best-effort. */ }
|
|
33
|
+
finally {
|
|
34
|
+
response.end();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const VALUE_MUTATION_REQUIRED = {
|
|
38
|
+
set: "namespace, key, scope (global|workspace|user), and valueJson are required",
|
|
39
|
+
clear: "namespace, key, and scope (global|workspace|user) are required",
|
|
40
|
+
};
|
|
41
|
+
/** The fields a value write needs, or null when one is missing or the scope is unknown. Pure, O(1). */
|
|
42
|
+
export function readValueMutation(body, operation) {
|
|
43
|
+
const namespace = String(body.namespace ?? "");
|
|
44
|
+
const key = String(body.key ?? "");
|
|
45
|
+
const scope = body.scope;
|
|
46
|
+
const complete = namespace && key && SETTING_SCOPES.includes(scope) && (operation === "clear" || "valueJson" in body);
|
|
47
|
+
return complete ? { namespace, key, scope } : null;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=http-request.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SettingsHttpRequired, SettingsHttpOptions } from "./contracts.js";
|
|
2
|
-
/** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
|
|
3
|
-
|
|
2
|
+
/** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
|
|
3
|
+
* Injected policy and services preserve the settings HTTP response envelopes.
|
|
4
4
|
* O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
|
|
5
5
|
* @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
|
|
6
6
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, } from "../index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
|
|
3
3
|
import { openSettingsChangeStream } from "./stream.js";
|
|
4
|
+
import { failRequest, readValueMutation, resolvePrincipal, SETTING_SCOPES, unauthenticated, VALUE_MUTATION_REQUIRED } from "./http-request.js";
|
|
4
5
|
const VALUE_ERRORS = [
|
|
5
6
|
{ matches: (e) => e instanceof PrincipalNotFoundError, status: 404, code: "PRINCIPAL_NOT_FOUND" },
|
|
6
7
|
{ matches: (e) => e instanceof DefinitionNotFoundError, status: 404, code: "DEFINITION_NOT_FOUND" },
|
|
@@ -18,7 +19,6 @@ const DEFINITION_ERRORS = [
|
|
|
18
19
|
{ matches: (e) => e instanceof ForbiddenError, status: 403, code: "FORBIDDEN" },
|
|
19
20
|
];
|
|
20
21
|
const RESET_ERRORS = VALUE_ERRORS.filter((m) => m.code === "FORBIDDEN");
|
|
21
|
-
const scopes = ["global", "workspace", "user"];
|
|
22
22
|
function validation(res, error) {
|
|
23
23
|
res.status(400).json({ error, code: "VALIDATION_ERROR" });
|
|
24
24
|
}
|
|
@@ -29,8 +29,8 @@ function denied(res, principalId, permission, reason) {
|
|
|
29
29
|
function bodyOf(req) {
|
|
30
30
|
return req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
31
31
|
}
|
|
32
|
-
/** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
|
|
33
|
-
|
|
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
34
|
* O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
|
|
35
35
|
* @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
|
|
36
36
|
*/
|
|
@@ -47,36 +47,16 @@ export function registerSettingsRoutes(required, optional = {}) {
|
|
|
47
47
|
res.status(404).json({ error: "workspace was not found" });
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
|
|
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
|
-
}
|
|
50
|
+
const principal = await resolvePrincipal(required, { request: req, response: res });
|
|
60
51
|
if (!principal) {
|
|
61
|
-
res
|
|
52
|
+
unauthenticated(res);
|
|
62
53
|
return;
|
|
63
54
|
}
|
|
64
55
|
try {
|
|
65
56
|
await handler(req, res, principal.id);
|
|
66
57
|
}
|
|
67
58
|
catch (error) {
|
|
68
|
-
|
|
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 });
|
|
59
|
+
failRequest({ response: res, error, mappings }, optional);
|
|
80
60
|
}
|
|
81
61
|
};
|
|
82
62
|
const check = async (res, principalId, permission, entityType) => {
|
|
@@ -152,13 +132,12 @@ export function registerSettingsRoutes(required, optional = {}) {
|
|
|
152
132
|
}, DEFINITION_ERRORS));
|
|
153
133
|
const mutate = (operation) => async (req, res, callerPrincipalId) => {
|
|
154
134
|
const body = bodyOf(req);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
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");
|
|
135
|
+
const fields = readValueMutation(body, operation);
|
|
136
|
+
if (!fields) {
|
|
137
|
+
validation(res, VALUE_MUTATION_REQUIRED[operation]);
|
|
160
138
|
return;
|
|
161
139
|
}
|
|
140
|
+
const { namespace, key, scope } = fields;
|
|
162
141
|
const target = resolveTargetWorkspaceId({ workspaceId, input: { bodyWorkspaceId: body.workspaceId, scope } });
|
|
163
142
|
if (!target.ok) {
|
|
164
143
|
validation(res, target.error);
|
|
@@ -178,7 +157,7 @@ export function registerSettingsRoutes(required, optional = {}) {
|
|
|
178
157
|
const body = bodyOf(req);
|
|
179
158
|
const namespace = String(body.namespace ?? "");
|
|
180
159
|
const scope = body.scope;
|
|
181
|
-
if (!namespace || !
|
|
160
|
+
if (!namespace || !SETTING_SCOPES.includes(scope)) {
|
|
182
161
|
validation(res, "namespace and scope (global|workspace|user) are required");
|
|
183
162
|
return;
|
|
184
163
|
}
|
|
@@ -198,12 +177,10 @@ export function registerSettingsRoutes(required, optional = {}) {
|
|
|
198
177
|
const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
|
|
199
178
|
changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
|
|
200
179
|
onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
streams.add(close);
|
|
206
|
-
}
|
|
180
|
+
if (disposed)
|
|
181
|
+
close();
|
|
182
|
+
else if (!res.destroyed && !res.writableEnded)
|
|
183
|
+
streams.add(close);
|
|
207
184
|
}));
|
|
208
185
|
return { dispose: () => { disposed = true; for (const close of streams)
|
|
209
186
|
close(); streams.clear(); } };
|
|
@@ -14,8 +14,8 @@ interface StreamRequired {
|
|
|
14
14
|
}) => void;
|
|
15
15
|
isDisposed: () => boolean;
|
|
16
16
|
}
|
|
17
|
-
/** Starts after HTTP authorization. All scheduling and storage are injected.
|
|
18
|
-
|
|
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
19
|
* O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
|
|
20
20
|
* Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
|
|
21
21
|
*/
|
|
@@ -1,29 +1,16 @@
|
|
|
1
|
-
/** Starts after HTTP authorization. All scheduling and storage are injected.
|
|
2
|
-
|
|
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
3
|
* O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
|
|
4
4
|
* Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
|
|
5
5
|
*/
|
|
6
6
|
export async function openSettingsChangeStream(required, optional = {}) {
|
|
7
7
|
const { request, response, workspaceId, principalId, changeFeed, scheduler } = required;
|
|
8
|
-
const pageSize = optional
|
|
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
|
-
}
|
|
8
|
+
const { pageSize, pollIntervalMs, keepaliveIntervalMs, reauthorizeIntervalMs } = readStreamTimings(optional);
|
|
13
9
|
let closed = false;
|
|
14
10
|
let ticking = false;
|
|
15
11
|
let checkingAuthorization = false;
|
|
16
12
|
const cancellations = [];
|
|
17
|
-
const reportError = (
|
|
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
|
-
};
|
|
13
|
+
const reportError = createErrorReporter(optional);
|
|
27
14
|
const recover = (operation, effect) => {
|
|
28
15
|
try {
|
|
29
16
|
effect();
|
|
@@ -52,18 +39,15 @@ export async function openSettingsChangeStream(required, optional = {}) {
|
|
|
52
39
|
response.on("close", close);
|
|
53
40
|
try {
|
|
54
41
|
const head = await changeFeed.head({});
|
|
55
|
-
|
|
42
|
+
const gone = () => closed || required.isDisposed() || response.destroyed || response.writableEnded;
|
|
43
|
+
if (gone()) {
|
|
56
44
|
close();
|
|
57
45
|
return close;
|
|
58
46
|
}
|
|
59
|
-
|
|
60
|
-
let cursor = Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, head) : head;
|
|
47
|
+
let cursor = resumeCursor({ lastEventId: request.headers["last-event-id"], head });
|
|
61
48
|
let lastEmittedId = cursor;
|
|
62
49
|
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
|
-
});
|
|
50
|
+
response.writeHead(200, sseHeaders({ httpVersionMajor: request.httpVersionMajor }));
|
|
67
51
|
const tick = async () => {
|
|
68
52
|
if (closed || ticking)
|
|
69
53
|
return;
|
|
@@ -116,8 +100,8 @@ export async function openSettingsChangeStream(required, optional = {}) {
|
|
|
116
100
|
else
|
|
117
101
|
cancellations.push(cancel);
|
|
118
102
|
};
|
|
119
|
-
schedule(
|
|
120
|
-
schedule(
|
|
103
|
+
schedule(pollIntervalMs, () => { void tick(); });
|
|
104
|
+
schedule(keepaliveIntervalMs, () => {
|
|
121
105
|
if (closed)
|
|
122
106
|
return;
|
|
123
107
|
try {
|
|
@@ -128,7 +112,7 @@ export async function openSettingsChangeStream(required, optional = {}) {
|
|
|
128
112
|
close();
|
|
129
113
|
}
|
|
130
114
|
});
|
|
131
|
-
schedule(
|
|
115
|
+
schedule(reauthorizeIntervalMs, () => { void reauthorize(); });
|
|
132
116
|
if (!closed)
|
|
133
117
|
response.write(": connected\n\n");
|
|
134
118
|
return close;
|
|
@@ -144,4 +128,42 @@ export async function openSettingsChangeStream(required, optional = {}) {
|
|
|
144
128
|
throw error;
|
|
145
129
|
}
|
|
146
130
|
}
|
|
131
|
+
/** Fill the documented defaults, then refuse any non-positive page size or interval before an effect. Pure. */
|
|
132
|
+
function readStreamTimings(optional) {
|
|
133
|
+
const timings = {
|
|
134
|
+
pageSize: optional.revisionPageSize ?? 200,
|
|
135
|
+
pollIntervalMs: optional.pollIntervalMs ?? 1_000,
|
|
136
|
+
keepaliveIntervalMs: optional.keepaliveIntervalMs ?? 25_000,
|
|
137
|
+
reauthorizeIntervalMs: optional.reauthorizeIntervalMs ?? 30_000,
|
|
138
|
+
};
|
|
139
|
+
const intervals = [timings.pollIntervalMs, timings.keepaliveIntervalMs, timings.reauthorizeIntervalMs];
|
|
140
|
+
if (!Number.isSafeInteger(timings.pageSize) || timings.pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
|
|
141
|
+
throw new RangeError("settings feed page size and intervals must be positive");
|
|
142
|
+
}
|
|
143
|
+
return timings;
|
|
144
|
+
}
|
|
145
|
+
function createErrorReporter(optional) {
|
|
146
|
+
return (operation, error) => {
|
|
147
|
+
// Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
|
|
148
|
+
try {
|
|
149
|
+
if (optional.onError)
|
|
150
|
+
optional.onError({ operation, error });
|
|
151
|
+
else
|
|
152
|
+
console.error(`[settings] change feed ${operation} failed`, error);
|
|
153
|
+
}
|
|
154
|
+
catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** A client's Last-Event-ID resumes at or before the ledger head; a missing or unusable id starts at head. Pure. */
|
|
158
|
+
function resumeCursor(required) {
|
|
159
|
+
const resumeFrom = Number(required.lastEventId);
|
|
160
|
+
return Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, required.head) : required.head;
|
|
161
|
+
}
|
|
162
|
+
/** HTTP/2 forbids the Connection header; HTTP/1 needs it to keep the stream open. Pure. */
|
|
163
|
+
function sseHeaders(required) {
|
|
164
|
+
return {
|
|
165
|
+
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
|
|
166
|
+
...(required.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
147
169
|
//# sourceMappingURL=stream.js.map
|
|
@@ -179,8 +179,11 @@ export class InMemorySettingsRepo {
|
|
|
179
179
|
nextSeq: this.nextSeq,
|
|
180
180
|
};
|
|
181
181
|
this.inTransaction = true;
|
|
182
|
+
// No `finally`: the flag is released on both exits explicitly (V8 coverage counts the
|
|
183
|
+
// unreachable continuation after a catch-block `throw` inside try/finally as a missed branch).
|
|
184
|
+
let result;
|
|
182
185
|
try {
|
|
183
|
-
|
|
186
|
+
result = await fn();
|
|
184
187
|
}
|
|
185
188
|
catch (error) {
|
|
186
189
|
this.definitions = snapshot.definitions;
|
|
@@ -189,11 +192,11 @@ export class InMemorySettingsRepo {
|
|
|
189
192
|
this.userValues = snapshot.userValues;
|
|
190
193
|
this.revisions = snapshot.revisions;
|
|
191
194
|
this.nextSeq = snapshot.nextSeq;
|
|
192
|
-
throw error;
|
|
193
|
-
}
|
|
194
|
-
finally {
|
|
195
195
|
this.inTransaction = false;
|
|
196
|
+
throw error;
|
|
196
197
|
}
|
|
198
|
+
this.inTransaction = false;
|
|
199
|
+
return result;
|
|
197
200
|
}
|
|
198
201
|
}
|
|
199
202
|
//# sourceMappingURL=repo.memory.js.map
|